feat: fdroid-publish composite action (beta/stable, pinned cert + host key, no-downgrade guard)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sysadminandClaude Opus 5.5 committed 2026-09-30 21:18:51 +01:00
commit b9c96adc22
3 files changed
+217

No files matched your search

+50
View File
@@ -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"
+127
View File
@@ -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 <repoRoot>/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/<package>.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/<package>.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