feat(android): assembleRelease + env-var signing config (#165) (#185)
All checks were successful
CI / Lint, Typecheck & Unit Tests (push) Successful in 30s
CI / E2E (Standalone single-file) (push) Successful in 1m27s
CI / E2E (Web) (push) Successful in 2m43s

Local-only Android release signing, env-var driven. The maintainer keeps the .jks on their machine; CI age-encrypted-in-repo variant deferred to a follow-up. Adds signingConfigs.release reading 4 env vars (v1+v2+v3 enabled), scripts/build-apk-local.sh --release with .env auto-sourcing and --env-file secret handling, .env.sample template, root .gitignore for *.jks/*.keystore, peer-promoted Signed release APK section in docs/android-apk.md, and new docs/android-signing.md with the keystore ceremony, threat model, backup checklist, and rotation procedure.

Closes #165.
This commit is contained in:
forgejo_admin 2026-05-22 12:14:01 +04:00
parent e3f630a913
commit b2dec037a8
6 changed files with 582 additions and 46 deletions

26
.env.sample Normal file
View file

@ -0,0 +1,26 @@
# MetaScrub local environment overrides.
#
# Copy to `.env` and edit. `.env` is gitignored; never commit it.
# cp .env.sample .env
#
# Sourced automatically by scripts/build-apk-local.sh when `--release` is used.
# For day-to-day debug builds, no .env is required.
#
# See docs/android-signing.md for the keystore generation ceremony, backup
# checklist, and rotation procedure.
# --- Android release signing -------------------------------------------------
# Absolute path to the .jks keystore on the host. The script bind-mounts this
# file read-only into the build container; it never enters the repo working
# tree. Keep the keystore (and its backups) outside the repo.
METASCRUB_KEYSTORE_FILE=/absolute/path/to/metascrub-release.jks
# Key alias used inside the keystore (the -alias passed to keytool -genkey).
METASCRUB_KEYSTORE_ALIAS=metascrub
# Store password and key password. Often identical; keep them distinct for
# defence in depth. Use a password manager — never paste these into chat,
# commit messages, or shell history (`HISTCONTROL=ignorespace` + leading space
# avoids the latter for one-off invocations).
METASCRUB_KEYSTORE_PASSWORD=change-me
METASCRUB_KEY_PASSWORD=change-me

7
.gitignore vendored
View file

@ -319,3 +319,10 @@ test-results/
# Local Docker build caches (see scripts/build-apk-local.sh)
.docker-cache/
# Android signing keystores — never committed at any path. android/.gitignore
# already has `*.jks` (matching any .jks under android/); this root-level rule
# catches a keystore generated outside android/ — e.g. from the repo root
# during the Phase 0 ceremony in docs/android-signing.md.
*.jks
*.keystore

View file

@ -16,10 +16,47 @@ android {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Release signing is driven by four environment variables the keystore
// itself is never committed. See docs/android-signing.md for the keytool
// ceremony, backup expectations, and rotation procedure.
//
// METASCRUB_KEYSTORE_FILE absolute path to the .jks file
// METASCRUB_KEYSTORE_ALIAS key alias inside the keystore
// METASCRUB_KEYSTORE_PASSWORD store password
// METASCRUB_KEY_PASSWORD key password (often same as store password)
//
// When METASCRUB_KEYSTORE_FILE is absent, `assembleRelease` deliberately
// produces `app-release-unsigned.apk` the artifact F-Droid's build server
// expects. Local maintainers wanting a self-signed APK set the env vars
// (via `.env` + `scripts/build-apk-local.sh --release`).
def releaseKeystoreFile = System.getenv("METASCRUB_KEYSTORE_FILE")
signingConfigs {
release {
if (releaseKeystoreFile) {
storeFile file(releaseKeystoreFile)
storePassword System.getenv("METASCRUB_KEYSTORE_PASSWORD")
keyAlias System.getenv("METASCRUB_KEYSTORE_ALIAS")
keyPassword System.getenv("METASCRUB_KEY_PASSWORD")
// v1 (JAR) + v2 (APK Signature Scheme v2) are AGP defaults and
// are required for our minSdk 23 (Android 6.0) install path:
// Android 6 only understands v1, Android 7+ uses v2. v3 enables
// the signing-key rotation lineage scheme required by the
// rotation procedure in docs/android-signing.md and by the
// issue #165 acceptance criteria. v4 is for Play Store
// streaming installs (out of scope; not enabled).
enableV1Signing true
enableV2Signing true
enableV3Signing true
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
if (releaseKeystoreFile) {
signingConfig signingConfigs.release
}
}
}
}

View file

@ -132,6 +132,49 @@ The Dockerfile is in [`docker/android-builder/`](../docker/android-builder/) —
---
## Signed release APK
The default `./scripts/build-apk-local.sh` invocation produces a **debug** APK signed with Android's auto-generated per-developer debug keystore. That's the right artifact for personal sideloading and forensic verification, but it cannot install over a release APK signed by anyone else — and Play Protect surfaces a warning on a downloaded debug APK that does not appear on a release APK.
For distribution beyond personal use (a download page, F-Droid, multi-dev rebuilds that should install over each other), switch to a signed release build:
```bash
./scripts/build-apk-local.sh --release
# Output: android/app/build/outputs/apk/release/app-release.apk
```
The flag is enabled by populating `.env` at the repo root (gitignored) from the committed template:
```bash
cp .env.sample .env
$EDITOR .env # set the four METASCRUB_* variables
```
Four environment variables drive the signing config in `android/app/build.gradle`:
| Variable | Meaning |
| --- | --- |
| `METASCRUB_KEYSTORE_FILE` | Absolute path to the `.jks` keystore on the host. Bind-mounted read-only into the build container; never enters the repo working tree. |
| `METASCRUB_KEYSTORE_ALIAS` | The key alias inside the keystore (the `-alias` passed to `keytool -genkey`). |
| `METASCRUB_KEYSTORE_PASSWORD` | Store password. |
| `METASCRUB_KEY_PASSWORD` | Key password. Often identical to the store password; keep them distinct for defence in depth. |
If any variable is missing or the keystore file is unreadable, the script refuses to build — silent fallback to an unsigned APK would let you ship an artifact that fails update-install checks on user devices without ever noticing during the build.
When all four env vars are absent, `./gradlew assembleRelease` (run directly, without the script) deliberately produces `app-release-unsigned.apk` — the artifact F-Droid's build server expects. The script's `--release` path always signs; the unsigned-release artifact is for F-Droid submission only.
> **Generating the keystore is a one-time ceremony with permanent consequences.** The SHA-256 fingerprint of this keystore becomes the app's permanent identity. A lost keystore means no future APK can install as an update on top of an existing one — there is no recovery mechanism. A leaked keystore lets an attacker produce APKs that Android treats as legitimate updates to MetaScrub, inheriting all granted permissions. Read **[`docs/android-signing.md`](android-signing.md)** before running `keytool -genkey`; it covers the recommended parameters, the offline-backup checklist, and the rotation procedure.
Verify the signature before distributing the APK:
```bash
apksigner verify --print-certs android/app/build/outputs/apk/release/app-release.apk
```
The certificate fingerprint must match the one published in `docs/android-signing.md` § Fingerprint.
---
## One-time project setup
These steps add Capacitor to the repo. Run them once, commit the result, then `cap sync` is all you do on subsequent builds.
@ -223,24 +266,9 @@ The debug APK is ~510 MB depending on how the WebView strips assets. That's t
2. On the device, allow "Install from unknown sources" for the file manager being used (Android 8+ scopes this per-app rather than system-wide).
3. Tap the APK. Android shows an install dialog. After install, MetaScrub appears in the launcher like any other app.
### Signed release APK
### Release signing
For distribution beyond personal use (e.g. F-Droid, a download link), switch to a signed release build:
```bash
# Generate a project-owned signing key (once; store securely — never commit it)
keytool -genkey -v -keystore metascrub-release.jks \
-alias metascrub -keyalg RSA -keysize 2048 -validity 10000
# Configure signing in android/app/build.gradle
# signingConfigs { release { storeFile file("...") storePassword "..." ... } }
# buildTypes { release { signingConfig signingConfigs.release } }
cd android && ./gradlew assembleRelease
# Output: android/app/build/outputs/apk/release/app-release.apk
```
Store the keystore and its passwords as CI secrets if CI APK builds are added later.
For distribution beyond personal use, see [§ Signed release APK](#signed-release-apk) above and [`docs/android-signing.md`](android-signing.md) for the keystore ceremony, backup expectations, and rotation procedure.
### F-Droid
@ -340,7 +368,7 @@ The fallback path additionally runs `setup-java@v4` + `android-actions/setup-and
**Why not on push/PR:** Android SDK install + Gradle build add ~510 min per run on a cold cache, with no upside for the normal CI path (web build, lint, typecheck, unit tests, e2e are all platform-agnostic). The APK only needs to be rebuilt when there is something to distribute.
**Signing:** Debug APK only — sufficient for personal sideloading and forensic verification. Release signing is out of scope for this iteration (it would require a keystore committed as a CI secret); see the "Signed release APK" section above for the manual path.
**Signing:** CI builds the debug APK only. Release signing is local-only today — run `./scripts/build-apk-local.sh --release` from a machine with the keystore mounted; see [§ Signed release APK](#signed-release-apk) above. Wiring CI release signing (age-encrypted-in-repo keystore + Forgejo Secrets) is tracked as a follow-up in [`docs/android-signing.md`](android-signing.md) § Future work.
---

286
docs/android-signing.md Normal file
View file

@ -0,0 +1,286 @@
# Android release signing
> **One-time ceremony.** Do this once per project. The signing key generated
> here becomes the app's permanent identity — re-generating it is operationally
> impossible without breaking every existing install. Read the whole document
> before running any commands.
## Why a release key
Every APK installed on Android is signed; the signing certificate's SHA-256
fingerprint is how Android decides whether one APK is an *update* of another
already on disk:
- Same package name (`com.metascrub.app`) + same signature → install replaces
the existing app, **keeps user data and permissions**.
- Same package name + **different** signature → install fails with
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`; the user must uninstall first.
The debug keystore that `./gradlew assembleDebug` uses is generated per
developer machine and works only for sideloading by that one developer. A
single project-owned **release** keystore means every release ever produced —
on any dev machine, on any CI runner — signs with the same key, so devices
treat every release as a smooth update of the same app.
## Threat model — what a leak or loss means
| Failure mode | Consequence | Recovery |
| --- | --- | --- |
| **Leak** (attacker has it, you also have it) | Attacker can build APKs that Android trusts as legitimate updates to MetaScrub. A modified APK distributed through a typo-squatted domain or fake mirror installs over the legitimate one with no warning, inheriting all granted permissions. | APK Signature Scheme v3+ key rotation: generate a new key signed by the old key as proof of authority, ship new releases using both. Old key is deprecated. **Requires the old key to be available.** |
| **Loss** (nobody has it) | Rotation is impossible — rotation requires signing with the old key. No future release can install as an update on any existing device. | Users must uninstall the old version (losing data) and reinstall fresh under a different signature. |
For a privacy tool whose value proposition is "no telemetry, no network
calls," a leak is unusually damaging: the audience trusts the signature as
evidence of provenance.
## Decisions before generating
### Validity period
`keytool -validity` defaults to **90 days** — useless for an app you intend
to support for years. Recommended: `-validity 36500` (100 years).
F-Droid recommends 30+ years; Play Store wants 25+; 100 is the "good enough
forever" choice.
### Key algorithm and size
RSA 4096. The default is 2048, which is fine cryptographically but costs you
nothing at sign/verify time to upgrade — and you're stuck with this key for
decades. `-keyalg RSA -keysize 4096`.
### Passwords
Two **separate** strong passwords for the store and the key, generated by a
password manager (24+ characters, no dictionary words). Store them:
- In your password manager (1Password, Bitwarden, etc. — fine for *passwords*;
the privacy invariants forbid cloud-syncing the keystore, not its
passwords).
- In Forgejo Secrets (only when CI release signing is wired up — currently
deferred; see [Future work](#future-work)).
Never paste passwords into:
- Shell history (`HISTCONTROL=ignorespace` + leading space avoids this for
one-off invocations).
- Commit messages or PR descriptions.
- Chat transcripts (including conversations with AI assistants).
- The `.env` file you commit. `.env` is gitignored — but **verify** with
`git check-ignore .env` before saving real passwords there.
### Distinguished name (`-dname`)
Populated explicitly so the cert subject isn't blank. Do **not** put a real
mailing address, phone number, or personal email in here — it ends up
embedded in every APK forever, visible to anyone running
`apksigner verify --print-certs`.
Recommended skeleton:
```
CN=MetaScrub Release, O=MetaScrub, C=XX
```
## Storage strategy (currently: maintainer-only)
The `.jks` file lives on the maintainer's machine. CI does **not** release-sign
today; only local builds via `./scripts/build-apk-local.sh --release` produce
signed APKs.
Trade-offs of this choice:
- ✅ Strictest privacy — the keystore never enters the Forgejo trust boundary,
never sits encrypted-in-repo.
- ✅ No additional tooling (age, sops, git-crypt) to install.
- ⚠️ **Bus factor of 1.** Without a robust offline backup, a laptop loss is a
permanent identity loss. Backups are non-negotiable; see below.
- ⚠️ No CI release path. Every signed APK requires the maintainer to be
online with the keystore mounted.
Future work tracks the age-encrypted-in-repo option for CI signing; see below.
## Backup checklist
The keystore lives in **three** offline places, geographically separated.
Privacy invariants §5 (no telemetry, no cloud sync) rules out 1Password
cloud sync, iCloud Keychain, Google Drive, or any service that transmits the
keystore over the network. The keystore's *passwords* may live in a
password manager; the keystore file itself must not.
- [ ] **Encrypted USB stick** in a fireproof safe — full `.jks` file, sealed
envelope with both passwords on paper.
- [ ] **Paper backup** of `base64(.jks)` + passwords in a different fireproof
container (geographic redundancy). Use `paperkey`-style QR codes or a
print-friendly base64 dump.
- [ ] **Secondary offline drive** — duplicate of (1), in a different physical
location.
Verify each backup at least once a year by decoding it and running
`keytool -list -alias metascrub -keystore <restored.jks>` to confirm the
SHA-256 fingerprint matches `docs/android-signing.md` § Fingerprint.
## Generation ceremony
Run from a trusted machine. The host should be one you control — not a
shared workstation, not a borrowed laptop. Do this **after** the backup
destinations are physically ready (USB sticks formatted and encrypted,
fireproof container at hand, paper printer ready).
```bash
# 0. Work outside the repo. Generating the keystore in $REPO_ROOT risks an
# accidental `git add -A` — root .gitignore covers *.jks but defence in
# depth costs nothing here.
mkdir -p ~/.metascrub && cd ~/.metascrub
# 1. Generate the keystore. keytool prompts interactively for both passwords
# — do not pass them on the command line (they would leak into shell
# history and ps output).
keytool -genkey -v \
-keystore metascrub-release.jks \
-alias metascrub \
-keyalg RSA -keysize 4096 \
-validity 36500 \
-dname "CN=MetaScrub Release, O=MetaScrub, C=XX"
chmod 600 metascrub-release.jks # the script later warns if this drifts
# 2. Print the SHA-256 fingerprint. Copy it into this file under § Fingerprint
# and into any release notes — users can verify it with
# `apksigner verify --print-certs app-release.apk`.
keytool -list -v -alias metascrub -keystore metascrub-release.jks \
| grep -E 'SHA-?256'
# 3. Back up. Do all three before relying on the working copy. The keystore
# file itself is what's being copied — pipe-to-printer / pipe-to-qrencode
# avoids ever writing a plaintext base64 sidecar to disk.
# a) Encrypted USB #1 (the "primary" offline copy, kept in a safe):
cp ~/.metascrub/metascrub-release.jks /media/<usb-1>/metascrub/
# b) Paper backup. Pipe straight to the printer or to a QR encoder so no
# plaintext base64 file is left on disk. Pick whichever your printer
# and storage container support:
base64 ~/.metascrub/metascrub-release.jks | lp # printed pages
base64 ~/.metascrub/metascrub-release.jks | qrencode -o qr.png -t PNG -l H # then save qr.png to encrypted media
# c) Encrypted USB #2 (secondary copy in a different physical location):
cp ~/.metascrub/metascrub-release.jks /media/<usb-2>/metascrub/
# d) Verify each backup by listing the cert in the restored copy — the
# fingerprint must match step 2.
keytool -list -v -alias metascrub -keystore /media/<usb-1>/metascrub/metascrub-release.jks \
| grep -E 'SHA-?256'
# 4. Configure .env at the repo root. The working keystore at
# ~/.metascrub/metascrub-release.jks is kept — the .env points at it.
cd <path-to-exifcleaner-repo>
cp .env.sample .env
$EDITOR .env # set the four METASCRUB_* variables
# METASCRUB_KEYSTORE_FILE=$HOME/.metascrub/metascrub-release.jks
```
## Building a signed release APK
```bash
./scripts/build-apk-local.sh --release
```
The script:
1. Sources `.env` from the repo root if present.
2. Validates all four `METASCRUB_*` env vars are set and the keystore file
exists. **Refuses** to fall back to an unsigned build — silent fallback
would let you ship an APK that fails update-install checks on user
devices without ever noticing during the build.
3. Bind-mounts the keystore read-only into the build container at
`/keystore/release.jks` (a path outside `/workspace`, so it cannot leak
into git status, gradle output, or `cap sync`).
4. Runs `./gradlew assembleRelease`.
5. Reports the output path and the `apksigner verify` command.
Output: `android/app/build/outputs/apk/release/app-release.apk`.
Verify the signature before distribution:
```bash
apksigner verify --print-certs android/app/build/outputs/apk/release/app-release.apk
```
The certificate fingerprint must match the one in § Fingerprint below.
## Fingerprint
> _Fill in after running the ceremony._
| Field | Value |
| --- | --- |
| Alias | `metascrub` |
| Algorithm | `RSA 4096` |
| Validity | 100 years (`-validity 36500`) |
| SHA-256 fingerprint | `XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX` |
| Generated on | `YYYY-MM-DD` |
| Generated from commit | `<short SHA of the commit at which the keystore was generated>` |
Publishing the SHA-256 fingerprint lets users running
`apksigner verify --print-certs` independently verify they're installing an
APK built from this keystore.
## Rotation procedure
> Document now, even if you never use it. By the time you need to rotate,
> the old key is either lost (rotation impossible) or compromised (rotation
> is the only remaining option) — neither state leaves room for figuring
> out the procedure from scratch.
APK Signature Scheme v3+ rotation, in outline:
1. Generate the new keystore by the same ceremony as above (same backup
discipline, same `-keyalg` and `-keysize`). Different `-alias` is fine.
2. Configure `signingConfigs.release` to sign with the new key *and* include
a proof-of-authority lineage signed by the old key:
```groovy
signingConfigs {
release {
storeFile file(System.getenv("METASCRUB_KEYSTORE_FILE_NEW"))
storePassword System.getenv("METASCRUB_KEYSTORE_PASSWORD_NEW")
keyAlias System.getenv("METASCRUB_KEYSTORE_ALIAS_NEW")
keyPassword System.getenv("METASCRUB_KEY_PASSWORD_NEW")
// The lineage file is produced by apksigner rotate; see below.
// The old key signs the lineage as proof of authority.
}
}
```
3. Build the lineage file once with `apksigner rotate`:
```bash
apksigner rotate \
--in /dev/null \
--out metascrub-lineage.bin \
--old-signer --ks metascrub-release.jks --ks-key-alias metascrub \
--new-signer --ks metascrub-release-new.jks --ks-key-alias metascrub-new
```
Commit `metascrub-lineage.bin` to the repo — it's not secret. It just
proves the new key was authorized by the old one.
4. Ship a release built with the new signing config. Users who update
through the project's distribution channel get the lineage transparently;
devices verify the new signature against the old via the lineage.
5. Update § Fingerprint above with the new SHA-256 and the rotation date.
Keep the old fingerprint in a "Retired keys" section so users running
`apksigner verify --print-certs` on old APKs can still cross-check.
## Future work
- **CI release signing via age-encrypted-in-repo keystore.** Commit an
`android/metascrub-release.jks.age` to the repo; keep the age decryption
key in Forgejo Secrets (for CI) and on offline media (for the maintainer).
Build script gains `--age-key <path>` that decrypts to `/keystore/`,
builds, and shreds. Tracked in a follow-up issue.
- **F-Droid metadata.** F-Droid signs APKs with their own key on their build
server, so the project keystore is not involved in the F-Droid
distribution path. Submit `.fdroid.yml` once the APK distribution is
stable.

View file

@ -1,20 +1,30 @@
#!/usr/bin/env bash
# Build the MetaScrub Android debug APK locally using Docker only — no
# Android SDK, JDK, or Node required on the host.
# Build the MetaScrub Android APK locally using Docker only — no Android SDK,
# JDK, or Node required on the host.
#
# Mirrors the steps in .github/workflows/build-android.yml. The Dockerfile
# at docker/android-builder/Dockerfile is the local-dev counterpart to
# forgejo-stack/job-android:latest — same JDK 21 + Android SDK 35
# toolchain, but based on the public node:22-bookworm-slim image so it
# works on any host with Docker.
# Mirrors the steps in .github/workflows/build-android.yml. The Dockerfile at
# docker/android-builder/Dockerfile is the local-dev counterpart to
# forgejo-stack/job-android:latest — same JDK 21 + Android SDK 35 toolchain,
# but based on the public node:22-bookworm-slim image so it works on any host
# with Docker.
#
# Usage:
# ./scripts/build-apk-local.sh # build APK
# ./scripts/build-apk-local.sh # debug APK (default)
# ./scripts/build-apk-local.sh --release # signed release APK
# ./scripts/build-apk-local.sh --rebuild # force image rebuild
# ./scripts/build-apk-local.sh --clean # remove cache dirs and exit
#
# Output:
# android/app/build/outputs/apk/debug/app-debug.apk
# debug: android/app/build/outputs/apk/debug/app-debug.apk
# release: android/app/build/outputs/apk/release/app-release.apk
#
# Release signing reads four env vars from the shell (or .env at repo root):
# METASCRUB_KEYSTORE_FILE — absolute path to the .jks file
# METASCRUB_KEYSTORE_ALIAS — key alias inside the keystore
# METASCRUB_KEYSTORE_PASSWORD — store password
# METASCRUB_KEY_PASSWORD — key password
# See docs/android-signing.md for the keystore ceremony. `cp .env.sample .env`
# is the maintainer's starting point.
#
# Caches (gitignored, repo-local):
# .docker-cache/gradle Gradle dep cache (~/.gradle inside container)
@ -25,6 +35,10 @@ set -euo pipefail
IMAGE_TAG="metascrub-android-builder:local"
DOCKERFILE_DIR="docker/android-builder"
# Fixed in-container keystore mount point. Deliberately outside /workspace so
# it cannot leak into git status, the gradle build output, or `cap sync`.
CONTAINER_KEYSTORE_PATH="/keystore/release.jks"
# Resolve repo root from the script location so the script works from any cwd.
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
@ -66,7 +80,82 @@ cmd_build_image() {
docker build --network host -t "$IMAGE_TAG" "$DOCKERFILE_DIR"
}
# Source .env at the repo root if it exists. Only used by --release because
# debug builds need no secrets; loading .env unconditionally would surprise
# users who keep unrelated overrides there.
cmd_load_env() {
local env_file="$REPO_ROOT/.env"
if [[ -f "$env_file" ]]; then
log "Loading $env_file"
set -a
# shellcheck disable=SC1090
source "$env_file"
set +a
fi
}
# Validate the four release-signing env vars are set and the keystore exists.
# Refuses to fall back to an unsigned build — silent fallback would let users
# ship an APK that fails Play Protect or signature-update checks without ever
# noticing during the build.
cmd_check_release_env() {
local missing=()
for var in METASCRUB_KEYSTORE_FILE METASCRUB_KEYSTORE_ALIAS \
METASCRUB_KEYSTORE_PASSWORD METASCRUB_KEY_PASSWORD; do
if [[ -z "${!var:-}" ]]; then
missing+=("$var")
fi
done
if [[ "${#missing[@]}" -gt 0 ]]; then
err "Missing required env vars for --release:"
for var in "${missing[@]}"; do
err " - $var"
done
err "Set them in your shell or copy .env.sample to .env and edit."
err "See docs/android-signing.md for the keystore ceremony."
exit 1
fi
if [[ ! -f "$METASCRUB_KEYSTORE_FILE" ]]; then
err "METASCRUB_KEYSTORE_FILE does not exist: $METASCRUB_KEYSTORE_FILE"
exit 1
fi
if [[ ! -r "$METASCRUB_KEYSTORE_FILE" ]]; then
err "METASCRUB_KEYSTORE_FILE is not readable: $METASCRUB_KEYSTORE_FILE"
exit 1
fi
# Docker's --env-file parser is line-delimited; a newline embedded in any
# value (alias or password) would split the line and silently pass wrong
# credentials to Gradle. Password managers don't produce newlines, but a
# .env file mis-edit could — catch it here with an actionable message
# instead of a cryptic Gradle signing failure later.
for var in METASCRUB_KEYSTORE_ALIAS METASCRUB_KEYSTORE_PASSWORD METASCRUB_KEY_PASSWORD; do
if [[ "${!var}" == *$'\n'* ]]; then
err "$var contains a newline character — this would corrupt the docker --env-file."
err "Re-generate the value without embedded newlines, or re-save .env in an editor that doesn't wrap long lines."
exit 1
fi
done
# Docker bind-mount syntax uses ':' as the separator. A keystore path
# containing ':' (legal on Linux but rare) would parse wrong and either
# fail or — worse — silently mount something unexpected.
if [[ "$METASCRUB_KEYSTORE_FILE" == *:* ]]; then
err "METASCRUB_KEYSTORE_FILE must not contain ':' (breaks the docker bind mount)."
err "Move the keystore to a path without ':'."
exit 1
fi
# Warn (don't block) if the keystore is more permissive than 0600 — on a
# shared machine, world-readable signing material is a privacy hazard.
# Phase 0 ceremony in docs/android-signing.md chmods 600; this catches drift.
local mode
mode="$(stat -c %a "$METASCRUB_KEYSTORE_FILE" 2>/dev/null || stat -f %Lp "$METASCRUB_KEYSTORE_FILE" 2>/dev/null || echo "")"
if [[ -n "$mode" && "$mode" != "600" && "$mode" != "400" ]]; then
err "warn: $METASCRUB_KEYSTORE_FILE has mode $mode; expected 0600. Run: chmod 600 \"\$METASCRUB_KEYSTORE_FILE\""
fi
}
cmd_run_build() {
local build_type="${1:-debug}"
mkdir -p "$REPO_ROOT/.docker-cache/gradle" \
"$REPO_ROOT/.docker-cache/yarn" \
"$REPO_ROOT/.docker-cache/home"
@ -75,7 +164,59 @@ cmd_run_build() {
host_uid="$(id -u)"
host_gid="$(id -g)"
log "Running build container (uid=$host_uid gid=$host_gid)..."
# Assemble docker run args incrementally so the release path can layer in
# the keystore mount + env vars without duplicating the rest.
local -a docker_args=(
--rm
--network host
--user "$host_uid:$host_gid"
-v "$REPO_ROOT:/workspace"
-v "$REPO_ROOT/.docker-cache/gradle:/cache/gradle"
-v "$REPO_ROOT/.docker-cache/yarn:/cache/yarn"
-v "$REPO_ROOT/.docker-cache/home:/home/builder"
-e HOME=/home/builder
-e GRADLE_USER_HOME=/cache/gradle
-e YARN_CACHE_FOLDER=/cache/yarn
-w /workspace
)
local gradle_task env_file=""
if [[ "$build_type" == "release" ]]; then
gradle_task="assembleRelease"
# Secrets go via --env-file, not -e: `ps auxe` and `docker inspect`
# both surface `-e VAR=value` for the lifetime of the container; an
# env-file path is opaque. The file is mode 0600 (mktemp default) and
# shredded on exit by the trap below. INT/TERM are listed alongside
# EXIT because modern bash does fire EXIT on Ctrl+C in practice, but
# explicit signal traps make the cleanup contract independent of
# shell-version behaviour for this secrets-bearing file.
env_file="$(mktemp -t metascrub-env.XXXXXX)"
chmod 600 "$env_file"
# shellcheck disable=SC2064
trap "shred -u '$env_file' 2>/dev/null || rm -f '$env_file'" EXIT INT TERM
# `cat <<EOF` writes the values literally — no shell expansion of the
# values themselves; docker's --env-file parser treats everything after
# the first '=' as the value (no quote stripping). Passwords containing
# '$', '`', or quotes survive intact.
{
printf 'METASCRUB_KEYSTORE_ALIAS=%s\n' "$METASCRUB_KEYSTORE_ALIAS"
printf 'METASCRUB_KEYSTORE_PASSWORD=%s\n' "$METASCRUB_KEYSTORE_PASSWORD"
printf 'METASCRUB_KEY_PASSWORD=%s\n' "$METASCRUB_KEY_PASSWORD"
} > "$env_file"
# Keystore mounted read-only at a stable in-container path; the
# in-container env var the gradle file reads is overridden to that
# path so the host path never leaks into the build. The keystore
# path is not secret (just a file location), so -e is fine here.
docker_args+=(
-v "$METASCRUB_KEYSTORE_FILE:$CONTAINER_KEYSTORE_PATH:ro"
-e "METASCRUB_KEYSTORE_FILE=$CONTAINER_KEYSTORE_PATH"
--env-file "$env_file"
)
else
gradle_task="assembleDebug"
fi
log "Running build container (uid=$host_uid gid=$host_gid, task=$gradle_task)..."
# Container shell script: mirror the workflow steps verbatim.
#
@ -84,18 +225,11 @@ cmd_run_build() {
# --stacktrace: surfaces Gradle internal errors on failure. Matches the workflow.
# --network host: same rationale as in cmd_build_image — Gradle and yarn
# both pull large dependencies (~150 MB) over HTTPS during the build.
docker run --rm \
--network host \
--user "$host_uid:$host_gid" \
-v "$REPO_ROOT:/workspace" \
-v "$REPO_ROOT/.docker-cache/gradle:/cache/gradle" \
-v "$REPO_ROOT/.docker-cache/yarn:/cache/yarn" \
-v "$REPO_ROOT/.docker-cache/home:/home/builder" \
-e HOME=/home/builder \
-e GRADLE_USER_HOME=/cache/gradle \
-e YARN_CACHE_FOLDER=/cache/yarn \
-w /workspace \
"$IMAGE_TAG" \
#
# Heredoc is single-quoted (no host-side expansion). $gradle_task is
# passed as $1 to the inner bash so the script body stays literal —
# anything added later cannot accidentally get host-expanded.
docker run "${docker_args[@]}" "$IMAGE_TAG" \
bash -eu -o pipefail -c '
echo "==> yarn install"
yarn install --frozen-lockfile
@ -106,17 +240,26 @@ cmd_run_build() {
echo "==> npx cap sync android"
npx cap sync android
echo "==> ./gradlew assembleDebug"
echo "==> ./gradlew $1"
cd android
./gradlew assembleDebug --no-daemon --stacktrace
'
./gradlew "$1" --no-daemon --stacktrace
' build-apk-local "$gradle_task"
}
cmd_report() {
local apk="android/app/build/outputs/apk/debug/app-debug.apk"
local build_type="${1:-debug}"
local apk
if [[ "$build_type" == "release" ]]; then
apk="android/app/build/outputs/apk/release/app-release.apk"
else
apk="android/app/build/outputs/apk/debug/app-debug.apk"
fi
if [[ -f "$apk" ]]; then
log "APK built: $apk ($(du -h "$apk" | cut -f1))"
file "$apk" 2>/dev/null || true
if [[ "$build_type" == "release" ]]; then
log "Verify signature with: apksigner verify --print-certs $apk"
fi
else
err "Expected APK not found at $apk"
exit 1
@ -125,6 +268,7 @@ cmd_report() {
main() {
local rebuild=0
local build_type="debug"
for arg in "$@"; do
case "$arg" in
--clean)
@ -134,6 +278,9 @@ main() {
--rebuild)
rebuild=1
;;
--release)
build_type="release"
;;
-h | --help)
# Print the leading comment block (drops the shebang, stops at
# the first non-comment line). Sentinel-driven so growing the
@ -148,14 +295,19 @@ main() {
esac
done
if [[ "$build_type" == "release" ]]; then
cmd_load_env
cmd_check_release_env
fi
cmd_check_docker
if [[ "$rebuild" -eq 1 ]]; then
cmd_build_image --force
else
cmd_build_image
fi
cmd_run_build
cmd_report
cmd_run_build "$build_type"
cmd_report "$build_type"
}
main "$@"