build(release): several apps released in one run (train:minor learn), their version commits pushed without the checks
All checks were successful
CI / check (push) Successful in 41m7s
CI / deploy (push) Successful in 1m2s

The release tool and tool/with_signing_keys.sh take several apps, each with its version
after a colon or none, and release them one after another, each as alone, stopping at
the first that fails; the key script fetches every named app's key.

The pre-push hook skips tool/check.dart when a push brings release commits alone: the
release tool's chore(release) subject, an app's pubspec changed in its version line and
its texts of what's new, nothing else (tool/release_push.dart). Signatures are checked as
before, and CI still runs the checks. A release no longer costs a run of the checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 12:11:30 +01:00
commit f06fc23023
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 425 additions and 58 deletions

View file

@ -0,0 +1,46 @@
/// Whether a push to main brings release commits alone, which the pre-push
/// hook then lets through without the checks (tool/check.dart): a release
/// commit changes an app's version and adds its texts of what's new, nothing
/// the checks look at, as the release tool already holds when it asks CI
/// about the newest commit that isn't one (decided 3 Nov 2026, so that
/// releases in a row don't run the checks once each).
library;
import 'version.dart';
final _subject = RegExp(r'^chore\(release\): [a-z]+-v\d+\.\d+\.\d+$');
final _file = RegExp(
'^apps/[a-z]+/halkyon_[a-z]+_flutter/'
r'(pubspec\.yaml|assets/whats_new/\d+\.\d+\.\d+\.[a-z]{2}\.txt)$',
);
final _versionLine = RegExp(r'^[+-]version: \d+\.\d+\.\d+\+\d+$');
/// Why [commit] isn't a release commit, or null when it is one: its subject
/// the release tool's, its files an app's pubspec and texts of what's new
/// alone, and [pubspecLines], the lines its pubspecs' diff adds and removes
/// (`+…`, `-…`), the version's alone.
String? releaseCommitProblem(Commit commit, List<String> pubspecLines) {
if (!_subject.hasMatch(commit.subject)) {
return "${commit.subject}: not the release tool's subject.";
}
if (commit.files.isEmpty) {
return '${commit.subject}: changes no file.';
}
for (final file in commit.files) {
if (!_file.hasMatch(file)) {
return '${commit.subject}: changes $file.';
}
}
for (final line in pubspecLines) {
if (!_versionLine.hasMatch(line)) {
return '${commit.subject}: changes a pubspec beyond its version: $line';
}
}
return null;
}
/// Whether [commits], each with its pubspecs' changed lines, are some, and
/// every one of them a release commit.
bool onlyReleaseCommits(List<(Commit, List<String>)> commits) =>
commits.isNotEmpty &&
commits.every((entry) => releaseCommitProblem(entry.$1, entry.$2) == null);

View file

@ -0,0 +1,59 @@
/// The apps one run of the release tool releases, as its command line names
/// them: `learn`, `train minor` (as always), or several in a row,
/// `train:minor learn`, released one after another in that order.
library;
import 'version.dart';
/// An app to release, and the version asked for, if any.
final class const ReleaseTarget(final String app, [final Bump? chosen]) {
@override
bool operator ==(Object other) =>
other is ReleaseTarget && other.app == app && other.chosen == chosen;
@override
int get hashCode => Object.hash(app, chosen);
@override
String toString() => chosen == null ? app : '$app:${chosen!.name}';
}
/// The apps [words] name, of [apps], each with its version if asked for:
/// after a colon (`train:minor`) or as the word after it (`train minor`).
/// Throws a [FormatException] saying what is wrong otherwise.
List<ReleaseTarget> parseTargets(List<String> words, Set<String> apps) {
final bumps = Bump.values.asNameMap();
final targets = <ReleaseTarget>[];
for (final word in words) {
if (bumps[word] case final bump?) {
final last = targets.lastOrNull;
if (last == null || last.chosen != null) {
throw FormatException('$word: which app?');
}
targets[targets.length - 1] = ReleaseTarget(last.app, bump);
continue;
}
final [app, ...rest] = word.split(':');
if (!apps.contains(app)) {
throw FormatException('No app "$app": one of ${apps.join(', ')}.');
}
if (targets.any((target) => target.app == app)) {
throw FormatException('$app is named twice.');
}
switch (rest) {
case []:
targets.add(ReleaseTarget(app));
case [final name] when bumps[name] != null:
targets.add(ReleaseTarget(app, bumps[name]));
default:
throw FormatException(
'$word: the version after the colon is none of major, minor, '
'patch.',
);
}
}
if (targets.isEmpty) {
throw const FormatException('No app named.');
}
return targets;
}