Build the shared F-Droid publishing tooling: sign, index, publish

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>
This commit is contained in:
2026-09-09 06:34:36 +00:00
co-authored by bit
parent f1dcedd3f7
commit 3f14c01b00
15 changed files with 1292 additions and 0 deletions
Executable
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
#
# Align-check and sign a prebuilt, unsigned release APK with a keystore the
# caller supplies. Exists because binary-repo F-Droid mode never builds
# anything itself (fdroid-repo-ci#1) -- something still has to turn a Gradle
# build's output into a signed APK before `fdroid update` can read its
# certificate.
#
# KEYSTORE_PASSWORD=... bash sign-apk.sh \
# unsigned.apk signed.apk keystore.jks release-key KEYSTORE_PASSWORD
#
# Positional args: <unsigned.apk> <signed.apk> <keystore> <key-alias>
# <keystore-pass-env> [<key-pass-env>]. The last two are *names* of
# environment variables to read the passwords from, not the passwords
# themselves -- an argument shows up in the job log and in `ps`, a keystore
# password must not. If <key-pass-env> is omitted, the keystore password is
# reused as the key password, which is the common case for a keystore holding
# one key. Naming the variable rather than fixing it lets a caller with
# several keystores (an APK key here, a separate repo index key for
# fdroid-update.sh) avoid a collision without this repo inventing a
# convention for it.
#
# Any arguments after the fifth/sixth are forwarded verbatim to both
# `apksigner sign` and `apksigner verify`, e.g. `--min-sdk-version`/
# `--max-sdk-version` to bypass real manifest parsing for a fixture that has
# no valid binary AndroidManifest.xml -- see the test suite.
#
# ## Alignment, measured rather than assumed (fdroid-repo-ci#1)
#
# `zipalign` is not a standalone nixpkgs attribute -- only `androidenv`'s
# composed build-tools carry it, and that pulls a full versioned Android SDK
# for one binary. Before adding that dependency:
#
# developer.android.com/tools/zipalign states plainly that a build made
# through the Android Gradle Plugin -- which `flutter build apk` drives
# underneath, same as a plain Android Studio build -- already aligns its zip
# entries via zipflinger, and that `zipalign` is only needed afterwards to
# *verify* that, not to perform it. `apksigner` does not check or complain
# about alignment either way: signing and verifying a deliberately misaligned
# fixture here produced no warning from `apksigner verify --verbose` at all --
# measured while writing this script, not inferred from the docs.
#
# So this script does not depend on `zipalign`, but it does not take the AGP
# claim on faith either: `check_zip_alignment.py`, a few lines against the
# stdlib `zipfile` module, checks every *uncompressed* entry -- the only ones
# alignment affects -- before signing, and refuses to sign an APK that fails
# it, rather than silently producing one that behaves badly on a device. If
# that check ever trips on a real build, the fix is to pull in androidenv's
# zipalign, not to weaken the check.
#
# Confidence in "AGP already aligns its output" is high (Google's own docs,
# corroborated by fdroidserver 2.4.3's source never shelling out to zipalign
# anywhere at all) but NOT independently confirmed here against a real
# `flutter build apk` output -- that build was judged too expensive to run
# for this measurement (weblib-viewer#5 already declined a CI job on the same
# toolchain for the same reason, and this host had ~7.5G free against a
# multi-platform Android SDK + NDK). check_zip_alignment.py is the safety net
# for that gap: it runs against every APK this script is asked to sign, not
# just the fixtures in the test suite.
#
# Pins apksigner via with-nixpkgs.sh/nixpkgs-pin, next to this script.
set -euo pipefail
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
if [ "$#" -lt 5 ]; then
cat >&2 <<'USAGE'
usage: sign-apk.sh <unsigned.apk> <signed.apk> <keystore> <key-alias> \
<keystore-pass-env> [<key-pass-env>] [extra apksigner args...]
<keystore-pass-env> and <key-pass-env> name environment variables holding the
passwords -- they are never passed as arguments.
USAGE
exit 2
fi
unsigned=$1
signed=$2
keystore=$3
alias_name=$4
ks_pass_env=$5
shift 5
key_pass_env=$ks_pass_env
if [ "$#" -gt 0 ] && [[ $1 != -* ]]; then
key_pass_env=$1
shift
fi
extra_args=("$@")
[ -f "$unsigned" ] || { echo "sign-apk.sh: no such file: $unsigned" >&2; exit 2; }
[ -f "$keystore" ] || { echo "sign-apk.sh: no such keystore: $keystore" >&2; exit 2; }
: "${!ks_pass_env:?sign-apk.sh: environment variable $ks_pass_env (keystore password) is not set}"
: "${!key_pass_env:?sign-apk.sh: environment variable $key_pass_env (key password) is not set}"
echo "sign-apk.sh: checking zip alignment of $unsigned"
bash "$here/with-nixpkgs.sh" python3 python3 "$here/check_zip_alignment.py" "$unsigned"
echo "sign-apk.sh: signing $unsigned -> $signed"
# apksigner's --ks-pass/--key-pass take "env:VARNAME", reading the variable's
# *current* value at apksigner's own invocation, not ours -- pass the names
# through so nothing beyond this script ever holds the password in an
# argument list.
bash "$here/with-nixpkgs.sh" apksigner apksigner sign \
--ks "$keystore" \
--ks-key-alias "$alias_name" \
--ks-pass "env:$ks_pass_env" \
--key-pass "env:$key_pass_env" \
--out "$signed" \
"${extra_args[@]}" \
"$unsigned"
echo "sign-apk.sh: verifying $signed"
bash "$here/with-nixpkgs.sh" apksigner apksigner verify --print-certs \
"${extra_args[@]}" \
"$signed"