halkyon/tool/fdroid_repo.sh
Tizian 「ティツィアーン」 e61d3abb4f
build(todo): Todo deployable beside Learn's server, and released as an APK, on F-Droid and as a snap (ADR 0033, step 5)
deploy/compose.todo.yaml adds Todo's server at todo.halkyon.eu, a member
of Learn's register, its database halkyon_todo in the instance's
PostgreSQL, its image built as Train's. redeploy.sh and backup.sh go over
every app server .env switches on (train, todo). Each compose file names
its server to the register in a variable of its own
(INSTANCE_MEMBER_TRAIN, INSTANCE_MEMBER_TODO), which halkyon_base adds to
INSTANCE_MEMBERS, each address once, as a second compose file's default
would have replaced Train's.

apps/todo/release.dart configures the release tool for Todo's APK and
snap: a key of its own (pinned once made; until then nothing is
released), the F-Droid listing (Time, NonFreeNet), and the snap from
apps/todo/snap/, which the tool builds in a clone of the repository at the
tag with that folder in place of snap/, as snapcraft builds one recipe a
folder and the root's is Learn's. What's new for 0.1.0 in English and
German, now shown. Android's backup and transfer are off, as Train's
(D53): the reminders scheduled keep their tasks' titles unsealed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-02 16:04:00 +01:00

167 lines
7.6 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 and Todo's their snaps', 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 \
app.halkyon.todo:apps/todo/snap/gui/halkyon-todo.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."
# Once in the working folder (an earlier run whose upload failed), the same APK
# again would be a second file of the same build, which fdroid refuses.
apk_hash=$(sha256sum "$apk" | cut -d' ' -f1)
if sha256sum "$working"/repo/*.apk "$working"/archive/*.apk 2>/dev/null |
grep -q "^$apk_hash "; then
echo " $(basename "$apk") is in the repository already"
else
cp "$apk" "$working/repo/"
fi
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. By their contents (--checksum), not their
# times: a working folder restored on another laptop has every APK with a new time,
# and would send them all again (1.9 GB in October 2026, on a disk with less free).
for dir in repo archive; do
[ -d "$working/$dir" ] || continue
rsync -a --checksum --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 --checksum --chmod=D755,F644 --delete-after "$working/$dir/" "$upload_host:$dir/"
done
echo " $repo_url?fingerprint=$fingerprint"