CI/CD, Signing & Release Explained

BUILD & TOOLING › Delivery

Shipping an Android app safely comes down to one core fact: **Google never signs the APK you actually build**, at least not for long. Every release you publish is protected by two different keys under Play App Signing:

- The **upload key**, which you hold and use to sign the AAB you send to Play - The **app signing key**, which only Google holds, and which actually signs the APKs a user downloads

When you upload, Play verifies your upload signature, strips it, and re-signs the split APKs with the app signing key before delivery. That split exists so the key that faces the outside world, sitting on a laptop or a CI runner, is disposable, while the key users actually trust never has to leave Google's infrastructure.

The two keys fail very differently, and that asymmetry is exactly what interviewers probe. Lose the **upload key** (say your laptop with the keystore is stolen) and you're fine: open Play Console under Setup > App signing and request an **upload key reset**, register a new certificate, and keep shipping. The app signing key never moved, so existing users update with zero disruption.

Lose the **app signing key** itself, though, and there is no reset. If you self-manage signing (opted out of Play App Signing) and that key is gone, you cannot publish another update to that app, full stop, you'd have to launch a new app under a new package name and every existing install is stranded. This is exactly why Play App Signing, where Google is the one holding the signing key, is mandatory for new apps today.

New apps on Play must ship an **Android App Bundle (AAB)**, not a monolithic APK. An AAB doesn't contain a ready-to-install APK at all, it contains all your app's code and resources plus enough metadata for Google to build a slimmed-down, device-specific APK on the fly, split by ABI, screen density, and language, and to serve dynamic feature modules a la carte.

That's also *why* Play App Signing is enforced for AAB uploads: since Google is now the one generating and signing the final APKs a user gets, it has to be the one holding the app signing key. The Gradle task for the two artifact types differs:

./gradlew bundleRelease   # produces app-release.aab (required)
./gradlew assembleRelease # produces a monolithic APK (legacy path)

APK signing has gone through four schemes, and each one fixed a real gap in the last. **v1** (JAR signing) only covers individual files inside the archive, so a v1-only APK is vulnerable to attacks that add or swap unsigned entries around the signed ones. **v2** (Android 7+) signs the whole APK as one block instead, closing that gap and speeding up verification. **v3** (Android 9+) adds something neither predecessor had: a **proof-of-rotation key lineage**, a signed chain of certificates saying 'this new key is authorized to replace that old key,' so an app can change its signing key over time while devices that still trust the old key accept updates signed by the new one. **v4** (Android 11+) layers incremental, streaming installs on top of v3's signature, it doesn't add rotation of its own. Play App Signing leans on v3's lineage internally whenever it needs to change the key it re-signs your APKs with.

Once you have a signed AAB, you don't push straight to every user. Play Console organizes exposure into **tracks**, in increasing order of exposure: **internal testing** (up to 100 testers, processes almost instantly), **closed testing** (an invited alpha list), **open testing** (a public beta anyone can opt into), then **production**. You promote the same build up through tracks rather than rebuilding it each time.

Even in production, you rarely go to 100% immediately. A **staged (phased) rollout** ships to a percentage of users first, say 5%, then 20%, then 50%, while you watch crash rate and ANRs in Play Console's vitals. If something regresses, you halt the rollout before it reaches everyone else.

Halting a rollout only stops **further** distribution, it does not touch users who already updated. And Play never lets you push a lower versionCode to downgrade someone, once a device has version 43 installed, only a strictly higher versionCode will replace it, there is no server-side revert.

So the real playbook after a bad staged rollout is: halt it, fix the bug, and ship a new release with a higher versionCode, the affected users get the fix the same way everyone else gets updates.

android {
    defaultConfig {
        versionCode = 44   // was 43 for the broken release
        versionName = "2.1.1"
    }
}

Your CI runner needs the release keystore and its passwords to sign a build, but a .jks file and its passwords must never be committed to source control or printed in logs. The standard pattern: **base64-encode the keystore** and store both it and the passwords as encrypted secrets in your CI provider (GitHub Actions secrets, Bitrise secret env vars), then decode the keystore back to a file only inside the build job, right before it's needed.

- name: Decode keystore
  run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > release.jks

- name: Build release AAB
  run: ./gradlew bundleRelease
  env:
    KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
    KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
    STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}

The secret never appears in the repo, and GitHub automatically masks any secret value that shows up in log output.

CI minutes add up fast, and most of a clean Android build's time goes to downloading dependencies and recompiling modules that didn't even change. **Caching** fixes both: point your CI provider at Gradle's dependency and build-output directories, keyed by a hash of your Gradle files, so the next run restores them instead of starting from zero.

- name: Cache Gradle dependencies
  uses: actions/cache@v4
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
    restore-keys: ${{ runner.os }}-gradle-

Change a build.gradle.kts file and the key changes, so the cache misses and rebuilds cleanly; leave dependencies untouched and every later run skips the network entirely. It's one of the single easiest levers for cutting a slow pipeline down.

**Fastlane** automates the whole release pipeline in a Fastfile, where a **lane** is a named sequence of actions you run with one command, identical on your laptop or in CI:

lane :beta do
  gradle(task: "bundle", build_type: "Release")
  supply(track: "beta", json_key: "service-account.json")
end

The supply action is what actually uploads the AAB, along with metadata, screenshots, and listing text, to the Play Console via the Play Developer API. It authenticates with a **Google Cloud service-account JSON key**, not with your signing keystore and not with your personal Google login, that key has to be granted access in Play Console and enabled for the Play Android Developer API before supply can use it.

Put it all together and a real pipeline looks like: build the AAB with Gradle, sign it using secrets injected only at build time, then hand it to supply with a service-account key to land on a Play track, often gated behind a staged rollout in production.

One more question interviewers like to ask: how much access should that CI service account actually have? The answer is **least privilege**, grant it release permissions scoped to only the specific app (and tracks) it publishes to, nothing more. If that JSON key ever leaks from a compromised runner, the blast radius should be "can push a release to this one app," not "can manage billing or every app on the account."

You don't have to upload to Play just to see what a real device gets. bundletool turns an AAB into the same device-specific APK set Play would generate: build-apks produces a signed .apks file, and install-apks pushes only the splits that match a connected device.

bundletool build-apks --bundle=app-release.aab --output=app.apks \
  --ks=release.jks --ks-key-alias=mykey --ks-pass=pass:secret
bundletool install-apks --apks=app.apks

The same AAB also carries your release's other essential artifact: R8's mapping.txt. When you upload the AAB, Play Console automatically pulls the mapping file out of it and uses it to deobfuscate crash and ANR stack traces in vitals. Upload a standalone APK instead and there's no AAB for Play to extract it from, you have to attach mapping.txt yourself in the App bundle explorer, or crash reports stay meaningless obfuscated class and method names.

Back to CI/CD, Signing & Release