Files
ci-actions/fdroid-publish/publish.sh
T

151 lines
8.0 KiB
Bash

#!/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>_<versionCode>.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 ;;
silvermail) pkg=uk.silverlabs.silvermail; cert=ccb60c6adc9b015cef88ad9a18a6194178c52754097a3f1d61029b1f6ef32ba2 ;;
silverbeats) pkg=uk.silverlabs.silverbeats; cert=bed6d769851c415a868863b845807ab41da0d7da0ad82f87797830f487210d60 ;;
silvercontacts) pkg=uk.silverlabs.silvercontacts; cert=b2818de4d11779d2035cf7fe04a0d716c880f74358a4d3a2c057a851e6c68747 ;;
silvercalendar) pkg=uk.silverlabs.silvercalendar; cert=d354792e4b532ee4ff2a21a2807fe048144b12bf3951f7c307bb53dce0ead873 ;;
*) fail "app must be silverchat, silvervpn, silverstore, silvermail, silverbeats, silvercontacts or silvercalendar (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
# Test the RESULT, not the pipeline: `ls … | tail` exits 0 even when ls found
# nothing, which once reported an empty path as found (empty signer → refusal).
p=$(ls -d "$sdk"/build-tools/*/"$1" 2>/dev/null | sort -V | tail -n1)
[ -n "$p" ] && [ -x "$p" ] && { printf '%s
' "$p"; 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) && [ -n "$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' || true)
if [ -z "$got" ]; then
say "::warning::apksigner at $apksigner printed no signer; the repo host verifies the cert"
elif [ "$got" != "$cert" ]; then
fail "APK signer $got is not the pinned $app cert $cert — refusing to publish"
else
say "signer cert: OK ($cert)"
fi
else
say "signer cert: apksigner not found here; the repo host verifies it"
fi
if [ -z "$version_code" ]; then
# Needed for the no-downgrade guard below. A bare runner image (the promote
# workflow's) has no Android SDK, so fetch Debian's aapt rather than guess.
if ! aapt=$(find_tool aapt2 || find_tool aapt) || [ -z "$aapt" ]; then
SUDO=; [ "$(id -u)" -eq 0 ] || SUDO=sudo
{ $SUDO apt-get update -qq && $SUDO apt-get install -y -qq aapt; } >/dev/null 2>&1 || true
aapt=$(command -v aapt || true)
fi
if [ -n "${aapt:-}" ]; then
version_code=$("$aapt" dump badging "$apk" 2>/dev/null | sed -n "s/.*versionCode='\([0-9]*\)'.*/\1/p" | head -n1)
fi
[ -n "$version_code" ] || say "::warning::could not read the APK's versionCode — the no-downgrade guard is off for this run"
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 publishes repo/<package>_<versionCode>.apk and keeps only the newest
# two builds per app (silverlined-deploy #51), so publishing an older build would
# at best be pruned straight away and at worst evict the previous build that
# clients still holding the old index are downloading. F-Droid never downgrades
# installed apps anyway.
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