'Keystore file not set for signing config release' in CI But Not Locally
The release build works on your laptop. You run ./gradlew assembleRelease, it signs, it produces an APK, you have shipped it a hundred times. You push the same project to CI, the pipeline runs the same command, and it dies with this:
Keystore file not set for signing config release
The error names no variable, no path, no file. It just says something about the keystore is missing, and it is missing in a place you cannot easily poke at. Worse, when you try to reproduce it by running assembleDebug in CI, which has nothing to do with release signing, that fails too, with the same release error. That last part is the clue everyone misses, and it is the key to fixing this properly instead of hard-coding a path.
The build never worked because it was correct. It worked because your shell happened to have the right variable exported, and CI's shell does not. And it fails on debug builds because of when Gradle reads that variable, not whether debug needs it.
Your signingConfigs reads the keystore path from System.getenv(...), which returns null in CI because the variable was never provisioned there. Two fixes, do both:
- Provision the keystore and its credentials in CI as secrets. The cleanest wiring is Gradle's
ORG_GRADLE_PROJECT_env-var prefix so they arrive as project properties. - Make the release
signingConfigresolve lazily, so a missing release variable does not blow upassembleDebugor any other task at configuration time.
Never hard-code an absolute local keystore path in build.gradle to make the error disappear.
The error, and why debug builds hit it too
> Task :app:validateSigningRelease FAILED
Keystore file not set for signing config release
People expect this only on release tasks. The surprise is that ./gradlew assembleDebug triggers it as well, even though a debug build uses the debug keystore that Android generates for you. If debug does not need the release keystore, why does a missing release keystore break debug?
Why: Gradle evaluates signingConfigs at configuration time
A Gradle build has two phases. First, configuration: Gradle reads and executes the body of every build.gradle to build the task graph, for the whole project, regardless of which task you asked for. Then, execution: it runs the specific tasks needed for your request.
When your signingConfigs block calls System.getenv("KEYSTORE_FILE") and passes the result to storeFile, that call happens during configuration, not when a release task runs. So even ./gradlew assembleDebug, which never executes a release signing task, still configures the release signing config, which means it still evaluates System.getenv("KEYSTORE_FILE"). On your machine the variable is exported and it resolves. In CI it is null, storeFile is never set, and the moment anything validates the config the build throws "Keystore file not set for signing config release".
This is why hard-coding a path makes the symptom vanish but hides the real defect. The eager evaluation is the actual mechanism, and once you understand it, the fix is to defer the resolution until a task that genuinely needs release signing asks for it.
The fix
Step 1: stop hard-coding, read from properties with a guard
Load signing values from a keystore.properties file when it exists, and fall back to environment variables, and crucially do not throw when neither is present unless a release task actually runs. Here is a Groovy DSL version:
// app/build.gradle
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
def storePath = keystoreProperties['storeFile'] ?: System.getenv('KEYSTORE_FILE')
if (storePath) {
storeFile file(storePath)
storePassword keystoreProperties['storePassword'] ?: System.getenv('KEYSTORE_PASSWORD')
keyAlias keystoreProperties['keyAlias'] ?: System.getenv('KEY_ALIAS')
keyPassword keystoreProperties['keyPassword'] ?: System.getenv('KEY_PASSWORD')
}
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
The if (storePath) guard is the important line. When the variable is absent, storeFile is simply never set, and configuration completes without throwing. A debug build now configures cleanly. A release build with no keystore still fails, but it fails at the release task with a clear message, which is correct.
Step 2: provision the secrets in CI
Store the keystore itself and its credentials as CI secrets. The base64 approach avoids committing a binary. In GitHub Actions, for example:
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/release.jks"
- name: Build release
env:
KEYSTORE_FILE: ${{ runner.temp }}/release.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew assembleRelease
Step 3 (cleaner wiring): use the ORG_GRADLE_PROJECT_ prefix
Gradle reads any environment variable named ORG_GRADLE_PROJECT_foo as the project property foo. This lets CI inject secrets straight into project properties with no file plumbing:
def storePath = findProperty('KEYSTORE_FILE') ?: System.getenv('KEYSTORE_FILE')
env:
ORG_GRADLE_PROJECT_KEYSTORE_FILE: ${{ runner.temp }}/release.jks
ORG_GRADLE_PROJECT_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
findProperty returns null instead of throwing when the property is absent, which keeps configuration lazy by default.
Step 4: commit a template, gitignore the real file
# keystore.properties.template (committed)
storeFile=
storePassword=
keyAlias=
keyPassword=
# .gitignore
keystore.properties
*.jks
*.keystore
The template documents exactly which keys CI must populate, without a single secret entering the repository.
How to verify
- Prove the debug build no longer depends on release signing, by unsetting the variables and building debug:
This should now succeed. Before the guard, it failed with the release keystore error.env -u KEYSTORE_FILE ./gradlew assembleDebug - Confirm the release APK is actually signed with your key, not left unsigned:
It should print your certificate's SHA-256 and subject, not an error about the APK being unsigned.$ANDROID_HOME/build-tools/34.0.0/apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk
What people get wrong
Hard-coding an absolute keystore path. Writing storeFile file("/Users/you/keys/release.jks") makes CI stop complaining only if CI happens to have that exact path, which it does not. It also leaks your local filesystem layout into the repo and breaks for every other developer. Reference the keystore through a variable or CI secret that is explicitly documented and provisioned.
Blaming CI when the real fault is eager evaluation. The temptation is to decide CI is misconfigured and pile on more environment variables. But the same defect can bite a teammate on a fresh checkout who has not exported the variable. Making the release config lazy fixes the class of problem, not just the CI instance of it.
Assuming System.getenv() behaves the same on every runner. Environment-variable-based signing works on Linux and macOS but is a frequent source of breakage on Windows CI runners, where shell profiles and variable exporting differ. If your pipeline runs on Windows agents, do not assume parity, and prefer the keystore.properties file or the ORG_GRADLE_PROJECT_ prefix, which behave consistently across operating systems. The same cross-environment discipline applies to store submissions, covered in the guideline 2.3.1 rejection guide.
When it is still broken
- Now it says the keystore was tampered with or the password is wrong. Your base64 decode added a trailing newline or the secret was pasted with wrapping. Re-encode with
base64 -w0and confirm the decoded file's size matches the original.jks. - Release builds unsigned instead of failing. The guard skipped configuring the signing config, but you also never set
signingConfig signingConfigs.releaseon the release build type, so it built unsigned. Make sure the release build type references the config. - Works in CI, fails in Play Console upload. You may be signing with an upload key that does not match what Play App Signing expects. That is a key-registration problem, not a Gradle one; check the upload certificate fingerprint against the one registered in the Play Console.
- Fails only on a clean machine. That is the bug working as designed now: a fresh checkout with no secrets should fail a release build. Provide the secrets or run a debug build.
The build was never really working; it was borrowing a variable from your shell. Provision the secret where the build actually runs, and make the release config lazy so nothing that does not need it ever trips over it.
Frequently asked questions
- Why does my Android build sign fine locally but fail in CI with a keystore error?
- Your build.gradle reads the keystore path from an environment variable via System.getenv(). That variable is exported in your local shell profile but was never provisioned in CI, so it returns null and Gradle reports the keystore file is not set. The build works for you only because your machine happens to have the variable.
- Why does assembleDebug fail with a release signing error when debug does not even need release signing?
- Gradle evaluates signingConfigs at configuration time, before any task runs, not when the release task executes. So a missing release keystore variable throws during configuration even for tasks like assembleDebug that would never use the release config. Making the release config resolve its values lazily fixes this.
- What is the ORG_GRADLE_PROJECT_ prefix for?
- Any environment variable named ORG_GRADLE_PROJECT_foo is picked up by Gradle as the project property foo, readable with project.property('foo') or the findProperty helper. It lets CI inject secrets as project properties without extra plumbing, and keeps the values out of your committed gradle.properties.
- Should I commit keystore.properties to version control?
- No. keystore.properties contains the store path, passwords, and alias, so it must be gitignored. Commit a keystore.properties.template with the keys and empty values instead, so anyone setting up CI knows exactly which values to provide, without the secrets ever entering the repository.