A few years ago I nearly lost the keystore for one of my apps. The night before wiping an old Mac, I was looking over my backups and realized the .jks file wasn't in any of them. If I hadn't caught it that night, that app would have permanently lost any way to ship an update. Code can be regenerated. A signing key cannot.
Now that AI Studio and Antigravity connect generation to internal-test shipping in a single screen, most of the distribution pipeline can be handed to a machine. And it should be — those steps are reversible. But the signing key is different in kind. The key is the identity of the app itself; lose it and the app becomes something else. So in this article I'll write, as a design, how to handle the signing key inside an increasingly automated pipeline, and where a human keeps holding on.
First, split the key into two kinds
Google Play signing involves two keys of different natures. Load automation onto the pipeline while conflating them, and you'll place a key that must never be exposed within reach of the machine.
One is the app signing key. It signs the APK that finally reaches the user's device, and with Play App Signing, Google manages it. It is the root of the app's identity, and as a rule neither human nor machine touches it day to day. Not touching it is what keeps it safe.
The other is the upload key. It signs the AAB when you send it to the Play Console, and Google has it registered as your upload key. If CI sends builds automatically, the upload key is what signs them. The important point: the upload key can be re-registered if lost. Lose it, and you can swap in a new upload key through support. By contrast, in an old setup where you hold your own app signing key without Play App Signing, the moment you lose that key, updates are over.
That difference decides the line of automation directly.
| Key type | Consequence of loss | May automation touch it? |
|---|---|---|
| App signing key (Play-managed) | Held by Google; not touched | No (you don't even have it) |
| Upload key | Recoverable by re-registration | Yes, inject into CI (store it strictly) |
| Self-held app signing key (legacy) | Updates impossible forever | Never; consider moving to Play signing |
I myself moved every app I could to Play App Signing. Running automated distribution while holding a self-managed key felt like passing an irreversible risk through the pipeline every night. If you still have an old app you haven't moved, I'd settle that before strengthening automation.
Feed automated signing without leaving the key in plaintext
I said the upload key may be injected into CI. Even so, committing a .jks to the repo or hard-coding the password into a script is out of the question — all the more so when generative AI touches the code. Keep the key itself, and the password that opens it, outside the world of code.
Locally, put the key password in the Keychain or a secret store, and pass it to Gradle in a form that never shows the value directly.
// app/build.gradle.kts — don't bake key details into code; receive them from the env
import java.io.FileInputStream
import java.util.Properties
android {
signingConfigs {
create("release") {
// Receive the path from an env var; never put the key file in the repo.
val keystorePath = System.getenv("UPLOAD_KEYSTORE_PATH")
if (keystorePath != null) {
storeFile = file(keystorePath)
storePassword = System.getenv("UPLOAD_KEYSTORE_PASSWORD")
keyAlias = System.getenv("UPLOAD_KEY_ALIAS")
keyPassword = System.getenv("UPLOAD_KEY_PASSWORD")
}
}
}
buildTypes {
getByName("release") {
signingConfig = signingConfigs.getByName("release")
}
}
}In CI, store the key file as an encrypted secret in Base64, unpack it only into the job's temp area, and delete it reliably on exit. You confine the time the key sits on disk in plaintext to the few minutes of the build.
# GitHub Actions example — unpack the key temporarily, with cleanup as part of the gate
- name: Restore upload keystore
env:
KEYSTORE_B64: ${{ secrets.UPLOAD_KEYSTORE_B64 }}
run: |
echo "$KEYSTORE_B64" | base64 -d > "$RUNNER_TEMP/upload.jks"
echo "UPLOAD_KEYSTORE_PATH=$RUNNER_TEMP/upload.jks" >> "$GITHUB_ENV"
- name: Build & sign AAB
env:
UPLOAD_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }}
UPLOAD_KEY_ALIAS: ${{ secrets.UPLOAD_KEY_ALIAS }}
UPLOAD_KEY_PASSWORD: ${{ secrets.UPLOAD_KEY_PASSWORD }}
run: ./gradlew bundleRelease
- name: Shred keystore
if: always()
run: shred -u "$RUNNER_TEMP/upload.jks" 2>/dev/null || rm -f "$RUNNER_TEMP/upload.jks"The cleanup with if: always() is the crux — make sure the key file doesn't survive even when the build fails. When I first let an AI agent run all the way to automated signing, the pattern I fixed first was this "pair unpack with delete." Minimizing the time the key is exposed narrows the path by which it could slip into generated code or a temp log.
One caution: shred or rm only removes the file. Whether you've accidentally printed the key or password to the build log is a separate check. A setup that pipes the signing step's stdout straight into the log can, rarely, leak internal details. It's worth confirming your log masking settings once.