#!/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 # 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); 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 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