// 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 main(List 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 [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); } }