Take sign-apk.sh out of the default publish path; document the never-an-app-key invariant #10

Merged
claude merged 1 commits from builder-signs-repo-distributes into main 2026-09-09 12:42:12 +00:00
2 changed files with 112 additions and 30 deletions
+95 -24
View File
@@ -1,10 +1,30 @@
# fdroid-repo-ci # fdroid-repo-ci
Shared tooling to sign a prebuilt APK, build a signed F-Droid repo index over Shared tooling to build a signed F-Droid repo index over already-signed APKs
it, and publish the result to an S3-compatible bucket -- consumed as a and publish the result to an S3-compatible bucket -- consumed as a composite
composite action by whichever repository currently holds the signing keys action by whichever repository currently holds the (index) signing key and
and runs the publish. Which repository that is has changed more than once, runs the publish. Which repository that is has changed more than once, so
so nothing here names it; see "Not hard-coded" below. 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 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 private repository cannot be consumed cross-repo via `uses:` on this Gitea
@@ -22,7 +42,7 @@ here.
| script | what it does | | 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) | | `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 | | `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) | | `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` | | `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 ## 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 ```yaml
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- id: ci - 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" \ version_code=$(bash "${{ steps.ci.outputs.path }}/next-version-code.sh" \
"$FDROID_REPO_INDEX_URL" "$APPLICATION_ID" "$(git log -1 --format=%ct)") "$FDROID_REPO_INDEX_URL" "$APPLICATION_ID" "$(git log -1 --format=%ct)")
echo "version_code=$version_code" >> "$GITHUB_ENV" echo "version_code=$version_code" >> "$GITHUB_ENV"
# ... feed $version_code into whatever builds app-release-unsigned.apk # ... feed $version_code into whatever builds the already-signed
# app-release.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
- env: - env:
INDEX_KEYSTORE_PASSWORD: ${{ secrets.INDEX_KEYSTORE_PASSWORD }} 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 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 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 *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`/ `$INDEX_KEYSTORE`/`$FDROID_REPO_ROOT`/`$BUCKET`/`$S3_ENDPOINT_URL`/
`$S3_ENDPOINT_URL`/`$FDROID_REPO_INDEX_URL`/`$APPLICATION_ID` above are the `$FDROID_REPO_INDEX_URL`/`$APPLICATION_ID` above are the consumer's own
consumer's own paths and values -- nothing in this repo defaults or hints at paths and values -- nothing in this repo defaults or hints at them. The env
them. Env var *names* like `APK_KEYSTORE_PASSWORD` are the consumer's choice var *name* `INDEX_KEYSTORE_PASSWORD` is the consumer's choice too, not a
too, not a convention this repo imposes: `sign-apk.sh` and `fdroid-update.sh` convention this repo imposes: `fdroid-update.sh` takes the name of the
each take the name of the environment variable to read a password from as an environment variable to read the password from as an argument. Name it
argument, specifically so two different keystores in the same job (the APK distinctly from whatever holds the APK key's password even if, today, both
key and the separate repo index key -- see below) never have to share one happen to resolve to the same underlying keystore/secret (see "The repo
variable name. 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) ## 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 `sign-apk.sh`'s. Conflating the two would mean one compromised key
undermines both the apps and the index that vouches for them. 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 `<repo-root>/config.yml` itself, on every run, `fdroid-update.sh` writes `<repo-root>/config.yml` itself, on every run,
using fdroidserver's own `{env: VARNAME}` indirection for both passwords -- using fdroidserver's own `{env: VARNAME}` indirection for both passwords --
the exact mechanism its `common.py` implements *specifically so config.yml the exact mechanism its `common.py` implements *specifically so config.yml
+17 -6
View File
@@ -10,17 +10,28 @@
# - uses: actions/checkout@v4 # - uses: actions/checkout@v4
# - id: ci # - id: ci
# uses: https://git.chaosbit.de/weblib/fdroid-repo-ci@main # 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 # 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. # 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 name: F-Droid repo publishing tools
description: >- description: >-
Shared, key-free tooling to align-check and sign a prebuilt APK, run Key-free tooling to index an already-signed F-Droid binary repo (`fdroid
`fdroid update` over a binary repo, compute the next monotonic versionCode update`), compute the next monotonic versionCode for a commit's committer
for a commit's committer date, and publish the result to an S3-compatible date, and publish the result to an S3-compatible bucket. Also includes
bucket. The caller supplies the keys, the bucket, the APK and the commit sign-apk.sh for the exceptional consumer whose build does not already
being built; this action supplies the pinned toolchain and the scripts. 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: outputs:
path: path: