build(release): a Dart release tool for every app

tool/release.dart releases one app: `dart run tool/release.dart learn
[major | minor | patch] [--dry-run]`, each app configured in Dart beside
it (apps/learn/release.dart). Every step and check of tool/release.sh
is kept: the repository as pushed, resuming after a release commit and
tag, Flutter as CI pins it, the tools, the key and its certificate pin,
secrets asked for without echo and never written down, CI green on the
newest commit, the signed release commit and tag by a key deploys
accept, the APK built from a checkout of the tag and checked, the snap,
the signed checksums, pushing only after both builds passed, the
release on Forgejo, the F-Droid repository, the Snap Store once a
channel is set.

New: tags per app (learn-v0.19.0, Learn's v… counting as its
history); only commits touching the app decide its version (its folder,
the shared packages it uses by path, the root pubspec and lock, its
release files, where its packages were before the move); --dry-run
reads all of it, lists every step and the notes, and what a release
would refuse, changing nothing. The pure parts are tested in
test/release/. Learn releases build 21 with tool/release.sh, then
switches.

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-19 21:12:31 +02:00
commit e65f61bdd1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
23 changed files with 2334 additions and 12 deletions

View file

@ -79,7 +79,7 @@ Checklist after doing changes, in this order:
- Check `serverpod` MCP `tail_server_logs` and `tail_flutter_logs` for any issues. - 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. - **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. `tool/release.sh` and `tool/fdroid_repo.sh` are Learn's release, in Bash until build 21 is out; then a release tool in Dart, shared by the apps, takes over (ADR 0015). - **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.
## CI and deploys ## CI and deploys

View file

@ -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; 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. - **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; 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), Caddy's at a fixed address (172.30.80.2), 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). 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. - **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), Caddy's at a fixed address (172.30.80.2), 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). 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`. - **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: `tool/release.sh` on `main` as pushed and green in CI (Bash until build 21 is out; then the apps' shared Dart release tool takes over, tagging per app, `learn-vX.Y.Z`, ADR 0015; until then tags stay `vX.Y.Z`). 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-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 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.
## Rules the code relies on ## Rules the code relies on

View file

@ -150,10 +150,13 @@ may choose another there, and keep accounts on several.
release raises the build number: Android only installs a higher one over an older, and 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. the server tells apps below the build it supports to update.
- **Making one** is for maintainers, on their own machine and never in CI, from the - **Making one** is for maintainers, on their own machine and never in CI, from the
repository's root: `tool/release.sh`, or `tool/release.sh minor` (or `major`, `patch`) repository's root: `dart run tool/release.dart learn`, or with `minor` (or `major`,
to choose (until build 21 is out; then a release tool in Dart, shared by the apps, `patch`) after `learn` to choose; `--dry-run` shows what it would do and changes
takes over, [ADR 0015](../../docs/adr/0015-one-repository.md)). It needs `main` clean, pushed and green in CI, the app's key, the F-Droid repository's key and nothing. It is the apps' shared release tool ([ADR 0015](../../docs/adr/0015-one-repository.md)),
a Forgejo token; its header says more. It shows the version and its commits and asks 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
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 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 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 comes from `apps/learn/tool/app_icons.py`), checks both, signs the checksums, pushes, and

51
apps/learn/release.dart Normal file
View file

@ -0,0 +1,51 @@
// How Halkyon Learn is released (`dart run tool/release.dart learn`): the
// release tool's configuration of it, registered in tool/src/release/apps.dart.
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.
const learnRelease = AppRelease(
id: 'learn',
name: 'Halkyon Learn',
applicationId: 'app.halkyon.learn',
directory: 'apps/learn',
flutterPackage: 'apps/learn/halkyon_learn_flutter',
artifactName: 'halkyon-learn',
serverUrl: 'https://learn.halkyon.eu/',
forgejo: 'https://code.halkyon.app',
repository: 'project-halkyon/halkyon',
// 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,
// and lost, installed apps can't be updated.
android: AndroidSigning(
certificateFile: 'tool/release/android-certificate.sha256',
keystoreVariable: 'HALKYON_ANDROID_KEYSTORE',
defaultKeystore: '.local/share/halkyon-learn/android-release.p12',
aliasVariable: 'HALKYON_ANDROID_KEY_ALIAS',
defaultAlias: 'halkyon-learn',
),
fdroid: FdroidListing(
script: 'tool/fdroid_repo.sh',
listing: 'tool/release/fdroid',
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.
snap: SnapRelease(
name: 'halkyon-learn',
part: 'halkyon-learn',
directory: 'snap',
),
// Learn's releases up to build 21 were tagged v0.18.0 and so on.
legacyTagPrefix: 'v',
// Its packages were at the repository's root until it moved into apps/learn
// (19 Oct 2026, ADR 0015).
formerPaths: [
'halkyon_learn_client',
'halkyon_learn_curator',
'halkyon_learn_flutter',
'halkyon_learn_server',
],
);

View file

@ -57,7 +57,9 @@ repository for everything, with Melos on top.
production host has no Dart SDK and gets none. Learn's `tool/release.sh` stays Bash production host has no Dart SDK and gets none. Learn's `tool/release.sh` stays Bash
until build 21 is out, so the release that goes with ADR 0014's breaking deploy uses the until build 21 is out, so the release that goes with ADR 0014's breaking deploy uses the
script it was proven with; then a release tool in Dart, shared by the apps and tagging script it was proven with; then a release tool in Dart, shared by the apps and tagging
per app (`learn-vX.Y.Z`), takes over. per app (`learn-vX.Y.Z`), takes over. *Built 19 Oct 2026:* `tool/release.dart`, each
app's configuration in Dart beside it (`apps/learn/release.dart`), only commits
touching the app counting for its version (the developer's calls), and `--dry-run`.
8. **The move happens while deploys pause** (until 22 Oct, after the hackathon's judging): 8. **The move happens while deploys pause** (until 22 Oct, after the hackathon's judging):
the first deploy from the new layout comes with build 21. the first deploy from the new layout comes with build 21.

View file

@ -355,7 +355,7 @@ packages:
source: hosted source: hosted
version: "0.3.5+5" version: "0.3.5+5"
crypto: crypto:
dependency: transitive dependency: "direct dev"
description: description:
name: crypto name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@ -913,7 +913,7 @@ packages:
source: hosted source: hosted
version: "0.15.7" version: "0.15.7"
http: http:
dependency: transitive dependency: "direct dev"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@ -1286,7 +1286,7 @@ packages:
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
path: path:
dependency: transitive dependency: "direct dev"
description: description:
name: path name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
@ -2381,7 +2381,7 @@ packages:
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
yaml: yaml:
dependency: transitive dependency: "direct dev"
description: description:
name: yaml name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea

View file

@ -27,13 +27,18 @@ workspace:
- packages/halkyon_ui/example - packages/halkyon_ui/example
dev_dependencies: dev_dependencies:
# The tools' (tool/check.dart) options, and their tests (test/). # The tools' (tool/check.dart, tool/release.dart) options, and their tests (test/).
args: ^2.7.0 args: ^2.7.0
# The release tool's checksums, its calls to Forgejo's API, and the pubspecs it reads.
crypto: ^3.0.7
http: ^1.6.0
# Referenced by analysis_options.yaml in this directory. # Referenced by analysis_options.yaml in this directory.
leancode_lint: ^27.0.0 leancode_lint: ^27.0.0
# Runs scripts across the workspace's packages (`melos:` below): `dart run melos …`. # Runs scripts across the workspace's packages (`melos:` below): `dart run melos …`.
melos: ^8.9.0 melos: ^8.9.0
path: ^1.9.1
test: ^1.31.1 test: ^1.31.1
yaml: ^3.1.4
dependency_overrides: dependency_overrides:
# Learn's flutter_launcher_icons (0.14.4, its latest, from June 2025) asks for # Learn's flutter_launcher_icons (0.14.4, its latest, from June 2025) asks for

View file

@ -0,0 +1,77 @@
import 'dart:io';
import 'package:test/test.dart';
import '../../apps/learn/release.dart';
import '../../tool/src/release/apps.dart';
import '../../tool/src/release/version.dart';
import 'fixtures.dart';
void main() {
test("an app's release names its files and tags by its id", () {
expect(testApp.tagPrefix, 'notes-v');
expect(testApp.pubspec, 'apps/notes/halkyon_notes_flutter/pubspec.yaml');
expect(testApp.instance, 'notes.halkyon.eu');
expect(
testApp.apkFile(const Version(0, 19, 0)),
'halkyon-notes-0.19.0.apk',
);
expect(
testApp.snapFile(const Version(0, 19, 0)),
'halkyon-notes_0.19.0_amd64.snap',
);
});
test('a configuration that would release something wrong is refused', () {
expect(testApp.problems(), isEmpty);
expect(
testApp.copyWith(id: 'Notes App').problems(),
contains('The id "Notes App" is not lower-case letters and digits.'),
);
expect(
testApp.copyWith(serverUrl: 'http://notes.halkyon.eu/').problems(),
contains("The server URL http://notes.halkyon.eu/ isn't HTTPS."),
);
expect(
testApp.copyWith(serverUrl: 'https://notes.halkyon.eu').problems(),
contains(
"The server URL https://notes.halkyon.eu doesn't end with a slash, "
'as the app is built with it.',
),
);
expect(
testApp
.copyWith(flutterPackage: 'apps/todo/halkyon_todo_flutter')
.problems(),
contains(
"The Flutter package apps/todo/halkyon_todo_flutter isn't in "
'apps/notes.',
),
);
expect(
testApp.copyWith(applicationId: 'notes').problems(),
contains("The application ID notes isn't one Android takes."),
);
});
test("Learn's release is registered, sound, and names files that exist", () {
expect(releasedApps['learn'], same(learnRelease));
expect(learnRelease.problems(), isEmpty);
// The test runs in the repository's root, as `dart test` does.
for (final path in [
learnRelease.pubspec,
learnRelease.android.certificateFile,
...learnRelease.releaseFiles,
]) {
expect(
FileSystemEntity.typeSync(path),
isNot(FileSystemEntityType.notFound),
reason: path,
);
}
expect(
File('${learnRelease.snap!.directory}/snapcraft.yaml').readAsStringSync(),
contains('name: ${learnRelease.snap!.name}\n'),
);
});
}

View file

@ -0,0 +1,34 @@
import '../../tool/src/release/app_release.dart';
/// An app as a test needs it: Learn's shape, under names of its own.
const testApp = AppRelease(
id: 'notes',
name: 'Halkyon Notes',
applicationId: 'app.halkyon.notes',
directory: 'apps/notes',
flutterPackage: 'apps/notes/halkyon_notes_flutter',
artifactName: 'halkyon-notes',
serverUrl: 'https://notes.halkyon.eu/',
forgejo: 'https://code.example.org',
repository: 'project-halkyon/halkyon',
android: AndroidSigning(
certificateFile: 'tool/release/notes-certificate.sha256',
keystoreVariable: 'HALKYON_NOTES_KEYSTORE',
defaultKeystore: '.local/share/halkyon-notes/android-release.p12',
aliasVariable: 'HALKYON_NOTES_KEY_ALIAS',
defaultAlias: 'halkyon-notes',
),
fdroid: FdroidListing(
script: 'tool/fdroid_repo.sh',
listing: 'tool/release/fdroid',
indexCertificateFile: 'tool/release/fdroid-index-certificate.sha256',
repositoryUrl: 'https://halkyon.app/fdroid/repo',
),
snap: SnapRelease(
name: 'halkyon-notes',
part: 'halkyon-notes',
directory: 'snap',
),
legacyTagPrefix: 'v',
formerPaths: ['halkyon_notes_flutter', 'halkyon_notes_server'],
);

View file

@ -0,0 +1,37 @@
import 'package:test/test.dart';
import '../../tool/src/release/log.dart';
import '../../tool/src/release/version.dart';
void main() {
test("git's log is read as commits with their bodies and files", () {
// As `git log --format=<logFormat> --name-only` writes it.
const output =
'\x1eaaa111\x1ffeat(app): one\x1fWhy it matters.\n\n'
'BREAKING CHANGE: older apps stop.\n\x1f\n\n'
'apps/notes/a.dart\npackages/halkyon_ui/b.dart\n'
'\x1ebbb222\x1fchore: merge\x1f\x1f\n'
'\x1eccc333\x1ffix: two\x1f\x1f\n\npubspec.lock\n';
final commits = parseLog(output);
expect(commits.map((commit) => commit.hash), [
'aaa111',
'bbb222',
'ccc333',
]);
expect(commits.first.subject, 'feat(app): one');
expect(isBreaking(commits.first), isTrue);
expect(commits.first.files, [
'apps/notes/a.dart',
'packages/halkyon_ui/b.dart',
]);
// A merge lists no files of its own.
expect(commits[1].files, isEmpty);
expect(commits[2].files, ['pubspec.lock']);
});
test('no commits, no output', () {
expect(parseLog(''), isEmpty);
});
}

View file

@ -0,0 +1,129 @@
import 'package:test/test.dart';
import '../../tool/src/release/app_release.dart';
import '../../tool/src/release/notes.dart';
import '../../tool/src/release/version.dart';
import 'fixtures.dart';
void main() {
const certificate =
'27e243fcbdc826ff98ab7dbb10436c98c3bea24b2a015e94e4cfc7d1fc2a006c';
const fingerprint =
'E8082788756F6B7E682AA22FCEAD24B53E2A8B0912AD66D65E8C2D4F07BB94B0';
const signer = '0123456789ABCDEF0123456789ABCDEF01234567';
String notes({
AppRelease app = testApp,
String? previousTag = 'notes-v0.18.0',
List<Commit> commits = const [],
}) => releaseNotes(
app: app,
version: const Version(0, 19, 0),
previousTag: previousTag,
commits: commits,
certificate: certificate,
fdroidFingerprint: fingerprint,
signer: signer,
);
test("a release's notes list its features and fixes, oldest first, "
'without their types and scopes', () {
final text = notes(
commits: const [
Commit(hash: 'a', subject: 'feat(app): tiles on the first start'),
Commit(hash: 'b', subject: 'docs: why'),
Commit(hash: 'c', subject: 'fix(sync)!: no lost rating'),
Commit(hash: 'd', subject: 'perf: faster library'),
Commit(hash: 'e', subject: 'feat: flags'),
],
);
expect(
text,
startsWith(
'Halkyon Notes 0.19.0 for Android and Linux. Both connect to '
"notes.halkyon.eu, the project's instance.\n"
'\n'
'### Features\n'
'\n'
'- tiles on the first start\n'
'- flags\n'
'\n'
'### Fixes\n'
'\n'
'- no lost rating\n'
'- faster library\n'
'\n'
'### Installing\n',
),
);
});
test('they say how to install and check every file', () {
final text = notes(
commits: const [Commit(hash: 'a', subject: 'fix: x')],
);
expect(
text,
contains(
'- **Android:** `halkyon-notes-0.19.0.apk`, one APK for every phone, '
"signed with the app's key; the SHA-256 of its certificate is "
'`$certificate`.\n',
),
);
expect(
text,
contains(
'- **F-Droid:** the same APK in the repository '
'https://halkyon.app/fdroid/repo?fingerprint=$fingerprint\n',
),
);
expect(
text,
contains(
'- **Linux:** `halkyon-notes_0.19.0_amd64.snap`: `sudo snap install '
'--dangerous halkyon-notes_0.19.0_amd64.snap` (not in the Snap Store '
'yet).\n',
),
);
expect(
text,
endsWith(
"`SHA256SUMS` lists the files' checksums, signed with the GPG key "
'`$signer` (`SHA256SUMS.asc`).',
),
);
});
test('a snap in the Store is installed from its channel', () {
final text = notes(
app: testApp.copyWith(
snap: const SnapRelease(
name: 'halkyon-notes',
part: 'halkyon-notes',
directory: 'snap',
storeChannel: 'edge',
),
),
);
expect(
text,
contains(
'- **Linux:** `halkyon-notes_0.19.0_amd64.snap`, also in the Snap '
'Store: `snap install halkyon-notes --edge`.\n',
),
);
});
test('the first release says so, one without features or fixes too', () {
expect(notes(previousTag: null), contains('\n\nThe first release.\n\n'));
expect(
notes(
commits: const [Commit(hash: 'a', subject: 'docs: x')],
),
contains('\n\nNo features or fixes since notes-v0.18.0.\n\n'),
);
});
}

View file

@ -0,0 +1,128 @@
import 'package:test/test.dart';
import '../../tool/src/release/relevance.dart';
import '../../tool/src/release/version.dart';
import 'fixtures.dart';
void main() {
const rootPubspec = '''
name: _
workspace:
- apps/notes/halkyon_notes_client
- apps/notes/halkyon_notes_flutter
- apps/notes/halkyon_notes_server
- apps/todo/halkyon_todo_flutter
- packages/halkyon_app
- packages/halkyon_base_client
- packages/halkyon_base_server
- packages/halkyon_core
- packages/halkyon_ui
- packages/halkyon_ui/example
''';
const pubspecs = {
'apps/notes/halkyon_notes_client': '''
name: halkyon_notes_client
dependencies:
halkyon_base_client:
path: ../../../packages/halkyon_base_client
''',
'apps/notes/halkyon_notes_flutter': '''
name: halkyon_notes_flutter
dependencies:
halkyon_app:
path: ../../../packages/halkyon_app
halkyon_notes_client:
path: ../halkyon_notes_client
intl: ^0.20.2
dev_dependencies:
halkyon_ui_example:
path: ../../../packages/halkyon_ui/example
''',
'apps/notes/halkyon_notes_server': '''
name: halkyon_notes_server
dependencies:
halkyon_base_server:
path: ../../../packages/halkyon_base_server
''',
'apps/todo/halkyon_todo_flutter': '''
name: halkyon_todo_flutter
dependencies:
halkyon_core:
path: ../../../packages/halkyon_core
''',
'packages/halkyon_app': '''
name: halkyon_app
dependencies:
halkyon_ui:
path: ../halkyon_ui
halkyon_base_client:
path: ../halkyon_base_client
''',
'packages/halkyon_base_client': 'name: halkyon_base_client\n',
'packages/halkyon_base_server': 'name: halkyon_base_server\n',
'packages/halkyon_core': 'name: halkyon_core\n',
'packages/halkyon_ui': 'name: halkyon_ui\n',
'packages/halkyon_ui/example': 'name: halkyon_ui_example\n',
};
final paths = relevantPaths(
testApp,
rootPubspec: rootPubspec,
pubspecs: pubspecs,
);
test("an app's changes are its folder's, those of the shared packages it "
'uses, the workspace and its release files', () {
expect(paths, [
'apps/notes',
'packages/halkyon_app',
'packages/halkyon_base_client',
'packages/halkyon_base_server',
// Through halkyon_app, by path from there.
'packages/halkyon_ui',
'pubspec.yaml',
'pubspec.lock',
'tool/release/notes-certificate.sha256',
'tool/release/fdroid',
'tool/release/fdroid-index-certificate.sha256',
'snap',
// Where its packages were before it moved into its folder.
'halkyon_notes_flutter',
'halkyon_notes_server',
]);
// Neither another app's packages, nor one only the app's tests use.
expect(paths, isNot(contains('packages/halkyon_core')));
expect(paths, isNot(contains('packages/halkyon_ui/example')));
});
test('a commit counts when it changes one of them, and says which', () {
const ours = Commit(
hash: 'a',
subject: 'feat(app): x',
files: [
'packages/halkyon_ui/lib/src/theme/brand.dart',
'apps/notes/halkyon_notes_flutter/lib/main.dart',
'apps/todo/halkyon_todo_flutter/lib/main.dart',
],
);
expect(touchedPaths(ours.files, paths), [
'apps/notes',
'packages/halkyon_ui',
]);
// A package's name starting like another's isn't that one.
expect(
touchedPaths(['packages/halkyon_ui_extras/lib/a.dart'], paths),
isEmpty,
);
expect(touchedPaths(['apps/notes-web/index.html'], paths), isEmpty);
expect(touchedPaths(['packages/halkyon_core/lib/a.dart'], paths), isEmpty);
expect(touchedPaths(['pubspec.lock'], paths), ['pubspec.lock']);
expect(touchedPaths(['snap/snapcraft.yaml'], paths), ['snap']);
expect(touchedPaths(['README.md', 'docs/adr/0015.md'], paths), isEmpty);
// A commit from before the move counts by where the app was then.
expect(touchedPaths(['halkyon_notes_server/lib/server.dart'], paths), [
'halkyon_notes_server',
]);
});
}

View file

@ -0,0 +1,65 @@
import 'package:test/test.dart';
import '../../tool/src/release/tags.dart';
import '../../tool/src/release/version.dart';
import 'fixtures.dart';
void main() {
test("an app's tags carry its name", () {
expect(tagOf(testApp, const Version(0, 19, 0)), 'notes-v0.19.0');
expect(tagPatterns(testApp), ['notes-v[0-9]*', 'v[0-9]*']);
});
test("the tags of its history count as well as its own, another app's "
'not', () {
expect(versionOfTag(testApp, 'notes-v0.19.0'), const Version(0, 19, 0));
expect(versionOfTag(testApp, 'v0.17.0'), const Version(0, 17, 0));
expect(versionOfTag(testApp, 'todo-v1.0.0'), isNull);
expect(versionOfTag(testApp, 'notes-v0.19'), isNull);
});
group('a run carrying on with its release commit and tag', () {
String? refusal({
String tag = 'notes-v0.19.0',
Version declared = const Version(0, 19, 0),
bool originMainIsAncestor = true,
int aheadOfOriginMain = 1,
Bump? chosen,
}) => resumeRefusal(
app: testApp,
resumedTag: tag,
declared: declared,
originMainIsAncestor: originMainIsAncestor,
aheadOfOriginMain: aheadOfOriginMain,
chosen: chosen,
);
test('goes on when HEAD is the tag, at most the release commit ahead', () {
expect(refusal(), isNull);
expect(refusal(aheadOfOriginMain: 0), isNull);
});
test('stops when main and origin/main went apart, or more came', () {
expect(
refusal(originMainIsAncestor: false),
'main and origin/main have gone apart.',
);
expect(
refusal(aheadOfOriginMain: 2),
'main is more than the release commit ahead of origin/main.',
);
});
test('takes no other version than the tag says', () {
expect(
refusal(chosen: Bump.minor),
'HEAD is notes-v0.19.0 already; carrying on with it takes no argument.',
);
expect(
refusal(declared: const Version(0, 18, 0)),
'HEAD is notes-v0.19.0, but apps/notes/halkyon_notes_flutter/'
'pubspec.yaml says 0.18.0.',
);
});
});
}

View file

@ -0,0 +1,88 @@
import 'package:test/test.dart';
import '../../tool/src/release/version.dart';
void main() {
Commit commit(String subject, [String body = '']) =>
Commit(hash: 'c0ffee', subject: subject, body: body);
group('a version', () {
test('reads as semantic versioning writes it, and nothing else', () {
expect(Version.tryParse('0.17.0'), const Version(0, 17, 0));
expect(Version.tryParse('1.2.3')?.toString(), '1.2.3');
for (final text in [
'1.2',
'1.2.3.4',
'v1.2.3',
'1.2.3-beta',
'',
'x.y.z',
]) {
expect(Version.tryParse(text), isNull, reason: text);
}
});
test('is raised by one part, the parts after it starting again', () {
const version = Version(1, 4, 2);
expect(version.bumped(Bump.major), const Version(2, 0, 0));
expect(version.bumped(Bump.minor), const Version(1, 5, 0));
expect(version.bumped(Bump.patch), const Version(1, 4, 3));
});
test("is read with its build from a pubspec's version line", () {
expect(declaredVersion('name: x\nversion: 0.17.0+20\n'), (
const Version(0, 17, 0),
20,
));
expect(declaredVersion('name: x\nversion: 0.17.0\n'), isNull);
expect(declaredVersion('name: x\n'), isNull);
});
});
group('the commits since the last release', () {
const beforeOne = Version(0, 17, 0);
const afterOne = Version(1, 2, 0);
test('a feature raises the minor number, a fix or speed-up the patch', () {
expect(bumpFor(beforeOne, [commit('feat(study): x')]), Bump.minor);
expect(bumpFor(beforeOne, [commit('feat: x')]), Bump.minor);
expect(bumpFor(beforeOne, [commit('fix(sync): x')]), Bump.patch);
expect(bumpFor(beforeOne, [commit('perf: x')]), Bump.patch);
expect(
bumpFor(beforeOne, [commit('fix: x'), commit('feat(app): y')]),
Bump.minor,
);
});
test('a breaking change raises the major number from 1.0 on, the minor '
'one before', () {
expect(bumpFor(afterOne, [commit('refactor(auth)!: x')]), Bump.major);
expect(bumpFor(beforeOne, [commit('refactor(auth)!: x')]), Bump.minor);
expect(
bumpFor(afterOne, [
commit('docs: x', 'Why.\n\nBREAKING CHANGE: older apps stop.'),
]),
Bump.major,
);
// Only at a line's start does it count.
expect(
bumpFor(afterOne, [commit('docs: x', 'No BREAKING CHANGE: here.')]),
isNull,
);
});
test('other types release nothing by themselves', () {
expect(
bumpFor(beforeOne, [
commit('docs: x'),
commit('chore(release): learn-v0.18.0'),
commit('build: y'),
commit('Merge branch z'),
commit('halkyon_app: an untyped subject'),
]),
isNull,
);
expect(bumpFor(beforeOne, const []), isNull);
});
});
}

103
tool/release.dart Normal file
View file

@ -0,0 +1,103 @@
// Releases one of Halkyon's apps (ADR 0015): works out the next version from
// the commits touching the app since its last release, writes it into the
// app's pubspec in a signed commit with a signed tag (`learn-v0.19.0`), builds
// the Android APK (signed with the app's key, from a checkout of the tag) and
// the Linux snap, checks them, signs a list of their checksums with GPG,
// pushes, and publishes: a release on code.halkyon.app, the APK in the
// project's F-Droid repository, and, once a channel is set, the snap in the
// Snap Store. Run on a developer's laptop, never in CI: the app's key never
// leaves it. Each app's configuration is beside it (`apps/learn/release.dart`).
//
// dart run tool/release.dart learn the version the commits call for
// dart run tool/release.dart learn minor or major, or patch: that one
// dart run tool/release.dart learn --dry-run what it would do, changing nothing
//
// Versions follow semantic versioning, with 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
// app's last release (its own tags, `learn-v…`, and its history's, `v…`), only
// commits touching it count (its folder, the shared packages it uses, the
// workspace's pubspec and lock, its release files): a feat raises the minor
// number, a fix or perf the patch number, a breaking one (`!` or a
// `BREAKING CHANGE:` line) the major number from 1.0 on, the minor before.
// Other types release nothing by themselves. Raising minimumAppBuild in the
// server, for changes older apps can't follow, stays a decision of its own.
//
// It 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 passed their checks; a run that stopped after
// making the release commit and tag carries on with them when started again.
import 'dart:io';
import 'package:args/args.dart';
import 'src/release/apps.dart';
import 'src/release/releaser.dart';
import 'src/release/version.dart';
Future<void> main(List<String> arguments) async {
final parser = ArgParser()
..addFlag(
'dry-run',
negatable: false,
help:
'Show the version, the commits that count and every step a release '
'would take, changing nothing and asking for no secret.',
)
..addFlag('help', abbr: 'h', negatable: false);
final usage =
'Usage: dart run tool/release.dart <app> [major | minor | patch] '
'[--dry-run]\nApps: ${releasedApps.keys.join(', ')}\n${parser.usage}';
final ArgResults options;
try {
options = parser.parse(arguments);
} on FormatException catch (error) {
stderr.writeln('✗ ${error.message}\n$usage');
exit(64);
}
final rest = options.rest;
if (options.flag('help') || rest.isEmpty || rest.length > 2) {
(options.flag('help') ? stdout : stderr).writeln(usage);
exit(options.flag('help') ? 0 : 64);
}
final app = releasedApps[rest.first];
if (app == null) {
stderr.writeln(
'✗ No app "${rest.first}": one of ${releasedApps.keys.join(', ')}.',
);
exit(1);
}
final Bump? chosen;
if (rest.length == 2) {
chosen = Bump.values.asNameMap()[rest[1]];
if (chosen == null) {
stderr.writeln('✗ ${rest[1]} is none of major, minor, patch.');
exit(1);
}
} else {
chosen = null;
}
final top = await Process.run('git', ['rev-parse', '--show-toplevel']);
if (top.exitCode != 0) {
stderr.writeln('✗ Not in the repository.');
exit(1);
}
try {
await Releaser(
app: app,
root: Directory('${top.stdout}'.trim()),
chosen: chosen,
dryRun: options.flag('dry-run'),
).run();
} on ReleaseFailure catch (failure) {
stderr.writeln('✗ $failure');
exit(1);
}
}

View file

@ -0,0 +1,201 @@
/// How an app is released: what `tool/release.dart` reads of it.
library;
import 'version.dart';
/// Everything a release of one app names: its identity, where its Flutter
/// package is, the instance its builds connect to, where it is published and
/// the files that pin its keys. Each app's lives beside it
/// (`apps/<id>/release.dart`) and is registered in `apps.dart`.
final class const AppRelease({
/// The app's short name: the release tool's argument and its tags' prefix
/// (`learn` makes `learn-v0.19.0`). Never change it once released.
required final String id,
/// Its name as people know it, in the release's title and notes.
required final String name,
/// Its Android application ID, which every APK must carry.
required final String applicationId,
/// Its folder, relative to the repository's root: commits changing it count
/// for its next version.
required final String directory,
/// Its Flutter package, whose pubspec holds the version and which the APK is
/// built from.
required final String flutterPackage,
/// The stem of the files a release publishes (`halkyon-learn` makes
/// `halkyon-learn-0.19.0.apk`).
required final String artifactName,
/// The instance the builds connect to (`--dart-define=SERVER_URL`), with
/// its trailing slash, as the app takes it.
required final String serverUrl,
/// The Forgejo the repository and its releases are on.
required final String forgejo,
/// The repository on [forgejo], `<owner>/<name>`.
required final String repository,
/// The key the APK is signed with, and the file pinning its certificate.
required final AndroidSigning android,
/// Its listing in the project's F-Droid repository, if it has one.
final FdroidListing? fdroid,
/// Its snap, if it has one.
final SnapRelease? snap,
/// The prefix of the app's tags from before tags carried its id (`v` for
/// Learn's `v0.17.0`): they count as its releases too.
final String? legacyTagPrefix,
/// Where the app's packages were before it moved into [directory] (Learn's
/// at the root until 19 Oct 2026): a commit from then counts by them.
final List<String> formerPaths = const [],
}) {
/// The prefix of the app's tags: `learn-v`.
String get tagPrefix => '$id-v';
/// The pubspec holding the app's version and build.
String get pubspec => '$flutterPackage/pubspec.yaml';
/// The instance's host, as the release notes name it.
String get instance => Uri.parse(serverUrl).host;
/// The APK a release of [version] publishes.
String apkFile(Version version) => '$artifactName-$version.apk';
/// The snap a release of [version] publishes.
String snapFile(Version version) => '${snap?.name}_${version}_amd64.snap';
/// The files besides the app's folder that make a release what it is, so
/// that a commit changing one counts for the next version: the key pins, the
/// F-Droid listing, the snap's recipe.
List<String> get releaseFiles => [
android.certificateFile,
if (fdroid case final fdroid?) ...[
fdroid.listing,
fdroid.indexCertificateFile,
],
if (snap case final snap?) snap.directory,
];
/// What is wrong with this configuration, empty when nothing is.
List<String> problems() {
final problems = <String>[];
if (!RegExp(r'^[a-z][a-z0-9]*$').hasMatch(id)) {
problems.add('The id "$id" is not lower-case letters and digits.');
}
if (!RegExp(r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$')
.hasMatch(applicationId)) {
problems.add(
"The application ID $applicationId isn't one Android takes.",
);
}
if (!flutterPackage.startsWith('$directory/')) {
problems.add("The Flutter package $flutterPackage isn't in $directory.");
}
if (!serverUrl.startsWith('https://')) {
problems.add("The server URL $serverUrl isn't HTTPS.");
}
if (!serverUrl.endsWith('/')) {
problems.add(
"The server URL $serverUrl doesn't end with a slash, as the app is "
'built with it.',
);
}
if (!RegExp(r'^[^/\s]+/[^/\s]+$').hasMatch(repository)) {
problems.add("The repository $repository isn't <owner>/<name>.");
}
if (legacyTagPrefix case final legacy? when legacy.startsWith(tagPrefix)) {
problems.add(
"The legacy tag prefix $legacy would match the app's own tags.",
);
}
return problems;
}
/// This configuration with the values given replaced.
AppRelease copyWith({
String? id,
String? applicationId,
String? flutterPackage,
String? serverUrl,
SnapRelease? snap,
}) => AppRelease(
id: id ?? this.id,
name: name,
applicationId: applicationId ?? this.applicationId,
directory: directory,
flutterPackage: flutterPackage ?? this.flutterPackage,
artifactName: artifactName,
serverUrl: serverUrl ?? this.serverUrl,
forgejo: forgejo,
repository: repository,
android: android,
fdroid: fdroid,
snap: snap ?? this.snap,
legacyTagPrefix: legacyTagPrefix,
formerPaths: formerPaths,
);
}
/// The app's Android key: a PKCS #12 keystore outside the repository, and the
/// file pinning its certificate's SHA-256 (nothing signed otherwise is
/// released).
final class const AndroidSigning({
/// The file, relative to the root, holding the certificate's SHA-256 as
/// keytool prints it.
required final String certificateFile,
/// The environment variable naming the keystore, if not the default.
required final String keystoreVariable,
/// The keystore's default path, relative to the home folder.
required final String defaultKeystore,
/// The environment variable naming the key's alias, if not the default.
required final String aliasVariable,
/// The key's default alias.
required final String defaultAlias,
});
/// The app's listing in the project's F-Droid repository, which
/// [script] (Bash: glue for fdroidserver) updates and uploads.
final class const FdroidListing({
/// The script that checks the repository's tools and key (`--check`) and
/// publishes an APK in it.
required final String script,
/// The repository's settings and the app's listing.
required final String listing,
/// The file pinning the SHA-256 of the certificate signing the
/// repository's indexes.
required final String indexCertificateFile,
/// The repository's address, as the release notes name it.
required final String repositoryUrl,
});
/// The app's snap, built from [directory]'s `snapcraft.yaml` at the root.
final class const SnapRelease({
/// The snap's name.
required final String name,
/// The part building the app, cleaned before each release so it is built
/// anew from the tagged commit.
required final String part,
/// The folder holding `snapcraft.yaml`.
required final String directory,
/// The Snap Store channel the snap goes to (`edge`); null: not to the Store
/// yet, only attached to the release.
final String? storeChannel,
});

View file

@ -0,0 +1,10 @@
/// The apps `tool/release.dart` releases, by their ids.
library;
import '../../../apps/learn/release.dart';
import 'app_release.dart';
/// Every app with a release configuration, by the id the tool takes. A new
/// app's configuration lives beside it (`apps/<id>/release.dart`) and is
/// added here.
final releasedApps = <String, AppRelease>{learnRelease.id: learnRelease};

29
tool/src/release/log.dart Normal file
View file

@ -0,0 +1,29 @@
/// Reading git's log as commits.
library;
import 'version.dart';
/// The format [parseLog] reads, for `git log --format=<it> --name-only`:
/// each commit's hash, subject and body between separators no message holds,
/// the files it changed after them.
const logFormat = '%x1e%H%x1f%s%x1f%b%x1f';
/// The commits in [output], as `git log --format=$logFormat --name-only`
/// writes them, in its order.
List<Commit> parseLog(String output) => [
for (final record in output.split('\x1e'))
if (record.trim().isNotEmpty) _commit(record),
];
Commit _commit(String record) {
final fields = record.split('\x1f');
return Commit(
hash: fields[0].trim(),
subject: fields[1],
body: fields[2],
files: [
for (final line in fields.length > 3 ? fields[3].split('\n') : <String>[])
if (line.trim().isNotEmpty) line.trim(),
],
);
}

View file

@ -0,0 +1,74 @@
/// The notes a release is published with on Forgejo.
library;
import 'app_release.dart';
import 'version.dart';
/// What the release of [version] of [app] says: what it brings since
/// [previousTag] (the features and fixes among [commits], oldest first, by
/// their descriptions), how to install each file, and how to check them.
String releaseNotes({
required AppRelease app,
required Version version,
required String? previousTag,
required List<Commit> commits,
required String certificate,
required String? fdroidFingerprint,
required String signer,
}) {
List<String> changes(String types) => [
for (final commit in commits)
if (RegExp('^($types)(\\([^)]*\\))?!?: (.*)').firstMatch(commit.subject)
case final match?)
'- ${match.group(3)}',
];
final features = changes('feat');
final fixes = changes('fix|perf');
final summary = previousTag == null
? 'The first release.'
: features.isEmpty && fixes.isEmpty
? 'No features or fixes since $previousTag.'
: [
if (features.isNotEmpty) '### Features\n\n${features.join('\n')}',
if (fixes.isNotEmpty) '### Fixes\n\n${fixes.join('\n')}',
].join('\n\n');
final apk = app.apkFile(version);
final snap = app.snap;
final snapFile = app.snapFile(version);
final linux = switch (snap?.storeChannel) {
final channel? when channel.isNotEmpty =>
'`$snapFile`, also in the Snap Store: '
'`snap install ${snap!.name} --$channel`.',
_ =>
'`$snapFile`: `sudo snap install --dangerous $snapFile` (not in the '
'Snap Store yet).',
};
final title =
'${app.name} $version for Android${snap == null ? '' : ' and Linux'}. '
'${snap == null ? 'It connects' : 'Both connect'} to ${app.instance}, '
"the project's instance.";
final android =
'- **Android:** `$apk`, one APK for every phone, signed with the '
"app's key; the SHA-256 of its certificate is `$certificate`.";
final fdroid = switch (app.fdroid) {
final fdroid? when fdroidFingerprint != null =>
'- **F-Droid:** the same APK in the repository '
'${fdroid.repositoryUrl}?fingerprint=$fdroidFingerprint',
_ => null,
};
final checksums =
"`SHA256SUMS` lists the files' checksums, signed with the GPG key "
'`$signer` (`SHA256SUMS.asc`).';
return [
title,
summary,
[
'### Installing',
'',
android,
?fdroid,
if (snap != null) '- **Linux:** $linux',
].join('\n'),
checksums,
].join('\n\n');
}

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more