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

View file

@ -1,22 +1,37 @@
#!/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, the APK in the F-Droid repository at halkyon.app
# (tool/fdroid_repo.sh), 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.
# Releases Halkyon Learn. It works out the next version from the commits since the
# last release, writes it into the app's pubspec in a signed commit with a signed tag,
# builds 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, checks
# them, signs a list of their checksums with GPG, pushes, and publishes on every
# 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
# 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,
# the F-Droid repository key's password and a Forgejo token (scope write:repository)
# first, checks them, and then runs unattended until GPG asks for its passphrase (and
# ssh for the upload key's, unless an agent holds it); none is ever written down.
# Versions follow semantic versioning, <major>.<minor>.<patch>, and a build number
# (+N) that rises by one with every release: Android installs only a higher one over
# an older, and the server tells apps below the build it supports to update. Since
# the last release, a commit typed feat raises the minor number, fix or perf the
# patch number, and a breaking one (feat!: or a BREAKING CHANGE: line) the major
# number from 1.0 on, the minor one before. Other commits (docs, test, chore, ...)
# 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
# tool/release/android-certificate.sha256, and nothing signed otherwise is released.
set -euo pipefail
@ -28,6 +43,9 @@ readonly repository='project-halkyon/halkyon_learn'
readonly pubspec='halkyon_learn_flutter/pubspec.yaml'
readonly certificate_file='tool/release/android-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}"
readonly key_alias="${HALKYON_ANDROID_KEY_ALIAS:-halkyon-learn}"
@ -37,41 +55,88 @@ fail() {
}
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}"
[ $# -le 1 ] || fail 'Usage: tool/release.sh [major | minor | patch]'
chosen="${1:-}"
case "$chosen" in
'' | 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.'
[ "$(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."
current="${declared%%+*}"
current_build="${declared#*+}"
[[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$current_build" =~ ^[0-9]+$ ]] ||
fail "$pubspec's version isn't <major>.<minor>.<patch>+<build>: $declared"
# A run that stopped after its release commit and tag: HEAD is that tag, as pushed
# or one commit ahead of origin/main.
resumed=$(git describe --exact-match --tags --match 'v[0-9]*' HEAD 2>/dev/null || true)
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
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).'
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'
if [ -n "$snap_store_channel" ]; then
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."
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."
[ "$(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."
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 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:-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.
# The snap builds the repository's committed HEAD, which is the tag. 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"
@ -191,15 +281,53 @@ step 'Signing the checksums'
gpg --local-user "$signer" --armor --detach-sign --output "$out/SHA256SUMS.asc" \
"$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"
# 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, \
the project's instance.
$summary
### Installing
- **Android:** \`$apk\`, one APK for every phone, signed with the app's key; the \
SHA-256 of its certificate is \`$certificate\`.
- **F-Droid:** the same APK in the repository \
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.asc\`)."
@ -217,9 +345,11 @@ step 'Publishing the APK in the F-Droid repository'
tool/fdroid_repo.sh "$out/$apk"
unset HALKYON_FDROID_KEYSTORE_PASSWORD
# 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"
# Last, as the Store may hold a revision for review; the releases above stand either way.
if [ -n "$snap_store_channel" ]; then
step "Uploading the snap to the Snap Store's $snap_store_channel channel (as $account)"
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."