halkyon/tool/release.dart

103 lines
4.2 KiB
Dart
Raw Normal View History

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