Release keystore manual
Step-by-step procedure for creating, storing, wiring, testing, rotating and (if needed) recovering the Android DJ Tools release signing key. For how builds use the key, see Release signing.
Commands marked you run this publish to GitHub or handle secrets. They are meant to be typed by the maintainer, never pasted into a chat, issue, PR or log.
0. Rules that cannot be relaxed
- The key is the app's identity. Android refuses to update an installed app signed with a different certificate. Lose the key or its password and every installed copy is stranded.
- Never commit it.
*.jks,*.keystore,*.p12andkeystore.propertiesare git-ignored; do not override that withgit add -f. - Never reuse the debug key for releases. CI and
verify_release_signature.pyreject theAndroid Debugcertificate on tag builds. - Keep at least two independent, encrypted backups before the first public release.
- Do not experiment with the real key. Use a throwaway key for dry runs (section 7).
1. Prerequisites
| Need | Check |
|---|---|
JDK 17 keytool |
keytool -help |
Android SDK build-tools apksigner |
apksigner --version (or $ANDROID_HOME/build-tools/*/apksigner) |
| GitHub CLI, logged in with repo admin | gh auth status |
A password manager or an age/gpg setup for backups |
|
| Repository checkout on a branch containing the signing support | ls scripts/verify_release_signature.py |
2. Generate the key (you run this)
Use a private directory outside the repository.
install -d -m 700 ~/.local/share/androidjtools-signing
cd ~/.local/share/androidjtools-signing
keytool -genkeypair -v \
-storetype PKCS12 \
-keystore release.p12 \
-alias androidjtools \
-keyalg RSA -keysize 4096 \
-validity 10000 \
-dname "CN=Android DJ Tools, O=<your name or org>, C=<country code>"
chmod 600 release.p12
Notes:
keytoolprompts for the keystore password. Choose a long random password (a password manager's generator). Do not type it on the command line (-storepass) because it ends up in shell history and the process list.- PKCS12 uses one password for the store and the key. Use the same value for
ANDROID_KEYSTORE_PASSWORDandANDROID_KEY_PASSWORD. Ifkeytooloffers a separate key password, press Enter to reuse the store password. -validity 10000is about 27 years, comfortably beyond the Play Store's minimum validity (October 2033) should store distribution come later.- The
-dnameis public: it is shown in the certificate. Do not put anything private in it. - The file extension is cosmetic. Use
release.p12orrelease.jks; the build reads the format from the file, and both are git-ignored.
3. Record the certificate fingerprint
The SHA-256 fingerprint is public. CI pins it so a substituted key cannot publish a release.
keytool -list -v -keystore release.p12 -alias androidjtools | grep -E 'Owner|SHA256|Valid'
Copy the SHA256: value (colon-separated hex). verify_release_signature.py accepts colons
and upper or lower case. Save it somewhere durable, such as docs/release/signing.md after the
first release or your password manager.
Check the key is what you intended before going further:
keytool -list -v -keystore release.p12 | grep -E 'Key algorithm|Key size|Signature algorithm'
Expected: RSA, 4096 bits.
4. Back it up (do this before step 5)
Create an encrypted copy and store it in two physically or logically separate places (for example a password-manager attachment and an offline encrypted USB stick).
With age (preferred):
age -p -o release.p12.age release.p12 # prompts for a backup passphrase
With gpg:
gpg --symmetric --cipher-algo AES256 --output release.p12.gpg release.p12
Also store, next to each backup: the keystore password, the alias (androidjtools), the
certificate fingerprint, and the generation date. Without the password the file is useless.
Prove the backup works by decrypting it to a scratch location and listing it:
age -d release.p12.age > /tmp/restore-test.p12 # or: gpg --decrypt release.p12.gpg
keytool -list -keystore /tmp/restore-test.p12 -alias androidjtools
shred -u /tmp/restore-test.p12
The fingerprint printed must equal the one from step 3. Repeat this drill once a year and after any storage change.
5. Configure GitHub (you run this)
From a checkout of the repository (or add --repo OWNER/REPO):
cd ~/.local/share/androidjtools-signing
# keystore as one base64 line (GNU coreutils; on macOS use: base64 -i release.p12)
base64 -w0 release.p12 | gh secret set ANDROID_KEYSTORE_BASE64
gh secret set ANDROID_KEYSTORE_PASSWORD # prompts; paste the keystore password
gh secret set ANDROID_KEY_PASSWORD # same value as the keystore password for PKCS12
gh secret set ANDROID_KEY_ALIAS --body androidjtools
# public, so a variable rather than a secret: the pinned certificate fingerprint
gh variable set ANDROID_RELEASE_CERT_SHA256 --body '<SHA256 fingerprint from step 3>'
Verify the names exist without revealing values:
gh secret list
gh variable list
Expected: four secrets (ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD,
ANDROID_KEY_PASSWORD, ANDROID_KEY_ALIAS) and one variable
(ANDROID_RELEASE_CERT_SHA256). GitHub never shows secret values again, which is why the
backup in step 4 matters.
Optional hardening: create a GitHub environment named release with required reviewers,
move the secrets there, and add environment: release to the signing jobs. The current
workflows read repository-level secrets and do not use an environment.
Secrets are not available to workflows triggered from forks, so PR builds from forks stay debug-signed and unofficial by design.
6. Local release build with the real key (optional)
Prefer CI for official builds. If a local signed build is required:
umask 077
cat > keystore.properties <<'EOF'
storeFile=/home/<you>/.local/share/androidjtools-signing/release.p12
storePassword=<keystore password>
keyAlias=androidjtools
keyPassword=<same password for PKCS12>
EOF
./gradlew :app:assembleRelease -PrequireReleaseSigning=true
python3 scripts/verify_release_signature.py app/build/outputs/apk/release/app-release.apk \
--expected-sha256 '<fingerprint from step 3>'
keystore.properties is git-ignored. Delete it when finished if the machine is shared.
7. Dry run with a throwaway key
Use this to test build or workflow changes without risking the real key.
keytool -genkeypair -storetype PKCS12 -keystore /tmp/test.p12 -alias t \
-keyalg RSA -keysize 2048 -validity 30 -storepass testpass -dname 'CN=Throwaway Test'
ANDROID_KEYSTORE_FILE=/tmp/test.p12 ANDROID_KEYSTORE_PASSWORD=testpass \
ANDROID_KEY_ALIAS=t ANDROID_KEY_PASSWORD=testpass \
./gradlew :app:assembleRelease -PrequireReleaseSigning=true
python3 scripts/verify_release_signature.py app/build/outputs/apk/release/app-release.apk \
--expected-sha256 "$(keytool -list -v -keystore /tmp/test.p12 -storepass testpass | sed -n 's/.*SHA256: //p')"
rm -f /tmp/test.p12
Expected failures, which prove the guards work:
| Setup | Result |
|---|---|
no key, -PrequireReleaseSigning=true |
Release signing is required but missing: ... |
| only some variables set | Incomplete release signing configuration; missing: ... |
| nonexistent keystore path | Release keystore not found: ... |
verifier with a wrong --expected-sha256 |
certificate fingerprint ... does not match pinned ... |
| verifier on a debug-signed APK | APK is signed with the shared Android debug certificate |
8. First signed release, end to end
- Merge the signing branch to
mainand confirm CI is green (debug-signed, unofficial). - Check
versionName/versionCodeinapp/build.gradle.ktsand thatCHANGELOG.mdhas a heading## <versionName> - <date>. CI and the release workflow both require it. - Create and push the tag, which must equal
v<versionName>:sh git tag -s v0.2.0 -m "Android DJ Tools 0.2.0" # or: git tag -a ... git push origin v0.2.0 - Watch Android CI on the tag. It must restore the keystore, build with
-PrequireReleaseSigning=true, and pass the signature check against the pinned fingerprint. - Publish GitHub Release runs only from the tag-triggered CI run, re-verifies the APK, and
attaches the APK,
SHA256SUMS, andrelease-provenance.json(which records the certificate fingerprint andsigning_profile: release). - Independently verify what users will download:
sh gh release download v0.2.0 --pattern '*.apk' --pattern 'SHA256SUMS' --pattern 'release-provenance.json' sha256sum -c SHA256SUMS python3 scripts/verify_release_signature.py androidjtools-v0.2.0.apkRun the last command withANDROID_RELEASE_CERT_SHA256set to the value from step 3. - Install on a real device and run a smoke test. Then install the next release over it to prove that updates work (same signer).
If the tag build fails before the release job, nothing is published. Fix forward on main,
delete the failed tag (git push --delete origin v0.2.0 && git tag -d v0.2.0), and re-tag.
The release job refuses to overwrite an existing GitHub Release.
9. Upgrading from debug-signed builds
Earlier GitHub APKs are debug-signed. They cannot be updated in place to a release-signed build. Users must uninstall once (their on-device data is removed; Sample Lab remains the canonical library) and install the signed release. Say so in the release notes.
10. Rotation and compromise
Routine rotation is not needed: the key is valid for about 27 years.
If the key is lost (no usable backup): you cannot sign updates for the existing app ID. Users must uninstall and reinstall a build signed by a new key. Generate a new key with section 2, update the secrets and the pinned fingerprint, and publish a clear migration notice.
If the key or its password may be exposed:
- Stop publishing from the old key. Remove the GitHub secrets
(
gh secret delete ANDROID_KEYSTORE_BASE64, and the others). - Generate a new key (section 2) and back it up (section 4).
- Because
minSdkis 28, you can keep updates working with APK Signature Scheme v3 key rotation: create a lineage with the old key as the proof of rotation and sign with the new one.sh apksigner rotate --out lineage.bin \ --old-signer --ks old-release.p12 --ks-key-alias androidjtools \ --new-signer --ks new-release.p12 --ks-key-alias androidjtools apksigner sign --ks new-release.p12 --ks-key-alias androidjtools \ --lineage lineage.bin --out app-rotated.apk app-unsigned-or-release.apkThis requires the old key to still be in your possession, so it is only useful for exposure, not for loss. It would also require extending the Gradle and CI configuration, so treat it as a planned change with its own review, not an emergency edit. Note that an attacker holding the old key can still sign for users who have not updated. - Update
ANDROID_RELEASE_CERT_SHA256and publish the new fingerprint on the release page.
11. Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
keytool error: Keystore was tampered with, or password was incorrect |
Wrong password, or a corrupted base64 secret. Re-run step 5's base64 -w0 ... | gh secret set. |
CI: Release signing is required but missing: ... |
A secret is unset or empty, or the workflow runs from a fork. Check gh secret list. |
CI: Release keystore not found |
The restore step did not run; the build was not triggered by a tag. |
CI: an expected release certificate fingerprint is required |
ANDROID_RELEASE_CERT_SHA256 variable is missing. Set it with gh variable set. |
CI: certificate fingerprint ... does not match pinned ... |
The secret key differs from the one that was pinned. Do not "fix" by editing the pin without understanding why. |
CI: APK is signed with the shared Android debug certificate |
The tag build fell back to debug signing; check the Gradle step passed -PrequireReleaseSigning=true. |
Release workflow: not a release-signed run |
The triggering CI run was for main, not the tag; expected. Wait for the tag run. |
INSTALL_FAILED_UPDATE_INCOMPATIBLE on device |
The installed app has a different signer (for example a debug build). Uninstall it first. |
apksigner: command not found |
Add $ANDROID_HOME/build-tools/<version> to PATH, or set ANDROID_HOME. |
base64: invalid option -- 'w' |
macOS: use base64 -i release.p12 | tr -d '\n'. |
12. Checklist
- [ ] Key generated with RSA 4096, PKCS12, outside the repository, mode 600
- [ ] Fingerprint recorded
- [ ] Two encrypted backups stored, restore drill passed
- [ ] Four secrets and one variable set;
gh secret list/gh variable listmatch - [ ] Throwaway-key dry run passes locally
- [ ] Tag build is green and the published APK verifies against the pin
- [ ] An update installed over the first signed release succeeds on a device
- [ ] No store registration until all of the above are done