feat(client): signed Android APK build with runtime API URL (#187) #207

Merged
james merged 3 commits from 187-android-build into main 2026-06-21 15:57:27 +00:00
Owner

Summary

Closes #187. Three commits land the signed Android APK pipeline with a runtime API URL:

  1. feat(client): runtime API URL settings + secure storage — first-launch server-URL screen, expo-secure-store persistence, request middleware that splices the runtime URL into every native API call, "Change server" entry in the Account screen. Web is unaffected (same-origin).
  2. feat(client): android build wiring (prebuild + signing config) — Expo config plugin that injects a release signingConfig into the generated android/app/build.gradle, pnpm -F @carol/client build:android wrapper script, .gitignore entries for the generated tree and keystores, full notes in apps/client/README.md.
  3. feat(ci): forgejo workflow for signed android release.forgejo/workflows/release-android.yml triggers on the same v*.*.* tag as the API release. Installs JDK 21 + the Android SDK, runs expo prebuild + gradlew assembleRelease bundleRelease, attaches APK + AAB to the same Forgejo release.

No baked-in URL — the Android APK ships with nothing and prompts on first launch (per the ticket). Validation is http(s)://<host>; the reachability test is the next login attempt.

Secrets reviewers should provision before the next tag release

Set these on forge.wynning.tech (Settings → Actions → Secrets) before pushing a tag — without them the release-android workflow falls through to a debug-signed build and skips the asset upload, so the existing API release pipeline still succeeds:

  • ANDROID_KEYSTORE_BASE64base64 -w0 carol-release.jks
  • ANDROID_KEYSTORE_PASSWORD
  • ANDROID_KEY_ALIAS
  • ANDROID_KEY_PASSWORD

Keystore generation + the warning about backing it up live in apps/client/README.md "Android release build" and docs/ci.md "Release pipeline".

CI workflow summary

  • PR pipeline (this PR): existing jobs run. No Android build per PR — would burn substantial runner minutes installing the SDK every push. The Client (web export smoke) job covers the universal client.
  • Tag pipeline (post-merge, on v*.*.*): the existing release.yml workflow runs unchanged. The new release-android.yml runs in parallel, waits for release.yml to create the release page (polled every 10s for ~5 min), then uploads carol-vX.Y.Z.apk and carol-vX.Y.Z.aab as assets.

Test plan

Verified locally:

  • pnpm install --frozen-lockfile clean.
  • pnpm -F @carol/api typecheck / lint / test (574 passed, 107 skipped — sqlite leg only locally; postgres leg runs in CI).
  • pnpm -F @carol/api-client typecheck / lint / test / check.
  • pnpm -F @carol/client typecheck / lint / test (25 tests).
  • pnpm -F @carol/client export:web — web bundle unchanged; /server-setup route exports.
  • pnpm -F @carol/client prebuild:android — generates android/, plugin splices signingConfigs.release and rewires the release buildType to use the env-var-conditional config.
  • actionlint .forgejo/workflows/*.yml clean.
  • lefthook hooks pass on every commit (gitleaks, actionlint, conventional-commits).

Waits on first CI execution:

  • The actual signed-APK build on the Forgejo runner. The JDK 21 install via apt, Android SDK install via cmdline-tools, Gradle assembleRelease / bundleRelease, and the asset-upload polling step all run for the first time when the next tag is cut. If anything in that pipeline regresses, the fix-forward lands in a follow-up commit.

Surprises

  • expo prebuild rewrites apps/client/package.json (e.g. swaps "android": "expo start --android" for "expo run:android"). I revert the swap in the build script; the regression isn't fatal but worth flagging if a future contributor sees the script diff move.
  • The Expo SDK 56 template ships signingConfigs { debug { … } } already, so the plugin extends that block rather than creating one — the original "splice a new block before buildTypes" approach silently no-op'd. Caught by re-running prebuild + inspecting the gradle.
  • The signingConfig signingConfigs.debug literal appears in both the debug and release buildTypes; a global string replace would target the wrong one. The plugin scans for the release block by name and edits only inside it.

Follow-ups worth filing

  • iOS feasibility study. ADR-0027 explicitly scopes Android + Linux Flatpak; iOS is open. Useful to scope cost (developer cert, signing infra, App Store policy) even if it stays out of scope.
  • Play Store distribution. Same surface — we ship the AAB to Forgejo releases today; uploading to the Play Console is a separate flow.
  • Key rotation tooling. Today the maintainer holds one keystore, no rotation flow. If it ever leaks, every installed APK has to be replaced via an uninstall-first upgrade.

Generated with Claude Code

## Summary Closes #187. Three commits land the signed Android APK pipeline with a runtime API URL: 1. `feat(client): runtime API URL settings + secure storage` — first-launch server-URL screen, expo-secure-store persistence, request middleware that splices the runtime URL into every native API call, "Change server" entry in the Account screen. Web is unaffected (same-origin). 2. `feat(client): android build wiring (prebuild + signing config)` — Expo config plugin that injects a release signingConfig into the generated `android/app/build.gradle`, `pnpm -F @carol/client build:android` wrapper script, `.gitignore` entries for the generated tree and keystores, full notes in `apps/client/README.md`. 3. `feat(ci): forgejo workflow for signed android release` — `.forgejo/workflows/release-android.yml` triggers on the same `v*.*.*` tag as the API release. Installs JDK 21 + the Android SDK, runs `expo prebuild` + `gradlew assembleRelease bundleRelease`, attaches APK + AAB to the same Forgejo release. No baked-in URL — the Android APK ships with nothing and prompts on first launch (per the ticket). Validation is `http(s)://<host>`; the reachability test is the next login attempt. ## Secrets reviewers should provision before the next tag release Set these on `forge.wynning.tech` (Settings → Actions → Secrets) before pushing a tag — without them the release-android workflow falls through to a debug-signed build and skips the asset upload, so the existing API release pipeline still succeeds: - `ANDROID_KEYSTORE_BASE64` — `base64 -w0 carol-release.jks` - `ANDROID_KEYSTORE_PASSWORD` - `ANDROID_KEY_ALIAS` - `ANDROID_KEY_PASSWORD` Keystore generation + the warning about backing it up live in `apps/client/README.md` "Android release build" and `docs/ci.md` "Release pipeline". ## CI workflow summary - **PR pipeline** (this PR): existing jobs run. No Android build per PR — would burn substantial runner minutes installing the SDK every push. The `Client (web export smoke)` job covers the universal client. - **Tag pipeline** (post-merge, on `v*.*.*`): the existing release.yml workflow runs unchanged. The new release-android.yml runs in parallel, waits for release.yml to create the release page (polled every 10s for ~5 min), then uploads `carol-vX.Y.Z.apk` and `carol-vX.Y.Z.aab` as assets. ## Test plan Verified locally: - [x] `pnpm install --frozen-lockfile` clean. - [x] `pnpm -F @carol/api typecheck` / `lint` / `test` (574 passed, 107 skipped — sqlite leg only locally; postgres leg runs in CI). - [x] `pnpm -F @carol/api-client typecheck` / `lint` / `test` / `check`. - [x] `pnpm -F @carol/client typecheck` / `lint` / `test` (25 tests). - [x] `pnpm -F @carol/client export:web` — web bundle unchanged; `/server-setup` route exports. - [x] `pnpm -F @carol/client prebuild:android` — generates `android/`, plugin splices `signingConfigs.release` and rewires the release buildType to use the env-var-conditional config. - [x] `actionlint .forgejo/workflows/*.yml` clean. - [x] lefthook hooks pass on every commit (gitleaks, actionlint, conventional-commits). Waits on first CI execution: - [ ] The actual signed-APK build on the Forgejo runner. The JDK 21 install via apt, Android SDK install via cmdline-tools, Gradle assembleRelease / bundleRelease, and the asset-upload polling step all run for the first time when the next tag is cut. If anything in that pipeline regresses, the fix-forward lands in a follow-up commit. ## Surprises - `expo prebuild` rewrites `apps/client/package.json` (e.g. swaps `"android": "expo start --android"` for `"expo run:android"`). I revert the swap in the build script; the regression isn't fatal but worth flagging if a future contributor sees the script diff move. - The Expo SDK 56 template ships `signingConfigs { debug { … } }` already, so the plugin extends that block rather than creating one — the original "splice a new block before buildTypes" approach silently no-op'd. Caught by re-running prebuild + inspecting the gradle. - The `signingConfig signingConfigs.debug` literal appears in both the debug and release buildTypes; a global string replace would target the wrong one. The plugin scans for the release block by name and edits only inside it. ## Follow-ups worth filing - **iOS feasibility study.** ADR-0027 explicitly scopes Android + Linux Flatpak; iOS is open. Useful to scope cost (developer cert, signing infra, App Store policy) even if it stays out of scope. - **Play Store distribution.** Same surface — we ship the AAB to Forgejo releases today; uploading to the Play Console is a separate flow. - **Key rotation tooling.** Today the maintainer holds one keystore, no rotation flow. If it ever leaks, every installed APK has to be replaced via an uninstall-first upgrade. Generated with [Claude Code](https://claude.com/claude-code)
Adds the first-launch server-URL prompt and the secure-store-backed
runtime API URL plumbing the Android build needs. Per ADR-0027 §2 and
ticket #187, the native binary has no baked-in URL — the user enters
their instance URL on first launch and we persist it via
expo-secure-store (Android EncryptedSharedPreferences), mirroring the
pattern in lib/auth/storage.ts. apiClient.ts gains a request
middleware that rewrites each request's URL to the cached runtime
base; web stays a no-op (same-origin keeps working unchanged). The
account screen adds a "change server" entry that clears the URL,
which the root layout's server-URL gate observes and re-routes back
to /server-setup.
Adds the local Android build path: an Expo config plugin
(plugins/with-release-signing.js) splices a release signingConfig
into the generated android/app/build.gradle on every prebuild, and a
wrapper script (scripts/build-android.sh) picks signed vs unsigned
based on whether the four CAROL_ANDROID_* env vars are populated.
Wires up via `pnpm -F @carol/client build:android`. The generated
android/ + ios/ directories and keystore artifacts (*.jks,
*.keystore) are gitignored — the keystore lives in CI secrets per
apps/client/README.md "Android release build".

Verified locally:
- vitest covers the plugin's gradle-rewriting logic (idempotency,
  fallback to creating signingConfigs from scratch, error paths).
- `expo prebuild --platform android --no-install --clean` produces a
  build.gradle with the release signingConfig present and the
  release buildType using the env-var-conditional signingConfig.
- Web export unchanged.

The actual Gradle build runs on the Forgejo runner (no Android SDK
in this worktree); the workflow in the next commit drives it.
feat(ci): forgejo workflow for signed android release (#187)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 9s
PR / OSV-Scanner (pull_request) Successful in 2m13s
PR / pnpm audit (pull_request) Successful in 2m35s
PR / Static analysis (pull_request) Successful in 2m38s
PR / OpenAPI (pull_request) Successful in 3m3s
PR / Client (web export smoke) (pull_request) Successful in 3m56s
PR / Lint (pull_request) Successful in 4m2s
PR / Typecheck (pull_request) Successful in 4m9s
PR / Package age policy (soft) (pull_request) Successful in 1m29s
PR / Build (pull_request) Successful in 4m28s
PR / Test (postgres) (pull_request) Failing after 4m28s
PR / Test (sqlite) (pull_request) Successful in 4m28s
Secrets / gitleaks (pull_request) Successful in 1m27s
PR / Coverage (soft) (pull_request) Successful in 2m5s
PR / Trivy (image) (pull_request) Failing after 2m58s
0d407f371f
Adds .forgejo/workflows/release-android.yml — a parallel release
workflow that triggers on the same v*.*.* tag pattern as the API
container release. Sets up JDK 21 + the Android SDK on the runner,
runs `expo prebuild --platform android` followed by Gradle's
assembleRelease + bundleRelease, then attaches the signed APK and
AAB to the same Forgejo release page release.yml creates.

Both Java and the Android SDK install by curl (matching the pattern
release.yml uses for cosign/git-cliff/trivy) rather than via
third-party actions — keeps the SHA-pin surface narrow. The
keystore lands from base64-encoded Forgejo secrets onto a temp
file, gradle reads it through the four CAROL_ANDROID_* env vars
the plugin defined in the previous commit. Missing secrets fall
through to a debug build and skip the asset-upload step — same
mode-of-failure pattern as a missing cosign key in the API release.

Updates CLAUDE.md's release bullet, docs/ci.md, and PR workflow
comment to mention the new pipeline. actionlint clean against all
workflow files locally.

Waits on first CI run: the actual signed-APK build hasn't been
exercised on the runner yet. Likely fix-forward needed if the
distro's openjdk-21 package name has drifted or if Expo's
cmdline-tools revision expectations bumped.

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 70.4% ≥ 50%
Branches 61.2% ⚠️ ≥ 75%
Functions 68.0% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 70.4% ✅ | ≥ 50% | | Branches | 61.2% ⚠️ | ≥ 75% | | Functions | 68.0% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |
james merged commit 5a47d9f018 into main 2026-06-21 15:57:27 +00:00
james deleted branch 187-android-build 2026-06-21 15:57:28 +00:00
Sign in to join this conversation.
No description provided.