Releasing
How releases work
AutoBudget uses semantic-release on main (conventional commits) plus a
release-triggered artifact build:
flowchart LR
A[push to main] --> B[release.yml: semantic-release]
B -->|analyze commits| C[version bump + CHANGELOG.md]
C --> D[tag vX.Y.Z]
D --> V[version job: validate tag,<br>derive versionName/versionCode]
D --> N[notes job: OpenCode rewrites<br>release title/body + play_notes]
V --> E[build job: assembleRelease + bundleRelease]
E --> G[upload AutoBudget-X.Y.Z.apk + .aab to release]
E --> H{ENABLE_PLAY_UPLOAD?}
H -->|yes| I[reusable-play-upload.yml<br>mode: upload, internal track, completed]
N -. whatsnew text .-> I
J[manual workflow_dispatch, tag input] --> V
J --> N
K[manual Play Release dispatch<br>tag + track + status] --> L[reusable-play-upload.yml<br>mode: promote, existing versionCode]
L -. requires release asset .-> G
- Conventional commit types drive the bump:
feat:→ minor,fix:→ patch, breaking change /BREAKING CHANGE:→ major. See.releaserc.json. CHANGELOG.mdis generated by the release workflow — don't edit by hand. Release notes use theangularpreset; theconventionalcommitspreset package (v9+) breaks the bundledrelease-notes-generatorand yields empty changelog/release-note bodies.- Commits must follow conventional format or no release is cut.
- A deterministic version job validates the tag and computes
versionName/versionCode; the artifact build needs only that job. - On published releases, an AI Release Notes job rewrites the GitHub release title/body for end users and produces the Play "What's new" text — see AI release notes below. It is optional and has no influence on version metadata.
AI release notes
When a release is published, build-artifacts.yml runs an AI Release
Notes job in parallel with the deterministic version job. The artifact
build does not wait for it; only the optional Play upload waits, for the
whatsnew text:
- It checks out the new tag with full history and installs
opencode+jq+gh(via thesetup-toolsaction fromzharif/reusable-workflows). - The
run-opencode-taskaction sends the prompt in.github/prompts/release-notes.mdto OpenCode. The agent diffsvPREV..vNEWand must answer with one JSON block:{title, notes_markdown, play_notes}. - A plain bash step in the same read-only job validates the JSON and exposes
title,notes_markdownandplay_notesas job outputs; a separateapply-notesjob (the only one withcontents: write) runsgh release edit --title --notes-file.play_notesfeeds Play'swhatsnew-en-US. The agent job itself hascontents: read(issue #287; enough for checkout, no write token).
Properties worth knowing:
- Fallback is automatic: if OpenCode returns no usable JSON (model down,
bad output), the release keeps semantic-release's notes and Play falls back
to the version's
CHANGELOG.mdsection. The job runs withcontinue-on-error: true, so the artifact build never blocks on the LLM. - Never gates versioning: the
versionjob validates the tag and derivesversionName/versionCodewithout checking out the tag or calling any action; thebuildjob's only need isversion. An AI failure, timeout or empty result cannot produce an empty or stale version. - Play upload status policy: the upload job uses an explicit
!cancelled()condition withneeds.version.result == 'success'andneeds.build.result == 'success'. A failed or skipped notes job does not skip the upload (whatsnew falls back toCHANGELOG.md), while cancelling the workflow blocks it. - Model/keys reuse the existing
CI_AI_MODEL_NAMEvar andCI_AI_*secrets wired in.github/workflows/_opencode.yml— nothing new to set up. - Manual rebuilds run the LLM too: the notes job has no event gate —
only its final release edit step checks
event_name == 'release'. Aworkflow_dispatchre-run regeneratesplay_notes(and spends tokens) but leaves the GitHub release untouched. - Tweaking tone/structure = editing the prompt file via a normal PR; it is versioned at the tag being released.
Versioning
versionName / versionCode are computed from the tag by the workflow's
version job:
v1.2.3 → versionName=1.2.3, versionCode = 1*10000 + 2*100 + 3 = 10203
The tag must be exactly vMAJOR.MINOR.PATCH: no leading zeros (including in
MINOR/PATCH), no prerelease suffix, MAJOR at most 210000 (and at most six
digits), MINOR/PATCH 0–99, and the result in Play's 1..2100000000
versionCode range (v0.0.0 is rejected). String lengths are bounded before
any arithmetic so a huge component cannot wrap and collide with another
version. An invalid tag fails the version job with an ::error:: message
explaining the expected format before Gradle runs. The script is inline in the
workflow (not read from the checked-out tag), so manual rebuilds of older tags
keep working.
Set explicitly in the build: -PversionName=1.2.3 -PversionCode=10203.
Play requires versionCode to keep rising — it never goes backwards. A real
assembleRelease/bundleRelease now requires -PversionCode; only the
debug-signed releaseLocal variant keeps the local default (issue #346).
Signing: Play App Signing + upload key
Google holds the app signing key. CI signs with an upload key (rotatable without breaking installed app signatures).
One-time Play Console setup
- Create the upload keystore (local machine, never commit it):
keytool -genkey -v -keystore upload-keystore.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias upload -storepass 'CHANGE_ME' -keypass 'CHANGE_ME'
- In Play Console → app → Setup → App integrity → App signing:
- either let Google generate the app signing key, or upload your own;
- register the upload certificate from
upload-keystore.jks. - Grant the upload key the
uploadrole on the app.
Play upload auth: Workload Identity Federation
CI authenticates to Play without a service-account JSON key, via WIF
(Google Cloud google-github-actions/auth → ADC). The reusable Play publish
workflow exchanges its GitHub OIDC token for a GCP service account, then
r0adkll/upload-google-play reads the credentials file from
steps.auth.outputs.credentials_file_path (passed as the serviceAccountJson
input). Promote mode uses the same auth step's access token, scoped to
https://www.googleapis.com/auth/androidpublisher, to call the Play Developer
API directly.
- In Play Console → Users and permissions → invite
gha-runner@zharif.iam.gserviceaccount.comwith the app's Release (upload) role. This is the same GCP account WIF impersonates — no JSON key file ever leaves Google. - Minimum app permissions: View app information, Upload app bundles and APKs, Create and release draft releases, and Release to testing tracks. For the production track also grant Create and approve production releases. Manage Play App Signing is not needed (CI signs with the upload key; Play re-signs automatically).
- The WIF pool/provider + service account are already configured
org-wide; override per-repo with the
GCP_WORKLOAD_IDENTITY_PROVIDER/GCP_SERVICE_ACCOUNTvars if needed.
Required GitHub secrets (Actions → Secrets → Actions)
| Secret | Value |
|---|---|
RELEASE_KEYSTORE_BASE64 |
base64 -w0 upload-keystore.jks |
RELEASE_STORE_PASSWORD |
keystore password |
RELEASE_KEY_ALIAS |
key alias (upload) |
RELEASE_KEY_PASSWORD |
key password |
No Play credentials secret needed — the WIF auth step covers it.
Toggle
| Variable | Value | Effect |
|---|---|---|
ENABLE_PLAY_UPLOAD |
true |
build-artifacts.yml authenticates via WIF and uploads the AAB to Play internal track |
Off by default. The build job always signs with the upload key; when a
RELEASE_* secret is missing that build fails instead of emitting a
debug-signed artifact. assembleReleaseLocal remains the debug-signed variant
for local/E2E installs (issue #286). Before either upload path runs,
build-artifacts.yml asserts the APK certificate against the pinned upload-key
SHA-256 (scripts/check-upload-cert.sh) and checks the merged manifest and
backup rules (scripts/check-release-manifest.sh) — see issue #340/#353.
Rotating the upload key is a deliberate two-part change: register the new
certificate in Play Console, then update the EXPECTED_UPLOAD_CERT_SHA256
default in scripts/check-upload-cert.sh and this page in the same PR. The
current pin was read from the published v0.21.4 APK.
Note: the Play upload action requires the credentials passed explicitly via its
serviceAccountJsoninput. Merely exportingGOOGLE_APPLICATION_CREDENTIALS(as the WIF auth step does) is not enough — without the input the action fails immediately withUnknown error occurred.
Rebuild / re-upload an existing version
build-artifacts.yml accepts a manual workflow_dispatch with a required
tag input (e.g. v0.1.9). Use it to rebuild the APK/AAB for an existing tag
and re-upload to the GitHub release and/or Play (when ENABLE_PLAY_UPLOAD is
set) after a failed upload. The --clobber flag overwrites existing release
assets. The version job derives metadata from the tag input, so older tags
without this workflow change can still be rebuilt.
- Actions → Build Release Artifacts → Run workflow.
- Enter the existing tag in the
tagfield. - Trigger. Artifacts are rebuilt from that tag and (re)uploaded.
Closed testing and production promotion
The internal track stays automatic (toggle above). Testing tracks and
production are manual dispatches through reusable-play-upload.yml:
alpha— closed testingbeta— open testingproduction— public track
Promotion is not a re-upload. mode: promote derives the tag's
versionCode (MAJ*10000 + MIN*100 + PAT), verifies Play already lists that
versionCode on a track, then updates the target track through the Play
Developer API and commits the edit. Play rejects a bundle whose versionCode
already exists, so the internal upload must have shipped the build first.
- Actions → Google Play Release → Run workflow.
tag— an existingvX.Y.Zalready published on the internal track, whose GitHub release carriesAutoBudget-X.Y.Z.aab.track—alpha(closed testing),beta(open testing),production, or the exact name of a custom closed-testing track from Play Console.release-status—auto(default) resolves tocompletedfor testing tracks anddraftfor production. Override withdraft,inProgress(staged rollout; also setuser-fractionbetween 0 and 1 exclusive, e.g.0.1) orcompleted.-
Trigger. The workflow verifies the release asset, writes the Play "What's new" text (the tag's
CHANGELOG.mdsection), authenticates via WIF and promotes the versionCode. AI play notes exist only on the automatic internal run; a manual promotion falls back toCHANGELOG.md. -
The production job waits on the
play-productionenvironment. Add required reviewers under Settings → Environments; without reviewers a dispatch is only limited to users who can run workflows. play-closed-testingandplay-internalcan stay unprotected.- Play edits are serialized package-wide
(
play-edit-com.zharif.autobudget,queue: max), because Play allows one open edit per app/service account — not just one per version. - Inputs are validated before any Play call: a malformed tag or out-of-range versionCode, unknown track, invalid mode/status/fraction, a version not yet on any track, or a missing release asset fails with a named error. A rejected Play API call prints the response body; if the target track does not exist the error lists the tracks the app currently has, so a custom closed-testing track can be retried with its exact name.
- A
draftpromotion is completed (or rolled out) in Play Console or by a follow-up dispatch withcompleted/inProgress. - If Play still treats the app as a draft app (no published release yet),
it rejects non-draft releases. A
completedpromotion then retries as adraftrelease with a warning, so the first closed-testing promotion lands and can be completed in Play Console. - Rebuilding a tag (
Build Release Artifacts) replaces the release asset, but promotion does not upload that asset — it targets the Play bundle of the existing versionCode and only checks that the AAB is still attached. Treat rebuilds as recovery for a failed build, not as a way to change a tested artifact.
Manual release checklist
- Tests green:
./gradlew test lint ktlintCheck. - Smoke-test the debug APK on an emulator with the fixture script
(
android-app/scripts/post_fake_notifications.sh --broadcast). For the optional reproducible debug UI audit, follow Testing; its results supplement the signed release gates below. - Context audit:
bash scripts/docs-guard.shgreen; skim the CONTEXT.md glossary againstlsofcommon/,ui/components/,listener/for renames; rerun one or two evals if the context architecture changed this cycle. - Artifact gates on the release APK:
- Verify the upload certificate (issue #340):
bash scripts/check-upload-cert.sh android-app/app/build/outputs/apk/release/app-release.apk. The pinned SHA-256 is the Play App Signing upload certificate; a different or debug certificate fails before any upload. - Verify the manifest and backup rules (issue #353):
bash scripts/check-release-manifest.sh android-app/app/build/outputs/apk/release/app-release.apk. It rejects any debug-only receiver/action and proves cloud backup and device-to-device transfer exclude every data domain. - Verify 16 KB alignment:
zipalign -c -P 16 4 app-release.apk. - Verify the merged permission set (from the repo root):
bash scripts/check-release-permissions.sh android-app/app/build/outputs/apk/release/app-release.apk. The exact expected list lives in tooling.md; any addition fails the gate. - (Optional) verify Play signing locally:
cd android-app
export RELEASE_KEYSTORE_FILE="$(pwd)/upload-keystore.jks"
export RELEASE_STORE_PASSWORD='...' RELEASE_KEY_ALIAS='upload' RELEASE_KEY_PASSWORD='...'
./gradlew assembleRelease
- Set
ENABLE_PLAY_UPLOAD=truebefore releasing if you want the AAB on Play. - Merge conventional commits to
main; semantic-release tags the version. - Confirm
build-artifacts.ymluploadedAutoBudget-<version>.apkand.aabto the release, and (if enabled) the AAB landed in Play Console → internal testing with the version's changelog as release notes (fromCHANGELOG.md, truncated to 500 chars). If the Play upload failed, re-run the workflow manually with the tag input. - After the release gates below, dispatch Google Play Release for closed
testing (
track: alpha), then for production (track: production,autogives a draft release). See Closed testing and production promotion. - Complete or start the production rollout in Play Console when ready.
Production promotion gates (before internal → production)
- Pre-launch report — run it on the internal track; triage any crashes, ANR, or security findings with a written rationale before proceeding.
- 16 KB alignment and permissions —
zipalign -c -P 16 4 app-release.apk(also in the manual checklist) andbash scripts/check-release-permissions.sh android-app/app/build/outputs/apk/release/app-release.apkmust both pass; confirm the R8-shrunk release APK installs on an API 26 device. - MapLibre rendering on the release lane — the shipped runtime is
maplibre-compose-runtime-opengl-android, so use a device or emulator with working OpenGL ES (the API 26+ minimum profile; no Vulkan capability is required). Verify (a) the bundled overview renders, (b) a user-imported street-level pack renders streets at its maximum zoom, and (c) an out-of-coverage point shows the honest fallback instead of a blank or crash. Re-runzipalign -c -P 16 4 app-release.apkon the same artifact after the check. Manual gate — not run by docs/CI. - Capture + dedupe on the release build —
DebugFixtureReceiverand thepost_fake_notifications.shharness ship only in the debug source set (android-app/app/src/debug), so the fixture script cannot drive a release build. Prove capture and dedupe on the release build with real bank notifications (or a manual-entry transaction) and confirm the row appears once; use the fixture script on the debug build only (step 2) as a pre-check, not as release evidence. - Android vitals baseline — record crash/ANR baseline on the internal track (no crash-reporting SDK; rely on Play vitals + pre-launch report).
- Closed testing — keep the build on a closed track for ≥1 week with no
P0/P1 issues before promoting (dispatch
track: alpha). - Promote internal → production: dispatch Google Play Release with
track: production;autouploads a draft, then complete the rollout in Play Console or by re-dispatching withcompleted/inProgress.
Notes
- Workflows:
android-tests.yml(PR, plus merge queue when enabled; no duplicate main-push rerun, ADR-0081),docs-guard.yml(docs PRs),build-artifacts.yml(release),play-release.yml(manual Play promotion),reusable-play-upload.yml(Play upload path),release.yml(main), anddocs-screenshots.yml(currently disabled) — marketplace actions SHA-pinned (org reusable workflows tag-pinned), least-privilege permissions. Details in docs/architecture/tooling.md.android-tests.ymlkeeps the instrumented JUnit/report artifacts (instrumented-results) on every run, so a failing PR is triaged from the uploaded evidence instead of a re-run. Release artifacts are still built and checked from the tag; this policy only removes the duplicate post-merge test run. - The release workflow uses
zharif/reusable-workflows(see docs/agents/issue-tracker.md for repo conventions).