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: