feat: releases work out their version from the commits
All checks were successful
CI / check (push) Successful in 9m17s
CI / deploy (push) Successful in 14s

tool/release.sh no longer takes a version: it works it out from the
commits since the last release, following semantic versioning (feat
raises the minor number, fix or perf the patch number, a breaking change
the major number from 1.0 on and the minor one before; other types
release nothing), or takes major, minor or patch as chosen. It shows the
version and its commits and asks first, checks that CI is green on the
newest commit with code, writes the version and a raised build number
into the pubspec in a signed release commit with a signed tag, and
pushes only once the APK and the snap passed their checks. The release
notes list the features and fixes. A run that stopped after its release
commit and tag carries on with them.

Channels for now: the release on code.halkyon.app (APK, snap, checksums)
and the F-Droid repository; the Snap Store upload is off until
snap_store_channel names a channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-05 21:22:50 +02:00
commit 4d3d4d9592
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 208 additions and 71 deletions

View file

@ -120,7 +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. - **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. - **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`. - **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. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-features TetheredNet and NonFreeNet declared with their reasons and F-Droid's own icons); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to ramiel as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last. - **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` on `main` as pushed and green in CI. Versions follow semantic versioning, worked out from the commits since the last release (`feat` raises the minor number, `fix`/`perf` the patch number, breaking ones the major from 1.0 on; other types release nothing), so **commit messages keep their type prefix**. It writes the version and a raised build number into the app's pubspec in a signed release commit (`chore(release): vX.Y.Z`) with a signed tag (a key in `deploy/allowed-signers`), 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; pushes only once both builds passed their checks; and publishes on code.halkyon.app through Forgejo's API and in the F-Droid repository. The Snap Store upload is off until `snap_store_channel` in the script names a channel. A run that stopped after its release commit and tag carries on with them. 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. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-features TetheredNet and NonFreeNet declared with their reasons and F-Droid's own icons); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to ramiel as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last.
## Rules the code relies on ## Rules the code relies on

View file

@ -110,31 +110,38 @@ the CI runner described in `deploy/runner/`, deploys only commits signed by a ke
## Releases ## Releases
Releases are published on [code.halkyon.app](https://code.halkyon.app/project-halkyon/halkyon_learn/releases), Releases are published on [code.halkyon.app](https://code.halkyon.app/project-halkyon/halkyon_learn/releases),
for Android also in the project's F-Droid repository with the APK and the snap, and the APK also in the project's F-Droid repository
(`https://halkyon.app/fdroid/repo?fingerprint=E8082788756F6B7E682AA22FCEAD24B53E2A8B0912AD66D65E8C2D4F07BB94B0`, (`https://halkyon.app/fdroid/repo?fingerprint=E8082788756F6B7E682AA22FCEAD24B53E2A8B0912AD66D65E8C2D4F07BB94B0`,
added in the F-Droid app as a repository), and for Linux in the Snap Store added in the F-Droid app as a repository). The Snap Store (`halkyon-learn`) follows.
(`halkyon-learn`). Their builds connect to learn.halkyon.eu, the project's instance. Their builds connect to learn.halkyon.eu, the project's instance.
- **Checking a download:** `SHA256SUMS` lists the checksum of every file, signed with the - **Checking a download:** `SHA256SUMS` lists the checksum of every file, signed with the
maintainer's GPG key (`SHA256SUMS.asc`). The APK is signed with the app's key, whose maintainer's GPG key (`SHA256SUMS.asc`). The APK is signed with the app's key, whose
certificate has the SHA-256 certificate has the SHA-256
`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` `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`
(`apksigner verify --print-certs` shows it). (`apksigner verify --print-certs` shows it).
- **Versions** are `version` in `halkyon_learn_flutter/pubspec.yaml`, - **Versions** follow [semantic versioning](https://semver.org): `version` in
`<major>.<minor>.<patch>+<build>`. Every release raises the build number: Android only `halkyon_learn_flutter/pubspec.yaml`, `<major>.<minor>.<patch>+<build>`. Since the last
installs a higher one over an older, and the server tells apps below the build it release, a commit typed `feat` raises the minor number, `fix` or `perf` the patch
supports to update. number, and a breaking one (`feat!:` or a `BREAKING CHANGE:` line) the major number from
1.0 on, the minor one before; other commits release nothing by themselves. Every
release raises the build number: Android only installs a higher one over an older, and
the server tells apps below the build it supports to update.
- **Making one** is for maintainers, on their own machine and never in CI:
`tool/release.sh`, or `tool/release.sh minor` (or `major`, `patch`) to choose. It needs
`main` clean, pushed and green in CI, the app's key, the F-Droid repository's key and
a Forgejo token; its header says more. It shows the version and its commits and asks
first, then writes the version into the pubspec in a signed release commit with a
signed tag, builds the APK and the snap (`snap/snapcraft.yaml`, built in LXD; its icon
comes from `tool/snap_icon.py`), checks both, signs the checksums, pushes, and
publishes on code.halkyon.app and in the F-Droid repository; the Snap Store once it is
switched on in the script. Besides Flutter and the Android SDK, it uses snapcraft with
LXD, a JDK (keytool), GnuPG, curl, jq, unzip, squashfs-tools, rsync and fdroidserver.
Release builds made without the app's key are signed with the debug key and can't
update a released app.
- **The F-Droid repository** is kept by `tool/fdroid_repo.sh` (fdroidserver v2.4.5, - **The F-Droid repository** is kept by `tool/fdroid_repo.sh` (fdroidserver v2.4.5,
e.g. `pipx install fdroidserver==2.4.5`); its settings and the app's listing are in e.g. `pipx install fdroidserver==2.4.5`); its settings and the app's listing are in
`tool/release/fdroid/`. `tool/release/fdroid/`.
- **Making one** is for maintainers, on their own machine and never in CI:
`tool/release.sh vX.Y.Z`. Its header says what it needs (the signed tag, the app's
key, a Forgejo token, `snapcraft login`). It builds the APK and the snap
(`snap/snapcraft.yaml`, built in LXD; its icon comes from `tool/snap_icon.py`), checks
both, signs the checksums and publishes everything. Besides Flutter and the Android
SDK, it uses snapcraft with LXD, a JDK (keytool), GnuPG, curl, jq, unzip and
squashfs-tools. Release builds made without the app's key are signed with the debug
key and can't update a released app.
## More ## More

View file

@ -1,22 +1,37 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds a release and publishes it: the Android APK (one for every phone, signed with # Releases Halkyon Learn. It works out the next version from the commits since the
# the app's key) and the Linux snap, both connecting to learn.halkyon.eu, the project's # last release, writes it into the app's pubspec in a signed commit with a signed tag,
# instance; a list of their checksums signed with GPG; all of it as a release on # builds the Android APK (one for every phone, signed with the app's key) and the
# code.halkyon.app, the APK in the F-Droid repository at halkyon.app # Linux snap, both connecting to learn.halkyon.eu, the project's instance, checks
# (tool/fdroid_repo.sh), and the snap in the Snap Store's edge channel. Run on a # them, signs a list of their checksums with GPG, pushes, and publishes on every
# developer's laptop, never in CI: the app's key never leaves it. # channel: a release on code.halkyon.app (APK, snap, checksums), the APK in the
# F-Droid repository at halkyon.app (tool/fdroid_repo.sh), and, once switched on
# below, the snap in the Snap Store. Run on a developer's laptop, never in CI: the
# app's key never leaves it.
# #
# tool/release.sh v0.1.0 # tool/release.sh the version the commits call for
# tool/release.sh minor or major, or patch: that one instead
# #
# Before: the version, with a build number above the last release's, in # Versions follow semantic versioning, <major>.<minor>.<patch>, and a build number
# halkyon_learn_flutter/pubspec.yaml on main, pushed; the tag signed on that commit and # (+N) that rises by one with every release: Android installs only a higher one over
# pushed (git tag -s v0.1.0 -m 'Halkyon Learn 0.1.0' && git push origin v0.1.0); # an older, and the server tells apps below the build it supports to update. Since
# `snapcraft login` with the project's account. The script asks for the key's password, # the last release, a commit typed feat raises the minor number, fix or perf the
# the F-Droid repository key's password and a Forgejo token (scope write:repository) # patch number, and a breaking one (feat!: or a BREAKING CHANGE: line) the major
# first, checks them, and then runs unattended until GPG asks for its passphrase (and # number from 1.0 on, the minor one before. Other commits (docs, test, chore, ...)
# ssh for the upload key's, unless an agent holds it); none is ever written down. # release nothing by themselves. The first release is the version the pubspec holds.
# Raising minimumAppBuild in the server, for changes older apps can't follow, stays a
# decision of its own.
# #
# The app's key is a PKCS #12 keystore at $HALKYON_ANDROID_KEYSTORE (default below), its # The script shows the version and its commits and asks before it changes anything.
# It needs main clean and as pushed, CI green on its newest commit, and asks for the
# app key's password, the F-Droid repository key's password and a Forgejo token
# (scope write:repository) first; then it runs on its own until GPG asks for its
# passphrase (and ssh for the upload key's, unless an agent holds them). None of
# them is ever written down. Nothing is pushed or published before both builds have
# passed their checks; a run that stopped after making the release commit and tag
# carries on with them when started again.
#
# The app's key is a PKCS #12 keystore at $HALKYON_ANDROID_KEYSTORE (default below),
# alias $HALKYON_ANDROID_KEY_ALIAS. Its certificate's SHA-256 is committed in # alias $HALKYON_ANDROID_KEY_ALIAS. Its certificate's SHA-256 is committed in
# tool/release/android-certificate.sha256, and nothing signed otherwise is released. # tool/release/android-certificate.sha256, and nothing signed otherwise is released.
set -euo pipefail set -euo pipefail
@ -28,6 +43,9 @@ readonly repository='project-halkyon/halkyon_learn'
readonly pubspec='halkyon_learn_flutter/pubspec.yaml' readonly pubspec='halkyon_learn_flutter/pubspec.yaml'
readonly certificate_file='tool/release/android-certificate.sha256' readonly certificate_file='tool/release/android-certificate.sha256'
readonly fdroid_certificate_file='tool/release/fdroid-index-certificate.sha256' readonly fdroid_certificate_file='tool/release/fdroid-index-certificate.sha256'
# The Snap Store channel the snap goes to, e.g. edge; empty: not to the Store yet,
# only attached to the release on code.halkyon.app.
readonly snap_store_channel=''
keystore="${HALKYON_ANDROID_KEYSTORE:-$HOME/.local/share/halkyon-learn/android-release.p12}" keystore="${HALKYON_ANDROID_KEYSTORE:-$HOME/.local/share/halkyon-learn/android-release.p12}"
readonly key_alias="${HALKYON_ANDROID_KEY_ALIAS:-halkyon-learn}" readonly key_alias="${HALKYON_ANDROID_KEY_ALIAS:-halkyon-learn}"
@ -37,41 +55,88 @@ fail() {
} }
step() { echo "▸ $*"; } step() { echo "▸ $*"; }
[ $# -eq 1 ] || fail 'Usage: tool/release.sh v<major>.<minor>.<patch>' [ $# -le 1 ] || fail 'Usage: tool/release.sh [major | minor | patch]'
readonly tag="$1" chosen="${1:-}"
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "$tag is no version tag such as v1.2.3." case "$chosen" in
readonly version="${tag#v}" '' | major | minor | patch) ;;
*) fail "$chosen is none of major, minor, patch." ;;
esac
step "Checking what $tag releases" step 'Checking the repository'
[ -z "$(git status --porcelain)" ] || fail 'The working tree has changes.' [ -z "$(git status --porcelain)" ] || fail 'The working tree has changes.'
[ "$(git symbolic-ref --quiet --short HEAD)" = main ] || fail 'Not on main.' [ "$(git symbolic-ref --quiet --short HEAD)" = main ] || fail 'Not on main.'
git fetch --quiet origin 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=$(sed -n 's/^version: *//p' "$pubspec")
[ "${declared%%+*}" = "$version" ] || fail "$pubspec says ${declared%%+*}, not $version." current="${declared%%+*}"
readonly build="${declared#*+}" current_build="${declared#*+}"
[[ "$build" =~ ^[0-9]+$ ]] || fail "$pubspec's version lacks its build number (+N)." [[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$current_build" =~ ^[0-9]+$ ]] ||
# Every release raises the build number: Android installs only higher ones, and the fail "$pubspec's version isn't <major>.<minor>.<patch>+<build>: $declared"
# server tells apps below its minimum build to update.
if previous=$(git describe --tags --abbrev=0 --match 'v*' "$tag^" 2>/dev/null); then # A run that stopped after its release commit and tag: HEAD is that tag, as pushed
previous_build=$(git show "$previous:$pubspec" | sed -n 's/^version: *[^+]*+//p') # or one commit ahead of origin/main.
[ "$build" -gt "$previous_build" ] || resumed=$(git describe --exact-match --tags --match 'v[0-9]*' HEAD 2>/dev/null || true)
fail "Build $build isn't above $previous's build $previous_build." if [ -n "$resumed" ]; then
git merge-base --is-ancestor origin/main HEAD ||
fail 'main and origin/main have gone apart.'
[ "$(git rev-list --count origin/main..HEAD)" -le 1 ] ||
fail "main is more than the release commit ahead of origin/main."
[ -z "$chosen" ] || fail "HEAD is $resumed already; carrying on with it takes no argument."
[ "$resumed" = "v$current" ] || fail "HEAD is $resumed, but $pubspec says $current."
readonly tag="$resumed" version="$current" build="$current_build"
previous=$(git describe --tags --abbrev=0 --match 'v[0-9]*' "$tag^" 2>/dev/null || true)
echo " Carrying on with $tag (build $build)."
else
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] ||
fail 'main differs from origin/main: pull or push first.'
previous=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)
if [ -z "$previous" ]; then
# The first release: the version the pubspec holds.
[ -z "$chosen" ] || fail 'The first release is the version the pubspec holds.'
version="$current"
build="$current_build"
kind='the first release'
else
[ "$previous" = "v$current" ] ||
fail "$pubspec says $current, but the last release is $previous."
subjects=$(git log --format='%s' "$previous..HEAD")
bodies=$(git log --format='%B' "$previous..HEAD")
typed='^[a-z]+(\([^)]*\))?'
if [ -n "$chosen" ]; then
bump="$chosen"
elif grep -qE "$typed!:" <<<"$subjects" || grep -q '^BREAKING CHANGE:' <<<"$bodies"; then
bump='breaking'
elif grep -qE '^feat(\([^)]*\))?:' <<<"$subjects"; then
bump='minor'
elif grep -qE '^(fix|perf)(\([^)]*\))?:' <<<"$subjects"; then
bump='patch'
else
echo "Nothing to release: no feat, fix or perf commit since $previous."
exit 0
fi
IFS=. read -r major minor patch <<<"$current"
if [ "$bump" = breaking ]; then
# Before 1.0, a breaking change raises the minor number.
if [ "$major" -ge 1 ]; then bump='major'; else bump='minor'; fi
fi
case "$bump" in
major) version="$((major + 1)).0.0" ;;
minor) version="$major.$((minor + 1)).0" ;;
patch) version="$major.$minor.$((patch + 1))" ;;
esac
build=$((current_build + 1))
kind="a $bump release"
fi
readonly tag="v$version" version build
git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null &&
fail "The tag $tag exists already."
echo " Release $tag (build $build), $kind${previous:+, since $previous}."
if [ -n "$previous" ]; then
git log --format=' %s' "$previous..HEAD" | grep -E '^ [a-z]+(\([^)]*\))?!?:' |
grep -vE '^ chore\(release\):' || true
fi
read -rp 'Go on? [y/N] ' answer
[ "$answer" = y ] || [ "$answer" = Y ] || fail 'Stopped; nothing changed.'
fi fi
pinned=$(sed -n 's/^ *FLUTTER_VERSION: *//p' .forgejo/workflows/ci.yaml) pinned=$(sed -n 's/^ *FLUTTER_VERSION: *//p' .forgejo/workflows/ci.yaml)
@ -92,8 +157,10 @@ jdk_bin=$(dirname "$(command -v java || echo /snap/android-studio/current/jbr/bi
[ -x "$jdk_bin/java" ] && [ -x "$jdk_bin/keytool" ] || fail 'No JDK found (java, keytool).' [ -x "$jdk_bin/java" ] && [ -x "$jdk_bin/keytool" ] || fail 'No JDK found (java, keytool).'
export PATH="$jdk_bin:$PATH" export PATH="$jdk_bin:$PATH"
readonly keytool="$jdk_bin/keytool" readonly keytool="$jdk_bin/keytool"
account=$(snapcraft whoami 2>/dev/null | sed -n 's/^username: *//p' || true) if [ -n "$snap_store_channel" ]; then
[ -n "$account" ] || fail 'Not logged in to the Snap Store: snapcraft login' account=$(snapcraft whoami 2>/dev/null | sed -n 's/^username: *//p' || true)
[ -n "$account" ] || fail 'Not logged in to the Snap Store: snapcraft login'
fi
[ -f "$certificate_file" ] || fail "$certificate_file is missing." [ -f "$certificate_file" ] || fail "$certificate_file is missing."
certificate=$(grep -v '^#' "$certificate_file" | tr -d ' :\n' | tr 'A-F' 'a-f' || true) certificate=$(grep -v '^#' "$certificate_file" | tr -d ' :\n' | tr 'A-F' 'a-f' || true)
@ -133,7 +200,30 @@ forgejo_api --output /dev/null "$forgejo/api/v1/user" ||
fail "$forgejo doesn't take the token." fail "$forgejo doesn't take the token."
[ "$(forgejo_api --output /dev/null --write-out '%{http_code}' \ [ "$(forgejo_api --output /dev/null --write-out '%{http_code}' \
"$forgejo/api/v1/repos/$repository/releases/tags/$tag" 2>/dev/null || true)" = 404 ] || "$forgejo/api/v1/repos/$repository/releases/tags/$tag" 2>/dev/null || true)" = 404 ] ||
fail "A release of $tag exists already on $forgejo." fail "A release of $tag exists already on $forgejo. If only publishing it in the" \
"F-Droid repository failed: tool/fdroid_repo.sh build/release/$tag/halkyon-learn-$version.apk"
# CI checked the newest commit with code in it: release commits change only the
# version.
checked=$(git log -1 --format=%H --invert-grep --grep='^chore(release):' origin/main)
ci=$(forgejo_api "$forgejo/api/v1/repos/$repository/commits/$checked/status" |
jq -r .state)
[ "$ci" = success ] || fail "CI isn't green on ${checked:0:7} ($ci)."
if [ -z "$resumed" ]; then
if [ -n "$previous" ]; then
step "Writing $version+$build into the pubspec"
sed -i "s/^version: .*/version: $version+$build/" "$pubspec"
git commit --quiet -S -m "chore(release): $tag" -m "Build $build." -- "$pubspec"
fi
git tag -s "$tag" -m "Halkyon Learn $version"
fi
# 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
readonly out="build/release/$tag" readonly out="build/release/$tag"
readonly source="$out/source" readonly source="$out/source"
@ -176,8 +266,8 @@ grep -q "^package: name='app.halkyon.learn' versionCode='$build' versionName='$v
named=$(unzip -p "$out/$apk" lib/arm64-v8a/libapp.so | grep -caF "$server_url" || true) 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." [ "${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 # The snap builds the repository's committed HEAD, which is the tag. Its app part is
# app part is cleaned so that it is built anew from this commit; the Flutter part stays. # cleaned so that it is built anew from this commit; the Flutter part stays.
step "Building the snap from $tag (in LXD)" step "Building the snap from $tag (in LXD)"
snapcraft clean halkyon-learn snapcraft clean halkyon-learn
snapcraft pack --output "$out" snapcraft pack --output "$out"
@ -191,15 +281,53 @@ step 'Signing the checksums'
gpg --local-user "$signer" --armor --detach-sign --output "$out/SHA256SUMS.asc" \ gpg --local-user "$signer" --armor --detach-sign --output "$out/SHA256SUMS.asc" \
"$out/SHA256SUMS" "$out/SHA256SUMS"
step "Pushing $tag"
# The release commit goes through the pre-push check like any other; the tag, on the
# same commit, then needs none.
if ! git merge-base --is-ancestor HEAD origin/main; then
git push origin main
fi
git push --no-verify origin "$tag"
step "Publishing the release on $forgejo" step "Publishing the release on $forgejo"
# What it brings: the commits since the last release, by their type.
changes() {
[ -n "$previous" ] || return 0
git log --reverse --format='%s' "$previous..$tag" |
sed -nE "s/^($1)(\([^)]*\))?!?: (.*)/- \3/p"
}
features=$(changes feat)
fixes=$(changes 'fix|perf')
if [ -z "$previous" ]; then
summary='The first release.'
elif [ -z "$features$fixes" ]; then
summary="No features or fixes since $previous."
else
summary="${features:+### Features
$features
}${fixes:+### Fixes
$fixes}"
fi
if [ -n "$snap_store_channel" ]; then
linux="\`$snap\`, also in the Snap Store: \`snap install halkyon-learn --$snap_store_channel\`."
else
linux="\`$snap\`: \`sudo snap install --dangerous $snap\` (not in the Snap Store yet)."
fi
notes="Halkyon Learn $version for Android and Linux. Both connect to learn.halkyon.eu, \ notes="Halkyon Learn $version for Android and Linux. Both connect to learn.halkyon.eu, \
the project's instance. the project's instance.
$summary
### Installing
- **Android:** \`$apk\`, one APK for every phone, signed with the app's key; the \ - **Android:** \`$apk\`, one APK for every phone, signed with the app's key; the \
SHA-256 of its certificate is \`$certificate\`. SHA-256 of its certificate is \`$certificate\`.
- **F-Droid:** the same APK in the repository \ - **F-Droid:** the same APK in the repository \
https://halkyon.app/fdroid/repo?fingerprint=$fdroid_fingerprint https://halkyon.app/fdroid/repo?fingerprint=$fdroid_fingerprint
- **Linux:** \`$snap\`, also in the Snap Store: \`snap install halkyon-learn --edge\`. - **Linux:** $linux
\`SHA256SUMS\` lists the files' checksums, signed with the GPG key \`$signer\` \ \`SHA256SUMS\` lists the files' checksums, signed with the GPG key \`$signer\` \
(\`SHA256SUMS.asc\`)." (\`SHA256SUMS.asc\`)."
@ -217,9 +345,11 @@ step 'Publishing the APK in the F-Droid repository'
tool/fdroid_repo.sh "$out/$apk" tool/fdroid_repo.sh "$out/$apk"
unset HALKYON_FDROID_KEYSTORE_PASSWORD unset HALKYON_FDROID_KEYSTORE_PASSWORD
# Last, as the Store may hold a revision for review; the release above stands either way. # Last, as the Store may hold a revision for review; the releases above stand either way.
step "Uploading the snap to the Snap Store's edge channel (as $account)" if [ -n "$snap_store_channel" ]; then
snapcraft upload --release=edge "$out/$snap" || step "Uploading the snap to the Snap Store's $snap_store_channel channel (as $account)"
fail "The upload failed; once fixed: snapcraft upload --release=edge $out/$snap" snapcraft upload --release="$snap_store_channel" "$out/$snap" ||
fail "The upload failed; once fixed: snapcraft upload --release=$snap_store_channel $out/$snap"
fi
echo "✓ Released $tag." echo "✓ Released $tag."