feat(android): assembleRelease + env-var signing config (#165) #185

Merged
forgejo_admin merged 4 commits from fix/165-apk-release-signing into master 2026-05-22 08:14:01 +00:00

Closes #165.

Summary

Local-only Android release signing, env-var driven, no keystore artifact in the repo. The maintainer keeps the .jks on their machine (option-2 from the issue's Phase 0 table); the CI age-encrypted-in-repo variant is deferred to a follow-up.

  • android/app/build.gradlesigningConfigs.release reads four env vars; v1+v2+v3 enabled explicitly. When env vars are absent, assembleRelease deliberately produces app-release-unsigned.apk (the artifact F-Droid's build server expects). No regression on the debug path.
  • scripts/build-apk-local.sh --release — auto-sources .env at repo root, validates all four env vars + that the keystore is readable, bind-mounts the .jks read-only at /keystore/release.jks (outside /workspace, so it cannot leak into git status, gradle output, or cap sync), runs assembleRelease. Refuses to fall back to unsigned — silent fallback would let users ship an APK that fails update-install checks on user devices without ever noticing during the build.
  • .env.sample — committed template; cp .env.sample .env is the maintainer's entry point. .env is already gitignored.
  • docs/android-signing.md (new) — keystore generation ceremony, threat model (leak vs. loss), recommended parameters (RSA 4096, -validity 36500, two distinct strong passwords), three-place offline backup checklist, SHA-256 fingerprint slot, rotation procedure, and explicit "Future work" deferring CI age-decrypt.
  • docs/android-apk.md## Signed release APK promoted to a peer of ## Local Docker build. CI signing note updated to point at the new section.

Verification

End-to-end locally with a throwaway test keystore (generated in /tmp/, shredded after — not the project's identity keystore):

$ ./scripts/build-apk-local.sh           # debug, no env vars
BUILD SUCCESSFUL in 3m 37s
==> APK built: android/app/build/outputs/apk/debug/app-debug.apk (13M)

$ ./scripts/build-apk-local.sh --release  # with throwaway keystore env
BUILD SUCCESSFUL in 16s
==> APK built: android/app/build/outputs/apk/release/app-release.apk (12M)

$ apksigner verify --verbose app-release.apk
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): true

Validation behaviour:

$ ./scripts/build-apk-local.sh --release   # nothing exported
!! Missing required env vars for --release:
!!   - METASCRUB_KEYSTORE_FILE
!!   - METASCRUB_KEYSTORE_ALIAS
!!   - METASCRUB_KEYSTORE_PASSWORD
!!   - METASCRUB_KEY_PASSWORD

$ METASCRUB_KEYSTORE_FILE=/tmp/nope.jks ... ./scripts/build-apk-local.sh --release
!! METASCRUB_KEYSTORE_FILE does not exist: /tmp/nope.jks

Out of scope (follow-up)

  • CI release signing via age-encrypted-in-repo keystore + Forgejo Secrets — tracked in docs/android-signing.md § Future work. Per the issue, this PR keeps release builds deliberately manual.
  • The actual keytool -genkey ceremony — maintainer runs that on their own machine once offline backups are staged. The code here is ready for it.

Test plan

  • ./scripts/build-apk-local.sh (no flag) still produces app-debug.apk — verified
  • ./scripts/build-apk-local.sh --release with the four env vars produces a signed APK — verified
  • apksigner verify reports v1+v2+v3 valid — verified
  • Missing env vars / missing file / unreadable file all error cleanly with actionable messages — verified
  • No .jks / .keystore left in the worktree after testing — verified
Closes #165. ## Summary Local-only Android release signing, env-var driven, no keystore artifact in the repo. The maintainer keeps the `.jks` on their machine (option-2 from the issue's Phase 0 table); the CI age-encrypted-in-repo variant is deferred to a follow-up. - `android/app/build.gradle` — `signingConfigs.release` reads four env vars; v1+v2+v3 enabled explicitly. When env vars are absent, `assembleRelease` deliberately produces `app-release-unsigned.apk` (the artifact F-Droid's build server expects). No regression on the debug path. - `scripts/build-apk-local.sh --release` — auto-sources `.env` at repo root, validates all four env vars + that the keystore is readable, bind-mounts the `.jks` read-only at `/keystore/release.jks` (outside `/workspace`, so it cannot leak into git status, gradle output, or `cap sync`), runs `assembleRelease`. **Refuses to fall back to unsigned** — silent fallback would let users ship an APK that fails update-install checks on user devices without ever noticing during the build. - `.env.sample` — committed template; `cp .env.sample .env` is the maintainer's entry point. `.env` is already gitignored. - `docs/android-signing.md` *(new)* — keystore generation ceremony, threat model (leak vs. loss), recommended parameters (RSA 4096, `-validity 36500`, two distinct strong passwords), three-place offline backup checklist, SHA-256 fingerprint slot, rotation procedure, and explicit "Future work" deferring CI age-decrypt. - `docs/android-apk.md` — `## Signed release APK` promoted to a peer of `## Local Docker build`. CI signing note updated to point at the new section. ## Verification End-to-end locally with a throwaway test keystore (generated in `/tmp/`, shredded after — not the project's identity keystore): ``` $ ./scripts/build-apk-local.sh # debug, no env vars BUILD SUCCESSFUL in 3m 37s ==> APK built: android/app/build/outputs/apk/debug/app-debug.apk (13M) $ ./scripts/build-apk-local.sh --release # with throwaway keystore env BUILD SUCCESSFUL in 16s ==> APK built: android/app/build/outputs/apk/release/app-release.apk (12M) $ apksigner verify --verbose app-release.apk Verifies Verified using v1 scheme (JAR signing): true Verified using v2 scheme (APK Signature Scheme v2): true Verified using v3 scheme (APK Signature Scheme v3): true ``` Validation behaviour: ``` $ ./scripts/build-apk-local.sh --release # nothing exported !! Missing required env vars for --release: !! - METASCRUB_KEYSTORE_FILE !! - METASCRUB_KEYSTORE_ALIAS !! - METASCRUB_KEYSTORE_PASSWORD !! - METASCRUB_KEY_PASSWORD $ METASCRUB_KEYSTORE_FILE=/tmp/nope.jks ... ./scripts/build-apk-local.sh --release !! METASCRUB_KEYSTORE_FILE does not exist: /tmp/nope.jks ``` ## Out of scope (follow-up) - CI release signing via age-encrypted-in-repo keystore + Forgejo Secrets — tracked in `docs/android-signing.md` § Future work. Per the issue, this PR keeps release builds deliberately manual. - The actual `keytool -genkey` ceremony — maintainer runs that on their own machine once offline backups are staged. The code here is ready for it. ## Test plan - [x] `./scripts/build-apk-local.sh` (no flag) still produces `app-debug.apk` — verified - [x] `./scripts/build-apk-local.sh --release` with the four env vars produces a signed APK — verified - [x] `apksigner verify` reports v1+v2+v3 valid — verified - [x] Missing env vars / missing file / unreadable file all error cleanly with actionable messages — verified - [x] No `.jks` / `.keystore` left in the worktree after testing — verified
forgejo_admin added 1 commit 2026-05-22 06:23:02 +00:00
feat(android): assembleRelease + env-var signing config (#165)
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 36s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m41s
CI / E2E (Web) (pull_request) Successful in 3m9s
37e40b3c60
Wires android/app/build.gradle's signingConfigs.release to four env vars
(METASCRUB_KEYSTORE_FILE/ALIAS/PASSWORD + METASCRUB_KEY_PASSWORD) and adds
a --release flag to scripts/build-apk-local.sh that validates them,
bind-mounts the keystore read-only outside /workspace, and runs
assembleRelease. Refuses to silently fall back to unsigned — shipping an
unsigned APK would fail update-install checks on user devices without any
build-time warning.

Verified locally with a throwaway keystore: apksigner reports v1+v2+v3
all valid, matching the issue's acceptance criteria. Debug path
(./scripts/build-apk-local.sh, no flag) is unchanged.

The keystore generation ceremony, backup checklist, threat model, and
rotation procedure are documented in docs/android-signing.md. CI release
signing via an age-encrypted-in-repo keystore is deferred to a follow-up
issue per the maintainer's preference (local-only signing first).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
forgejo_admin force-pushed fix/165-apk-release-signing from 37e40b3c60 to 098885d23b 2026-05-22 06:38:18 +00:00 Compare
forgejo_admin added 1 commit 2026-05-22 06:53:33 +00:00
docs(signing): drop confusing .b64 sidecar from backup ceremony
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 28s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m30s
CI / E2E (Web) (pull_request) Successful in 3m3s
303c514f56
The previous wording introduced a transit file (metascrub-release.jks.b64)
that then needed shredding, and pointed METASCRUB_KEYSTORE_FILE at the
similarly-named .jks immediately after — a clear footgun where a reader
could conclude we were shredding the keystore itself.

Pipe base64 directly to the printer or to qrencode instead, so the
plaintext base64 form never exists on disk. The keystore file itself is
what's copied to each USB; the .env points at the kept working copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

### Code review No issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with [Claude Code](https://claude.ai/code) <sub>- If this code review was useful, please react with 👍. Otherwise, react with 👎.</sub>
forgejo_admin added 1 commit 2026-05-22 08:08:24 +00:00
review(165): trap INT/TERM, reject newline values, fix gitignore comment
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 30s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m25s
CI / E2E (Web) (pull_request) Successful in 2m59s
be2280ac3b
Applying review findings from PR #185 that scored above 35 confidence:

- 2a (75): trap fires on EXIT INT TERM, not just EXIT. Modern bash does
  fire EXIT on Ctrl+C, but explicit signal traps make the cleanup contract
  for the password-bearing env-file independent of shell-version behaviour.
- 2b (50): reject newlines in METASCRUB_KEYSTORE_ALIAS / KEYSTORE_PASSWORD
  / KEY_PASSWORD. Docker's --env-file parser is line-delimited; an
  embedded newline would split the value and silently pass wrong creds to
  Gradle, surfacing as a cryptic signing failure. Catch it early with an
  actionable error.
- 5b (55): root .gitignore comment misstated the existing android/
  .gitignore rule as `android/**/*.jks` — the actual rule is `*.jks`.
  Functionally equivalent but worth correcting to avoid future confusion.

Verified: newline detection fires with the expected error message when a
password containing $'\n' is supplied; existing validation order is
preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
forgejo_admin merged commit b2dec037a8 into master 2026-05-22 08:14:01 +00:00
Sign in to join this conversation.
No reviewers
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#185
No description provided.