Skip to content

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.md is generated by the release workflow — don't edit by hand. Release notes use the angular preset; the conventionalcommits preset package (v9+) breaks the bundled release-notes-generator and 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:

  1. It checks out the new tag with full history and installs opencode + jq + gh (via the setup-tools action from zharif/reusable-workflows).
  2. The run-opencode-task action sends the prompt in .github/prompts/release-notes.md to OpenCode. The agent diffs vPREV..vNEW and must answer with one JSON block: {title, notes_markdown, play_notes}.
  3. A plain bash step in the same read-only job validates the JSON and exposes title, notes_markdown and play_notes as job outputs; a separate apply-notes job (the only one with contents: write) runs gh release edit --title --notes-file. play_notes feeds Play's whatsnew-en-US. The agent job itself has contents: 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.md section. The job runs with continue-on-error: true, so the artifact build never blocks on the LLM.
  • Never gates versioning: the version job validates the tag and derives versionName/versionCode without checking out the tag or calling any action; the build job's only need is version. 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 with needs.version.result == 'success' and needs.build.result == 'success'. A failed or skipped notes job does not skip the upload (whatsnew falls back to CHANGELOG.md), while cancelling the workflow blocks it.
  • Model/keys reuse the existing CI_AI_MODEL_NAME var and CI_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'. A workflow_dispatch re-run regenerates play_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

  1. 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'
  1. In Play Console → app → Setup → App integrity → App signing:
  2. either let Google generate the app signing key, or upload your own;
  3. register the upload certificate from upload-keystore.jks.
  4. Grant the upload key the upload role 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.

  1. In Play Console → Users and permissions → invite gha-runner@zharif.iam.gserviceaccount.com with the app's Release (upload) role. This is the same GCP account WIF impersonates — no JSON key file ever leaves Google.
  2. 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).
  3. The WIF pool/provider + service account are already configured org-wide; override per-repo with the GCP_WORKLOAD_IDENTITY_PROVIDER / GCP_SERVICE_ACCOUNT vars 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 serviceAccountJson input. Merely exporting GOOGLE_APPLICATION_CREDENTIALS (as the WIF auth step does) is not enough — without the input the action fails immediately with Unknown 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.

  1. Actions → Build Release Artifacts → Run workflow.
  2. Enter the existing tag in the tag field.
  3. 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 testing
  • beta — open testing
  • production — 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.

  1. Actions → Google Play Release → Run workflow.
  2. tag — an existing vX.Y.Z already published on the internal track, whose GitHub release carries AutoBudget-X.Y.Z.aab.
  3. track — alpha (closed testing), beta (open testing), production, or the exact name of a custom closed-testing track from Play Console.
  4. release-status — auto (default) resolves to completed for testing tracks and draft for production. Override with draft, inProgress (staged rollout; also set user-fraction between 0 and 1 exclusive, e.g. 0.1) or completed.
  5. Trigger. The workflow verifies the release asset, writes the Play "What's new" text (the tag's CHANGELOG.md section), authenticates via WIF and promotes the versionCode. AI play notes exist only on the automatic internal run; a manual promotion falls back to CHANGELOG.md.

  6. The production job waits on the play-production environment. Add required reviewers under Settings → Environments; without reviewers a dispatch is only limited to users who can run workflows.

  7. play-closed-testing and play-internal can stay unprotected.
  8. 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.
  9. 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.
  10. A draft promotion is completed (or rolled out) in Play Console or by a follow-up dispatch with completed/inProgress.
  11. If Play still treats the app as a draft app (no published release yet), it rejects non-draft releases. A completed promotion then retries as a draft release with a warning, so the first closed-testing promotion lands and can be completed in Play Console.
  12. 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

  1. Tests green: ./gradlew test lint ktlintCheck.
  2. 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.
  3. Context audit: bash scripts/docs-guard.sh green; skim the CONTEXT.md glossary against ls of common/, ui/components/, listener/ for renames; rerun one or two evals if the context architecture changed this cycle.
  4. Artifact gates on the release APK:
  5. 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.
  6. 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.
  7. Verify 16 KB alignment: zipalign -c -P 16 4 app-release.apk.
  8. 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.
  9. (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
  1. Set ENABLE_PLAY_UPLOAD=true before releasing if you want the AAB on Play.
  2. Merge conventional commits to main; semantic-release tags the version.
  3. Confirm build-artifacts.yml uploaded AutoBudget-<version>.apk and .aab to the release, and (if enabled) the AAB landed in Play Console → internal testing with the version's changelog as release notes (from CHANGELOG.md, truncated to 500 chars). If the Play upload failed, re-run the workflow manually with the tag input.
  4. After the release gates below, dispatch Google Play Release for closed testing (track: alpha), then for production (track: production, auto gives a draft release). See Closed testing and production promotion.
  5. Complete or start the production rollout in Play Console when ready.

Production promotion gates (before internal → production)

  1. Pre-launch report — run it on the internal track; triage any crashes, ANR, or security findings with a written rationale before proceeding.
  2. 16 KB alignment and permissions — zipalign -c -P 16 4 app-release.apk (also in the manual checklist) and bash scripts/check-release-permissions.sh android-app/app/build/outputs/apk/release/app-release.apk must both pass; confirm the R8-shrunk release APK installs on an API 26 device.
  3. 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-run zipalign -c -P 16 4 app-release.apk on the same artifact after the check. Manual gate — not run by docs/CI.
  4. Capture + dedupe on the release build — DebugFixtureReceiver and the post_fake_notifications.sh harness 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.
  5. Android vitals baseline — record crash/ANR baseline on the internal track (no crash-reporting SDK; rely on Play vitals + pre-launch report).
  6. Closed testing — keep the build on a closed track for ≥1 week with no P0/P1 issues before promoting (dispatch track: alpha).
  7. Promote internal → production: dispatch Google Play Release with track: production; auto uploads a draft, then complete the rollout in Play Console or by re-dispatching with completed/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), and docs-screenshots.yml (currently disabled) — marketplace actions SHA-pinned (org reusable workflows tag-pinned), least-privilege permissions. Details in docs/architecture/tooling.md. android-tests.yml keeps 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).