// 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 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]\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 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 = []; 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); } }