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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 viauses:(CLAUDE.md, 2026-09-07 — whileweblib-ciwas 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), nowweblib-vieweritself (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
sign-apk.shfdroid-update.shfdroid updateover arepo/directory of already-signed APKs and produce the signed indexpublish.shaction.ymlDeliberately 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
zipalignthenapksigner. The reverse is the oldjarsignerorder and invalidates the signature.apksigneris a standalone nixpkgs attribute (nixpkgs#apksigner, 35.0.6, verified).zipalignis not — it lives only insideandroidenv'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 onlyapksigneris needed. Measure it; do not choose on theory.fdroidserveris in nixpkgs asfdroidserver, 2.4.3.fdroidreads 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 updateneeds no network. Only the publish step does.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 noflake.lock, so it cannot usewith-nixpkgs.shas-is — carry an explicit rev (see weblib-ci#9, the same problem one repo over) and fail loudly rather than silently degrading to an unpinnednix shell nixpkgs#....Not to hard-code
This repo is expected to move to the
chaosbitorganisation, so noweblib/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 andapksigner verifypasses.apksigner verify --print-certsis the check that the APK is signed by the key we think it is, which is worth asserting rather than eyeballing.Done — PR #2 merged. Everything in scope is built:
sign-apk.sh,fdroid-update.sh,publish.sh,action.yml, pluswith-nixpkgs.shand this repo's ownnixpkgs-pin(it has noflake.lock, so it carries an explicit rev and fails loudly rather than degrading to an unpinnednix 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
apksignerand a realfdroid update, and a skip would look exactly like a pass.The zipalign constraint resolved as: not needed.
apksigneralone suffices — from Google's own documentation and corroborated by fdroidserver 2.4.3 never shelling out tozipalignanywhere 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.pyruns against every APKsign-apk.shsigns 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 updateneedsJAVA_HOMEset explicitly — fdroidserver globs standard system JDK paths and a nix store path matches none, so it is derived at run time from the resolvedjarsigner.config.ymlhas 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.