Local APK build: support assembleRelease + signing config #165

Closed
opened 2026-05-20 15:05:37 +00:00 by forgejo_admin · 0 comments

Phase 0 — Keystore preparation (do this before any code changes)

Before writing the gradle/script changes in Phases 1-4 below, the maintainer needs to make four decisions and generate a keystore. The SHA-256 fingerprint of this keystore becomes part of the app's identity forever — every future install for com.metascrub.app checks against it. A lost keystore means no further APK can install as an update on top of an existing one (users would have to uninstall first), and there is no recovery mechanism.

Decisions

  1. Validity period.

    • keytool -validity default is 90 days — far too short.
    • Play Store: 25 years minimum.
    • F-Droid: 30+ years recommended.
    • Recommendation: -validity 36500 (100 years).
  2. Keystore location. Trades off against .claude/rules/privacy-invariants.md §5 (no telemetry, no cloud sync) and CI ergonomics:

    Option CI release builds Local release builds Notes
    Committed to repo, age/sops/git-crypt encrypted Yes (CI decrypts via secret) Yes (maintainer decrypts) Single source of truth; survives clone
    Maintainer's machine only No Yes Strictest privacy; rules out automated release
    Forgejo Secrets only Yes Yes (manual export step) Secret stays inside the trust boundary; harder to back up

    Recommendation: age-encrypted in the repo at android/metascrub-release.jks.age, with the decryption key in Forgejo Secrets for CI and in the maintainer's offline backup for manual builds. The encrypted file in git makes "every clone can build a release" achievable; the offline backup of the decryption key keeps the keystore itself recoverable.

  3. Backup strategy. Must align with privacy invariants §5 — no cloud sync, no transmission.

    • Acceptable: offline encrypted USB stored in a safe; paper printout of the base64 keystore in a fireproof container; age-encrypted file on a secondary offline drive.
    • Not acceptable: 1Password cloud sync, iCloud Keychain, Google Drive, any service that transmits the keystore over the network. The audience that cares about MetaScrub's no-telemetry stance notices the difference.
  4. Rotation policy. APK Signature Scheme v3+ supports key rotation, but the new key must be signed by the old key. This is another reason the original keystore backup is load-bearing — even rotation requires the original. Document the rotation procedure now even if you never use it; once the original is lost, rotation is no longer possible.

Outputs of Phase 0

  • A generated metascrub-release.jks (the file itself stays out of git; only the age-encrypted form is committed if option 1 was chosen)
  • A short docs/android-signing.md covering:
    • The verbatim keytool -genkey -v command used (no secrets, just the public parameters: alias, keyalg, keysize, validity)
    • Where the backup lives (description, not the actual path)
    • The keystore's SHA-256 fingerprint (keytool -list -alias metascrub -keystore metascrub-release.jks output, for users who want to verify the APK they downloaded)
    • The rotation procedure
  • The keystore present in whichever location option 2 settled on

Only after Phase 0 is complete do Phases 1-4 below have a concrete artifact to wire up. The signingConfigs.release block in android/app/build.gradle and the --release flag in scripts/build-apk-local.sh both depend on the location decision.


Background

scripts/build-apk-local.sh (added in #163) and .github/workflows/build-android.yml (added in #156) both run ./gradlew assembleDebug, producing app-debug.apk signed with the developer's per-machine debug keystore. This is the right default for personal sideloading and for the on-demand CI builds we have today.

It is not sufficient for:

  • F-Droid submission (F-Droid does its own signing, but app-release-unsigned.apk is what their build server expects)
  • Distributing the APK on a download page where users will see Play Protect warnings on debug builds
  • Producing a single canonical APK that multiple devs can rebuild without signature mismatch on user devices

The Signed release APK section of docs/android-apk.md already documents the keytool + signingConfigs.release flow but it's a manual recipe — nothing in android/app/build.gradle is configured to actually pick up a keystore today.

Scope

  1. Wire signingConfigs.release into android/app/build.gradle — read keystore path + alias + passwords from environment variables (METASCRUB_KEYSTORE_FILE, METASCRUB_KEYSTORE_ALIAS, METASCRUB_KEYSTORE_PASSWORD, METASCRUB_KEY_PASSWORD) so the keystore is never committed.
  2. Extend scripts/build-apk-local.sh with a --release flag that:
    • Errors with a clear message if any of the four env vars is unset (don't auto-fallback to unsigned — users would not notice)
    • Bind-mounts the keystore file into the container at a stable path
    • Runs ./gradlew assembleRelease instead of assembleDebug
    • Reports the output path (android/app/build/outputs/apk/release/app-release.apk)
  3. Extend .github/workflows/build-android.yml with a release_build boolean dispatch input that wires up the keystore from Forgejo Secrets when set. Keep the default false so the existing workflow_dispatch shape is unchanged.
  4. Update docs/android-apk.md — promote the signed-release section to a peer of the Local Docker build section and document the four env vars + how to generate the keystore.

Out of scope

  • Publishing to F-Droid (separate decision — see the F-Droid section of the doc)
  • Play Store publishing (settled no per .claude/rules/project-direction.md)
  • App Bundle (.aab) output — APK is enough; AAB is a Play Store concept
  • Auto-signing in CI without explicit dispatch (we deliberately keep release builds manual)

Acceptance criteria

  • ./scripts/build-apk-local.sh --release produces app-release.apk when the env vars are set, with apksigner verify reporting a valid v1+v2+v3 signature against the configured keystore
  • The default ./scripts/build-apk-local.sh invocation still produces the same app-debug.apk it does today — no regression on the personal-sideload path
  • apksigner verify --print-certs app-release.apk shows the project keystore's fingerprint, not the per-developer debug key
  • Doc covers keytool -genkey invocation, env var reference, and warning that the keystore is not recoverable if lost (rotating signing keys breaks update installs for every existing user)

References

## Phase 0 — Keystore preparation (do this before any code changes) Before writing the gradle/script changes in Phases 1-4 below, the maintainer needs to make four decisions and generate a keystore. **The SHA-256 fingerprint of this keystore becomes part of the app's identity forever** — every future install for `com.metascrub.app` checks against it. A lost keystore means no further APK can install as an update on top of an existing one (users would have to uninstall first), and there is no recovery mechanism. ### Decisions 1. **Validity period.** - `keytool -validity` default is 90 days — far too short. - Play Store: 25 years minimum. - F-Droid: 30+ years recommended. - **Recommendation:** `-validity 36500` (100 years). 2. **Keystore location.** Trades off against `.claude/rules/privacy-invariants.md` §5 (no telemetry, no cloud sync) and CI ergonomics: | Option | CI release builds | Local release builds | Notes | | --- | --- | --- | --- | | Committed to repo, age/sops/git-crypt encrypted | Yes (CI decrypts via secret) | Yes (maintainer decrypts) | Single source of truth; survives clone | | Maintainer's machine only | **No** | Yes | Strictest privacy; rules out automated release | | Forgejo Secrets only | Yes | Yes (manual export step) | Secret stays inside the trust boundary; harder to back up | **Recommendation:** age-encrypted in the repo at `android/metascrub-release.jks.age`, with the decryption key in Forgejo Secrets for CI and in the maintainer's offline backup for manual builds. The encrypted file in git makes "every clone can build a release" achievable; the offline backup of the decryption key keeps the keystore itself recoverable. 3. **Backup strategy.** Must align with privacy invariants §5 — no cloud sync, no transmission. - **Acceptable:** offline encrypted USB stored in a safe; paper printout of the base64 keystore in a fireproof container; age-encrypted file on a secondary offline drive. - **Not acceptable:** 1Password cloud sync, iCloud Keychain, Google Drive, any service that transmits the keystore over the network. The audience that cares about MetaScrub's no-telemetry stance notices the difference. 4. **Rotation policy.** APK Signature Scheme v3+ supports key rotation, but the new key must be signed by the old key. This is another reason the original keystore backup is load-bearing — even rotation requires the original. Document the rotation procedure now even if you never use it; once the original is lost, rotation is no longer possible. ### Outputs of Phase 0 - A generated `metascrub-release.jks` (the file itself stays out of git; only the age-encrypted form is committed if option 1 was chosen) - A short `docs/android-signing.md` covering: - The verbatim `keytool -genkey -v` command used (no secrets, just the public parameters: alias, keyalg, keysize, validity) - Where the backup lives (description, not the actual path) - The keystore's SHA-256 fingerprint (`keytool -list -alias metascrub -keystore metascrub-release.jks` output, for users who want to verify the APK they downloaded) - The rotation procedure - The keystore present in whichever location option 2 settled on Only after Phase 0 is complete do Phases 1-4 below have a concrete artifact to wire up. The `signingConfigs.release` block in `android/app/build.gradle` and the `--release` flag in `scripts/build-apk-local.sh` both depend on the location decision. --- ## Background `scripts/build-apk-local.sh` (added in #163) and `.github/workflows/build-android.yml` (added in #156) both run `./gradlew assembleDebug`, producing `app-debug.apk` signed with the developer's per-machine debug keystore. This is the right default for personal sideloading and for the on-demand CI builds we have today. It is **not** sufficient for: - F-Droid submission (F-Droid does its own signing, but `app-release-unsigned.apk` is what their build server expects) - Distributing the APK on a download page where users will see Play Protect warnings on debug builds - Producing a single canonical APK that multiple devs can rebuild without signature mismatch on user devices The `Signed release APK` section of [`docs/android-apk.md`](docs/android-apk.md#signed-release-apk) already documents the `keytool` + `signingConfigs.release` flow but it's a manual recipe — nothing in `android/app/build.gradle` is configured to actually pick up a keystore today. ## Scope 1. **Wire `signingConfigs.release` into `android/app/build.gradle`** — read keystore path + alias + passwords from environment variables (`METASCRUB_KEYSTORE_FILE`, `METASCRUB_KEYSTORE_ALIAS`, `METASCRUB_KEYSTORE_PASSWORD`, `METASCRUB_KEY_PASSWORD`) so the keystore is never committed. 2. **Extend `scripts/build-apk-local.sh`** with a `--release` flag that: - Errors with a clear message if any of the four env vars is unset (don't auto-fallback to unsigned — users would not notice) - Bind-mounts the keystore file into the container at a stable path - Runs `./gradlew assembleRelease` instead of `assembleDebug` - Reports the output path (`android/app/build/outputs/apk/release/app-release.apk`) 3. **Extend `.github/workflows/build-android.yml`** with a `release_build` boolean dispatch input that wires up the keystore from Forgejo Secrets when set. Keep the default `false` so the existing workflow_dispatch shape is unchanged. 4. **Update `docs/android-apk.md`** — promote the signed-release section to a peer of the Local Docker build section and document the four env vars + how to generate the keystore. ## Out of scope - Publishing to F-Droid (separate decision — see the `F-Droid` section of the doc) - Play Store publishing (settled `no` per `.claude/rules/project-direction.md`) - App Bundle (.aab) output — APK is enough; AAB is a Play Store concept - Auto-signing in CI without explicit dispatch (we deliberately keep release builds manual) ## Acceptance criteria - `./scripts/build-apk-local.sh --release` produces `app-release.apk` when the env vars are set, with `apksigner verify` reporting a valid v1+v2+v3 signature against the configured keystore - The default `./scripts/build-apk-local.sh` invocation still produces the same `app-debug.apk` it does today — no regression on the personal-sideload path - `apksigner verify --print-certs app-release.apk` shows the project keystore's fingerprint, not the per-developer debug key - Doc covers `keytool -genkey` invocation, env var reference, and warning that the keystore is not recoverable if lost (rotating signing keys breaks update installs for every existing user) ## References - #156 — original Android CI build - #163 — local Docker build - [`docs/android-apk.md` § Signed release APK](docs/android-apk.md#signed-release-apk) - [`.claude/rules/project-direction.md`](.claude/rules/project-direction.md) — confirms APK is for personal sideloading + F-Droid, not Play Store - Android signing docs: https://developer.android.com/studio/publish/app-signing
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: forgejo_admin/exifcleaner-web#165
No description provided.