snapcraft builds in LXD with the project mounted at /root/project and refuses any --output naming a folder of this machine: the root's own build/release/ for Learn's snap (found releasing Learn 0.23.0) as the clone itself for Todo's and Train's (Todo 0.1.0, both 3 Nov 2026). Every snap now packs into its project and is moved into the release's folder from there, checked with snapcraft 9.2.1 at the root. A failure after none of several apps was done now says "none" rather than nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
112 lines
4.9 KiB
Dart
112 lines
4.9 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.isEmpty ? 'none' : done.map((t) => t.app).join(', ')}; '
|
|
'not started: ${targets.skip(done.length + 1).map((t) => t.app).join(', ')}.',
|
|
);
|
|
}
|
|
exit(1);
|
|
}
|
|
done.add(target);
|
|
}
|
|
}
|