Adds sign-apk.sh (align-check + apksigner), fdroid-update.sh (fdroid update over a binary repo, signing the index with its own separate key), publish.sh (S3-compatible upload) and the composite action tying them together, matching weblib-ci's shape. zipalign is not a standalone nixpkgs attribute; resolved by measurement rather than assumption that Gradle/AGP already aligns its output (Android's own docs, corroborated by fdroidserver never shelling out to zipalign) and added check_zip_alignment.py as the safety net, since apksigner itself does not check alignment at all. nixpkgs is pinned via a committed nixpkgs-pin revision, since this repo has no flake.lock of its own for with-nixpkgs.sh to read (fdroid-repo-ci#1, weblib-ci#9) -- a missing or malformed pin fails loudly rather than degrading to an unpinned nix shell. No consumer repo, bucket, hostname or channel name is hard-coded: which repo holds the keys and runs the publish has already changed once during this issue and may again, so every example uses a placeholder. 42 offline/local-only test assertions across five suites (run_tests.sh), including fault injection on the alignment check, the password/argument validation, and the with-nixpkgs.sh fail-loud paths. Co-authored-by: bit <bit@das-labor.org>
229 lines
12 KiB
Markdown
229 lines
12 KiB
Markdown
# 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.
|
|
|
|
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
|
|
instance (see weblib-ci's README) -- every cross-repo form fails while the
|
|
repo being referenced is private, and only the full-URL form works once it
|
|
is public.
|
|
|
|
**Nothing secret belongs here, ever.** Keys, keystores, credentials, bucket
|
|
names and hostnames all live in the private repo that consumes this one and
|
|
arrive as inputs -- arguments and environment variables, never committed
|
|
here.
|
|
|
|
## What is here
|
|
|
|
| script | what it does |
|
|
|---|---|
|
|
| `sign-apk.sh` | check alignment, then sign a prebuilt unsigned release APK with a keystore the caller supplies |
|
|
| `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 |
|
|
| `with-nixpkgs.sh` | run a command with one or more nixpkgs packages on PATH, pinned to the revision in `nixpkgs-pin` |
|
|
| `check_zip_alignment.py` | the alignment check `sign-apk.sh` runs before signing (see "Align before sign" below) |
|
|
| `action.yml` | the composite action a consumer includes to get all of the above |
|
|
|
|
Deliberately **not** in scope: fetching the APK. That needs an upstream
|
|
credential and is the consumer's business, not the tooling's -- which also
|
|
keeps this repo from knowing which upstreams exist.
|
|
|
|
All of it is standard library / plain bash plus the pinned tools below. They
|
|
are *run*, not built, so this repo has no flake -- see "Pinning nixpkgs"
|
|
below for what that costs and why it is still the right call here.
|
|
|
|
## Using it
|
|
|
|
```yaml
|
|
- uses: actions/checkout@v4
|
|
- id: ci
|
|
uses: https://git.chaosbit.de/weblib/fdroid-repo-ci@main
|
|
- 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:
|
|
INDEX_KEYSTORE_PASSWORD: ${{ secrets.INDEX_KEYSTORE_PASSWORD }}
|
|
run: |
|
|
bash "${{ steps.ci.outputs.path }}/fdroid-update.sh" \
|
|
"$FDROID_REPO_ROOT" "$INDEX_KEYSTORE" repo-key INDEX_KEYSTORE_PASSWORD
|
|
|
|
- env:
|
|
AWS_ACCESS_KEY_ID: ${{ secrets.PUBLISH_ACCESS_KEY_ID }}
|
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.PUBLISH_SECRET_ACCESS_KEY }}
|
|
run: |
|
|
bash "${{ steps.ci.outputs.path }}/publish.sh" \
|
|
"$FDROID_REPO_ROOT/repo" "$BUCKET" "$S3_ENDPOINT_URL"
|
|
```
|
|
|
|
**Use the full URL, `https://git.chaosbit.de/weblib/fdroid-repo-ci@main`, not
|
|
`weblib/fdroid-repo-ci@main`.** Measured on weblib-ci (weblib-archive#44):
|
|
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.
|
|
|
|
`$APK_KEYSTORE`/`$INDEX_KEYSTORE`/`$FDROID_REPO_ROOT`/`$BUCKET`/
|
|
`$S3_ENDPOINT_URL` 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.
|
|
|
|
## Align before sign, and how "no `zipalign`" was verified
|
|
|
|
`zipalign` then `apksigner`. Signing first and aligning after is the old
|
|
`jarsigner`-era order and invalidates an `apksigner`-era (v2/v3) signature,
|
|
because that scheme signs the exact bytes of the packaged zip and alignment
|
|
padding changes those bytes.
|
|
|
|
**`apksigner` is a standalone nixpkgs attribute** (`nixpkgs#apksigner`,
|
|
verified at 35.0.6). **`zipalign` is not** -- it exists only inside
|
|
`androidenv`'s composed build-tools, which pulls a full versioned Android SDK
|
|
and its licence machinery for one binary. Before accepting that as a
|
|
dependency, three things were measured rather than assumed:
|
|
|
|
1. **Google's own docs** (developer.android.com/tools/zipalign): "If you
|
|
build using Android Studio, which uses the Android Gradle plugin (AGP),
|
|
this is done automatically. In this case, you should still use zipalign
|
|
to verify that the APK is aligned, but you don't need to align it." AGP is
|
|
what `flutter build apk` (or any Gradle-based Android build) drives
|
|
underneath, so this applies to any such build's output, not just
|
|
Android-Studio-initiated ones.
|
|
2. **`fdroidserver` 2.4.3's own source never calls `zipalign`, anywhere** --
|
|
grepped across all 38 of its modules. Its APK-signature-copying code
|
|
(`apksigcopier.py`, used for reproducible-build verification, a different
|
|
flow than binary-repo mode) implements realignment itself in pure Python
|
|
against the zip format directly, rather than shelling out to the
|
|
`zipalign` binary.
|
|
3. **`apksigner` does not check or enforce alignment at all**, in either
|
|
direction -- measured directly: a deliberately misaligned fixture zip
|
|
(including a `.so` misaligned to the stricter 4096-byte boundary) was
|
|
signed and then verified with `apksigner verify --verbose`, and the
|
|
output carried no warning about alignment whatsoever.
|
|
|
|
**Confidence: high** that a Gradle/AGP-built unsigned release APK is already
|
|
aligned, resting on (1) and (2) above. **Not independently confirmed**
|
|
against a real `flutter build apk` output run in this environment -- that
|
|
build was judged too expensive to attempt for this measurement. A sibling
|
|
repo's own CI proposal for building the Flutter app was rejected on the same
|
|
"the toolchain is too large to pull on every push" grounds this pin sidesteps
|
|
(composing a multi-platform Android SDK plus NDK pulls tens of gigabytes),
|
|
and the host available for this measurement had well under that in free
|
|
disk space.
|
|
|
|
Point (3) is why this repo does not simply trust point (1) and move on:
|
|
since nothing downstream complains about misalignment either, a build that
|
|
stopped emitting aligned output would fail silently -- an APK that signs and
|
|
verifies fine but behaves badly (or refuses to install) on a real device.
|
|
`check_zip_alignment.py`, a few lines against the standard library `zipfile`
|
|
module, checks every uncompressed entry of the APK `sign-apk.sh` is asked to
|
|
sign, and refuses to sign one that fails it. If that check ever legitimately
|
|
trips, the fix is to add `androidenv`'s `zipalign` as a dependency here, not
|
|
to weaken or remove the check.
|
|
|
|
## The repo index key is not the APK signing key
|
|
|
|
Binary-repo mode (every APK arrives already signed) means `fdroid update`
|
|
reads each APK's existing signature and never touches an app's own signing
|
|
key. It signs the **index** with a separate key of its own -- `fdroid-update.sh`
|
|
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.
|
|
|
|
`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
|
|
can be safely git-managed*: "it is a good idea to manage config.yml (WITHOUT
|
|
PASSWORDS!) in git." A caller never has to know or hand-write fdroidserver's
|
|
config format.
|
|
|
|
## Pinning nixpkgs
|
|
|
|
`fdroidserver`'s output feeds a *signed index*, so an unpinned or
|
|
channel-drifting nixpkgs is a worse trade here than for a disposable test
|
|
job. This repo carries a `nixpkgs-pin` file -- one line, a 40-character
|
|
nixpkgs revision -- and `with-nixpkgs.sh` refuses to run at all if that file
|
|
is missing or malformed, rather than silently falling back to an unpinned
|
|
`nix shell nixpkgs#foo`, which re-resolves the registry and fetches whatever
|
|
the channel currently points at.
|
|
|
|
This is deliberately **not** the same mechanism as [weblib-ci]'s
|
|
`with-nixpkgs.sh`, which reads the *consuming* repo's `flake.lock`. This repo
|
|
has no flake (see weblib-ci#9 for the same tension in that repo: a
|
|
`flake.lock` existing only to hold one revision string), and per this repo's
|
|
own originating issue, the repo that consumes it holds signing keys and is
|
|
not guaranteed to be a flake either -- reading a caller's `flake.lock` the
|
|
way weblib-ci's script does would not even find one here. So the pin travels
|
|
with *this* repo instead, in a file named `nixpkgs-pin` on purpose: it is the
|
|
exact fallback weblib-ci#9 proposes adding to the shared script (flake.lock
|
|
present -> today's behaviour there; else `nixpkgs-pin` -> a bare revision;
|
|
else fail loudly). If that lands, this repo already speaks its format and
|
|
nothing here needs to change.
|
|
|
|
A `nixpkgs-pin` file has no `nix flake update` -- it is bumped by hand.
|
|
Accepted here for the same reason weblib-ci#9 accepted it for a
|
|
stdlib-only test suite, generalised: the alternative (a flake that builds
|
|
nothing, just to hold a lockfile) is a lot of structure for one string, and a
|
|
genuinely broken pinned nixpkgs would be caught immediately by this repo's
|
|
own test suite.
|
|
|
|
`fdroidserver` is in nixpkgs as `fdroidserver`, verified at 2.4.3.
|
|
`publish.sh` uses `awscli2` for the same S3-compatible upload every consumer
|
|
here needs, resolved through the same pin.
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
bash run_tests.sh
|
|
```
|
|
|
|
42 assertions across five suites, ~20s on a warm nix store:
|
|
|
|
* `test_check_zip_alignment.py` -- pure standard library, no nix, no
|
|
network at all; builds an aligned and a misaligned fixture zip by hand and
|
|
checks both directions, including the 4096-byte boundary specific to `.so`
|
|
entries.
|
|
* `test_with_nixpkgs.sh` -- offline; every way the pinning wrapper is
|
|
supposed to refuse rather than silently degrade (missing pin, malformed
|
|
pin, too few arguments). Does not exercise the happy path of actually
|
|
resolving a `github:nixos/nixpkgs/...` reference automatically, since that
|
|
needs network (or an already-warm local cache) the same way weblib-ci's
|
|
own `with-nixpkgs.sh` does, and that repo does not automate a test for it
|
|
either. It was checked by hand instead.
|
|
* `test_sign_apk.sh` and `test_fdroid_update.sh` -- full end-to-end runs
|
|
against a throwaway keystore generated fresh per run, real `apksigner`
|
|
and `fdroid update`. Needs the pinned toolchain to be resolvable (locally
|
|
cached, or fetched once over the network); nothing beyond that.
|
|
* `test_publish.sh` -- argument/credential validation and `--dry-run`
|
|
command construction, fully offline, plus one real invocation against a
|
|
closed local port (`127.0.0.1:1`, nothing listens there) to prove the
|
|
built command genuinely attempts a network call rather than failing on its
|
|
own arguments. The actual upload path is out of scope for an offline
|
|
suite, matching the constraint that only `publish.sh` needs network in the
|
|
first place.
|
|
|
|
No `.gitea/workflows` runs this, on the same reasoning weblib-ci#9 recorded:
|
|
run it by hand before pushing. If that changes for weblib-ci it should
|
|
probably change here too, at the same time.
|
|
|
|
## Not hard-coded
|
|
|
|
No `weblib/` (or any other organisation) in a path, URL or workflow
|
|
reference. No bucket name, repo URL, app id, keystore path, or channel name
|
|
or count -- a consumer may have one publish target or several, under
|
|
whatever names it likes; nothing here assumes "release" and "dev", or that
|
|
there are exactly two.
|
|
|
|
[weblib-ci]: https://git.chaosbit.de/weblib/weblib-ci
|