Release Keystore Migration — v1.0 Play Store
This document captures the one-time migration from Candela's checked-in debug keystore (stable since v0.4.15) to a Play-Store-grade release keystore for the v1.0 Play Store launch.
Audience. JP (the developer) and any future maintainer who needs to rebuild a release APK or recover from a keystore loss. Public-facing context for users lives in Privacy Policy and the install guide.
Why a new keystore
The repo today ships a storyvox-debug.keystore at
app/storyvox-debug.keystore — generated by keytool with the default
androiddebugkey alias and android password. Every Candela APK from
v0.4.15 onward has been signed with this key.
Two reasons that key cannot ship to the Play Store:
- Google rejects debug keystores at upload time. The Play Console's
automated keystore check flags the well-known
CN=Android Debug, O=Android, C=UScertificate and refuses the upload. - The private key is in the public repo. Anyone with a checkout can sign
an APK with
applicationId = in.jphe.storyvoxand Android will accept it as an upgrade. Fine for sideload-only distribution; unacceptable for a distribution channel that relies on signature continuity as a security guarantee.
The new keystore is a real, never-committed signing key. JP holds the private key + password in Vaultwarden; Google holds the app-signing key (via Play App Signing — see Recovery below).
Sideload-to-Play-Store continuity (known one-time disruption)
Heads-up for early sideload users. When Candela v1.0 lands on the Play Store, the APK will be signed with a different certificate than every v0.4.x / v0.5.x sideload build. Android's package manager refuses to install an APK signed by a different certificate over an existing install of the same
applicationId(in.jphe.storyvox).
This means a v1.0 Play Store install over a sideloaded v0.5.x fails with:
INSTALL_FAILED_UPDATE_INCOMPATIBLE: Package in.jphe.storyvox signatures do not match …
The workaround for existing sideload users is uninstall first, then install from Play Store. Uninstalling wipes the local app data (library state, voice download cache, settings, encrypted WebView cookies). For users with sync enabled (InstantDB email sign-in), library + reading position + voice preferences sync back from the InstantDB record after re-sign-in; voice downloads + WebView cookies have to be re-acquired.
This is a one-time disruption at the v1.0 Play Store cutover. After v1.0 all upgrades flow through Play Store with the same signing key — Play App Signing guarantees Google re-signs every future upload with the canonical app signing key, even if JP rotates the upload key. See Recovery below.
The release-notes blurb for v1.0 must call this out prominently. Suggested copy:
Migrating from a sideloaded build? v1.0 is signed with a new Play-Store-grade certificate. Android can't replace a sideloaded install with a Play Store install signed by a different key, so please uninstall the sideloaded Candela first, then install from Google Play. If you have sync enabled, your library and reading positions will come back as soon as you sign back in. (Sorry — this is a one-time disruption at the v0.5→v1.0 cutover; every future Play Store update upgrades in place.)
The Slack release announcement template at
docs/slack-release-template.md should carry the same blurb for v1.0.
Generate the keystore (JP's manual step)
JP runs these manually — Claude does not generate the keystore, because the password and the file itself must never touch the agent's context.
# 1. Pick a home OUTSIDE the repo. ~/ is fine; gitignored regardless.
mkdir -p ~/.storyvox-keystore
chmod 700 ~/.storyvox-keystore
# 2. Generate. RSA 2048+ (Play Console accepts 2048; 4096 is fine too),
# validity ≥ 25 years past the planned launch (Play Console enforces
# the cert validity > 22 October 2033 rule; setting 10,000 days
# comfortably clears that and is the documented Google convention).
keytool -genkeypair \
-alias storyvox \
-keyalg RSA -keysize 4096 \
-validity 10000 \
-keystore ~/.storyvox-keystore/storyvox-release.keystore \
-storetype JKS
# keytool will prompt for:
# - Keystore password (use `bw generate -lus --length 32` and store in vault)
# - Key password (Google's convention: same as keystore password)
# - DN — Common name: storyvox; Organization: TechEMPOWER;
# Org Unit: storyvox; City/State/Country: JP's call
# 3. Verify
keytool -list -v -keystore ~/.storyvox-keystore/storyvox-release.keystore
Then store the credentials in Vaultwarden as a single Secure Note. Suggested
item name: storyvox-release-keystore (JP to confirm). The note holds:
- Keystore file path on JP's laptop
- Keystore password
- Key alias (
storyvox) - Key password
- SHA-256 fingerprint from
keytool -list -v(Play Console displays this; useful for sanity-checking which keystore is loaded) - Date created, intended purpose ("Play Store upload key for in.jphe.storyvox")
Back up the keystore file itself somewhere safe (the Vaultwarden attachment slot for that same item, or an offline encrypted backup). If you lose the keystore AND the upload key, recovery requires Google support — see below.
Wire it into the build
The build-gradle plumbing is already in place — see app/build.gradle.kts:
-
At configure time, the build reads
local.propertiesand looks for these four keys:storyvox.releaseStoreFile=/home/jp/.storyvox-keystore/storyvox-release.keystore storyvox.releaseStorePassword=<keystore password from Vaultwarden> storyvox.releaseKeyAlias=storyvox storyvox.releaseKeyPassword=<key password from Vaultwarden> - When all four are present AND the keystore file exists, the
signingConfigs.releaseblock is registered andbuildTypes.releasepicks it up. - When any are missing, the build silently falls back to the checked-in debug keystore (existing behavior). This keeps fresh checkouts, CI before the secret is wired in, and contributors who only do sideload builds working without surprise breakage.
To use it, add the four lines to local.properties (gitignored):
cat >> local.properties <<EOF
# Play Store release keystore (see docs/release-keystore.md). Never commit.
storyvox.releaseStoreFile=$HOME/.storyvox-keystore/storyvox-release.keystore
storyvox.releaseStorePassword=…paste from Vaultwarden…
storyvox.releaseKeyAlias=storyvox
storyvox.releaseKeyPassword=…paste from Vaultwarden…
EOF
Then build:
./gradlew :app:assembleRelease
# → app/build/outputs/apk/release/candela-v<versionName>.apk
# signed with the new release keystore
Verify the APK signer:
$ANDROID_HOME/build-tools/35.0.0/apksigner verify --print-certs \
app/build/outputs/apk/release/candela-v*.apk
# Should show:
# Signer #1 certificate DN: CN=storyvox, OU=storyvox, O=TechEMPOWER, …
# Signer #1 certificate SHA-256 digest: <matches Vaultwarden record>
CI wiring (GitHub-hosted runners, since 2026-09-06), and the upload key in CI (since 2026-10-23)
CI runs on GitHub-hosted ubuntu-latest (.github/workflows/android.yml).
The build job makes only the two sideload APKs (:app:assembleRelease,
:wear:assembleRelease), both debug-signed for sideload upgrade continuity
(#952) and then re-signed with the sideload key (#1756). The Materialize
local.properties step writes the file from Actions secrets: INSTANTDB_APP_ID
(a public client id kept out of the tree) and, when present, the optional
NOTION_OAUTH_* / GOOGLE_OAUTH_* client ids.
The Play AAB is never a GitHub release asset. An AAB cannot be installed by anyone (it is not sideloadable); its only consumer is Play Console, which re-signs it with the app signing key and serves users APKs.
The upload key is in CI, scoped to the play-upload environment (JP approved 2026-10-23, #1456).
The Play upload (draft) job builds :app:bundleRelease on every v* tag,
refuses any bundle whose signer is not the upload certificate
(UPLOAD_CERT_SHA256), and uploads it to the closed-testing track alpha as a
draft. A human still clicks Roll out in Play Console. Scope:
- The four secrets (
PLAY_UPLOAD_KEYSTORE_B64,PLAY_UPLOAD_KEYSTORE_PASSWORD,PLAY_UPLOAD_KEY_PASSWORD,PLAY_SERVICE_ACCOUNT_JSON) are environment secrets ofplay-upload, never repo secrets. That environment's deployment policy allows onlyv*tags, so no branch or PR run can read them. - The environment is a ref gate, not a job gate. Any job that names
environment: play-uploadand runs on av*tag could read them. Only theplay-uploadjob names it, andSecretScopeTestintools/test_play_upload.pyfails CI if another job reads aPLAY_*secret. That test is a regression check, not the access boundary: the environment policy is the boundary. A writer could alsoworkflow_dispatcha dispatch-enabled workflow on an existingv*tag. That is harmless today because no such workflow names the environment; keep it that way, or add required reviewers to the environment. - The tag ruleset "Release tags: admins only (play-upload key)" limits
creating, moving and deleting
v*tags to repo admins. - The job runs with
contents: read, a read-only Gradle cache and no configuration cache, and it deletes the key material in analways()step. - The Play service account gets only "Release to testing tracks" for Candela in Play Console, never production. The job always stages a draft, but the permission itself is not draft-only. Someone holding both a leaked service-account JSON and the upload key could release a build to the testers on a testing track. Either one alone can't: the JSON can't sign a bundle with the upload key, and the upload key has no Play API access.
- Until all four secrets exist, the job logs a notice and passes. Then the manual path still works:
./gradlew :app:bundleRelease # SEPARATE invocation from assembleRelease (#952)
# → app/build/outputs/bundle/release/app-release.aab → upload in Play Console
The vault item storyvox-release-keystore stays the record of truth; the
environment secrets are a copy. History: on 2026-09-06 the keystore was briefly
copied into repo-level Actions secrets and CI attached AABs to releases
v1.7.0–v1.14.1. Both were reverted the same day (secrets deleted, all 17 assets
removed). The 2026-10-23 setup is the scoped replacement.
Rotating the upload key (or the service account key)
Set every secret through stdin, never on the command line, and never print it.
Upload key (planned rotation, or after a suspected leak):
- Generate a new upload keystore exactly as in Generate the keystore, and
store it and its passwords in Vaultwarden (
storyvox-release-keystore, with the old one kept as…-retired-<date>until Google accepts the new one). - Export the certificate (
keytool -export -rfc … -alias storyvox > upload-cert.pem). Then file Play Console → App integrity → Upload key reset with that certificate. Google takes 1–3 business days. - Once Google accepts it, replace the three environment secrets:
base64 -w0 new-upload.keystore | gh secret set PLAY_UPLOAD_KEYSTORE_B64 --env play-upload gh secret set PLAY_UPLOAD_KEYSTORE_PASSWORD --env play-upload # paste at the prompt, or pipe from bw gh secret set PLAY_UPLOAD_KEY_PASSWORD --env play-upload - Update
UPLOAD_CERT_SHA256inandroid.ymlto the new certificate's SHA-256 (the job refuses a bundle signed with anything else), plus thestoryvox.release*lines inlocal.propertieson the build host. - Cut the next tag and check that the job's "Refuse a bundle…" step passes.
Service account key (PLAY_SERVICE_ACCOUNT_JSON, planned rotation; for a
suspected leak, revoke first as below): in Google Cloud → IAM → Service
accounts, add a new key to the Play service account, set it with
gh secret set PLAY_SERVICE_ACCOUNT_JSON --env play-upload < key.json, delete
the local key.json, then delete the old key in Google Cloud.
Suspected leak: contain first, in this order.
- Stop publishing: disable the workflow (
gh workflow disable android.yml) and cancel any running tag build (gh run cancel <id>). - Revoke at Google: in Google Cloud → IAM → Service accounts, disable or delete the service-account key now. If unsure which key leaked, remove the account's access in Play Console → Users and permissions.
- Remove the GitHub copies:
gh secret delete <name> --env play-uploadfor all four. This stops future runs only; it doesn't recall a secret already delivered to a run. - Start the upload-key reset (steps 1–2 above) if the upload key may have leaked.
- Inspect Play: Play Console → Test and release, plus the Play Console activity log. Check every testing track for releases nobody made, and halt or replace any you find.
- Only then create new keys and re-enable the workflow.
Production users are not exposed by an upload-key or service-account leak: Google's app signing key, which only Google holds, signs what reaches devices, and the service account has no production permission. Testers on a testing track can be exposed if both leak together (see above).
Recovery — what if the keystore is lost
Candela uses Play App Signing (the default for any new Play Console app). This means:
- Upload key — the keystore generated above. JP signs APKs/AABs with this key and uploads them. Recoverable via Google support if lost.
- App signing key — held by Google. Google re-signs every uploaded artifact with this key before distributing to users. Recoverable via Google's key-reset process if it ever leaks; users get the new key on next Play Store update automatically.
If JP loses the upload key (file gone AND Vaultwarden record gone):
- Generate a new upload keystore with the same procedure above.
- Extract the public certificate:
keytool -export -rfc -keystore … -alias storyvox > upload-cert.pem - In Play Console → App integrity → Upload key, file an "Upload key reset" request and attach the new public cert.
- Google reviews (1–3 business days typically), then accepts the new upload key. User-facing experience is unaffected — Play App Signing means the re-signed APK that ships to users carries the same app signing certificate regardless of which upload key JP holds.
If the app signing key at Google ever leaks (extremely unlikely — Google holds it in their HSM), Google offers a Play Console flow to upgrade the signing key. Users get the new key on their next Play Store update; the transition is transparent to the app.
The recovery path is the whole reason JP can comfortably hold the upload keystore in Vaultwarden rather than a dedicated HSM: losing it is a 1–3 day inconvenience, not a project-ending event.
Migration checklist for v1.0
- JP generates
storyvox-release.keystoreper the procedure above - JP creates the
storyvox-release-keystoreVaultwarden Secure Note (or whatever name JP picks — confirm here) - JP adds the four
storyvox.release*lines to/home/jp/Projects/candela/local.properties(katana) - JP creates the
INSTANTDB_APP_IDGitHub Actions repo secret so non-katana runners produce sync-enabled release APKs - JP copies the keystore to
~/.storyvox-keystore/on the katana CI runner - Local
./gradlew :app:assembleReleaseproduces an APK signed with the new cert (verify withapksigner verify --print-certs) - CI tag build does the same (check fingerprint on the artifact attached to the GitHub release)
- First Play Console upload of the AAB succeeds and Play App Signing enrolls the upload key
- Document the user-facing "uninstall sideload first" disruption in the v1.0 release notes + Slack announcement
Why we keep the debug keystore alongside
The debug keystore stays in the repo and stays wired to the debug
build type. Two reasons:
- AGP refuses to assemble the
releasevariant without asigningConfigpointing at SOMETHING. The fallback inbuild.gradle.kts(use debug if release isn't wired) means a fresh checkout — or a contributor who doesn't have JP's Vaultwarden access — still gets a working assemble. The CI workflow file's setup also works unchanged on a clean clone. - The
:baselineprofileproducer variant derives fromrelease. It only needs A signing config to exist; we don't care which one when we're just generating profiles. Wiring the debug keystore there keeps profile generation unblocked on any machine.
Once the release keystore is in place on JP's laptop and on the CI runner, every shipped artifact is signed with it. The debug keystore is just there as a safety net for development environments.