ADR-0080: Play track promotion via a reusable workflow
- Status: Accepted
- Date: 2026-10-26
- Deciders: maintainers
- Related: releasing.md, tooling.md, ADR-0018
Context
ADR-0018 stopped at an opt-in automatic upload to Play's internal track and kept every promotion as a manual Play Console click. That made the first two review stages (closed testing, production) impossible to reproduce, audit or script: the release notes, signing artifact and WIF credentials already existed in CI, but the track assignment and rollout state lived only in the console.
At the same time, an unconditional automatic production upload would contradict ADR-0018's "no human gate between tag and public users". Promotion also needs the same AAB that was built for the GitHub release — rebuilding it would allow the Play artifact and the published APK to drift, and re-signing would defeat the upload-key contract.
Decision
Publish every Play track through one reusable workflow,
.github/workflows/reusable-play-upload.yml (on: workflow_call), and keep the
human gate outside it:
- Single Play path — input validation (tag, track, mode, status,
staged-rollout fraction, versionCode bounds),
gh release downloadof the version's signedAutoBudget-<version>.aab, Play "What's new" preparation and WIF auth live only in the reusable workflow. It never runs Gradle, so the artifact published bymode: uploadis the one attached to the GitHub release. - Automatic internal track —
build-artifacts.ymlcalls the reusable withtrack: internal,mode: upload,release-status: completed, still gated byvars.ENABLE_PLAY_UPLOADand its existing notes/version/build success policy. Behavior is unchanged apart from the refactor. - Promotion is not a re-upload —
mode: promotenever callsbundles.upload. Play rejects an AAB whose versionCode already exists, so promotion computes the tag'sversionCode(MAJ*10000+MIN*100+PAT), creates a Play edit, verifies that versionCode is on an existing track, thentracks.updates the target track and commits the edit. The same path moves a productiondrafttocompleted/inProgress. - Manual promotion —
.github/workflows/play-release.ymlisworkflow_dispatch-only and offerstrack(alpha = closed testing, beta = open testing, production) plus arelease-statusofauto|draft|inProgress|completed.autoresolves tocompletedfor testing tracks anddraftfor production, so a dispatch cannot publish to all users unless the operator explicitly sayscompleted(or starts a stagedinProgressrollout with a fraction). - Environment gate — the called job sets
environment: ${{ inputs.environment }}(play-closed-testing,play-production,play-internal). Caller jobs cannot setenvironmenton a reusable-workflow call, and required reviewers onplay-productionare the human gate the ADR-0018 decision asked for. - Serialized Play edits — the reusable job locks on
play-edit-com.zharif.autobudget(package-wide,queue: max), because Play allows one open edit per app/service account. Per-version locks would let an upload and a promotion of different versions collide.
Consequences
- Closed testing and production are reproducible, auditable dispatches; the console is no longer the only record of the track/rollout decision.
- Promotion requires the version to already be published (internal upload first) and its release asset to exist; the run checks both and fails with a named error before the target track is updated (the temporary Play edit is discarded).
- Promotion targets the Play bundle of that versionCode; it checks that the GitHub release still lists the AAB but does not compare bytes, so a manual rebuild cannot change the bytes already published on Play.
- Production defaults to a draft release. Completing the rollout remains a
deliberate action, either a separate dispatch with
completed/inProgressor a Play Console click. - A Play app that has never had a published release is a draft app: Play
rejects any non-draft release there. When a
completedpromotion hits that exact error, the run retries as adraftrelease and says so; completing it stays a Play Console action. - The WIF service account needs Play app permissions for testing-track and
production releases (plus required reviewers on
play-production); see releasing.md. - A manual rebuild of an existing tag still replaces the release asset
(
gh release upload --clobber); promotion consumes whatever AAB is attached at dispatch time. Rebuilding is for recovering a failed build, not for changing an already-tested artifact. scripts/test-play-release-workflow.shruns the reusable workflow's validation script against the versionCode bounds and the tag/track/mode/ status/fraction matrix, and pins the manual-only trigger, the promotion path, pinned actions and the package-wide lock.
Alternatives considered
- Automatic closed testing + automatic production — rejected: production needs an explicit human decision and a rollout state, not just a tag.
- Inline upload steps per workflow — rejected: three copies of WIF + whatsnew + upload logic drift, and only one of them gets audited.
- Play Console-only promotion (status quo) — rejected: no audit trail, no staged-rollout control from CI, and release notes must be re-entered by hand.
- Rebuilding the AAB inside the promotion workflow — rejected: could publish an artifact that differs from the released one.