#!/usr/bin/env bash # 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 [--deploy] # 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- of # HEAD's commit date (Europe/Stockholm), passed as -Pkvitt.versionCode / -Pkvitt.versionName # 3. reads KVITT_PUBLIC_HOST from infra/pi/.env (read only; refuses a missing file or key, or a CHANGE_ME value) and # passes it as -Pkvitt.publicHost, the host of the app's release server and invite links (D-121) # 4. :app:assembleRelease through tools/gradle-serial.sh (JDK 25 from Homebrew on PATH, JAVA_HOME untouched) # 5. writes into infra/pi/out/apk/: kvitt-.apk, kvitt-.mapping.txt (R8 mapping, for # 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/ # 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-.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 # installed over an earlier release (D-024, D-101). set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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; } version_code="$(git rev-list --count HEAD)" short_sha="$(git rev-parse --short=7 HEAD)" commit_day="$(TZ=Europe/Stockholm git show -s --date=format-local:%Y.%m.%d --format=%cd HEAD)" version_name="$commit_day-$short_sha" # The public host the app is built for (D-121), the value the Pi's compose.yaml reads from the same .env (D-120). env_file="$repo_root/infra/pi/.env" [[ -f "$env_file" ]] || { echo "publish-apk.sh: no infra/pi/.env; cp infra/pi/.env.example infra/pi/.env and set KVITT_PUBLIC_HOST." >&2; exit 1; } KVITT_PUBLIC_HOST="$(sed -n 's/^KVITT_PUBLIC_HOST=//p' "$env_file" | tail -n 1)" [[ -n "$KVITT_PUBLIC_HOST" ]] || { echo "publish-apk.sh: infra/pi/.env has no KVITT_PUBLIC_HOST (or it is empty)." >&2; exit 1; } [[ "$KVITT_PUBLIC_HOST" != CHANGE_ME* ]] || { echo "publish-apk.sh: KVITT_PUBLIC_HOST in infra/pi/.env is still $KVITT_PUBLIC_HOST." >&2; exit 1; } [[ "$KVITT_PUBLIC_HOST" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$ ]] \ || { echo "publish-apk.sh: KVITT_PUBLIC_HOST in infra/pi/.env must be a host name such as kvitt.example.org (no scheme, no port), not $KVITT_PUBLIC_HOST." >&2; exit 1; } [[ "$KVITT_PUBLIC_HOST" != icehack.asuscomm.com ]] \ || { echo "publish-apk.sh: KVITT_PUBLIC_HOST cannot be icehack.asuscomm.com, NPM's proxy host for Home Assistant (D-120)." >&2; exit 1; } echo "Release $version_name (version_code $version_code) for https://$KVITT_PUBLIC_HOST" PATH="/opt/homebrew/opt/openjdk@25/bin:$PATH" tools/gradle-serial.sh :app:assembleRelease \ "-Pkvitt.versionCode=$version_code" "-Pkvitt.versionName=$version_name" "-Pkvitt.publicHost=$KVITT_PUBLIC_HOST" out_dir="$repo_root/android/app/build/outputs/apk/release" apk="$out_dir/app-release.apk" mapping="$repo_root/android/app/build/outputs/mapping/release/mapping.txt" if [[ ! -f "$apk" ]]; then if [[ -f "$out_dir/app-release-unsigned.apk" ]]; then echo "publish-apk.sh: the build is unsigned (no kvitt.release.* keys in android/local.properties); not published." >&2 else echo "publish-apk.sh: no APK in $out_dir" >&2 fi exit 1 fi # The APK must be the one just built: output-metadata.json carries the version code Gradle used. python3 - "$out_dir/output-metadata.json" "$version_code" <<'PY' import json, sys meta = json.load(open(sys.argv[1])) codes = {str(e.get("versionCode")) for e in meta.get("elements", [])} if sys.argv[2] not in codes: sys.exit(f"publish-apk.sh: output-metadata.json has version codes {sorted(codes)}, expected {sys.argv[2]}") PY [[ -f "$mapping" ]] || { echo "publish-apk.sh: no R8 mapping at $mapping" >&2; exit 1; } dest="$repo_root/infra/pi/out/apk" mkdir -p "$dest" file_name="kvitt-$version_code.apk" cp "$apk" "$dest/.$file_name.tmp" && mv -f "$dest/.$file_name.tmp" "$dest/$file_name" cp "$mapping" "$dest/kvitt-$version_code.mapping.txt" size_bytes="$(wc -c < "$dest/$file_name" | tr -d ' ')" sha256="$(shasum -a 256 "$dest/$file_name" | cut -d' ' -f1)" released_at="$(python3 -c 'import datetime; t=datetime.datetime.now(datetime.timezone.utc); print(t.strftime("%Y-%m-%dT%H:%M:%S.") + f"{t.microsecond // 1000:03d}Z")')" tmp="$(mktemp "$dest/.latest.json.XXXXXX")" cat > "$tmp" <:kvitt/data/apk/" echo " scp infra/pi/out/apk/latest.json jacobhagstedt@: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"