Release Automation: Tag → Signed Build → Store, No Humans
A push of a tag should produce a signed AAB, an uploaded mapping file and a store listing on the internal track. The pipeline steps, the two rules that make it trustworthy, and the real test: shipping a hotfix at 11pm without the person who knows the signing setup.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Pushing a tag should be the entire release ritual. CI verifies the tag, derives the
versionCode, builds and signs the AAB, runs tests in release config, uploads the mapping file,
publishes to the internal track with notes from the repo, and cuts a GitHub release. Two rules make it
trustworthy: the store build never comes from a laptop, and the pipeline runs on every merge, not
every release. Keep two things human — cutting the tag, and the halt criteria. The real test is
whether you can ship a hotfix at 11pm without the one person who knows the signing setup.
The shape of it
git tag v1.42.0
git push origin v1.42.0
Everything downstream is deterministic. The value isn't the saved hours — it's that the release stops being an event. Events get scheduled, deferred, and done by the person who did it last time.
What the pipeline actually does
1. Verify the tag. Cheap, and it prevents the worst class of mistake:
- name: Verify tag is on the release branch
run: |
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
test -z "$(git status --porcelain)"
A tag pointing at a commit that never merged is how you ship code nobody reviewed. Ten seconds of check for a whole category of incident.
2. Derive the version. Never type it.
// build.gradle.kts
val tag = System.getenv("GITHUB_REF_NAME") ?: "v0.0.0-local"
val (maj, min, patch) = tag.removePrefix("v").split("-")[0].split(".").map(String::toInt)
android.defaultConfig {
versionName = tag.removePrefix("v")
versionCode = maj * 1_000_000 + min * 1_000 + patch
}
Hand-maintained versionCode in build.gradle is a merge conflict on every release branch and a typo
away from a rejected upload. Derive it from the tag or from a monotonic CI run number — the only hard
requirement is that it never goes backwards, because Play will refuse the upload and it'll be 6pm.
3. Sign it, without the key touching a repo or a laptop. Use Play App Signing so the key CI holds is only the upload key — a compromised upload key can be reset by Google; a compromised app signing key ends the app.
- run: echo "${{ secrets.UPLOAD_KEYSTORE_BASE64 }}" | base64 -d > upload.jks
- run: ./gradlew bundleRelease
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
Base64 in a secret store is the floor, not the ceiling — a KMS or your CI provider's secure file storage
is better. What matters most is that the decoded keystore never persists on a runner you don't control
and never appears in a log. Mask the values and don't set -x that step.
4. Test in release config. This is the step most pipelines skip, and it's the one that catches real bugs:
./gradlew testReleaseUnitTest connectedReleaseAndroidTest
Debug-config green tells you nothing about what R8 just stripped — reflection-based serialization,
a keep rule someone deleted, a Class.forName in a third-party SDK. R8 breaks things in
production-only ways, and a release-config test run is how you find
out before the store does.
5. Upload the mapping file. Non-negotiable, and the single step humans forget most:
app/build/outputs/mapping/release/mapping.txt
Play needs it for its own crash reports, Crashlytics needs it for yours. Forgetting it isn't noticed on release day — it's noticed three weeks later when a P1 stack trace is unreadable and the build that produced it is long gone.
6. Publish, with notes that live in the repo. Gradle Play Publisher or fastlane's supply:
./gradlew publishReleaseBundle --track internal
Release notes belong in distribution/whatsnew/whatsnew-en-US — text that reaches every user, reviewed
in a PR like any other. Notes written into the Play Console at upload time are written by whoever is
uploading, at speed, alone.
7. Cut the GitHub release and announce it. Changelog from the merged PR titles since the last tag, the AAB and mapping attached as artifacts, a Slack message with the version and the track. Free, and it makes "what's actually in 1.42.0?" a link instead of a conversation.
The two rules that make it trustworthy
The store build never comes from a laptop. Not once, not for an urgent hotfix, not "just this time". A locally built AAB is unreproducible by definition — you cannot later prove what was in it. And practically, the exception is how the keystore ends up permanently on one person's machine and the pipeline quietly stops being the real path.
Run the pipeline on every merge, not every release. Every merge to main builds, signs and uploads
to internal. The tag only changes which track gets promoted. A release pipeline that runs once a
fortnight breaks on the fortnight you need it — usually via an expired token, a runner image change, or
a Play API deprecation nobody read the email about. Exercising it continuously turns those into a
Tuesday annoyance.
What stays human
Cutting the tag. That's the decision to ship, and it's a judgement about readiness, not a build step. Automating it means shipping whatever happened to merge.
The halt criteria. Written down before the rollout starts, not negotiated at 2am while the crash rate climbs — staged rollouts need their thresholds set in advance.
The service account's permissions. Give CI "release to testing tracks" and let a human promote to production, at least until the halt criteria are automated. Full auto-publish is a fine end state; it's a bad starting point.
The real test
Not the happy path. This:
It's 11pm. A crash is affecting 4% of sessions. The person who set up signing is on a plane. Can someone else ship a fix within the hour?
That question exposes everything the happy path hides — whether the secrets are shared or personal, whether the runbook exists, whether a hotfix branch can even build, whether the version scheme allows a patch release off a tag. If the honest answer is "we'd wait for Sara", you don't have release automation. You have a script and a single point of failure who occasionally sleeps.
Rehearse it. Once a quarter, someone who has never done a release does one, from the runbook, while the usual person deliberately says nothing. Everything that breaks in that hour would otherwise have broken during a real incident.
What it's worth
Concretely: release day goes from a three-hour ritual with a checklist to about twelve unattended minutes. But the number that actually changes behaviour is time-to-hotfix. When shipping costs three hours of a senior engineer, teams batch fixes and wait. When it costs one command, the fix goes out today — and that's a user-facing quality improvement disguised as build tooling.
The rule
If a release needs a specific person, it isn't automated — it's delegated. The pipeline exists so that shipping is a decision, not a skill.