Merge pull request 'Take sign-apk.sh out of the default publish path; document the never-an-app-key invariant' (#10) from builder-signs-repo-distributes into main

This commit was merged in pull request #10.
This commit is contained in:
2026-09-09 12:42:11 +00:00
2 changed files with 112 additions and 30 deletions
+95 -24
View File
@@ -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 `<repo-root>/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
+17 -6
View File
@@ -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: