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

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);
}
}