Android keystore in CI
Every Android team has the same artefact: an upload keystore that must reach the release build and must never reach the repository. The usual workarounds — base64 in a CI secret, a file in a private bucket, a Slack message from three years ago — all lose the same thing: who has it, and who last used it.
This is the walkthrough for storing it as a secret file instead.
What you need#
- A project in Envpilot, with the keystore's environment (
production) available - The CLI installed and linked:
envpilot init - An API key for CI with the GitHub Action surface and the
filesresource
Store the keystore#
Upload it#
envpilot files add ./upload.jks \
--path android/app/upload.jks \
--name "Play upload keystore" \
--env production \
--mode 0600The --path is where every client will write it — pick the path your Gradle config already expects.
Store the passwords as variables#
envpilot secrets set KEYSTORE_PASSWORD -e production
envpilot secrets set KEY_ALIAS -e production
envpilot secrets set KEY_PASSWORD -e productionMasked prompts, so nothing lands in your shell history.
Delete the local original#
Once it is uploaded and you have verified a pull works, the copy in your Downloads folder is a liability. envpilot files pull will bring it back.
Wire up Gradle#
Read the passwords from the environment rather than a checked-in gradle.properties:
android {
signingConfigs {
release {
storeFile file("upload.jks")
storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias System.getenv("KEY_ALIAS")
keyPassword System.getenv("KEY_PASSWORD")
}
}
}Local builds#
envpilot files pull -e production # writes android/app/upload.jks, mode 0600
envpilot run --env production -- ./gradlew bundleReleasefiles pull gitignores the path before writing it, and refuses to overwrite a local copy that differs from the server unless you pass --force.
The CI workflow#
name: Release
on:
push:
tags: ["v*"]
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rafay99-epic/envpilot-action@v1
with:
token: ${{ secrets.ENVPILOT_TOKEN }}
environment: production
project: mobile-app
files: true
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- run: ./gradlew bundleReleaseOne step brings both halves: the passwords are exported to $GITHUB_ENV and masked in the log, and the keystore is written to android/app/upload.jks with mode 0600.
project is required whenever files: true — the files endpoint is project-scoped.
What you gained#
| Before | After |
|---|---|
| Keystore in a base64 CI secret | Encrypted at rest, envelope-encrypted across two stores |
| No record of who downloaded it | Every fetch audited against a person or an API key |
| Rotating means editing every repo secret | Replace contents once; path and grants stay put |
| New hire needs someone to send it | envpilot files pull |
Rotating the keystore#
Use Replace contents in Project → Files: the bytes change, the path, environments and per-file grants stay. Every client picks up the new file on its next pull, and the old one is unrecoverable.
Limits#
- 8 MB per file on Pro, 256 KB on Free. A keystore is a few KB; a provisioning bundle might not be.
- Every content fetch is audited and rate-limited — a workflow that pulls on every push produces an entry per run.
- The Action overwrites the destination path without a conflict check. That is right for a runner, wrong for a laptop, which is why the CLI behaves differently.
- The
filesresource is never granted by default. A key that does not carry it is refused, not partially served.