Skip to content

ADR-0080: Play track promotion via a reusable workflow

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 download of the version's signed AutoBudget-<version>.aab, Play "What's new" preparation and WIF auth live only in the reusable workflow. It never runs Gradle, so the artifact published by mode: upload is the one attached to the GitHub release.
  • Automatic internal track — build-artifacts.yml calls the reusable with track: internal, mode: upload, release-status: completed, still gated by vars.ENABLE_PLAY_UPLOAD and its existing notes/version/build success policy. Behavior is unchanged apart from the refactor.
  • Promotion is not a re-upload — mode: promote never calls bundles.upload. Play rejects an AAB whose versionCode already exists, so promotion computes the tag's versionCode (MAJ*10000+MIN*100+PAT), creates a Play edit, verifies that versionCode is on an existing track, then tracks.updates the target track and commits the edit. The same path moves a production draft to completed/inProgress.
  • Manual promotion — .github/workflows/play-release.yml is workflow_dispatch-only and offers track (alpha = closed testing, beta = open testing, production) plus a release-status of auto|draft|inProgress|completed. auto resolves to completed for testing tracks and draft for production, so a dispatch cannot publish to all users unless the operator explicitly says completed (or starts a staged inProgress rollout with a fraction).
  • Environment gate — the called job sets environment: ${{ inputs.environment }} (play-closed-testing, play-production, play-internal). Caller jobs cannot set environment on a reusable-workflow call, and required reviewers on play-production are 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/inProgress or 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 completed promotion hits that exact error, the run retries as a draft release 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.sh runs 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.