chore(release): tool/release.sh goes, and the snap goes to the Store's edge
Build 21 (v0.18.0) was the release tool/release.sh was kept for, the one going with ADR 0014's breaking deploy; every release since is `dart run tool/release.dart learn`. The comments that named the script (the F-Droid script, the certificate pin, the Gradle signing) now name the Dart tool. Learn's release uploads the snap to the Snap Store's edge channel last, where v0.18.0 went by hand; the release refuses to start unless `snapcraft whoami` shows a login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8d6785f5c6
commit
87ae5fca89
3 changed files with 12 additions and 368 deletions
|
|
@ -2,7 +2,7 @@
|
|||
# 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.sh runs it with each
|
||||
# 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.
|
||||
#
|
||||
|
|
|
|||
356
tool/release.sh
356
tool/release.sh
|
|
@ -1,356 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# 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 the version the commits call for
|
||||
# tool/release.sh minor or major, or patch: that one instead
|
||||
#
|
||||
# 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 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
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
readonly server_url='https://learn.halkyon.eu/'
|
||||
readonly forgejo='https://code.halkyon.app'
|
||||
readonly repository='project-halkyon/halkyon'
|
||||
readonly pubspec='apps/learn/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}"
|
||||
|
||||
fail() {
|
||||
echo "✗ $*" >&2
|
||||
exit 1
|
||||
}
|
||||
step() { echo "▸ $*"; }
|
||||
|
||||
[ $# -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 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
|
||||
declared=$(sed -n 's/^version: *//p' "$pubspec")
|
||||
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)
|
||||
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"
|
||||
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)
|
||||
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 "Password of the F-Droid repository's key: " HALKYON_FDROID_KEYSTORE_PASSWORD
|
||||
echo
|
||||
export HALKYON_FDROID_KEYSTORE_PASSWORD
|
||||
tool/fdroid_repo.sh --check
|
||||
fdroid_fingerprint=$(grep -v '^#' "$fdroid_certificate_file" | tr -d ' :\n' | tr 'a-f' 'A-F')
|
||||
readonly fdroid_fingerprint
|
||||
|
||||
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") "$@"
|
||||
}
|
||||
# The token's scope, write:repository, covers the repository, not the account.
|
||||
forgejo_api --output /dev/null "$forgejo/api/v1/repos/$repository" ||
|
||||
fail "$forgejo doesn't take the token for $repository."
|
||||
[ "$(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. 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"
|
||||
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/apps/learn/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/apps/learn/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. 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 "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:** $linux
|
||||
|
||||
\`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"
|
||||
|
||||
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 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."
|
||||
|
|
@ -1,3 +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.
|
||||
# halkyon-learn), as keytool prints it. tool/release.dart 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