Build the shared F-Droid publishing tooling: sign, index, publish #1

Closed
opened 2026-09-09 06:11:06 +00:00 by claude · 1 comment
Member

The shared tooling for maintaining a self-hosted F-Droid repository, consumed as a composite action by whichever repository holds the keys and runs the publish. Same shape as weblib-ci, and public for the same measured reason: a private repo cannot be consumed cross-repo via uses: (CLAUDE.md, 2026-09-07 — while weblib-ci was private, every form failed).

Nothing secret belongs here, ever. Keys, keystores and credentials live in the consuming repository and are passed in as inputs.

Do not name a specific consumer anywhere in this repo — not in the README, action.yml, comments or examples. It has already changed twice: first two private repos (fdroid-repo, fdroid-repo-dev), now weblib-viewer itself (bit, weblib-viewer#13, 2026-09-09), and it may move again when the release key is isolated (weblib-viewer#14, on hold). Channel names, bucket, repo URL and app id are inputs, and the number of channels is not fixed at two.

Scope

script what it does
sign-apk.sh align and sign a prebuilt, unsigned APK with a keystore supplied by the caller
fdroid-update.sh run fdroid update over a repo/ directory of already-signed APKs and produce the signed index
publish.sh upload the resulting repo tree to an S3-compatible bucket
action.yml the composite action the consumer uses

Deliberately not in scope: fetching the APK. That is the consumer's business, and keeping it out is what lets this repo stay ignorant of which upstreams exist.

Constraints, each measured rather than assumed

  • Align before signing. zipalign then apksigner. The reverse is the old jarsigner order and invalidates the signature.
  • apksigner is a standalone nixpkgs attribute (nixpkgs#apksigner, 35.0.6, verified). zipalign is not — it lives only inside androidenv's composed build-tools, which pulls a full versioned Android SDK and its licence machinery for one binary. Resolve this first: either accept that dependency, or establish that the Gradle build already emits an aligned APK so only apksigner is needed. Measure it; do not choose on theory.
  • fdroidserver is in nixpkgs as fdroidserver, 2.4.3.
  • Binary-repo mode: APKs are already signed when they arrive, so fdroid reads their signature and never needs the app signing key. The repo index key is a separate key from the APK signing key; do not conflate them.
  • fdroid update needs no network. Only the publish step does.
  • An F-Droid repo is static files. F-Droid's own mirror docs show a plain static webserver; the client fetches paths named inside the signed index, so no directory listing is required. Ranged GETs are already proven against Garage.
  • Pin nixpkgs. fdroidserver's output feeds a signed index; letting its version drift with the channel is a worse trade here than for a test job. This repo has no flake.lock, so it cannot use with-nixpkgs.sh as-is — carry an explicit rev (see weblib-ci#9, the same problem one repo over) and fail loudly rather than silently degrading to an unpinned nix shell nixpkgs#....

Not to hard-code

This repo is expected to move to the chaosbit organisation, so no weblib/ in any path, URL or workflow reference — nor any bucket name, repo URL or app id.

Verification

Offline, and needing no key that matters: generate a throwaway keystore in a temp dir, sign a fixture APK with it, run fdroid update, and assert the index exists and apksigner verify passes. apksigner verify --print-certs is the check that the APK is signed by the key we think it is, which is worth asserting rather than eyeballing.

The shared tooling for maintaining a self-hosted F-Droid repository, consumed as a composite action by whichever repository holds the keys and runs the publish. Same shape as `weblib-ci`, and public for the same measured reason: a private repo cannot be consumed cross-repo via `uses:` (`CLAUDE.md`, 2026-09-07 — while `weblib-ci` was private, every form failed). **Nothing secret belongs here, ever.** Keys, keystores and credentials live in the consuming repository and are passed in as inputs. **Do not name a specific consumer** anywhere in this repo — not in the README, `action.yml`, comments or examples. It has already changed twice: first two private repos (`fdroid-repo`, `fdroid-repo-dev`), now `weblib-viewer` itself (bit, weblib-viewer#13, 2026-09-09), and it may move again when the release key is isolated (weblib-viewer#14, on hold). Channel names, bucket, repo URL and app id are **inputs**, and the number of channels is not fixed at two. ## Scope | script | what it does | |---|---| | `sign-apk.sh` | align and sign a prebuilt, unsigned APK with a keystore supplied by the caller | | `fdroid-update.sh` | run `fdroid update` over a `repo/` directory of already-signed APKs and produce the signed index | | `publish.sh` | upload the resulting repo tree to an S3-compatible bucket | | `action.yml` | the composite action the consumer uses | Deliberately **not** in scope: fetching the APK. That is the consumer's business, and keeping it out is what lets this repo stay ignorant of which upstreams exist. ## Constraints, each measured rather than assumed - **Align before signing.** `zipalign` then `apksigner`. The reverse is the old `jarsigner` order and invalidates the signature. - **`apksigner` is a standalone nixpkgs attribute** (`nixpkgs#apksigner`, 35.0.6, verified). **`zipalign` is not** — it lives only inside `androidenv`'s composed build-tools, which pulls a full versioned Android SDK and its licence machinery for one binary. **Resolve this first:** either accept that dependency, or establish that the Gradle build already emits an aligned APK so only `apksigner` is needed. Measure it; do not choose on theory. - **`fdroidserver` is in nixpkgs** as `fdroidserver`, 2.4.3. - **Binary-repo mode**: APKs are already signed when they arrive, so `fdroid` reads their signature and never needs the app signing key. The **repo index key** is a separate key from the **APK signing key**; do not conflate them. - **`fdroid update` needs no network.** Only the publish step does. - **An F-Droid repo is static files.** F-Droid's own mirror docs show a plain static webserver; the client fetches paths named inside the signed index, so no directory listing is required. Ranged GETs are already proven against Garage. - **Pin nixpkgs.** `fdroidserver`'s output feeds a *signed index*; letting its version drift with the channel is a worse trade here than for a test job. This repo has no `flake.lock`, so it cannot use `with-nixpkgs.sh` as-is — carry an explicit rev (see weblib-ci#9, the same problem one repo over) and fail loudly rather than silently degrading to an unpinned `nix shell nixpkgs#...`. ## Not to hard-code This repo is expected to move to the `chaosbit` organisation, so no `weblib/` in any path, URL or workflow reference — nor any bucket name, repo URL or app id. ## Verification Offline, and needing no key that matters: generate a throwaway keystore in a temp dir, sign a fixture APK with it, run `fdroid update`, and assert the index exists and `apksigner verify` passes. `apksigner verify --print-certs` is the check that the APK is signed by the key we think it is, which is worth asserting rather than eyeballing.
Author
Member

Done — PR #2 merged. Everything in scope is built: sign-apk.sh, fdroid-update.sh, publish.sh, action.yml, plus with-nixpkgs.sh and this repo's own nixpkgs-pin (it has no flake.lock, so it carries an explicit rev and fails loudly rather than degrading to an unpinned nix shell).

42 tests, 0 skipped, 0 failed — re-run independently, not taken from the report. Zero skips is the number that matters here: the interesting cases need a real apksigner and a real fdroid update, and a skip would look exactly like a pass.

The zipalign constraint resolved as: not needed. apksigner alone suffices — from Google's own documentation and corroborated by fdroidserver 2.4.3 never shelling out to zipalign anywhere in its 38 modules. That was not confirmed against a real Flutter build, which was judged too expensive to run here, and the gap is defended rather than merely disclosed: check_zip_alignment.py runs against every APK sign-apk.sh signs and refuses a misaligned one. If the claim is ever wrong it fails at the first real APK instead of shipping something that misbehaves on a device.

Two things worth not re-deriving, now in comments where they bite:

  • fdroid update needs JAVA_HOME set explicitly — fdroidserver globs standard system JDK paths and a nix store path matches none, so it is derived at run time from the resolved jarsigner.
  • fdroidserver's config.yml has a built-in {env: VARNAME} indirection, added precisely so a config naming a keystore and its passwords can still be committed. Used instead of inventing a templating scheme.

Not in scope and still not done: fetching the APK. That stays with the consumer, which is what keeps this repo ignorant of which upstreams exist — and it is now weblib-viewer, per weblib-viewer#13.

Done — PR #2 merged. Everything in scope is built: `sign-apk.sh`, `fdroid-update.sh`, `publish.sh`, `action.yml`, plus `with-nixpkgs.sh` and this repo's own `nixpkgs-pin` (it has no `flake.lock`, so it carries an explicit rev and fails loudly rather than degrading to an unpinned `nix shell`). **42 tests, 0 skipped, 0 failed** — re-run independently, not taken from the report. Zero skips is the number that matters here: the interesting cases need a real `apksigner` and a real `fdroid update`, and a skip would look exactly like a pass. **The zipalign constraint resolved as: not needed.** `apksigner` alone suffices — from Google's own documentation and corroborated by fdroidserver 2.4.3 never shelling out to `zipalign` anywhere in its 38 modules. That was *not* confirmed against a real Flutter build, which was judged too expensive to run here, and the gap is defended rather than merely disclosed: `check_zip_alignment.py` runs against **every** APK `sign-apk.sh` signs and refuses a misaligned one. If the claim is ever wrong it fails at the first real APK instead of shipping something that misbehaves on a device. Two things worth not re-deriving, now in comments where they bite: - **`fdroid update` needs `JAVA_HOME` set explicitly** — fdroidserver globs standard system JDK paths and a nix store path matches none, so it is derived at run time from the resolved `jarsigner`. - **fdroidserver's `config.yml` has a built-in `{env: VARNAME}` indirection**, added precisely so a config naming a keystore and its passwords can still be committed. Used instead of inventing a templating scheme. Not in scope and still not done: **fetching the APK**. That stays with the consumer, which is what keeps this repo ignorant of which upstreams exist — and it is now weblib-viewer, per weblib-viewer#13.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: weblib/fdroid-repo-ci#1