halkyon/tool/release.dart
Tizian 「ティツィアーン」 f06fc23023
All checks were successful
CI / check (push) Successful in 41m7s
CI / deploy (push) Successful in 1m2s
build(release): several apps released in one run (train:minor learn), their version commits pushed without the checks
The release tool and tool/with_signing_keys.sh take several apps, each with its version
after a colon or none, and release them one after another, each as alone, stopping at
the first that fails; the key script fetches every named app's key.

The pre-push hook skips tool/check.dart when a push brings release commits alone: the
release tool's chore(release) subject, an app's pubspec changed in its version line and
its texts of what's new, nothing else (tool/release_push.dart). Signatures are checked as
before, and CI still runs the checks. A release no longer costs a run of the checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 12:16:10 +01:00

112 lines
4.8 KiB
Dart

// 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
// dart run tool/release.dart train:minor learn several, one after another
//
// Several apps are released one after another, in the order named, each as
// alone (its own "Go on?"); the run stops at the first that fails. Their
// version commits go without the checks (tool/pre-push, release commits
// alone), so a row of releases runs them never.
//
// 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/targets.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] '
'[<app>[:…] …] [--dry-run]\n'
' (one app may take its version as the word after it: learn minor)\n'
'Apps: ${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);
}
if (options.flag('help') || options.rest.isEmpty) {
(options.flag('help') ? stdout : stderr).writeln(usage);
exit(options.flag('help') ? 0 : 64);
}
final List<ReleaseTarget> targets;
try {
targets = parseTargets(options.rest, releasedApps.keys.toSet());
} on FormatException catch (error) {
stderr.writeln('✗ ${error.message}\n$usage');
exit(64);
}
final top = await Process.run('git', ['rev-parse', '--show-toplevel']);
if (top.exitCode != 0) {
stderr.writeln('✗ Not in the repository.');
exit(1);
}
final done = <ReleaseTarget>[];
for (final target in targets) {
if (targets.length > 1) {
stdout.writeln('\n━━ ${releasedApps[target.app]!.name} ━━');
}
try {
await Releaser(
app: releasedApps[target.app]!,
root: Directory('${top.stdout}'.trim()),
chosen: target.chosen,
dryRun: options.flag('dry-run'),
).run();
} on ReleaseFailure catch (failure) {
stderr.writeln('✗ $failure');
if (targets.length > 1) {
stderr.writeln(
' Done before it: ${done.map((t) => t.app).join(', ')}; not '
'started: ${targets.skip(done.length + 1).map((t) => t.app).join(', ')}.',
);
}
exit(1);
}
done.add(target);
}
}