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:
commit
b9c96adc22
3 files changed
+217
No files matched your search
@@ -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 `<repoRoot>/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/<package>.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).
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user