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
8 changed files with 12 additions and 368 deletions
|
|
@ -80,7 +80,7 @@ Checklist after doing changes, in this order:
|
|||
- Check `serverpod` MCP `tail_server_logs` and `tail_flutter_logs` for any issues.
|
||||
|
||||
- **Melos** (decided 2026-10-19, ADR 0015) runs scripts across the workspace's packages: a dev dependency of the root (`dart run melos …`, its version in the lock), its scripts under `melos:` in the root `pubspec.yaml`: `analyze`; `test:dart` (the packages without Flutter, one at a time, as each server's tests start a PostgreSQL of their own on the same port); `test:flutter` (`flutter test -x requires-server`, one package at a time); `generate` (Serverpod's code generation in every package with a `config/generator.yaml`, the CLI on the PATH); `check` (`tool/check.dart`). Every script takes Melos' filters: `dart run melos run test:flutter --scope=halkyon_app`, or `--diff=origin/main --include-dependents` for what changed and what depends on it; `dart run melos list` lists the packages. The root overrides `cli_util` to 0.5.2 for it: Learn's `flutter_launcher_icons` (0.14.4, its latest) asks for ^0.4.1 and uses only the logger, which 0.5 keeps; drop the override once that package allows 0.5.
|
||||
- **Tools are Dart where they hold logic** (decided 2026-10-19): conditions, parsing, parallel work, anything worth a test, as `tool/check.dart` (its file selection in `tool/src/check_files.dart`, tested in the root's `test/`). **Bash for glue**, short and linear (`tool/pre-push`, `tool/check_signatures.sh`, `tool/workspace_only.sh`, which narrows the root pubspec's workspace to the packages an image builds), **and for everything run on the production host**, which has no Dart SDK and gets none (`deploy/redeploy.sh`, `deploy/backup.sh`). **Python only where a library exists only there** (Learn's icons with Pillow, its Anki fixtures with Anki itself); Learn's data scripts and `packages/halkyon_app/tool/subdivision_names.py` need nothing but Python's standard library and may move to Dart when they next change. **Releases come from `tool/release.dart`** (decided 2026-10-19, ADR 0015), shared by the apps, each configured in Dart beside it (`apps/<id>/release.dart`, typed and commented, registered in `tool/src/release/apps.dart`): `dart run tool/release.dart learn [major | minor | patch] [--dry-run]`, on a developer's laptop, never in CI. Tags are per app (`learn-v0.19.0`; an app's earlier tags, Learn's `v…`, count as its history), and only commits touching the app decide its version: its folder, the shared packages its packages use by path (transitively), the root pubspec and lock, its release files (key pins, F-Droid listing, snap recipe) and where its packages were before a move. `--dry-run` reads everything a release would (the version, the commits that count and what each touches, the steps, the notes it would publish), changes nothing, asks for no secret, and lists what a release would refuse. Its pure logic is in `tool/src/release/`, tested in the root's `test/release/`. `tool/fdroid_repo.sh` stays Bash (glue for fdroidserver). `tool/release.sh` releases build 21 alone, the release going with ADR 0014's breaking deploy, by the script it was proven with; then it goes.
|
||||
- **Tools are Dart where they hold logic** (decided 2026-10-19): conditions, parsing, parallel work, anything worth a test, as `tool/check.dart` (its file selection in `tool/src/check_files.dart`, tested in the root's `test/`). **Bash for glue**, short and linear (`tool/pre-push`, `tool/check_signatures.sh`, `tool/workspace_only.sh`, which narrows the root pubspec's workspace to the packages an image builds), **and for everything run on the production host**, which has no Dart SDK and gets none (`deploy/redeploy.sh`, `deploy/backup.sh`). **Python only where a library exists only there** (Learn's icons with Pillow, its Anki fixtures with Anki itself); Learn's data scripts and `packages/halkyon_app/tool/subdivision_names.py` need nothing but Python's standard library and may move to Dart when they next change. **Releases come from `tool/release.dart`** (decided 2026-10-19, ADR 0015), shared by the apps, each configured in Dart beside it (`apps/<id>/release.dart`, typed and commented, registered in `tool/src/release/apps.dart`): `dart run tool/release.dart learn [major | minor | patch] [--dry-run]`, on a developer's laptop, never in CI. Tags are per app (`learn-v0.19.0`; an app's earlier tags, Learn's `v…`, count as its history), and only commits touching the app decide its version: its folder, the shared packages its packages use by path (transitively), the root pubspec and lock, its release files (key pins, F-Droid listing, snap recipe) and where its packages were before a move. `--dry-run` reads everything a release would (the version, the commits that count and what each touches, the steps, the notes it would publish), changes nothing, asks for no secret, and lists what a release would refuse. Its pure logic is in `tool/src/release/`, tested in the root's `test/release/`. `tool/fdroid_repo.sh` stays Bash (glue for fdroidserver). Build 21 (`v0.18.0`) was the last release of `tool/release.sh`, the Bash script it replaced, removed since.
|
||||
|
||||
## CI and deploys
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **From the single repository** (ADR 0015): learn.halkyon.eu deploys this repository's `main`, whichever app or shared package a push changed. Its images are built from the repository's root with only what they need (Learn's server and the module it builds on, with the module's client, which the module's tests depend on; the curator), each Dockerfile narrowing the workspace to those packages (`tool/workspace_only.sh`). The first deploy after the move starts the server once more than it needs to, as the deployed copy of `redeploy.sh` looks for migrations at their old path.
|
||||
- **Sidecars** (`deploy/sidecars/README.md`, decided 2026-10-14): services an instance may run beside itself, each a compose file of its own beside `deploy/compose.yaml` (`compose.jellyfin.yaml`; later LanguageTool, Fedify), switched on in `deploy/.env` (`COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml`), so `redeploy.sh` deploys them with the instance. The instance's compose has two networks: `edge` (`halkyon-learn-edge`, 172.30.80.0/24 and the unique local fd4a:6c6b:7e80::/64, so Docker hands Caddy IPv6 visitors' own addresses), Caddy's at fixed addresses (172.30.80.2, fd4a:6c6b:7e80::2; the server's `TRUSTED_PROXIES`), shared with the server, and the internal default one, where the server reaches Postgres; a sidecar joins `edge` alone and publishes no port (the CI runner may reach `127.0.0.1`, never Docker's 172.16.0.0/12 or fd00::/8). A sidecar reached from outside mounts its site from `deploy/sidecars/` into Caddy's `/etc/caddy/sidecars/`, which the Caddyfile imports (none without), and `redeploy.sh` recreates Caddy when a site changes. Each runs as a user ID of its own (Jellyfin 61096), never one a person, another container or the runner uses; its files on the server are git-ignored (`deploy/jellyfin/`). The Jellyfin sidecar sets the server's `JELLYFIN_URL`, so the instance suggests it.
|
||||
- **Backups** (`deploy/backups.md`): every hour, `deploy/backup.sh` (a systemd timer, as `deploy`) dumps the database, which holds people's files too, then bundles each published studyset's repository (`git bundle`, from the server's `repositories` volume), and restic stores both 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 F-Droid listing point to the project's page on Halkyon's website, halkyon.app/learn (its own repository, `project-halkyon/website`; learn.halkyon.app redirects there); the builds name learn.halkyon.eu, the project's instance (`SERVER_URL`), which a new install offers first (see *Instances*). They come from a developer's laptop, never from CI: `dart run tool/release.dart learn` on `main` as pushed and green in CI (the apps' shared release tool, ADR 0015, configured in `apps/learn/release.dart`; `--dry-run` shows the version, the commits that count, every step and the notes without changing anything). Build 21 alone comes from `tool/release.sh`, the Bash script it replaces, tagged `v0.18.0`; then that script goes. Versions follow semantic versioning, worked out from the commits touching Learn since its last release (`apps/learn/`, the shared packages it uses, the root pubspec and lock, its release files, and its packages' folders at the root from before the move; `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): learn-vX.Y.Z`) with a signed tag (`learn-vX.Y.Z`, by a key in `deploy/allowed-signers`; Learn's earlier `vX.Y.Z` tags count as its history), 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 `storeChannel` in `apps/learn/release.dart` 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 release tool 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-feature NonFreeNet declared with its reasons and F-Droid's own icon; not TetheredNet, as the app chooses its instance); 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 the halkyon.app server (`ssh ramiel-fdroid`) 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 F-Droid listing point to the project's page on Halkyon's website, halkyon.app/learn (its own repository, `project-halkyon/website`; learn.halkyon.app redirects there); the builds name learn.halkyon.eu, the project's instance (`SERVER_URL`), which a new install offers first (see *Instances*). They come from a developer's laptop, never from CI: `dart run tool/release.dart learn` on `main` as pushed and green in CI (the apps' shared release tool, ADR 0015, configured in `apps/learn/release.dart`; `--dry-run` shows the version, the commits that count, every step and the notes without changing anything). Build 21 (`v0.18.0`) was the last from `tool/release.sh`, the Bash script it replaced, removed since. Versions follow semantic versioning, worked out from the commits touching Learn since its last release (`apps/learn/`, the shared packages it uses, the root pubspec and lock, its release files, and its packages' folders at the root from before the move; `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): learn-vX.Y.Z`) with a signed tag (`learn-vX.Y.Z`, by a key in `deploy/allowed-signers`; Learn's earlier `vX.Y.Z` tags count as its history), 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 goes to the Snap Store's edge channel last (`storeChannel` in `apps/learn/release.dart`; the release refuses to start unless `snapcraft whoami` shows a login). 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 release tool 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-feature NonFreeNet declared with its reasons and F-Droid's own icon; not TetheredNet, as the app chooses its instance); 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 the halkyon.app server (`ssh ramiel-fdroid`) 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
|
||||
|
||||
|
|
|
|||
|
|
@ -149,15 +149,14 @@ may choose another there, and keep accounts on several.
|
|||
repository's root: `dart run tool/release.dart learn`, or with `minor` (or `major`,
|
||||
`patch`) after `learn` to choose; `--dry-run` shows what it would do and changes
|
||||
nothing. It is the apps' shared release tool ([ADR 0015](../../docs/adr/0015-one-repository.md)),
|
||||
configured in `apps/learn/release.dart`; build 21 alone still comes from
|
||||
`tool/release.sh`. It needs `main` clean, pushed and green in CI, the app's key, the
|
||||
configured in `apps/learn/release.dart`. 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. Tags are
|
||||
`learn-vX.Y.Z`, and only commits touching Learn count for its version. 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 `apps/learn/tool/app_icons.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
|
||||
publishes on code.halkyon.app, in the F-Droid repository and in the Snap Store's edge
|
||||
channel (`snapcraft login` first). 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.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ android {
|
|||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// The app's own key signs release builds when tool/release.sh hands it over
|
||||
// The app's own key signs release builds when tool/release.dart 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
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import '../../tool/src/release/app_release.dart';
|
|||
|
||||
/// Halkyon Learn's releases: the APK and the snap, both connecting to
|
||||
/// learn.halkyon.eu, the project's instance, published on code.halkyon.app,
|
||||
/// in the project's F-Droid repository and, once a channel is set, in the
|
||||
/// Snap Store.
|
||||
/// in the project's F-Droid repository and in the Snap Store's edge channel.
|
||||
const learnRelease = AppRelease(
|
||||
id: 'learn',
|
||||
name: 'Halkyon Learn',
|
||||
|
|
@ -32,11 +31,13 @@ const learnRelease = AppRelease(
|
|||
indexCertificateFile: 'tool/release/fdroid-index-certificate.sha256',
|
||||
repositoryUrl: 'https://halkyon.app/fdroid/repo',
|
||||
),
|
||||
// No Store channel yet: the snap is only attached to the release.
|
||||
// To the Store's edge channel (since 23 Oct 2026, as v0.18.0 went there by
|
||||
// hand): stable once the app leaves its early days.
|
||||
snap: SnapRelease(
|
||||
name: 'halkyon-learn',
|
||||
part: 'halkyon-learn',
|
||||
directory: 'snap',
|
||||
storeChannel: 'edge',
|
||||
),
|
||||
// Learn's releases up to build 21 were tagged v0.18.0 and so on.
|
||||
legacyTagPrefix: 'v',
|
||||
|
|
|
|||
|
|
@ -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