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:
46
.github/workflows/publish-play-store.yml
vendored
46
.github/workflows/publish-play-store.yml
vendored
@@ -26,6 +26,13 @@ on:
|
|||||||
- completed
|
- completed
|
||||||
- draft
|
- 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:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -143,12 +150,47 @@ jobs:
|
|||||||
name: app-release-bundle
|
name: app-release-bundle
|
||||||
path: android/app/build/outputs/bundle/release/app-release.aab
|
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
|
- name: Publish to Play Store
|
||||||
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
|
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
|
||||||
with:
|
with:
|
||||||
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
|
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
|
||||||
packageName: cc.agentlabs.opencode
|
packageName: cc.agentlabs.opencode
|
||||||
releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
|
releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
|
||||||
track: ${{ github.event.inputs.track || 'internal' }}
|
track: ${{ steps.channel.outputs.track }}
|
||||||
status: ${{ github.event.inputs.status || 'completed' }}
|
status: ${{ steps.channel.outputs.status }}
|
||||||
whatsNewDirectory: distribution/whatsnew
|
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"
|
||||||
|
|||||||
@@ -40,22 +40,24 @@ The publish workflow runs on:
|
|||||||
- GitHub Release publish events
|
- GitHub Release publish events
|
||||||
- Tag pushes matching `v*`
|
- 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)
|
## 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).
|
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`.
|
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.
|
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, 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.
|
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. **Promote to production** (see below).
|
5. Nothing else to do. There is no second promotion step.
|
||||||
|
|
||||||
## Promoting to production
|
## 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.
|
- **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)
|
## Resubmitting after a Data Safety rejection (issue #143)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
Pushing a `v*` tag (or publishing a GitHub Release) now uploads straight to the
|
||||||
defaults `track: internal` for non-dispatch runs. Production is a second,
|
**production** track with `status: completed`. Only `workflow_dispatch` honours
|
||||||
manual dispatch off `main`:
|
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
|
```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
|
Every run **rebuilds** the AAB, so it gets its own versionCode
|
||||||
(`github.run_number + 100`) — it does not promote the internal artifact in
|
(`github.run_number + 100`) — it never promotes an existing artifact in place,
|
||||||
place. Expect the production versionCode to be one higher than the internal
|
and it is never equal to `app.json`'s committed `android.versionCode`.
|
||||||
one from the tag push, and never equal to `app.json`'s committed
|
|
||||||
`android.versionCode`.
|
|
||||||
|
|
||||||
### Release history (production track)
|
### Release history (production track)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user