# RUNBOOK — Android release pipeline (CI -> offline signing -> F-Droid) How an `android-v*` tag becomes a release published in this project's self-hosted F-Droid repository, and why signing — of both the APK and the repository index — never happens on this VPS. ## 1. Why this is not a fully automated pipeline (the trade-off) The distribution requirement asks for "a new version is built and published... by an automated pipeline". An earlier, already locked decision requires the APK master signing key to **never** exist on this VPS — it is a public server, and that key has no safety net (there is no Play App Signing behind it; see `docs/android-signing-keystore.md`). The correction recorded in `docs/android-fdroid-repo.md` §1-2 extends exactly the same rule to this project's second key: the `repokey` that signs the F-Droid repository index **also** never exists on this VPS. Those two requirements are not compatible with "everything runs by itself": there is no command or API that signs something with a key which, by design, is not reachable from where the command runs. The pipeline below automates literally everything — build, versioning, fingerprint verification, publishing, regenerating whatever can be regenerated without the key — and isolates the two operations that require a private key into exactly two manual, offline points on the operator's machine: signing the APK and signing the index. Apart from that, nothing else asks for human intervention. ## 2. The two keys and what each one signs | Key | Lives on | Signs | Never touches this VPS | |---|---|---|---| | APK master key | Operator's machine, offline (`docs/android-signing-keystore.md`) | The APK itself (`apksigner sign`) | Yes — since Phase 2 | | F-Droid `repokey` | Operator's machine, offline (`docs/android-fdroid-repo.md`) | The repository index (`fdroid update`) | Yes — corrected during this phase | This VPS keeps only the two **public fingerprints** derived from those keys (`docs/android-signing-keystore.md` §5 and the `fdroid_repo_fingerprint` key in `data/secrets.vault`) — never the keys, never the passwords. ## 3. versionCode / versionName strategy - **`versionCode = github.run_number`** — the `android-release` job in `.github/workflows/ci.yml` uses `${{ github.run_number }}` directly. GitHub guarantees that number is monotonically increasing across the whole history of the workflow in the repository, even through reverts or retags — exactly the property Android requires to accept a new APK as an update to an installed one (same `applicationId`, same signing certificate, strictly greater `versionCode`). It does not depend on any hand-maintained state file in this repository, so there is no way to forget to bump it. - **`versionName`** comes literally from whatever was tagged: an `android-v1.2.3` tag produces `VERSION_NAME=1.2.3` (`${GITHUB_REF#refs/tags/android-v}`). Purely cosmetic for the user — it plays no part in Android's update logic. - Both reach Gradle through `-PversionCode=... -PversionName=...`, read in `android/app/build.gradle.kts`: ```kotlin versionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1 versionName = project.findProperty("versionName") as String? ?: "0.1.0" ``` A local build without those properties falls back to the development defaults (`1` / `"0.1.0"`) without breaking anything. - **Reproducibility:** given the same commit + the same tag, the build is deterministic except for `versionCode`, which is intentionally tied to the workflow run number (not to the commit content) — it is expected, and correct, that re-running the same workflow (say, after a failure) produces a different `versionCode` than it would have before, because `run_number` advances on every run. `versionName` is reproducible (it depends only on the tag). This is the desired behavior: a `versionCode` must never be reused, even if the previous build failed before publishing. ## 4. ABI — nothing to decide here A single APK covering `arm64-v8a` and `x86_64` (`android/terminal-engine/build.gradle.kts`, `abiFilters`), with no `splits.abi` — a policy of the Android module (Phase 4), only referenced here. This pipeline builds whatever `:app:assembleRelease` already produces and treats it as a single artifact. ## 5. The flow, step by step ### 5.1. Automatic (`android-release` job, `.github/workflows/ci.yml`) Triggered only by a `git push` of an `android-v*` tag (never by an ordinary branch/PR push): 1. Checkout of the tagged commit. 2. `./gradlew :app:assembleRelease -PversionCode=$VERSION_CODE -PversionName=$VERSION_NAME` — produces `android/app/build/outputs/apk/release/app-release-unsigned.apk` (**unsigned** — the job never had, and never will have, access to any private key). 3. Copies that unsigned APK to `data/android-release-staging//app-release-unsigned.apk` on this same VPS (the same runner that builds it), and also publishes it as a workflow artifact (`actions/upload-artifact`), for whoever prefers `gh run download` over plain SSH. ### 5.2. Manual — offline APK signing (operator, checkpoint 1) On the operator's machine, **never** on this VPS: 1. Download `app-release-unsigned.apk` (via `scp` from the staging path, or `gh run download`). 2. `apksigner sign --ks servercontrolpanel-release.jks --ks-key-alias servercontrolpanel --out app-release-signed.apk app-release-unsigned.apk` 3. `apksigner verify --print-certs app-release-signed.apk` and check that the `SHA-256 digest` matches `docs/android-signing-keystore.md` §5 — if it does not, **stop**, do not move on to the next step. 4. Copy `app-release-signed.apk` back to this VPS, into the same staging path: `data/android-release-staging//app-release-signed.apk`. ### 5.3. Manual — offline F-Droid index regeneration (operator, same session) Also on the operator's machine, because that is where the `repokey` lives: 1. Sync (`rsync`/`scp`) the canonical mirror of `data/fdroid/` from this VPS to the operator's machine, if he does not already have an up-to-date working copy. 2. Copy `app-release-signed.apk` into the `repo/` of that mirror. 3. Run `fdroid update` (the `fdroidserver` tool, with its config pointing at the operator's local `repokey`) — this regenerates and signs `index-v2.json`, `index-v1.jar`, `entry.json`, `entry.jar` and the icons inside `repo/` itself. 4. Send the whole `repo/` (now with the new APK + signed index) back to this VPS, into `data/android-release-staging//fdroid-repo/`. ### 5.4. Automatic — publishing (`scripts/android-publish.sh `, on this VPS) Runs on this VPS (the operator triggers it, over ordinary SSH or a future `workflow_dispatch` — the script itself handles no key): 1. Checks the fingerprint of the signed APK against `docs/android-signing-keystore.md` §5 once more (defense in depth). A mismatched fingerprint = refusal, nothing is published. 2. Confirms that `data/android-release-staging//fdroid-repo/index-v2.json` references the exact name of the published APK (a stale index is a silent failure). 3. `rsync -a --delete-after` of the package received from the operator into `data/fdroid/repo/` — the directory that is actually served (`internal/api/handlers_fdroid.go`, no redirect). This script **never** exports anything from `data/secrets.vault`, never invokes `fdroid update` and never sees the `repokey` — it only checks a public fingerprint and copies already-signed bytes to the right place. Covered by TDD in `scripts/test-android-publish.sh` (3 behaviors: mismatched fingerprint refused; matching fingerprint published with an updated index; no key material handled). ### 5.x — generating the incremental patches (last step, automatic) Once the `rsync` is done, `android-publish.sh` calls `scripts/android-patches.sh`, which generates the binary patches (HDiffPatch) from the **already signed** APKs that were just published — the only moment this VPS has the final bytes in hand, since signing is offline. That is why the step comes after the fingerprint gate, not before. A failure here does not undo the publish: the F-Droid repository is already live and is the channel that always works; without a catalog, the app merely loses the incremental path. Details, measured numbers and the API are in `docs/android-incremental-updates.md`. ## 6. Who does what, where — summary | Step | Who | Where | Needs a private key? | |---|---|---|---| | Build + version stamp + staging the unsigned APK | CI (`android-release`) | Self-hosted runner (this VPS) | No | | Sign the APK | Operator | Operator's offline machine | Yes — APK master key | | Regenerate and sign the repository index | Operator | Operator's offline machine | Yes — `repokey` | | Verify fingerprint + publish into `data/fdroid/repo/` | `scripts/android-publish.sh` | This VPS | No | | Generate incremental patches + manifest | `scripts/android-patches.sh` | This VPS | No | ## 7. What breaks if each key is lost - **Losing the `repokey`:** reissuing with a new key forces **every device** to re-add the repository (new QR/URL). Inconvenient, not destructive — no user data is lost, no existing installation breaks by itself (`docs/android-fdroid-repo.md` §2). - **Losing the APK master key:** no device with the app already installed ever receives another update — the certificate of the new APK will never match the installed one. There is no fix at the level of this pipeline; the only safety net is the backup/restore runbook in `docs/android-signing-keystore.md` §6. If the backup is lost too, every existing installation needs a manual reinstall — wiping the app's data, with no fallback on the Android side. These two losses are **not symmetric**: the `repokey` can be reissued at the cost of inconvenience; the APK key has no substitute that preserves update continuity for anyone who already installed the app.