152 lines
8.2 KiB
Bash
152 lines
8.2 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 ;;
|
|
silvermaps) pkg=uk.silverlabs.silvermaps; cert=a8d93835c996e24c5150b7f2a59facf5acca4ef95d8c5d0979e0cc376b9e62d7 ;;
|
|
*) fail "app must be silverchat, silvervpn, silverstore, silvermail, silverbeats, silvercontacts, silvercalendar or silvermaps (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
|