Files
opencode-mobile/.github/workflows/publish-play-store.yml
Den 4d64700b1b tools(sentry): anchor the measurement windows on the gate's rollout instant (#178)
A window that spans the 2026-08-14 14:22Z production rollout contains devices
that could not possibly have run the gate. Its rate is neither a baseline nor a
result, and it prints identically to both. This was not hypothetical: a
`post=08-14T07:00Z..now` window (84% of it pre-rollout) was run against this
script and reported opencode-mobile *rising* to 4.44/h.

`noise-gate-report.mjs` shipped with the same defect built into its default:
`post = now-7d..now` straddles the rollout on every run before 08-21, diluting
the after-rate toward baseline - biased toward grading the gate as ineffective
on exactly the dates the ticket schedules its reads (08-17, 08-21).

- sentry-volume-report: `--since-rollout` reads the instant from the release
  history table in docs/playstore.md (production versionCode >= 150, earliest
  such release, so a later v0.4.15 does not restart the window) and splits
  there. Every window is labelled [pre]/[post]/[mixed]; mixed prints how much
  of it predates the gate, a young post window prints its uptake age, and an
  unparseable table reports "unknown" rather than assuming post.
- noise-gate-report: defaults post to the rollout instant, returns UNGRADED for
  a mixed/unknown post window, and pins the baseline to the documented
  post-box-bot-fix window instead of a 7d lookback that dragged ~22k/mo of
  already-fixed box-bot volume into the org outlook (it read "MISSES by 18,612"
  for a dead reason; now 628/mo, clears).
- before_send == 0 is now reported as expected in a pre/mixed/young window and
  as a failure only after 24h+ of gated production.

Re-probed every server-side lever with a WRITE-scoped token so none of the
answers is a permissions artifact, and corrected the record in docs/analytics.md:
per-key rate limit returns 200 and silently drops the field; error-message
filters return 400 "You do not have that feature enabled" (a plan gate, not
absence - it is the one lever that would reach never-updating installs); spike
protection is not 403-unavailable, it is already enabled everywhere and simply
does not fire on sustained baseline volume.

Co-authored-by: engineer <engineer@macbookpro.lan>
2026-08-14 10:54:43 -07:00

200 lines
8.6 KiB
YAML

name: Publish to Google Play Store
on:
release:
types: [published]
push:
tags: ["v*"]
workflow_dispatch:
inputs:
track:
description: "Play track to release to"
required: false
default: "internal"
type: choice
options:
- internal
- alpha
- beta
- production
status:
description: "Release status (draft = uploads without going live/review; completed = submit)"
required: false
default: "completed"
type: choice
options:
- completed
- draft
# A tag push and a `release: published` for the same version must not race two
# uploads into the same track. Serialize per ref instead of cancelling, because
# cancelling mid-upload can leave a half-created Play release.
concurrency:
group: publish-play-store-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
env:
EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }}
EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.EXPO_PUBLIC_POSTHOG_KEY }}
EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER: ${{ secrets.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@v4
- name: Cache Gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
android/.gradle
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Cache Android build outputs
uses: actions/cache@v5
with:
path: |
android/app/build/intermediates
android/build
android/app/.cxx
key: ${{ runner.os }}-android-build-${{ hashFiles('package-lock.json', 'android/**/*.gradle*') }}
restore-keys: |
${{ runner.os }}-android-build-
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Bump android.versionCode in app.json
# Play Store rejects duplicate versionCodes, so derive a monotonic value
# from github.run_number + offset. expo prebuild reads this into build.gradle.
# Offset 100 skips past historical collisions (run 31 collided with a
# manual upload at versionCode 31). Next run = run_number + 100.
run: |
node -e "const f='app.json';const j=require('./'+f);j.expo.android=j.expo.android||{};j.expo.android.versionCode=${{ github.run_number }}+100;require('fs').writeFileSync(f,JSON.stringify(j,null,2)+'\n')"
echo "versionCode now: $(node -p "require('./app.json').expo.android.versionCode")"
- name: Set Sentry release identifiers
# sentry.gradle (applied from android/app/build.gradle) defaults the
# upload's --release/--dist to `${applicationId}@${versionName}+${versionCode}`,
# which does NOT match the release/dist Sentry.init() reports at runtime
# (`opencode-mobile@${app.json version}`, see src/lib/sentry.ts). That
# mismatch made every uploaded source map land under a release Sentry
# never looks up, so symbolication silently failed. Pin the Gradle-side
# values to exactly what the app reports. (Only expo.version matters
# here, not the android.versionCode bumped above.)
run: |
VERSION=$(node -p "require('./app.json').expo.version")
echo "SENTRY_RELEASE=opencode-mobile@${VERSION}" >> "$GITHUB_ENV"
echo "SENTRY_DIST=${VERSION}" >> "$GITHUB_ENV"
echo "Sentry release=opencode-mobile@${VERSION} dist=${VERSION}"
- name: Purge stale generated sources
# The Android build cache (restore-keys prefix fallback) can restore a
# generated autolinking tree from a previous package id. Gradle then
# reuses ReactNativeApplicationEntryPoint.java referencing the OLD
# package (ai.opencode.mobile.BuildConfig) and compileReleaseJavaWithJavac
# fails. Delete generated sources so prebuild + Gradle regenerate them
# for the current package (cc.agentlabs.opencode).
run: rm -rf android/app/build/generated android/build/generated android/app/build/intermediates
- name: Expo prebuild
run: npx expo prebuild --platform android --no-install
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore
- name: Build AAB
working-directory: android
env:
RELEASE_STORE_FILE: release.keystore
RELEASE_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleRelease
- name: Verify the Sentry noise gate is in the artifact
# AGE-105: the org error quota is defended ONLY by the client-side gate.
# Every server-side lever on this Sentry plan was probed and is dead
# (docs/analytics.md): per-key rate limit answers 200 and silently drops
# the field, custom error-message filters answer 400 "You do not have
# that feature enabled", and spike protection is already on everywhere
# but only catches spikes, not this steady baseline. If a build ships
# without the gate — or without a DSN, which makes Sentry a silent
# no-op — the org goes back over quota, and while it is over quota
# Sentry stores nothing, so the regression is invisible in Sentry itself
# until the monthly reset.
# Grep the shipped Hermes bundle instead. Verified to discriminate:
# v0.4.14 passes, pre-gate v0.4.13 fails.
run: node scripts/verify-release-bundle.mjs android/app/build/outputs/bundle/release/app-release.aab
- name: Upload AAB artifact
uses: actions/upload-artifact@v7
with:
name: app-release-bundle
path: android/app/build/outputs/bundle/release/app-release.aab
- name: Resolve Play track
id: channel
# AGE-110: releases used to land on `internal` and stop there — production
# only moved when a human remembered to run workflow_dispatch. It served
# versionCode 136 (v0.4.5, 2026-06-22) for EIGHT weeks for that reason,
# which is why a client-side fix shipped in v0.4.14 could not reach the
# install base. A release tag is already a deliberate act; treat it as one
# and publish it to the auto-updating channel. Manual dispatch keeps its
# inputs so `internal`/`draft` dry runs are still one click away.
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TRACK="${{ github.event.inputs.track || 'internal' }}"
STATUS="${{ github.event.inputs.status || 'completed' }}"
else
TRACK=production
STATUS=completed
fi
echo "track=$TRACK" >> "$GITHUB_OUTPUT"
echo "status=$STATUS" >> "$GITHUB_OUTPUT"
echo "event=${{ github.event_name }} -> track=$TRACK status=$STATUS"
- name: Publish to Play Store
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
packageName: cc.agentlabs.opencode
releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
track: ${{ steps.channel.outputs.track }}
status: ${{ steps.channel.outputs.status }}
whatsNewDirectory: distribution/whatsnew
- name: Record where it landed
if: always()
run: |
{
echo "### Play publish"
echo ""
echo "- event: \`${{ github.event_name }}\`"
echo "- track: \`${{ steps.channel.outputs.track }}\`"
echo "- status: \`${{ steps.channel.outputs.status }}\`"
echo "- versionCode: \`$(node -p "require('./app.json').expo.android.versionCode" 2>/dev/null || echo unknown)\`"
echo "- version: \`$(node -p "require('./app.json').expo.version" 2>/dev/null || echo unknown)\`"
} >> "$GITHUB_STEP_SUMMARY"