From dde4553016e284d029357e90959554344be27cfe Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:36:16 -0700 Subject: [PATCH] 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 Co-authored-by: Paperclip --- .github/workflows/publish-play-store.yml | 46 ++++++++++++++++++++++-- PUBLISHING.md | 14 ++++---- docs/playstore.md | 28 +++++++++------ 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/.github/workflows/publish-play-store.yml b/.github/workflows/publish-play-store.yml index d9ca9eb..3efd794 100644 --- a/.github/workflows/publish-play-store.yml +++ b/.github/workflows/publish-play-store.yml @@ -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" diff --git a/PUBLISHING.md b/PUBLISHING.md index b18fcd8..89c059b 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -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 -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 -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) diff --git a/docs/playstore.md b/docs/playstore.md index a8092bc..a2e8833 100644 --- a/docs/playstore.md +++ b/docs/playstore.md @@ -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)