From b9c96adc22b640e1b5266c37b5666ce9eb3dc7d4 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Wed, 30 Sep 2026 21:18:51 +0100 Subject: [PATCH] feat: fdroid-publish composite action (beta/stable, pinned cert + host key, no-downgrade guard) Co-Authored-By: Claude Opus 5.5 --- README.md | 40 ++++++++++++ fdroid-publish/action.yml | 50 +++++++++++++++ fdroid-publish/publish.sh | 127 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 README.md create mode 100644 fdroid-publish/action.yml create mode 100644 fdroid-publish/publish.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ef532b --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# ci-actions + +Reusable Gitea Actions for SilverLined repos. + +## fdroid-publish + +Publishes an already-built, signed APK to a SilverLABS F-Droid channel: + +| channel | repo | who sees it | +|---|---|---| +| `beta` | `https://apps.silverlined.uk/fdroid-test/repo` | devices with SilverSTORE's "Beta channel" on | +| `stable` | `https://apps.silverlined.uk/fdroid/repo` | the whole fleet (F-Droid auto-updates) | + +Same method as SilverMetal's `Publish-FdroidApp.ps1`: `scp` to `/incoming`, +run the host's `/srv/fdroid/bin/ingest.sh` (pinned-cert check, index rebuild under a +lock), then read the published index back. + +```yaml +- uses: https://git.silverlabs.uk/SilverLined/ci-actions/fdroid-publish@v1 + with: + app: silverchat # silverchat | silvervpn | silverstore + apk: path/to/app-release.apk + channel: beta # default + version-code: ${{ github.run_number }} # optional; read from the APK if omitted + ssh-key: ${{ secrets.FDROID_SSH_KEY }} +``` + +Safety: +- The host refuses an APK not signed by the app's pinned cert (the action checks + first when `apksigner` is available). +- A channel holds one APK per app (`ingest.sh` replaces `repo/.apk`), so the + action **skips** any build whose versionCode is not newer than what the channel + already serves — an older, slower run can never roll a channel back. +- `dry-run: 'true'` validates everything and stops before uploading. + +`FDROID_SSH_KEY` is a SilverLined org secret: the `fdroid-ci` key, authorised on +`silverlined.uk` with `restrict,from="82.8.176.11"` (the lab's egress IP). + +Promotion to `stable` is manual: each app repo has a `Promote to F-Droid stable` +workflow (dispatch with the tested tag). diff --git a/fdroid-publish/action.yml b/fdroid-publish/action.yml new file mode 100644 index 0000000..73492e7 --- /dev/null +++ b/fdroid-publish/action.yml @@ -0,0 +1,50 @@ +name: fdroid-publish +description: > + Publish an already-built, signed APK to a SilverLABS F-Droid channel + (beta = apps.silverlined.uk/fdroid-test, stable = apps.silverlined.uk/fdroid). + The host verifies the signer against the app's pinned cert; the action refuses + to publish a versionCode that is not newer than what the channel serves, and + reads the index back afterwards. +inputs: + app: + description: silverchat | silvervpn | silverstore + required: true + apk: + description: Path to the signed APK + required: true + channel: + description: beta | stable + required: false + default: beta + version-code: + description: The APK's versionCode (read from the APK with aapt2 when omitted) + required: false + default: '' + ssh-key: + description: Private key authorised for the repo host's incoming dir (secrets.FDROID_SSH_KEY) + required: true + dry-run: + description: "'true' to validate everything and stop before uploading" + required: false + default: 'false' + ssh-host: + required: false + default: silverlined.uk + ssh-user: + required: false + default: sysadmin +runs: + using: composite + steps: + - name: Publish to F-Droid ${{ inputs.channel }} + shell: bash + env: + INPUT_APP: ${{ inputs.app }} + INPUT_APK: ${{ inputs.apk }} + INPUT_CHANNEL: ${{ inputs.channel }} + INPUT_VERSION_CODE: ${{ inputs.version-code }} + INPUT_SSH_KEY: ${{ inputs.ssh-key }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} + INPUT_SSH_HOST: ${{ inputs.ssh-host }} + INPUT_SSH_USER: ${{ inputs.ssh-user }} + run: bash "${{ github.action_path }}/publish.sh" diff --git a/fdroid-publish/publish.sh b/fdroid-publish/publish.sh new file mode 100644 index 0000000..c4f394c --- /dev/null +++ b/fdroid-publish/publish.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# fdroid-publish — ingest an already-built, already-signed APK into a SilverLABS +# F-Droid channel (beta: apps.silverlined.uk/fdroid-test, stable: /fdroid). +# +# The same method as SilverMetal's android/silver-updates/Publish-FdroidApp.ps1, +# run from the app's own CI instead of a laptop: +# scp the APK to /incoming on the repo host, run the host's +# /srv/fdroid/bin/ingest.sh (which verifies the signer cert against the pin, +# moves it to repo/.apk and rebuilds the index under a lock), then read +# the published index back — ingest returning 0 is not proof a client sees it. +# +# Inputs arrive as INPUT_* environment variables (see action.yml). +set -euo pipefail + +say() { printf '%s\n' "$*"; } +fail() { printf '::error::%s\n' "$*"; exit 1; } + +app="${INPUT_APP:-}" +apk="${INPUT_APK:-}" +channel="${INPUT_CHANNEL:-beta}" +version_code="${INPUT_VERSION_CODE:-}" +dry_run="${INPUT_DRY_RUN:-false}" +ssh_host="${INPUT_SSH_HOST:-silverlined.uk}" +ssh_user="${INPUT_SSH_USER:-sysadmin}" + +# Per-app facts. Mirrors SilverMetal's fdroid-channel.json (package + the signer +# cert every build of that app is pinned to). The host re-checks the cert, so a +# stale pin here can only make a publish FAIL, never let a wrong APK through. +case "$app" in + silverchat) pkg=uk.silverlabs.silverchat; cert=06c4ac92e17594d94d75eb9ed85c619aa3aa73cee3d9e64c12ab11255d28a280 ;; + silvervpn) pkg=uk.silverlabs.silvervpn; cert=706a56b202b37d5fb2f5d4e89dea819fa53a372eecc31999a125782e6a3c8cab ;; + silverstore) pkg=uk.silverlabs.silverstore; cert=f3d835bbd3717f151400b4e0e95212289d585f568690fb05634881f26ec251e4 ;; + *) fail "app must be silverchat, silvervpn or silverstore (got '$app')" ;; +esac +case "$channel" in + beta) root=/srv/fdroid-test; url=https://apps.silverlined.uk/fdroid-test/repo ;; + stable) root=/srv/fdroid; url=https://apps.silverlined.uk/fdroid/repo ;; + *) fail "channel must be beta or stable (got '$channel')" ;; +esac +[ -f "$apk" ] || fail "APK not found: $apk" + +# Tools. The Android build images are Debian/Ubuntu based but not all ship an +# ssh client or jq; install what is missing rather than fail on a bare image. +need=() +for t in ssh scp curl jq; do command -v "$t" >/dev/null 2>&1 || need+=("$t"); done +if [ ${#need[@]} -gt 0 ]; then + say "installing missing tools: ${need[*]}" + pk=(); for t in "${need[@]}"; do case "$t" in ssh|scp) pk+=(openssh-client) ;; *) pk+=("$t") ;; esac; done + SUDO=; [ "$(id -u)" -eq 0 ] || SUDO=sudo + $SUDO apt-get update -qq >/dev/null && $SUDO apt-get install -y -qq "${pk[@]}" >/dev/null +fi + +find_tool() { # newest build-tools copy of $1 under the Android SDK, or PATH + command -v "$1" 2>/dev/null && return 0 + for sdk in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" /opt/android-sdk /opt/android/sdk /usr/lib/android-sdk; do + [ -n "$sdk" ] && [ -d "$sdk/build-tools" ] || continue + ls -d "$sdk"/build-tools/*/"$1" 2>/dev/null | sort -V | tail -n1 && return 0 + done + return 1 +} + +# Signer check before anything leaves the runner, so a mis-signed build fails +# here with a clear message instead of as CERT_MISMATCH on the host. +if apksigner=$(find_tool apksigner); then + got=$("$apksigner" verify --print-certs "$apk" 2>/dev/null | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | head -n1 | tr 'A-F' 'a-f') + [ "$got" = "$cert" ] || fail "APK signer $got is not the pinned $app cert $cert — refusing to publish" + say "signer cert: OK ($cert)" +else + say "signer cert: apksigner not found here; the repo host verifies it" +fi + +if [ -z "$version_code" ]; then + if aapt=$(find_tool aapt2 || find_tool aapt); then + version_code=$("$aapt" dump badging "$apk" 2>/dev/null | sed -n "s/.*versionCode='\([0-9]*\)'.*/\1/p" | head -n1) + fi +fi + +head_code() { # highest versionCode the channel serves for $pkg (0 if none) + curl -fsS -H 'Cache-Control: no-cache' "$url/index-v2.json" \ + | jq -r --arg p "$pkg" '[.packages[$p].versions // {} | .[] | .manifest.versionCode] | max // 0' +} +before=$(head_code) || fail "could not read the $channel index at $url" +say "$channel serves $pkg versionCode $before; this APK is ${version_code:-unknown}" + +# ingest.sh REPLACES repo/.apk, so the channel only ever holds one APK +# per app. Publishing an older build over a newer one would roll the channel +# back — F-Droid never downgrades installed apps, so it would just go stale. +if [ -n "$version_code" ] && [ "$version_code" -le "$before" ]; then + say "::notice::$pkg $version_code is not newer than what $channel already serves ($before) — nothing to publish" + exit 0 +fi + +if [ "$dry_run" = "true" ]; then + say "[dry run] would ingest $apk into $channel ($url) as $pkg" + exit 0 +fi + +[ -n "${INPUT_SSH_KEY:-}" ] || fail "ssh-key is empty — pass secrets.FDROID_SSH_KEY" +sshdir=$(mktemp -d) +trap 'rm -rf "$sshdir"' EXIT +printf '%s\n' "${INPUT_SSH_KEY//$'\r'/}" > "$sshdir/key" +chmod 600 "$sshdir/key" +# Host key pinned, never learned: this job hands an APK to the host that ships +# it to every phone. Public information, so it lives here rather than a secret. +printf '%s %s\n' "$ssh_host" "${INPUT_KNOWN_HOST_KEY:-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFR7Tl9TucEfnUog5KamgOBNjZ/hdAFaEkrkt6loX/8Q}" > "$sshdir/known_hosts" +ssh_opts=(-i "$sshdir/key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes + -o UserKnownHostsFile="$sshdir/known_hosts" -o ConnectTimeout=20) + +# A per-run name: two runs of the same app must not overwrite each other's +# upload before ingest's lock. ingest.sh moves (consumes) the file. +remote="$root/incoming/$pkg-${GITHUB_RUN_ID:-local}-$$.apk" +say "uploading to $ssh_host:$remote" +scp -q "${ssh_opts[@]}" "$apk" "$ssh_user@$ssh_host:$remote" || fail "scp failed" +say "ingesting (cert check + index rebuild)" +ssh "${ssh_opts[@]}" "$ssh_user@$ssh_host" "/srv/fdroid/bin/ingest.sh '$remote' '$pkg' '$cert' '$root'" \ + || { ssh "${ssh_opts[@]}" "$ssh_user@$ssh_host" "rm -f '$remote'" || true; fail "ingest failed — $pkg was NOT published to $channel"; } + +after=$(head_code) || fail "published, but the $channel index could not be read back" +if [ -n "$version_code" ]; then + [ "$after" -ge "$version_code" ] || fail "ingest reported success but $channel still serves $after, not $version_code" +else + [ "$after" -gt 0 ] || fail "ingest reported success but $pkg is not in the $channel index" +fi +say "published $app ($pkg) versionCode $after to $channel — clients see it on their next repo refresh" +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + printf '### F-Droid %s\n%s `%s` versionCode **%s** → %s\n' "$channel" "$app" "$pkg" "$after" "$url" >> "$GITHUB_STEP_SUMMARY" +fi