ADR-0018: Play Store distribution with Play App Signing
- Status: Accepted
- Date: 2026-08-12
- Deciders: maintainers
- Related: releasing.md, tooling.md
Context
Releases previously stopped at the GitHub release page: build-apk.yml
built assembleRelease and uploaded a debug-signed APK as a release asset.
There was no Play Store distribution, no real signing key, and only an APK
was produced (Google Play now wants AABs for new apps).
Decision
Ship to Google Play via a release-triggered pipeline that keeps secrets out of the repo:
- Play App Signing: Google holds the app signing key; CI signs with a dedicated upload key stored only as GitHub secrets. The upload key can be rotated without breaking installed app signatures.
- AAB for Play, APK for GitHub:
bundleReleasefeeds Play;assembleReleasekeeps the direct-download APK on the GitHub release. - Toggleable upload: the AAB upload step runs only when
vars.ENABLE_PLAY_UPLOAD == 'true', targeting theinternaltrack. Promotion to production stays a manual Play Console step. - Graceful fallback: when the
RELEASE_*env vars/secrets are absent (local/E2E),app/build.gradle.ktsfalls back to debug signing, so the existing local release workflow is unchanged. r0adkll/upload-google-playaction handles the Play API call. Auth is Workload Identity Federation:google-github-actions/authexchanges the GitHub OIDC token for the GCP service account and writes ADC credentials (GOOGLE_APPLICATION_CREDENTIALS), which the upload action consumes. No service-account JSON secret — the service account is invited directly in Play Console.
Consequences
- Release artifacts: signed APK on GitHub + (opt-in) signed AAB on Play internal track.
- Four new GitHub secrets (keystore only) and one toggle variable — see releasing.md for exact setup.
- Play
versionCodemust keep rising; the tag-scheme (MAJ*10000 + MIN*100 + PAT) guarantees monotonicity while SemVer bumps. - Debug-key fallback means a misconfigured CI still produces a working APK rather than failing silently with the wrong signature.
- APK + AAB are built in the same workflow so the two can't drift.
Alternatives considered
- Gradle Play Publisher plugin — rejected: more build wiring, version churn, keeps credentials in Gradle state rather than a dedicated action step.
- Uploading directly to
production— rejected: no human gate between tag and public users. - Storing the keystore in-repo (encrypted) — rejected: encrypted-at-rest files add key-management burden without benefit over GitHub secrets.
Amendment (2026-10-19): release version metadata is independent of AI notes
A dedicated version job validates the tag and computes
versionName/versionCode (MAJOR*10000 + MINOR*100 + PATCH, component
strings bounded before any arithmetic so a huge tag cannot wrap into a colliding
code, MAJOR ≤ 210000, code in 1..2100000000). The build job depends only
on that job, so an optional AI-notes failure, timeout, skip or empty result can
never skip or blank release versions; the Play upload may depend on notes for
whatsnew text only and uses an explicit !cancelled() condition requiring
version+build success. scripts/test-build-artifacts-version.sh pins the graph
and the calculation.
Amendment (2026-10-26): track promotion is a workflow dispatch
Promotion beyond the internal track no longer requires hand-editing the Play
Console. play-release.yml dispatches the shared
reusable-play-upload.yml in promote mode to alpha (closed testing),
beta (open testing) or production, behind the play-closed-testing /
play-production environments. Promotion points the track at the versionCode
the internal upload already published — it never re-uploads the bundle. The
internal upload stays automatic; production still defaults to a draft
release, so publishing to all users remains a deliberate human action. See
ADR-0080.