infra: publish-apk.sh --deploy copies the APK to the Pi and checks /apk/latest (D-139)

A dedicated forced-command key (scp -t into data/apk only) copies
kvitt-<code>.apk then latest.json with legacy scp (-O), host key pinned in
infra/secrets/pi_known_hosts on first use, then curls
https://$KVITT_PUBLIC_HOST/apk/latest with X-Kvitt-App (fed on stdin) and
requires the new version_code. Without --deploy nothing changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 20:47:47 +02:00
parent 44c8cbb771
commit 70cb25c5d0
2 changed files with 95 additions and 9 deletions
+30 -4
View File
@@ -316,9 +316,34 @@ tags of the Gitea workflow (`v1.0.N`) are separate and say nothing about the APK
builds `:app:assembleRelease` through `tools/gradle-serial.sh` (JDK 25 on `PATH`), refuses an unsigned APK, and builds `:app:assembleRelease` through `tools/gradle-serial.sh` (JDK 25 on `PATH`), refuses an unsigned APK, and
writes into `infra/pi/out/apk/`: `kvitt-<version_code>.apk`, `kvitt-<version_code>.mapping.txt` (the R8 mapping, writes into `infra/pi/out/apk/`: `kvitt-<version_code>.apk`, `kvitt-<version_code>.mapping.txt` (the R8 mapping,
for reading stack traces; never served) and `latest.json` (temp file, then rename); it keeps the newest 5 APKs for reading stack traces; never served) and `latest.json` (temp file, then rename); it keeps the newest 5 APKs
with their mappings. Then the owner, on the Mac, copies the APK first and `latest.json` last into the server's with their mappings.
`data/apk/` (the server reads `latest.json` on every `GET /apk/latest`, so it must never name a file that is not
there yet): **Deploy (D-139):** `infra/publish-apk.sh --deploy` then copies the APK first and `latest.json` last into the server's
`data/apk/` on the Kvitt Pi (the server reads `latest.json` on every `GET /apk/latest`, so it must never name a file
that is not there yet) and checks that `https://$KVITT_PUBLIC_HOST/apk/latest` returns the new `version_code`, with
the header `X-Kvitt-App` from `kvitt.appKey` in `android/local.properties` (the NPM app gate, D-126; the key is fed
to curl on stdin and never printed), so it works from anywhere. It prints `Pi serves version_code N` or fails. The
copy is, per file:
```bash
scp -O -i infra/secrets/pi_deploy_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes \
-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=infra/secrets/pi_known_hosts \
-P 22 <file> jacobhagstedt@gitea.local:
```
Host, user and port are constants at the top of the script (`.env` holds no SSH details, D-125). Without the key file
`--deploy` refuses to start; without `kvitt.appKey` it refuses too. The Pi's host key is recorded on first use in
`infra/secrets/pi_known_hosts` (git-ignored); if the Pi is reinstalled, delete that file.
**The deploy key.** `infra/secrets/pi_deploy_ed25519` (git-ignored, on the Mac only) is a dedicated key whose public
half is one line in `~/.ssh/authorized_keys` of `jacobhagstedt` on the Kvitt Pi, prefixed with
`command="scp -t /home/jacobhagstedt/kvitt/data/apk",restrict`: whatever the client asks for, the Pi runs only that
`scp` receiver, so the key can write files into `data/apk/` and nothing else (no shell, no forwarding, no pty; the
target path given on the Mac is ignored, which is why the command ends in a bare `:` and needs `-O`, the legacy scp
protocol a forced `scp -t` speaks). To revoke it, delete that line from `~/.ssh/authorized_keys` on the Pi.
**Manual fallback** (no key, or the deploy fails): without `--deploy` the script only stages, and the owner copies
from the Mac, the APK first and `latest.json` last:
```bash ```bash
cd /Users/jhag/projects/kvitt cd /Users/jhag/projects/kvitt
@@ -328,7 +353,8 @@ curl -s http://<kvitt-pi-address>:8080/apk/latest # shows the new version_co
``` ```
The mapping files stay on the Mac. Older APKs on the Pi are removed by hand when wanted The mapping files stay on the Mac. Older APKs on the Pi are removed by hand when wanted
(`ssh jacobhagstedt@<kvitt-pi-address> ls kvitt/data/apk`, then `rm` the old `kvitt-<n>.apk` files; about 10 MB each). (`ssh jacobhagstedt@<kvitt-pi-address> ls kvitt/data/apk`, then `rm` the old `kvitt-<n>.apk` files; about 10 MB each);
the deploy key cannot list or delete.
## Pi: what the owner does ## Pi: what the owner does
+63 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build the release APK on the Mac and stage it for the Pi (SPEC §10.6, D-100). # Build the release APK on the Mac and stage it for the Pi (SPEC §10.6, D-100).
# Usage, from the main checkout: infra/publish-apk.sh # Usage, from the main checkout: infra/publish-apk.sh [--deploy]
# 1. refuses a dirty working tree (the version is derived from HEAD) # 1. refuses a dirty working tree (the version is derived from HEAD)
# 2. version (SPEC §10.6): version_code = `git rev-list --count HEAD`, version_name = YYYY.MM.DD-<short sha> of # 2. version (SPEC §10.6): version_code = `git rev-list --count HEAD`, version_name = YYYY.MM.DD-<short sha> of
# HEAD's commit date (Europe/Stockholm), passed as -Pkvitt.versionCode / -Pkvitt.versionName # HEAD's commit date (Europe/Stockholm), passed as -Pkvitt.versionCode / -Pkvitt.versionName
@@ -10,13 +10,47 @@
# 5. writes into infra/pi/out/apk/: kvitt-<version_code>.apk, kvitt-<version_code>.mapping.txt (R8 mapping, for # 5. writes into infra/pi/out/apk/: kvitt-<version_code>.apk, kvitt-<version_code>.mapping.txt (R8 mapping, for
# reading stack traces; never served), and latest.json (written to a temp file, then renamed) # reading stack traces; never served), and latest.json (written to a temp file, then renamed)
# 6. keeps the newest 5 APKs (and their mappings) in infra/pi/out/apk/ # 6. keeps the newest 5 APKs (and their mappings) in infra/pi/out/apk/
# Then the owner copies the APK and then latest.json into ~/kvitt/data/apk/ on the Pi with scp (infra/README.md). # Without --deploy the owner then copies the APK and then latest.json into ~/kvitt/data/apk/ on the Pi with scp
# (infra/README.md). With --deploy (D-139) the script does that itself:
# 7. copies kvitt-<version_code>.apk, then latest.json, to the Pi with the deploy key infra/secrets/pi_deploy_ed25519,
# whose authorized_keys line on the Pi forces `scp -t /home/jacobhagstedt/kvitt/data/apk` (it can only receive
# files into that folder; the target path given here is ignored)
# 8. checks that https://$KVITT_PUBLIC_HOST/apk/latest returns the new version_code, sending X-Kvitt-App with
# kvitt.appKey from android/local.properties (the NPM app gate, D-126; the key is never printed)
# Refuses an unsigned build: without the kvitt.release.* keys in android/local.properties the APK cannot be # Refuses an unsigned build: without the kvitt.release.* keys in android/local.properties the APK cannot be
# installed over an earlier release (D-024, D-101). # installed over an earlier release (D-024, D-101).
set -euo pipefail set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root" cd "$repo_root"
# The Kvitt Pi for --deploy (D-139). Constants here, not in infra/pi/.env: .env holds no SSH details (D-125).
PI_HOST="gitea.local"
PI_USER="jacobhagstedt"
PI_SSH_PORT="22"
deploy_key="$repo_root/infra/secrets/pi_deploy_ed25519"
known_hosts="$repo_root/infra/secrets/pi_known_hosts"
local_properties="$repo_root/android/local.properties"
deploy=0
for arg in "$@"; do
case "$arg" in
--deploy) deploy=1 ;;
-h|--help) echo "Usage: infra/publish-apk.sh [--deploy]"; exit 0 ;;
*) echo "publish-apk.sh: unknown argument $arg (usage: infra/publish-apk.sh [--deploy])" >&2; exit 2 ;;
esac
done
# The app key for the NPM gate (D-126), read, never printed.
read_app_key() {
[[ -f "$local_properties" ]] || return 0
sed -n 's/^[[:space:]]*kvitt\.appKey[[:space:]]*[=:][[:space:]]*//p' "$local_properties" | tail -n 1 | tr -d '\r'
}
if (( deploy )); then
[[ -f "$deploy_key" ]] || { echo "publish-apk.sh: --deploy needs the deploy key infra/secrets/pi_deploy_ed25519, which is missing (infra/README.md, Publishing an APK)." >&2; exit 1; }
[[ -n "$(read_app_key)" ]] || { echo "publish-apk.sh: --deploy needs kvitt.appKey in android/local.properties to check /apk/latest through the app gate (D-126)." >&2; exit 1; }
fi
[[ -z "$(git status --porcelain)" ]] || { echo "publish-apk.sh: the working tree is dirty; commit or stash first." >&2; exit 1; } [[ -z "$(git status --porcelain)" ]] || { echo "publish-apk.sh: the working tree is dirty; commit or stash first." >&2; exit 1; }
version_code="$(git rev-list --count HEAD)" version_code="$(git rev-list --count HEAD)"
@@ -95,6 +129,32 @@ done
echo "Staged in infra/pi/out/apk/: $file_name ($size_bytes bytes, sha256 $sha256) and latest.json." echo "Staged in infra/pi/out/apk/: $file_name ($size_bytes bytes, sha256 $sha256) and latest.json."
echo "Built for the release server https://$KVITT_PUBLIC_HOST (KVITT_PUBLIC_HOST in infra/pi/.env)." echo "Built for the release server https://$KVITT_PUBLIC_HOST (KVITT_PUBLIC_HOST in infra/pi/.env)."
echo "Copy to the Pi, the APK first and latest.json last (infra/README.md, Publishing an APK):" if (( ! deploy )); then
echo "Copy to the Pi, the APK first and latest.json last (infra/README.md, Publishing an APK), or rerun with --deploy:"
echo " scp infra/pi/out/apk/$file_name jacobhagstedt@<kvitt-pi-address>:kvitt/data/apk/" echo " scp infra/pi/out/apk/$file_name jacobhagstedt@<kvitt-pi-address>:kvitt/data/apk/"
echo " scp infra/pi/out/apk/latest.json jacobhagstedt@<kvitt-pi-address>:kvitt/data/apk/" echo " scp infra/pi/out/apk/latest.json jacobhagstedt@<kvitt-pi-address>:kvitt/data/apk/"
exit 0
fi
# Deploy (D-139). The forced `scp -t` on the Pi writes into kvitt/data/apk/ whatever the target path; -O selects the
# legacy scp protocol that a forced `scp -t` speaks (the SFTP default would not). The APK goes first and latest.json
# last, because the server reads latest.json on every GET /apk/latest and must never name a file it does not have.
pi_copy() {
scp -O -i "$deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes \
-o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$known_hosts" \
-P "$PI_SSH_PORT" "$1" "$PI_USER@$PI_HOST:"
}
echo "Copying $file_name to $PI_USER@$PI_HOST ..."
pi_copy "$dest/$file_name"
echo "Copying latest.json to $PI_USER@$PI_HOST ..."
pi_copy "$dest/latest.json"
# Check what the Pi now serves, through NPM and the app gate; the header is fed on stdin so the key never reaches argv.
served="$(printf 'X-Kvitt-App: %s\n' "$(read_app_key)" \
| curl -fsS --max-time 30 -H @- "https://$KVITT_PUBLIC_HOST/apk/latest")" \
|| { echo "publish-apk.sh: copied, but GET https://$KVITT_PUBLIC_HOST/apk/latest failed." >&2; exit 1; }
served_code="$(printf '%s' "$served" | python3 -c 'import json, sys; print(json.load(sys.stdin).get("version_code", ""))')" \
|| { echo "publish-apk.sh: copied, but /apk/latest did not return JSON." >&2; exit 1; }
[[ "$served_code" == "$version_code" ]] \
|| { echo "publish-apk.sh: copied, but the Pi serves version_code $served_code, expected $version_code." >&2; exit 1; }
echo "Pi serves version_code $served_code"