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
6 changed files with 425 additions and 58 deletions

View file

@ -1,7 +1,8 @@
#!/usr/bin/env bash
# The pre-push hook: commits going to main must be signed as deploying it requires
# (tool/check_signatures.sh, a moment), then the checks CI runs (tool/check.dart,
# minutes). A push only goes through when both pass.
# minutes), but for a push of release commits alone (below). A push only goes through
# when both pass.
#
# Install it in a fresh clone: ln -s ../../tool/pre-push .git/hooks/pre-push
# Skip it for one push: git push --no-verify
@ -15,6 +16,14 @@ cd "$(git rev-parse --show-toplevel)"
# below needs them: git finds the repository from the folder it runs in.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY GIT_PREFIX
# git's arguments (the remote) and stdin (the refs pushed) go to the signature check.
tool/check_signatures.sh "$@"
# git's arguments (the remote) and stdin (the refs pushed) go to the signature check, and
# to the question whether the push brings release commits alone: a release's version
# commit changes an app's version and its texts of what's new, nothing the checks look at,
# so it goes without them (tool/release_push.dart; decided 3 Nov 2026, so that releases in
# a row don't run the checks once each). Signed or not is checked all the same.
refs=$(cat)
printf '%s\n' "$refs" | tool/check_signatures.sh "$@"
if printf '%s\n' "$refs" | dart run tool/release_push.dart "$@"; then
exit 0
fi
exec dart run tool/check.dart

View file

@ -11,6 +11,12 @@
// 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,
@ -37,7 +43,7 @@ import 'package:args/args.dart';
import 'src/release/apps.dart';
import 'src/release/releaser.dart';
import 'src/release/version.dart';
import 'src/release/targets.dart';
Future<void> main(List<String> arguments) async {
final parser = ArgParser()
@ -50,8 +56,10 @@ Future<void> main(List<String> arguments) async {
)
..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}';
'Usage: dart run tool/release.dart <app>[:major | :minor | :patch] '
'[<app>[:…] …] [--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 {
@ -60,28 +68,16 @@ Future<void> main(List<String> arguments) async {
stderr.writeln('✗ ${error.message}\n$usage');
exit(64);
}
final rest = options.rest;
if (options.flag('help') || rest.isEmpty || rest.length > 2) {
if (options.flag('help') || options.rest.isEmpty) {
(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 List<ReleaseTarget> 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']);
@ -89,15 +85,28 @@ Future<void> main(List<String> arguments) async {
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);
final done = <ReleaseTarget>[];
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.map((t) => t.app).join(', ')}; not '
'started: ${targets.skip(done.length + 1).map((t) => t.app).join(', ')}.',
);
}
exit(1);
}
done.add(target);
}
}

77
tool/release_push.dart Normal file
View file

@ -0,0 +1,77 @@
// Says whether a push to main brings release commits alone (exit 0) or
// anything else (exit 1), for tool/pre-push, which then skips the checks: a
// release commit changes an app's version and its texts of what's new alone
// (tool/src/release/release_push.dart says why that is enough). Reads what
// git hands a pre-push hook: the remote as the argument, a line per ref pushed
// on stdin (local ref, local commit, remote ref, remote commit), and looks at
// the commits pushed to main that no ref of that remote has yet, as
// tool/check_signatures.sh does.
import 'dart:convert';
import 'dart:io';
import 'src/release/log.dart';
import 'src/release/release_push.dart';
import 'src/release/version.dart';
Future<void> main(List<String> arguments) async {
final remote = arguments.firstOrNull ?? 'origin';
final commits = <(Commit, List<String>)>[];
var pushesMain = false;
await for (final line
in stdin.transform(utf8.decoder).transform(const LineSplitter())) {
final fields = line.trim().split(RegExp(r'\s+'));
if (fields.length != 4 || fields[2] != 'refs/heads/main') {
continue;
}
final local = fields[1];
if (RegExp(r'^0+$').hasMatch(local)) {
continue;
}
pushesMain = true;
final log = await _git([
'log',
'--format=$logFormat',
'--name-only',
local,
'--not',
'--remotes=$remote',
]);
for (final commit in parseLog(log)) {
final diff = await _git([
'show',
'--format=',
'--unified=0',
commit.hash,
'--',
'*pubspec.yaml',
]);
commits.add((
commit,
[
for (final line in const LineSplitter().convert(diff))
if ((line.startsWith('+') || line.startsWith('-')) &&
!line.startsWith('+++') &&
!line.startsWith('---'))
line,
],
));
}
}
if (pushesMain && onlyReleaseCommits(commits)) {
stdout.writeln(
'▸ Release commits alone (${commits.map((c) => c.$1.subject).join('; ')}): '
'no checks needed.',
);
exit(0);
}
exit(1);
}
Future<String> _git(List<String> arguments) async {
final result = await Process.run('git', arguments);
if (result.exitCode != 0) {
stderr.writeln('git ${arguments.join(' ')}: ${result.stderr}');
exit(1);
}
return '${result.stdout}';
}

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

View file

@ -10,6 +10,7 @@
#
# tool/with_signing_keys.sh <app> [release.dart's arguments]
# tool/with_signing_keys.sh train minor --dry-run
# tool/with_signing_keys.sh train:minor learn several, one after another
# tool/with_signing_keys.sh fdroid [tool/fdroid_repo.sh's arguments]
#
# The last runs tool/fdroid_repo.sh alone, with the F-Droid index's key and its password
@ -29,19 +30,34 @@ fail() {
exit 1
}
[ $# -ge 1 ] || fail 'Usage: tool/with_signing_keys.sh <app | fdroid> [arguments]'
app="$1"
shift
[ $# -ge 1 ] || fail 'Usage: tool/with_signing_keys.sh <app[:version] … | fdroid> [arguments]'
# Each app released (tool/src/release/apps.dart): the variable its keystore's path goes
# in (its AndroidSigning's keystoreVariable) and the item keeping it.
case "$app" in
fdroid) ;;
# in (its AndroidSigning's keystoreVariable) and the item keeping it, set for the app $1.
key_of() {
case "$1" in
learn) app_variable=HALKYON_ANDROID_KEYSTORE app_item='Halkyon Learn – Android release keystore' ;;
train) app_variable=HALKYON_TRAIN_ANDROID_KEYSTORE app_item='Halkyon Train – Android release keystore' ;;
todo) app_variable=HALKYON_TODO_ANDROID_KEYSTORE app_item='Halkyon Todo – Android release keystore' ;;
*) fail "No signing key known for $app." ;;
esac
*) fail "No signing key known for $1." ;;
esac
}
# The apps the arguments name (`train:minor learn`, `train minor --dry-run`): every
# word but the options and a version on its own, without a version after a colon.
apps=()
if [ "$1" != fdroid ]; then
for word in "$@"; do
case "$word" in
-* | major | minor | patch) ;;
*) apps+=("${word%%:*}") ;;
esac
done
[ ${#apps[@]} -ge 1 ] || fail 'No app named.'
for app in "${apps[@]}"; do
key_of "$app"
done
fi
# The F-Droid repository's index key, the same for every app listed there.
readonly fdroid_item='Halkyon Learn – F-Droid index keystore'
# The Forgejo token releases publish with (write:repository), a login's password in
@ -95,7 +111,8 @@ forgejo_token() {
printf '%s' "$value"
}
if [ "$app" = fdroid ]; then
if [ "$1" = fdroid ]; then
shift
echo "▸ Fetching the F-Droid index's key from $vault"
fetch "$fdroid_item" fdroid-index.p12 "$keys/fdroid-index.p12"
export HALKYON_FDROID_KEYSTORE="$keys/fdroid-index.p12"
@ -107,25 +124,32 @@ if [ "$app" = fdroid ]; then
exit "$status"
fi
echo "▸ Fetching the signing keys from $vault"
fetch "$app_item" android-release.p12 "$keys/android-release.p12"
fetch "$fdroid_item" fdroid-index.p12 "$keys/fdroid-index.p12"
export "$app_variable=$keys/android-release.p12"
export HALKYON_FDROID_KEYSTORE="$keys/fdroid-index.p12"
dry_run=false
for argument in "$@"; do
[ "$argument" = --dry-run ] && dry_run=true
done
echo "▸ Fetching the signing keys from $vault"
# Each app's keystore under a name of its own, as each has a variable of its own.
for app in "${apps[@]}"; do
key_of "$app"
fetch "$app_item" android-release.p12 "$keys/$app-android-release.p12"
export "$app_variable=$keys/$app-android-release.p12"
if ! $dry_run; then
app_password=$(password "$app_item")
export "${app_variable}_PASSWORD=$app_password"
unset app_password
fi
done
fetch "$fdroid_item" fdroid-index.p12 "$keys/fdroid-index.p12"
export HALKYON_FDROID_KEYSTORE="$keys/fdroid-index.p12"
if ! $dry_run; then
app_password=$(password "$app_item")
fdroid_password=$(password "$fdroid_item")
export "${app_variable}_PASSWORD=$app_password"
export HALKYON_FDROID_KEYSTORE_PASSWORD="$fdroid_password"
token=$(forgejo_token)
export HALKYON_FORGEJO_TOKEN="$token"
unset app_password fdroid_password token
unset fdroid_password token
fi
status=0
dart run tool/release.dart "$app" "$@" || status=$?
dart run tool/release.dart "$@" || status=$?
exit "$status"