From a5e4cb6a019d9397d86ebd0e0e7079a6f0e5c518 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 9 Sep 2026 12:40:03 +0000 Subject: [PATCH] Take sign-apk.sh out of the default publish path; document the never-an-app-key invariant bit settled the model on weblib-viewer#19: the builder signs an APK, and this repo only distributes it -- the same signature has to work whether the APK reached a device via a git release or via F-Droid, and fdroid update never creates a signature of its own. sign-apk.sh remains for a consumer whose build genuinely produces an unsigned artifact, but action.yml's header comment and the README's "Using it" example no longer show it as a default-flow step, and both now say explicitly that this repo is never handed an app signing key -- only the repo index key. Also records two decisions that had nowhere to live yet: the index key's config/secret names must stay distinct from the APK key's even while both resolve to the same keystore for now (bit intends to split them later), and the index key must be generated as RSA 4096, overriding F-Droid's own docs, which suggest 2048. Fixes #9. Co-authored-by: bit --- README.md | 119 ++++++++++++++++++++++++++++++++++++++++++----------- action.yml | 23 ++++++++--- 2 files changed, 112 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 768925b..c760cfd 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,30 @@ # fdroid-repo-ci -Shared tooling to sign a prebuilt APK, build a signed F-Droid repo index over -it, and publish the result to an S3-compatible bucket -- consumed as a -composite action by whichever repository currently holds the signing keys -and runs the publish. Which repository that is has changed more than once, -so nothing here names it; see "Not hard-coded" below. +Shared tooling to build a signed F-Droid repo index over already-signed APKs +and publish the result to an S3-compatible bucket -- consumed as a composite +action by whichever repository currently holds the (index) signing key and +runs the publish. Which repository that is has changed more than once, so +nothing here names it; see "Not hard-coded" below. + +**The builder signs; this repo only distributes.** Settled on +weblib-viewer#19: an APK can reach a device via a git release *and* via +F-Droid, and updates from either route have to install over each other, +which needs the *same* app signature on both -- so signing has to happen +once, at the build, not again here. This is also simply how F-Droid's +binary-repo mode works: `fdroid update` **reads** each APK's existing +signature and never creates one (see "The repo index key is not the APK +signing key" below). `sign-apk.sh` exists only for the exceptional consumer +whose build genuinely emits an unsigned artifact -- it is not part of the +default pipeline and `action.yml` does not call it. + +**This repository is never handed an app signing key.** The only key it +ever receives is the **repo index key**, which signs `index-v1.jar`/ +`entry.jar` and identifies the repo itself to a client (pinned via +`?fingerprint=` in the repo URL) -- never an individual app. A workflow +change that starts passing an APK keystore or its password into this repo's +scripts (other than as an explicit, opt-in `sign-apk.sh` argument for the +unsigned-artifact exception above) is a sign the invariant has been broken, +not a new normal. Same shape as [weblib-ci], and public for the same measured reason: a private repository cannot be consumed cross-repo via `uses:` on this Gitea @@ -22,7 +42,7 @@ here. | script | what it does | |---|---| | `next-version-code.sh` | print the next monotonic `versionCode` for an application id and a commit's committer date, read from a repo index -- called before the build, CI and a manual build alike (see "One versionCode rule" below) | -| `sign-apk.sh` | check alignment, then sign a prebuilt unsigned release APK with a keystore the caller supplies | +| `sign-apk.sh` | **the exception, not the default flow** (see above) -- check alignment, then sign a prebuilt unsigned release APK with a keystore the caller supplies, for a consumer whose build does not already produce a signed APK | | `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 -- files first, index last, and refuses to overwrite an already-published APK (see "Publish ordering and safety" below) | | `with-nixpkgs.sh` | run a command with one or more nixpkgs packages on PATH, pinned to the revision in `nixpkgs-pin` | @@ -40,6 +60,11 @@ below for what that costs and why it is still the right call here. ## Using it +This is the default flow: the APK arrives at this action **already signed** +by the builder (weblib-viewer#19), so the pipeline only computes the next +`versionCode`, indexes and publishes. It never touches an app signing key -- +only the repo index key, in the `fdroid-update.sh` step. + ```yaml - uses: actions/checkout@v4 - id: ci @@ -49,14 +74,8 @@ below for what that costs and why it is still the right call here. version_code=$(bash "${{ steps.ci.outputs.path }}/next-version-code.sh" \ "$FDROID_REPO_INDEX_URL" "$APPLICATION_ID" "$(git log -1 --format=%ct)") echo "version_code=$version_code" >> "$GITHUB_ENV" - # ... feed $version_code into whatever builds app-release-unsigned.apk - -- env: - APK_KEYSTORE_PASSWORD: ${{ secrets.APK_KEYSTORE_PASSWORD }} - run: | - bash "${{ steps.ci.outputs.path }}/sign-apk.sh" \ - app-release-unsigned.apk app-release.apk \ - "$APK_KEYSTORE" release-key APK_KEYSTORE_PASSWORD + # ... feed $version_code into whatever builds the already-signed + # app-release.apk - env: INDEX_KEYSTORE_PASSWORD: ${{ secrets.INDEX_KEYSTORE_PASSWORD }} @@ -78,17 +97,43 @@ the bare `owner/repo` form resolves against the instance's default actions URL rather than this host and fails outright. The full-URL form is also the only one that works while a referenced repo is private -- irrelevant for *this* repo, which is public on purpose, but relevant for whatever repo is -consuming it, since that one holds the keys. +consuming it, since that one holds the index key. -`$APK_KEYSTORE`/`$INDEX_KEYSTORE`/`$FDROID_REPO_ROOT`/`$BUCKET`/ -`$S3_ENDPOINT_URL`/`$FDROID_REPO_INDEX_URL`/`$APPLICATION_ID` above are the -consumer's own paths and values -- nothing in this repo defaults or hints at -them. Env var *names* like `APK_KEYSTORE_PASSWORD` are the consumer's choice -too, not a convention this repo imposes: `sign-apk.sh` and `fdroid-update.sh` -each take the name of the environment variable to read a password from as an -argument, specifically so two different keystores in the same job (the APK -key and the separate repo index key -- see below) never have to share one -variable name. +`$INDEX_KEYSTORE`/`$FDROID_REPO_ROOT`/`$BUCKET`/`$S3_ENDPOINT_URL`/ +`$FDROID_REPO_INDEX_URL`/`$APPLICATION_ID` above are the consumer's own +paths and values -- nothing in this repo defaults or hints at them. The env +var *name* `INDEX_KEYSTORE_PASSWORD` is the consumer's choice too, not a +convention this repo imposes: `fdroid-update.sh` takes the name of the +environment variable to read the password from as an argument. Name it +distinctly from whatever holds the APK key's password even if, today, both +happen to resolve to the same underlying keystore/secret (see "The repo +index key is not the APK signing key" below) -- that way separating the two +keys later is a value change on the consumer's side, not a rename of this +convention. + +### The exception: a consumer whose build does not already sign + +`sign-apk.sh` is **not part of the flow above** and `action.yml` does not +call it. It exists only for a consumer whose build genuinely emits an +unsigned APK and has nowhere else to sign it -- an app signing key cannot be +rotated, so a build that produces an unsigned artifact and relies on this +step is a real (if narrow) case, not a shortcut around the settled model. +Insert it between the build and the `fdroid-update.sh` step above: + +```yaml +- env: + APK_KEYSTORE_PASSWORD: ${{ secrets.APK_KEYSTORE_PASSWORD }} + run: | + bash "${{ steps.ci.outputs.path }}/sign-apk.sh" \ + app-release-unsigned.apk app-release.apk \ + "$APK_KEYSTORE" release-key APK_KEYSTORE_PASSWORD +``` + +Using it at all means this repo's job **is** handed an app signing key for +that run, which is exactly the thing "This repository is never handed an +app signing key" (above) warns against as the default. Treat a workflow +that includes this step as the documented exception, and prefer fixing the +build to emit a signed APK over reaching for it. ## One `versionCode` rule, shared by CI and a manual build (fdroid-repo-ci#6) @@ -277,6 +322,32 @@ takes its own keystore/alias/password arguments, deliberately unrelated to `sign-apk.sh`'s. Conflating the two would mean one compromised key undermines both the apps and the index that vouches for them. +This is also, concretely, the boundary of what this repo ever holds: it +receives the **repo index key**, and nothing here is the *app* signing key +except through the narrow, opt-in `sign-apk.sh` exception documented above. +The index key identifies this repo to a client, which pins it via +`?fingerprint=` in the repo URL; the app key identifies each app to Android +and, unlike the index key, can never be rotated once a device has installed +against it. + +As of 2026-09-09 (bit), the index key is *for now* the same keystore as the +APK key -- they are expected to be **split later**. That is exactly why +`fdroid-update.sh` and `sign-apk.sh` take independently-named keystore/alias/ +password arguments rather than sharing one: a consumer's workflow should +already name the index key's secrets (e.g. `INDEX_KEYSTORE`, +`INDEX_KEYSTORE_PASSWORD`) distinctly from the APK key's (e.g. +`APK_KEYSTORE`, `APK_KEYSTORE_PASSWORD`), even while both names resolve to +the same underlying secret today. Splitting the keys later is then a value +change on the consumer's side -- a new keystore, a new secret -- not a +rename of any argument or config key here. + +**Generate the index key as RSA 4096, never 2048.** F-Droid's own +documentation suggests 2048 for a repo signing key; that is not followed +here on purpose (bit, 2026-09-09) -- use 4096 regardless of what a `keytool +-genkeypair`/`fdroid init` example elsewhere shows. Recorded here so the +choice is not "corrected" back to F-Droid's default by someone going only +off those docs. + `fdroid-update.sh` writes `/config.yml` itself, on every run, using fdroidserver's own `{env: VARNAME}` indirection for both passwords -- the exact mechanism its `common.py` implements *specifically so config.yml diff --git a/action.yml b/action.yml index bee4ce6..1f8086e 100644 --- a/action.yml +++ b/action.yml @@ -10,17 +10,28 @@ # - uses: actions/checkout@v4 # - id: ci # uses: https://git.chaosbit.de/weblib/fdroid-repo-ci@main -# - run: bash "${{ steps.ci.outputs.path }}/sign-apk.sh" ... +# - run: bash "${{ steps.ci.outputs.path }}/fdroid-update.sh" ... +# - run: bash "${{ steps.ci.outputs.path }}/publish.sh" ... +# +# sign-apk.sh is deliberately not in that default flow: the builder signs +# the APK before it ever reaches this action (weblib-viewer#19), so this +# repo's normal job is to index and publish an already-signed artifact. +# sign-apk.sh stays available for the exceptional consumer whose build +# genuinely produces an unsigned APK -- see the README. # # Nothing here reads a secret or names a bucket, a keystore or a consumer # repo -- those all stay on the caller's side of this action's output path. +# This action is also never handed an *app* signing key -- only the repo +# index key that fdroid-update.sh signs the index with (see README). name: F-Droid repo publishing tools description: >- - Shared, key-free tooling to align-check and sign a prebuilt APK, run - `fdroid update` over a binary repo, compute the next monotonic versionCode - for a commit's committer date, and publish the result to an S3-compatible - bucket. The caller supplies the keys, the bucket, the APK and the commit - being built; this action supplies the pinned toolchain and the scripts. + Key-free tooling to index an already-signed F-Droid binary repo (`fdroid + update`), compute the next monotonic versionCode for a commit's committer + date, and publish the result to an S3-compatible bucket. Also includes + sign-apk.sh for the exceptional consumer whose build does not already + produce a signed APK. The caller supplies the keys, the bucket, the APK + and the commit being built; this action supplies the pinned toolchain and + the scripts. outputs: path: