103 lines
4.2 KiB
Dart
103 lines
4.2 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
|
||
|
|
//
|
||
|
|
// 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);
|
||
|
|
}
|
||
|
|
}
|