feat: a release script for the APK and the snap
tool/release.sh builds a release on the maintainer's laptop: one APK for every phone, from a checkout of the signed tag and signed with the app's own key, and the snap, both connecting to learn.halkyon.eu, the project's instance; their checksums signed with GPG; the release on code.halkyon.app through Forgejo's API, and the snap in the Store's edge channel. Before building it checks that main is clean and pushed, that the tag is signed by a key deploys accept, the version and a raised build number, the pinned Flutter, and that the keystore holds the key whose certificate tool/release/android-certificate.sha256 names. After building it checks the APK's signer, package, version, that it isn't debuggable and that it names the server, and the snap's version. The key's password and the Forgejo token are asked for at each run and never stored. Release builds take the app's key from the environment and otherwise fall back to the debug key, so `flutter run --release` works anywhere. The native libraries go into the APK compressed: an APK for three architectures was 110 MB with them stored as they are, 48 MB now. The list of dependencies Gradle adds for Google Play is left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
103881a28d
commit
88b82cda4b
4 changed files with 251 additions and 3 deletions
|
|
@ -120,6 +120,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **CI** (`.forgejo/workflows/ci.yaml`) runs `tool/check.sh` on every push, on the Forgejo runner on sahaquiel (`deploy/runner/`). Jobs run directly on the host as the unprivileged user `runner`, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no `/home`, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it. `runner` has user ID 61000 and must never share one with a container's processes (PostgreSQL and the server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (*Sandbox*) fails if any of this leaks. Flutter is pinned in the workflow (`FLUTTER_VERSION`): move it together with the local one. The server tests there get throwaway passwords from the environment (`SERVERPOD_…`, new for every job), since `passwords.yaml` is never committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI also runs `serverpod generate` and fails if the committed generated code differs from its output. CI runs on pushes only: a pull request from a fork would run its code on the production server.
|
||||
- **A green `main` deploys learn.halkyon.eu** through `deploy/redeploy.sh`, the only command the runner's key may run as `deploy` (pinned in `authorized_keys`, from sahaquiel itself only). It deploys the commit CI checked while it is still the tip, as a fast-forward, only if every new commit is signed by a key in `deploy/allowed-signers` (the deployed copy decides), after dumping the database (the last 3 dumps stay in `~deploy/backups`: the way back from a deploy, not backups), applies the migrations in a run of their own before switching (`--role=maintenance --apply-migrations`: a failing migration stops the deploy while the old server keeps serving, so migrations must keep the old server working until the switch), and fails unless the new server answers. A failed deploy leaves the checkout at the deployed commit; run the workflow again to retry. By hand: `ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh <commit>`. Never `docker compose down -v` there.
|
||||
- **Backups** (`deploy/backups.md`): every hour, `deploy/backup.sh` (a systemd timer, as `deploy`) dumps the database, which holds people's files too, and restic stores it in a Hetzner Storage Box (24 hourly, 7 daily, 4 weekly, 3 monthly); the box's own snapshots protect it from a taken-over server. A restore takes the instance back up to an hour, and people who started since lose what their device held: their app no longer finds its session, starts a new anonymous account and deletes the old folder. So a change that makes devices lose their session, or keeps deleted people's data longer, changes what a restore costs; say so in `deploy/backups.md`.
|
||||
- **Releases** are the project's, not an instance's: the release, the Snap Store listing and the website point to learn.halkyon.app; the builds connect to learn.halkyon.eu, the project's instance, until the app lets people choose one. They come from a developer's laptop, never from CI: `tool/release.sh vX.Y.Z` on `main` as pushed, with the tag signed by a key in `deploy/allowed-signers` and pushed, and the build number raised. It builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; creates the release on code.halkyon.app through Forgejo's API; and uploads the snap to the Store's edge channel. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the script releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere.
|
||||
|
||||
## Rules the code relies on
|
||||
|
||||
|
|
|
|||
|
|
@ -28,13 +28,48 @@ android {
|
|||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// The app's own key signs release builds when tool/release.sh hands it over
|
||||
// through the environment: the keystore's path and the key's alias, and the
|
||||
// password asked for at each release, never written down. Other release builds
|
||||
// are signed with the debug key, so `flutter run --release` works anywhere; the
|
||||
// release script refuses to build without the app's key.
|
||||
val releaseKeystore = System.getenv("HALKYON_ANDROID_KEYSTORE")
|
||||
signingConfigs {
|
||||
if (releaseKeystore != null) {
|
||||
create("release") {
|
||||
storeFile = file(releaseKeystore)
|
||||
keyAlias = System.getenv("HALKYON_ANDROID_KEY_ALIAS")
|
||||
// A PKCS #12 keystore has one password, for the store and its key.
|
||||
storePassword = System.getenv("HALKYON_ANDROID_KEYSTORE_PASSWORD")
|
||||
keyPassword = System.getenv("HALKYON_ANDROID_KEYSTORE_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig =
|
||||
signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
// The native libraries go into the APK compressed. One APK serves every phone, so
|
||||
// it holds them for three architectures; stored uncompressed (the default, made
|
||||
// for stores that send each phone its own) they made it 110 MB to download and to
|
||||
// keep. Compressed it's about half that, and Android unpacks the phone's own
|
||||
// libraries as it installs the app.
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
// Leaves out the list of dependencies Gradle would otherwise add to the APK,
|
||||
// encrypted for Google Play alone; F-Droid's and others' scanners flag it.
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
|
|
|||
209
tool/release.sh
Executable file
209
tool/release.sh
Executable file
|
|
@ -0,0 +1,209 @@
|
|||
#!/usr/bin/env bash
|
||||
# Builds a release and publishes it: the Android APK (one for every phone, signed with
|
||||
# the app's key) and the Linux snap, both connecting to learn.halkyon.eu, the project's
|
||||
# instance; a list of their checksums signed with GPG; all of it as a release on
|
||||
# code.halkyon.app, and the snap in the Snap Store's edge channel. Run on a developer's
|
||||
# laptop, never in CI: the app's key never leaves it.
|
||||
#
|
||||
# tool/release.sh v0.1.0
|
||||
#
|
||||
# Before: the version, with a build number above the last release's, in
|
||||
# halkyon_learn_flutter/pubspec.yaml on main, pushed; the tag signed on that commit and
|
||||
# pushed (git tag -s v0.1.0 -m 'Halkyon Learn 0.1.0' && git push origin v0.1.0);
|
||||
# `snapcraft login` with the project's account. The script asks for the key's password
|
||||
# and a Forgejo token (scope write:repository) first, checks both, and then runs
|
||||
# unattended until GPG asks for its passphrase; neither is ever written down.
|
||||
#
|
||||
# The app's key is a PKCS #12 keystore at $HALKYON_ANDROID_KEYSTORE (default below), its
|
||||
# alias $HALKYON_ANDROID_KEY_ALIAS. Its certificate's SHA-256 is committed in
|
||||
# tool/release/android-certificate.sha256, and nothing signed otherwise is released.
|
||||
set -euo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
readonly server_url='https://learn.halkyon.eu/'
|
||||
readonly forgejo='https://code.halkyon.app'
|
||||
readonly repository='project-halkyon/halkyon_learn'
|
||||
readonly pubspec='halkyon_learn_flutter/pubspec.yaml'
|
||||
readonly certificate_file='tool/release/android-certificate.sha256'
|
||||
keystore="${HALKYON_ANDROID_KEYSTORE:-$HOME/.local/share/halkyon-learn/android-release.p12}"
|
||||
readonly key_alias="${HALKYON_ANDROID_KEY_ALIAS:-halkyon-learn}"
|
||||
|
||||
fail() {
|
||||
echo "✗ $*" >&2
|
||||
exit 1
|
||||
}
|
||||
step() { echo "▸ $*"; }
|
||||
|
||||
[ $# -eq 1 ] || fail 'Usage: tool/release.sh v<major>.<minor>.<patch>'
|
||||
readonly tag="$1"
|
||||
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "$tag is no version tag such as v1.2.3."
|
||||
readonly version="${tag#v}"
|
||||
|
||||
step "Checking what $tag releases"
|
||||
[ -z "$(git status --porcelain)" ] || fail 'The working tree has changes.'
|
||||
[ "$(git symbolic-ref --quiet --short HEAD)" = main ] || fail 'Not on main.'
|
||||
git fetch --quiet origin main
|
||||
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] ||
|
||||
fail 'main differs from origin/main: pull or push first.'
|
||||
[ "$(git cat-file -t "$tag" 2>/dev/null)" = tag ] ||
|
||||
fail "No annotated tag $tag: git tag -s $tag -m 'Halkyon Learn $version'"
|
||||
[ "$(git rev-parse "$tag^{commit}")" = "$(git rev-parse HEAD)" ] ||
|
||||
fail "$tag isn't on HEAD."
|
||||
[ "$(git ls-remote origin "refs/tags/$tag" | cut -f1)" = "$(git rev-parse "$tag")" ] ||
|
||||
fail "origin doesn't have $tag as it is here: git push origin $tag"
|
||||
# Signed by a key deploys accept too: the primary key's fingerprint ends GnuPG's
|
||||
# VALIDSIG line, as %GP gives it for the commits deploy/redeploy.sh checks.
|
||||
validsig=$(git verify-tag --raw "$tag" 2>&1 | grep '^\[GNUPG:\] VALIDSIG ' || true)
|
||||
readonly signer="${validsig##* }"
|
||||
if [ -z "$validsig" ] || ! grep -qx "$signer" deploy/allowed-signers; then
|
||||
fail "$tag isn't signed by a key in deploy/allowed-signers."
|
||||
fi
|
||||
|
||||
declared=$(sed -n 's/^version: *//p' "$pubspec")
|
||||
[ "${declared%%+*}" = "$version" ] || fail "$pubspec says ${declared%%+*}, not $version."
|
||||
readonly build="${declared#*+}"
|
||||
[[ "$build" =~ ^[0-9]+$ ]] || fail "$pubspec's version lacks its build number (+N)."
|
||||
# Every release raises the build number: Android installs only higher ones, and the
|
||||
# server tells apps below its minimum build to update.
|
||||
if previous=$(git describe --tags --abbrev=0 --match 'v*' "$tag^" 2>/dev/null); then
|
||||
previous_build=$(git show "$previous:$pubspec" | sed -n 's/^version: *[^+]*+//p')
|
||||
[ "$build" -gt "$previous_build" ] ||
|
||||
fail "Build $build isn't above $previous's build $previous_build."
|
||||
fi
|
||||
|
||||
pinned=$(sed -n 's/^ *FLUTTER_VERSION: *//p' .forgejo/workflows/ci.yaml)
|
||||
running=$(flutter --version --machine | sed -n 's/^ *"frameworkVersion": *"\([^"]*\)".*/\1/p')
|
||||
[ "$running" = "$pinned" ] || fail "Flutter is $running here, CI pins $pinned."
|
||||
|
||||
step 'Checking the tools'
|
||||
for tool in snapcraft unsquashfs gpg curl jq sha256sum unzip; do
|
||||
command -v "$tool" >/dev/null || fail "$tool is missing."
|
||||
done
|
||||
sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
|
||||
build_tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -n 1)
|
||||
[ -n "$build_tools" ] || fail "No Android build tools in $sdk (ANDROID_HOME)."
|
||||
readonly apksigner="$build_tools/apksigner" aapt2="$build_tools/aapt2"
|
||||
# keytool, and the java apksigner runs on, come with a JDK: the one on the PATH, else
|
||||
# the one Android Studio brings.
|
||||
jdk_bin=$(dirname "$(command -v java || echo /snap/android-studio/current/jbr/bin/java)")
|
||||
[ -x "$jdk_bin/java" ] && [ -x "$jdk_bin/keytool" ] || fail 'No JDK found (java, keytool).'
|
||||
export PATH="$jdk_bin:$PATH"
|
||||
readonly keytool="$jdk_bin/keytool"
|
||||
account=$(snapcraft whoami 2>/dev/null | sed -n 's/^username: *//p' || true)
|
||||
[ -n "$account" ] || fail 'Not logged in to the Snap Store: snapcraft login'
|
||||
|
||||
[ -f "$certificate_file" ] || fail "$certificate_file is missing."
|
||||
certificate=$(grep -v '^#' "$certificate_file" | tr -d ' :\n' | tr 'A-F' 'a-f' || true)
|
||||
readonly certificate
|
||||
[[ "$certificate" =~ ^[0-9a-f]{64}$ ]] || fail "$certificate_file holds no SHA-256."
|
||||
[ -f "$keystore" ] || fail "No keystore at $keystore (HALKYON_ANDROID_KEYSTORE)."
|
||||
keystore=$(realpath "$keystore")
|
||||
readonly keystore
|
||||
|
||||
step 'Asking for the secrets'
|
||||
read -rsp "Password of $keystore: " password
|
||||
echo
|
||||
key_certificate=$(HALKYON_ANDROID_KEYSTORE_PASSWORD="$password" "$keytool" -list -v \
|
||||
-keystore "$keystore" -alias "$key_alias" \
|
||||
-storepass:env HALKYON_ANDROID_KEYSTORE_PASSWORD 2>/dev/null |
|
||||
sed -n 's/^.*SHA256: *//p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f' || true)
|
||||
[ -n "$key_certificate" ] || fail "Wrong password, or no key $key_alias in $keystore."
|
||||
[ "$key_certificate" = "$certificate" ] ||
|
||||
fail "The key $key_alias in $keystore isn't the one in $certificate_file."
|
||||
|
||||
read -rsp "Forgejo token for $forgejo (write:repository): " token
|
||||
echo
|
||||
# The token goes to curl in a configuration read from a file descriptor, so it never
|
||||
# shows in the list of processes as an argument would.
|
||||
forgejo_api() {
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--config <(printf 'header = "Authorization: token %s"\n' "$token") "$@"
|
||||
}
|
||||
forgejo_api --output /dev/null "$forgejo/api/v1/user" ||
|
||||
fail "$forgejo doesn't take the token."
|
||||
[ "$(forgejo_api --output /dev/null --write-out '%{http_code}' \
|
||||
"$forgejo/api/v1/repos/$repository/releases/tags/$tag" 2>/dev/null || true)" = 404 ] ||
|
||||
fail "A release of $tag exists already on $forgejo."
|
||||
|
||||
readonly out="build/release/$tag"
|
||||
readonly source="$out/source"
|
||||
readonly apk="halkyon-learn-$version.apk"
|
||||
readonly snap="halkyon-learn_${version}_amd64.snap"
|
||||
rm -rf "$out"
|
||||
git worktree prune
|
||||
mkdir -p "$out"
|
||||
cleanup() { git worktree remove --force "$source" 2>/dev/null || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# The APK is built from a checkout of the tag, so nothing in the working tree (build
|
||||
# caches, files git ignores) can get into it.
|
||||
step "Building the APK from $tag"
|
||||
git worktree add --quiet --detach "$source" "$tag"
|
||||
(
|
||||
cd "$source/halkyon_learn_flutter"
|
||||
flutter pub get --enforce-lockfile
|
||||
HALKYON_ANDROID_KEYSTORE="$keystore" \
|
||||
HALKYON_ANDROID_KEY_ALIAS="$key_alias" \
|
||||
HALKYON_ANDROID_KEYSTORE_PASSWORD="$password" \
|
||||
flutter build apk --release --dart-define=SERVER_URL="$server_url"
|
||||
# The Gradle daemon would otherwise live on with the password in its environment.
|
||||
(cd android && ./gradlew --stop >/dev/null) || true
|
||||
)
|
||||
cp "$source/halkyon_learn_flutter/build/app/outputs/flutter-apk/app-release.apk" "$out/$apk"
|
||||
unset password
|
||||
|
||||
step 'Checking the APK'
|
||||
signers=$("$apksigner" verify --print-certs "$out/$apk" |
|
||||
sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p' || true)
|
||||
[ "$signers" = "$certificate" ] ||
|
||||
fail "The APK isn't signed by the app's key alone, but by: $signers"
|
||||
badging=$("$aapt2" dump badging "$out/$apk")
|
||||
grep -q "^package: name='app.halkyon.learn' versionCode='$build' versionName='$version'" \
|
||||
<<<"$badging" || fail "The APK isn't app.halkyon.learn $version, build $build."
|
||||
! grep -q '^application-debuggable' <<<"$badging" || fail 'The APK is debuggable.'
|
||||
# grep's count is kept, not sent to /dev/null: there grep stops at the first match, which
|
||||
# ends unzip with SIGPIPE, and pipefail counts that as a failure.
|
||||
named=$(unzip -p "$out/$apk" lib/arm64-v8a/libapp.so | grep -caF "$server_url" || true)
|
||||
[ "${named:-0}" -gt 0 ] || fail "The APK doesn't name $server_url."
|
||||
|
||||
# The snap builds the repository's committed HEAD, which is the tag (checked above). Its
|
||||
# app part is cleaned so that it is built anew from this commit; the Flutter part stays.
|
||||
step "Building the snap from $tag (in LXD)"
|
||||
snapcraft clean halkyon-learn
|
||||
snapcraft pack --output "$out"
|
||||
[ -f "$out/$snap" ] || fail "snapcraft made no $snap."
|
||||
snap_yaml=$(unsquashfs -cat "$out/$snap" meta/snap.yaml)
|
||||
grep -qx "version: '\{0,1\}$version'\{0,1\}" <<<"$snap_yaml" ||
|
||||
fail "The snap isn't version $version."
|
||||
|
||||
step 'Signing the checksums'
|
||||
(cd "$out" && sha256sum "$apk" "$snap" >SHA256SUMS)
|
||||
gpg --local-user "$signer" --armor --detach-sign --output "$out/SHA256SUMS.asc" \
|
||||
"$out/SHA256SUMS"
|
||||
|
||||
step "Publishing the release on $forgejo"
|
||||
notes="Halkyon Learn $version for Android and Linux. Both connect to learn.halkyon.eu, \
|
||||
the project's instance.
|
||||
|
||||
- **Android:** \`$apk\`, one APK for every phone, signed with the app's key; the \
|
||||
SHA-256 of its certificate is \`$certificate\`.
|
||||
- **Linux:** \`$snap\`, also in the Snap Store: \`snap install halkyon-learn --edge\`.
|
||||
|
||||
\`SHA256SUMS\` lists the files' checksums, signed with the GPG key \`$signer\` \
|
||||
(\`SHA256SUMS.asc\`)."
|
||||
release=$(jq -n --arg tag "$tag" --arg name "Halkyon Learn $version" --arg body "$notes" \
|
||||
'{tag_name: $tag, name: $name, body: $body}' |
|
||||
forgejo_api --header 'Content-Type: application/json' --data @- \
|
||||
"$forgejo/api/v1/repos/$repository/releases" | jq -r .id)
|
||||
for file in "$apk" "$snap" SHA256SUMS SHA256SUMS.asc; do
|
||||
forgejo_api --output /dev/null --form "attachment=@$out/$file" \
|
||||
"$forgejo/api/v1/repos/$repository/releases/$release/assets?name=$file"
|
||||
done
|
||||
echo " $forgejo/$repository/releases/tag/$tag"
|
||||
|
||||
# Last, as the Store may hold a revision for review; the release above stands either way.
|
||||
step "Uploading the snap to the Snap Store's edge channel (as $account)"
|
||||
snapcraft upload --release=edge "$out/$snap" ||
|
||||
fail "The upload failed; once fixed: snapcraft upload --release=edge $out/$snap"
|
||||
|
||||
echo "✓ Released $tag."
|
||||
3
tool/release/android-certificate.sha256
Normal file
3
tool/release/android-certificate.sha256
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# The SHA-256 of the certificate that signs Halkyon Learn's Android releases (key alias
|
||||
# halkyon-learn), as keytool prints it. tool/release.sh releases no APK signed otherwise.
|
||||
27:E2:43:FC:BD:C8:26:FF:98:AB:7D:BB:10:43:6C:98:C3:BE:A2:4B:2A:01:5E:94:E4:CF:C7:D1:FC:2A:00:6C
|
||||
Loading…
Reference in a new issue