halkyon/tool/fdroid_repo.sh
Tizian 「ティツィアーン」 7727317c68
feat(app): what's new after an update, drafted by the release tool (ADR 0030, D61)
halkyon_app's WhatsNew reads what each release changed from the app's
assets/whats_new/ (<version>.<language>.txt, English and German, a change
a line): after an update a sheet lists every release since the one the
device showed last, newest first, once; Settings shows the running
version's again, above About. Nothing on a fresh install.

tool/release.dart drafts the missing texts from the commits that count
after "Go on?" (German marked TRANSLATE:), opens them in the editor until
they fit (no mark left, F-Droid's 500 characters) and commits them with
the version. The English is the Forgejo notes' What's new, each language
F-Droid's changelog of the build, written at publishing and copied in by
tool/fdroid_repo.sh. Learn and Train bundle their texts, Learn's 0.22.0
and Train's 0.1.0 written to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 19:36:20 +01:00

156 lines
7 KiB
Shell
Executable file

#!/usr/bin/env bash
# Updates the F-Droid repository at https://halkyon.app/fdroid/repo and
# uploads it: adds an APK if one is given, signs the index with the repository's
# key, and uploads everything to the halkyon.app server as the user fdroid, whose key can only
# write into the folder Caddy serves (rrsync). tool/release.dart runs it with each
# release's APK; run alone, it publishes the repository as it is, e.g. after its
# settings changed.
#
# tool/fdroid_repo.sh [APK]
# tool/fdroid_repo.sh --check (the tools, the key and its password only)
#
# The repository's working folder, outside Git, keeps every APK released so far:
# $HALKYON_FDROID_DIR (default below). Its settings and the apps' listings come
# from tool/release/fdroid/, copied in at every run. The index key is a PKCS #12
# keystore at $HALKYON_FDROID_KEYSTORE, alias halkyon-fdroid, whose certificate's
# SHA-256 is pinned in tool/release/fdroid-index-certificate.sha256: an index
# signed otherwise would make the F-Droid app refuse the repository. Its password
# comes from $HALKYON_FDROID_KEYSTORE_PASSWORD, or is asked for; it's never
# written down. Needs fdroidserver v2.4.5 (pipx), rsync, curl, jq, a JDK, the Android
# SDK, and the ssh host ramiel-fdroid. APKs released before that the working folder
# lacks come from the repository first (restore, below).
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
readonly working="${HALKYON_FDROID_DIR:-$HOME/.local/share/halkyon-learn/fdroid}"
keystore="${HALKYON_FDROID_KEYSTORE:-$HOME/.local/share/halkyon-learn/fdroid-index.p12}"
readonly key_alias='halkyon-fdroid'
readonly pinned_file='tool/release/fdroid-index-certificate.sha256'
readonly upload_host='ramiel-fdroid'
readonly repo_url='https://halkyon.app/fdroid/repo'
fail() {
echo "✗ $*" >&2
exit 1
}
step() { echo "▸ $*"; }
[ $# -le 1 ] || fail 'Usage: tool/fdroid_repo.sh [APK | --check]'
check_only=false
apk=''
case "${1:-}" in
--check) check_only=true ;;
'') ;;
*) apk="$1" ;;
esac
for tool in fdroid rsync curl jq; do
command -v "$tool" >/dev/null || fail "$tool is missing."
done
# fdroid takes jarsigner and keytool from the PATH, apksigner from the SDK.
jdk_bin=$(dirname "$(command -v java || echo /snap/android-studio/current/jbr/bin/java)")
[ -x "$jdk_bin/jarsigner" ] && [ -x "$jdk_bin/keytool" ] ||
fail 'No JDK found (jarsigner, keytool).'
export PATH="$jdk_bin:$PATH"
export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
[ -f "$keystore" ] || fail "No keystore at $keystore (HALKYON_FDROID_KEYSTORE)."
keystore=$(realpath "$keystore")
export HALKYON_FDROID_KEYSTORE="$keystore"
[ -f "$pinned_file" ] || fail "$pinned_file is missing."
pinned=$(grep -v '^#' "$pinned_file" | tr -d ' :\n' | tr 'a-f' 'A-F' || true)
[[ "$pinned" =~ ^[0-9A-F]{64}$ ]] || fail "$pinned_file holds no SHA-256."
if [ -z "${HALKYON_FDROID_KEYSTORE_PASSWORD:-}" ]; then
read -rsp "Password of $keystore: " HALKYON_FDROID_KEYSTORE_PASSWORD
echo
fi
export HALKYON_FDROID_KEYSTORE_PASSWORD
# The F-Droid app knows the repository by this fingerprint: the certificate's
# SHA-256 in capitals, without colons.
fingerprint=$(keytool -list -v -keystore "$keystore" -alias "$key_alias" \
-storepass:env HALKYON_FDROID_KEYSTORE_PASSWORD 2>/dev/null |
sed -n 's/^.*SHA256: *//p' | head -n 1 | tr -d ':' | tr 'a-f' 'A-F' || true)
[ -n "$fingerprint" ] || fail "Wrong password, or no key $key_alias in $keystore."
[ "$fingerprint" = "$pinned" ] ||
fail "The key $key_alias in $keystore isn't the one in $pinned_file."
if $check_only; then
exit 0
fi
step 'Updating the F-Droid repository'
mkdir -p "$working/repo"
# A working folder on another laptop, or a new one, holds none of the APKs released
# before, and the upload below mirrors it with --delete-after: so every APK the
# published indexes name (repo and archive) comes from the repository first, unless the
# folder has it, its SHA-256 checked against the index's either way. The index is the
# repository's own, over HTTPS; the check guards against a broken download, not
# against the halkyon.app server.
restore() {
local dir="$1" status name hash type
mkdir -p "$working/$dir"
status=$(curl -sSL -o "$working/$dir/.published-index" -w '%{http_code}' \
"${repo_url%/repo}/$dir/index-v1.json") || fail "Can't reach the published $dir index."
case "$status" in
200) ;;
404) return 0 ;; # nothing published there yet
*) fail "The published $dir index answered $status." ;;
esac
jq -r '.packages[][] | [.apkName, .hash, .hashType] | @tsv' "$working/$dir/.published-index" |
while IFS=$'\t' read -r name hash type; do
[ "$type" = sha256 ] || fail "$name: the index gives no SHA-256."
[[ "$name" =~ ^[A-Za-z0-9._-]+\.apk$ ]] || fail "An APK named $name in the index."
if [ ! -f "$working/$dir/$name" ]; then
echo " $dir/$name from the repository"
curl -sSfL -o "$working/$dir/$name.part" "${repo_url%/repo}/$dir/$name"
mv "$working/$dir/$name.part" "$working/$dir/$name"
fi
echo "$hash $working/$dir/$name" | sha256sum --check --quiet ||
fail "$dir/$name isn't the APK the index names."
done
rm "$working/$dir/.published-index"
}
restore repo
restore archive
cp -r tool/release/fdroid/. "$working/"
# What's new in the release as each language's changelog of its build
# (metadata/<app id>/<locale>/changelogs/<build>.txt), which tool/release.dart
# writes from the app's texts and names here; kept in the working folder after.
if [ -n "${HALKYON_FDROID_METADATA:-}" ]; then
cp -r "$HALKYON_FDROID_METADATA/." "$working/metadata/"
fi
# fdroid refuses a config.yml others can read.
chmod 600 "$working/config.yml"
# Each app's own icon in the F-Droid app (the repository's is the kingfisher,
# halkyon.png in tool/release/fdroid/): Learn's the snap's, Train's its launcher's
# source. fdroid can't find the launcher icon in a release APK, whose build shortens
# the resources' paths (res/9w.png rather than res/mipmap-hdpi/ic_launcher.png;
# fdroidserver issue 885).
for icon in app.halkyon.learn:snap/gui/halkyon-learn.png \
app.halkyon.train:apps/train/halkyon_train_flutter/assets/icon/halkyon_train_icon.png; do
mkdir -p "$working/metadata/${icon%%:*}/en-US"
cp "${icon#*:}" "$working/metadata/${icon%%:*}/en-US/icon.png"
done
if [ -n "$apk" ]; then
[ -f "$apk" ] || fail "No APK at $apk."
cp "$apk" "$working/repo/"
fi
# Names every APK as F-Droid does (<app id>_<version code>.apk) and signs the
# indexes with the repository's key.
(cd "$working" && fdroid update --rename-apks)
step "Uploading it to $upload_host"
# The APKs and icons first, the indexes last, so the F-Droid app never reads an
# index naming a file that isn't there yet.
for dir in repo archive; do
[ -d "$working/$dir" ] || continue
rsync -a --chmod=D755,F644 --exclude='index*' --exclude='entry.*' \
"$working/$dir/" "$upload_host:$dir/"
done
for dir in repo archive; do
[ -d "$working/$dir" ] || continue
rsync -a --chmod=D755,F644 --delete-after "$working/$dir/" "$upload_host:$dir/"
done
echo " $repo_url?fingerprint=$fingerprint"