Android keystore in CI

Stop emailing the upload keystore — store it once, pull it in the build, and keep the audit trail.

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 files resource

Store the keystore#

Upload it#

terminal
envpilot files add ./upload.jks \
  --path android/app/upload.jks \
  --name "Play upload keystore" \
  --env production \
  --mode 0600

The --path is where every client will write it — pick the path your Gradle config already expects.

Store the passwords as variables#

terminal
envpilot secrets set KEYSTORE_PASSWORD -e production
envpilot secrets set KEY_ALIAS -e production
envpilot secrets set KEY_PASSWORD -e production

Masked 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:

groovy
android {
  signingConfigs {
    release {
      storeFile file("upload.jks")
      storePassword System.getenv("KEYSTORE_PASSWORD")
      keyAlias System.getenv("KEY_ALIAS")
      keyPassword System.getenv("KEY_PASSWORD")
    }
  }
}

Local builds#

terminal
envpilot files pull -e production   # writes android/app/upload.jks, mode 0600
envpilot run --env production -- ./gradlew bundleRelease

files 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#

yaml
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 bundleRelease

One 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#

BeforeAfter
Keystore in a base64 CI secretEncrypted at rest, envelope-encrypted across two stores
No record of who downloaded itEvery fetch audited against a person or an API key
Rotating means editing every repo secretReplace contents once; path and grants stay put
New hire needs someone to send itenvpilot 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 files resource is never granted by default. A key that does not carry it is refused, not partially served.

See also#