ci(play): publish release tags straight to production, not internal (#177)

Play production served versionCode 136 (v0.4.5, 2026-06-22) for eight weeks
because a tag push only reached the `internal` track and production needed a
second, easily-forgotten workflow_dispatch. Sentry release health on 2026-08-14
shows the cost: 64% of 30d-active users pinned to v0.4.10 and 0.2% on the
gated v0.4.14, which caps the AGE-105 client-side noise gate at a small slice
of the error volume it was written to remove.

- non-dispatch runs (tag push / release published) resolve to
  track=production, status=completed
- workflow_dispatch keeps its track/status inputs (default internal) for dry runs
- serialize per-ref with a concurrency group so a tag push and a
  `release: published` for the same version cannot race two uploads
- job summary records event -> resolved track/status + the real versionCode
- PUBLISHING.md claimed the service account is "internal track only"; run
  31807432647 published to production successfully on 2026-08-14, so that
  claim is removed rather than worked around

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Den
2026-08-14 09:36:16 -07:00
committed by GitHub
parent 1852eb8466
commit dde4553016
3 changed files with 70 additions and 18 deletions

View File

@@ -26,6 +26,13 @@ on:
- 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
@@ -143,12 +150,47 @@ jobs:
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: ${{ github.event.inputs.track || 'internal' }}
status: ${{ github.event.inputs.status || 'completed' }}
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"

View File

@@ -40,22 +40,24 @@ The publish workflow runs on:
- GitHub Release publish events
- Tag pushes matching `v*`
It builds an AAB (Android App Bundle), signs it with the release keystore, and uploads to the **internal** track. Promote to production via Play Console.
It builds an AAB (Android App Bundle), signs it with the release keystore, and uploads to the **production** track (`status: completed`). Manual `workflow_dispatch` runs still honour the `track`/`status` inputs and default to `internal`.
## Releasing (proven runbook)
1. Bump `version` in `package.json` **and** `app.json` (`expo.version`). Do **not** bother hand-bumping `android.versionCode` for Play — the publish workflow overrides it with `github.run_number + 100` at build time (so the Play `versionCode` is e.g. `142`, unrelated to the number in `app.json`; that field only matters for local/other builds).
2. Update the **Play** release notes in `distribution/whatsnew/whatsnew-en-US` (single file, applied to the build being uploaded; **max 500 chars**). This — not the `fastlane/metadata/android/en-US/changelogs/*.txt` files — is what the Play publish uses (`whatsNewDirectory` in the workflow). The fastlane `changelogs/*.txt` files feed **F-Droid**, not Play; keep them for F-Droid but don't expect Play to read them. Merge to `main`.
3. Tag the release: `git tag -a vX.Y.Z <sha> -m "..." && git push origin vX.Y.Z`. This triggers the publish workflow → **internal** track.
4. Verify the publish run is green, then confirm the build on the internal track. Note its real Play `versionCode` (run_number+100) — that's what you promote, not the `app.json` number.
5. **Promote to production** (see below).
3. Tag the release: `git tag -a vX.Y.Z <sha> -m "..." && git push origin vX.Y.Z`. This triggers the publish workflow → **production** track, `status: completed`.
4. Verify the publish run is green, and read the run summary — it records the event, resolved track/status, and the real Play `versionCode` (run_number+100, not the `app.json` number).
5. Nothing else to do. There is no second promotion step.
## Promoting to production
Production is **not** published by CI by default — the service account is scoped to the internal track only, which is intentional (a human gate before a build reaches all users).
CI does this for you on every release tag (AGE-110). The service account **does** hold "Release to production" — verified by run [31807432647](https://github.com/dzianisv/opencode-mobile/actions/runs/31807432647) (`Validating tracks: 'production'` → committed edit 02632873494323676310, 2026-08-14 14:22 UTC), so the older "internal only" note here was stale.
Manual paths, still available when you need them:
- **Recommended — Play Console:** Production → Create release → **Add from library** → select the build by its **versionName** (e.g. `0.4.10`) and confirm its `versionCode` (the run_number-derived one, e.g. `142` — not the `app.json` number) → review → roll out. If the "What's new" field is empty, paste from `distribution/whatsnew/whatsnew-en-US`. No rebuild.
- **Fully automated (optional):** grant the CI service account **"Release to production"** for this app in Play Console → Users & permissions, then run the workflow's `workflow_dispatch` with `track=production`, `status=completed`. **Without that permission the production dispatch fails with `The caller does not have permission` after building** — so don't dispatch `track=production` until the service account has been granted production access.
- **Fully automated (optional):** `workflow_dispatch` with `track=production`, `status=completed` — same thing the tag push does, useful for re-shipping `main` without cutting a tag.
## Resubmitting after a Data Safety rejection (issue #143)

View File

@@ -84,21 +84,29 @@ For full company facts (D-U-N-S, address, governor, etc.) see `~/.agents/skills/
After 14 days on Closed testing with 12+ active testers → promote to Production.
### Promoting a tagged release to Production
### Tagging a release publishes to Production (since AGE-110)
Pushing a `v*` tag only publishes to the **internal** track — the workflow
defaults `track: internal` for non-dispatch runs. Production is a second,
manual dispatch off `main`:
Pushing a `v*` tag (or publishing a GitHub Release) now uploads straight to the
**production** track with `status: completed`. Only `workflow_dispatch` honours
the `track`/`status` inputs, which still default to `internal`/`completed` for
dry runs.
Why the default flipped: while tag pushes stopped at `internal`, production kept
serving versionCode **136 (v0.4.5, 2026-06-22) for eight weeks**, because the
second manual dispatch was easy to forget. Sentry release health then showed
~64–78% of active users pinned to a single stale build, which capped the AGE-105
noise gate at a fraction of the error volume it was written to remove. The
human gate was not protecting users; it was silently withholding fixes from them.
Ad-hoc dry run (unchanged):
```bash
gh workflow run publish-play-store.yml --ref main -f track=production -f status=completed
gh workflow run publish-play-store.yml --ref main -f track=internal -f status=draft
```
That dispatch **rebuilds** the AAB, so it gets its own versionCode
(`github.run_number + 100`) — it does not promote the internal artifact in
place. Expect the production versionCode to be one higher than the internal
one from the tag push, and never equal to `app.json`'s committed
`android.versionCode`.
Every run **rebuilds** the AAB, so it gets its own versionCode
(`github.run_number + 100`) — it never promotes an existing artifact in place,
and it is never equal to `app.json`'s committed `android.versionCode`.
### Release history (production track)