build(release): several apps released in one run (train:minor learn), their version commits pushed without the checks
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:
parent
551a29445f
commit
f06fc23023
10 changed files with 425 additions and 58 deletions
|
|
@ -27,7 +27,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
|
|||
- Every decision carries its reason (in `NEXT.md` or an ADR under `docs/adr/`).
|
||||
- **Commits** are GPG-signed and carry a DCO `Signed-off-by` trailer (`git commit -s`; a local `prepare-commit-msg` hook adds it automatically), and **keep their type prefix** (`feat`, `fix`, `perf`, `refactor`, `docs`, `build`, `chore`, a `!` or a `BREAKING CHANGE:` line for what breaks): releases work out their versions from them (Learn's `AGENTS.md`, *Releases*).
|
||||
- **Servers are named in prose by the domain they serve** (learn.halkyon.eu's server, the halkyon.app server), which says what a reader needs; the SSH aliases and the CI runner's label keep the servers' own names (`sahaquiel-deploy`, `ramiel-fdroid`, `sahaquiel:host`).
|
||||
- **Pushes** pass the checks first, through a local `pre-push` hook (in a fresh clone: `ln -s ../../tool/pre-push .git/hooks/pre-push`): `tool/pre-push` first refuses a push to `main` that brings a commit not signed by a key in `deploy/allowed-signers` (`tool/check_signatures.sh`, the deploy's own rule), so it fails on the laptop rather than after CI, then runs `tool/check.dart`. CI runs the checks again, and a green `main` deploys learn.halkyon.eu (see *CI and deploys*), so an unsigned commit on `main` blocks every deploy after it.
|
||||
- **Pushes** pass the checks first, through a local `pre-push` hook (in a fresh clone: `ln -s ../../tool/pre-push .git/hooks/pre-push`): `tool/pre-push` first refuses a push to `main` that brings a commit not signed by a key in `deploy/allowed-signers` (`tool/check_signatures.sh`, the deploy's own rule), so it fails on the laptop rather than after CI, then runs `tool/check.dart`, but for a push of release commits alone (the release tool's `chore(release):` commits changing an app's version line and its texts of what's new, nothing else: `tool/release_push.dart`, decided 3 Nov 2026, so that releases in a row don't run the checks once each; CI still runs them). CI runs the checks again, and a green `main` deploys learn.halkyon.eu (see *CI and deploys*), so an unsigned commit on `main` blocks every deploy after it.
|
||||
- **Lints**: LeanCode's strict set (`leancode_lint`), configured once in the root `analysis_options.yaml`, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file: Learn's, Todo's and Train's servers and apps, Learn's curator and every shared package include it; the generated client packages keep their own, but for the module's, which includes it for its SRP half and leaves `lib/src/protocol/` out. Every `// ignore:` gets a comment saying why (`document_ignores`). Each server package switches off the four rules for Dart v3.13's constructor syntax (`use_primary_constructors`, `use_declaring_parameters`, `empty_container_bodies`, `unnecessary_type_name_in_constructor`): Serverpod's code generator (checked with v4.0.3) can't parse that syntax in endpoint files yet.
|
||||
|
||||
## Flutter and Serverpod
|
||||
|
|
@ -82,7 +82,7 @@ Checklist after doing changes, in this order:
|
|||
- Check `serverpod` MCP `tail_server_logs` and `tail_flutter_logs` for any issues.
|
||||
|
||||
- **Melos** (decided 2026-10-19, ADR 0015) runs scripts across the workspace's packages: a dev dependency of the root (`dart run melos …`, its version in the lock), its scripts under `melos:` in the root `pubspec.yaml`: `analyze`; `test:dart` (the packages without Flutter, one at a time, as each server's tests start a PostgreSQL of their own on the same port); `test:flutter` (`flutter test -x requires-server`, one package at a time); `generate` (Serverpod's code generation in every package with a `config/generator.yaml`, the CLI on the PATH); `check` (`tool/check.dart`). Every script takes Melos' filters: `dart run melos run test:flutter --scope=halkyon_app`, or `--diff=origin/main --include-dependents` for what changed and what depends on it; `dart run melos list` lists the packages. The root overrides `cli_util` to 0.5.2 for it: Learn's `flutter_launcher_icons` (0.14.4, its latest) asks for ^0.4.1 and uses only the logger, which 0.5 keeps; drop the override once that package allows 0.5.
|
||||
- **Tools are Dart where they hold logic** (decided 2026-10-19): conditions, parsing, parallel work, anything worth a test, as `tool/check.dart` (its file selection in `tool/src/check_files.dart`, tested in the root's `test/`). **Bash for glue**, short and linear (`tool/pre-push`, `tool/check_signatures.sh`, `tool/workspace_only.sh`, which narrows the root pubspec's workspace to the packages an image builds), **and for everything run on the production host**, which has no Dart SDK and gets none (`deploy/redeploy.sh`, `deploy/backup.sh`). **Python only where a library exists only there** (the apps' icons with librsvg and Pillow, `tool/app_icons.py`, its Anki fixtures with Anki itself); Learn's data scripts and `packages/halkyon_app/tool/subdivision_names.py` need nothing but Python's standard library and may move to Dart when they next change. **Releases come from `tool/release.dart`** (decided 2026-10-19, ADR 0015), shared by the apps, each configured in Dart beside it (`apps/<id>/release.dart`, typed and commented, registered in `tool/src/release/apps.dart`): `dart run tool/release.dart learn [major | minor | patch] [--dry-run]`, on a developer's laptop, never in CI, through `tool/with_signing_keys.sh learn …` (Bash glue): the keystores are kept in Proton Pass alone (vault *Infra · Signing*, each a note's attachment, the password its hidden field), and it downloads the app's and the F-Droid index's into a folder in memory (`$XDG_RUNTIME_DIR`), hands their paths and, but for a dry run, their passwords (each item's hidden field *Password*, in the environment as `<keystore variable>_PASSWORD`, so the tool asks for neither) and the Forgejo token (a login in the vault *Infra*, `HALKYON_FORGEJO_TOKEN`) to the release tool and removes the files however the run ends (`test/release/signing_keys_test.dart` holds it to every app released). Tags are per app (`learn-v0.19.0`; an app's earlier tags, Learn's `v…`, count as its history), and only commits touching the app decide its version: its folder, the shared packages its packages use by path (transitively), the root pubspec and lock, its release files (key pins, F-Droid listing, snap recipe) and where its packages were before a move. Each release's *What's new* (Train's ADR 0030, D61), one file a language in the app's `assets/whats_new/` (`<version>.<language>.txt`, English and German), is drafted by it from the commits after *Go on?* and edited in `$VISUAL`/`$EDITOR` until it fits (no `TRANSLATE:` left, F-Droid's 500 characters), committed with the version, and is the app's sheet after the update, the Forgejo notes and F-Droid's changelog. `--dry-run` reads everything a release would (the version, the commits that count and what each touches, the steps, the notes it would publish), changes nothing, asks for no secret, and lists what a release would refuse. Its pure logic is in `tool/src/release/`, tested in the root's `test/release/`. `tool/fdroid_repo.sh` stays Bash (glue for fdroidserver); `tool/with_signing_keys.sh fdroid [APK]` runs it alone with the index's key, e.g. when a release's upload failed after its release was made; a release colliding with the entry Forgejo makes of the pushed tag itself asks again, a few seconds later. Build 21 (`v0.18.0`) was the last release of `tool/release.sh`, the Bash script it replaced, removed since.
|
||||
- **Tools are Dart where they hold logic** (decided 2026-10-19): conditions, parsing, parallel work, anything worth a test, as `tool/check.dart` (its file selection in `tool/src/check_files.dart`, tested in the root's `test/`). **Bash for glue**, short and linear (`tool/pre-push`, `tool/check_signatures.sh`, `tool/workspace_only.sh`, which narrows the root pubspec's workspace to the packages an image builds), **and for everything run on the production host**, which has no Dart SDK and gets none (`deploy/redeploy.sh`, `deploy/backup.sh`). **Python only where a library exists only there** (the apps' icons with librsvg and Pillow, `tool/app_icons.py`, its Anki fixtures with Anki itself); Learn's data scripts and `packages/halkyon_app/tool/subdivision_names.py` need nothing but Python's standard library and may move to Dart when they next change. **Releases come from `tool/release.dart`** (decided 2026-10-19, ADR 0015), shared by the apps, each configured in Dart beside it (`apps/<id>/release.dart`, typed and commented, registered in `tool/src/release/apps.dart`): `dart run tool/release.dart learn [major | minor | patch] [--dry-run]`, or several apps one after another (`train:minor learn`, decided 3 Nov 2026: each as alone, the run stopping at the first that fails; `tool/src/release/targets.dart`), on a developer's laptop, never in CI, through `tool/with_signing_keys.sh learn …` (Bash glue, fetching every named app's key): the keystores are kept in Proton Pass alone (vault *Infra · Signing*, each a note's attachment, the password its hidden field), and it downloads the app's and the F-Droid index's into a folder in memory (`$XDG_RUNTIME_DIR`), hands their paths and, but for a dry run, their passwords (each item's hidden field *Password*, in the environment as `<keystore variable>_PASSWORD`, so the tool asks for neither) and the Forgejo token (a login in the vault *Infra*, `HALKYON_FORGEJO_TOKEN`) to the release tool and removes the files however the run ends (`test/release/signing_keys_test.dart` holds it to every app released). Tags are per app (`learn-v0.19.0`; an app's earlier tags, Learn's `v…`, count as its history), and only commits touching the app decide its version: its folder, the shared packages its packages use by path (transitively), the root pubspec and lock, its release files (key pins, F-Droid listing, snap recipe) and where its packages were before a move. Each release's *What's new* (Train's ADR 0030, D61), one file a language in the app's `assets/whats_new/` (`<version>.<language>.txt`, English and German), is drafted by it from the commits after *Go on?* and edited in `$VISUAL`/`$EDITOR` until it fits (no `TRANSLATE:` left, F-Droid's 500 characters), committed with the version, and is the app's sheet after the update, the Forgejo notes and F-Droid's changelog. `--dry-run` reads everything a release would (the version, the commits that count and what each touches, the steps, the notes it would publish), changes nothing, asks for no secret, and lists what a release would refuse. Its pure logic is in `tool/src/release/`, tested in the root's `test/release/`. `tool/fdroid_repo.sh` stays Bash (glue for fdroidserver); `tool/with_signing_keys.sh fdroid [APK]` runs it alone with the index's key, e.g. when a release's upload failed after its release was made; a release colliding with the entry Forgejo makes of the pushed tag itself asks again, a few seconds later. Build 21 (`v0.18.0`) was the last release of `tool/release.sh`, the Bash script it replaced, removed since.
|
||||
|
||||
## CI and deploys
|
||||
|
||||
|
|
|
|||
80
test/release/release_push_test.dart
Normal file
80
test/release/release_push_test.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import 'package:test/test.dart';
|
||||
|
||||
import '../../tool/src/release/release_push.dart';
|
||||
import '../../tool/src/release/version.dart';
|
||||
|
||||
void main() {
|
||||
Commit release({
|
||||
String subject = 'chore(release): train-v0.2.0',
|
||||
List<String> files = const [
|
||||
'apps/train/halkyon_train_flutter/pubspec.yaml',
|
||||
'apps/train/halkyon_train_flutter/assets/whats_new/0.2.0.en.txt',
|
||||
'apps/train/halkyon_train_flutter/assets/whats_new/0.2.0.de.txt',
|
||||
],
|
||||
}) => Commit(hash: 'abc', subject: subject, body: 'Build 2.', files: files);
|
||||
|
||||
const versionOnly = ['-version: 0.1.0+1', '+version: 0.2.0+2'];
|
||||
|
||||
test('a release commit, the version and what is new alone, needs no '
|
||||
'checks', () {
|
||||
expect(releaseCommitProblem(release(), versionOnly), isNull);
|
||||
expect(
|
||||
releaseCommitProblem(
|
||||
release(
|
||||
subject: 'chore(release): todo-v0.1.1',
|
||||
files: ['apps/todo/halkyon_todo_flutter/pubspec.yaml'],
|
||||
),
|
||||
versionOnly,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('anything else does: another subject, another file, or the pubspec '
|
||||
'changed beyond its version', () {
|
||||
expect(
|
||||
releaseCommitProblem(release(subject: 'fix(train): a fix'), versionOnly),
|
||||
isNotNull,
|
||||
);
|
||||
expect(
|
||||
releaseCommitProblem(
|
||||
release(subject: 'chore(release): train-v0.2.0 and more'),
|
||||
versionOnly,
|
||||
),
|
||||
isNotNull,
|
||||
);
|
||||
for (final file in [
|
||||
'apps/train/halkyon_train_flutter/lib/main.dart',
|
||||
'pubspec.lock',
|
||||
'apps/train/halkyon_train_flutter/assets/whats_new/notes.md',
|
||||
'tool/pre-push',
|
||||
]) {
|
||||
expect(
|
||||
releaseCommitProblem(
|
||||
release(files: [...release().files, file]),
|
||||
versionOnly,
|
||||
),
|
||||
isNotNull,
|
||||
reason: file,
|
||||
);
|
||||
}
|
||||
expect(releaseCommitProblem(release(files: []), versionOnly), isNotNull);
|
||||
expect(
|
||||
releaseCommitProblem(release(), [...versionOnly, '+ http: ^1.0.0']),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('a push needs no checks only when it brings commits, all of them '
|
||||
'release commits', () {
|
||||
expect(onlyReleaseCommits(const []), isFalse);
|
||||
expect(onlyReleaseCommits([(release(), versionOnly)]), isTrue);
|
||||
expect(
|
||||
onlyReleaseCommits([
|
||||
(release(), versionOnly),
|
||||
(release(subject: 'feat(learn): more'), versionOnly),
|
||||
]),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -64,13 +64,29 @@ void main() {
|
|||
|
||||
test("fdroid runs the F-Droid repository's script alone, with the "
|
||||
"index's key and its password only", () {
|
||||
expect(script, contains(' fdroid) ;;'));
|
||||
expect(script, contains(r'tool/fdroid_repo.sh "$@" || status=$?'));
|
||||
final alone = script.substring(
|
||||
script.indexOf(r'if [ "$app" = fdroid ]; then'),
|
||||
script.indexOf(r'if [ "$1" = fdroid ]; then'),
|
||||
script.indexOf(r'tool/fdroid_repo.sh "$@"'),
|
||||
);
|
||||
expect(alone, isNot(contains('app_item')));
|
||||
expect(alone, isNot(contains('forgejo_token')));
|
||||
});
|
||||
|
||||
test('several apps named, each gets its own key, under its own name and '
|
||||
'variable, and the release tool every argument as given', () {
|
||||
expect(script, contains(r'*) apps+=("${word%%:*}") ;;'));
|
||||
expect(script, contains('-* | major | minor | patch) ;;'));
|
||||
expect(
|
||||
script,
|
||||
contains(
|
||||
r'fetch "$app_item" android-release.p12 "$keys/$app-android-release.p12"',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
script,
|
||||
contains(r'export "$app_variable=$keys/$app-android-release.p12"'),
|
||||
);
|
||||
expect(script, contains(r'dart run tool/release.dart "$@" || status=$?'));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
47
test/release/targets_test.dart
Normal file
47
test/release/targets_test.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:test/test.dart';
|
||||
|
||||
import '../../tool/src/release/targets.dart';
|
||||
import '../../tool/src/release/version.dart';
|
||||
|
||||
void main() {
|
||||
const apps = {'learn', 'todo', 'train'};
|
||||
|
||||
test('one app, as before, with or without the version asked for', () {
|
||||
expect(parseTargets(['learn'], apps), [const ReleaseTarget('learn')]);
|
||||
expect(parseTargets(['train', 'minor'], apps), [
|
||||
const ReleaseTarget('train', Bump.minor),
|
||||
]);
|
||||
});
|
||||
|
||||
test('several apps, one after another in the order named, each with its '
|
||||
'own version asked for after a colon or not at all', () {
|
||||
expect(parseTargets(['train:minor', 'learn'], apps), [
|
||||
const ReleaseTarget('train', Bump.minor),
|
||||
const ReleaseTarget('learn'),
|
||||
]);
|
||||
expect(parseTargets(['learn', 'train', 'patch'], apps), [
|
||||
const ReleaseTarget('learn'),
|
||||
const ReleaseTarget('train', Bump.patch),
|
||||
]);
|
||||
});
|
||||
|
||||
test('refuses no app, an unknown one, one named twice, a version without '
|
||||
'its app or asked for twice, and an unknown version', () {
|
||||
for (final words in [
|
||||
<String>[],
|
||||
['meditate'],
|
||||
['learn', 'learn:minor'],
|
||||
['minor'],
|
||||
['learn:minor', 'patch'],
|
||||
['learn', 'minor', 'patch'],
|
||||
['learn:huge'],
|
||||
['learn:'],
|
||||
]) {
|
||||
expect(
|
||||
() => parseTargets(words, apps),
|
||||
throwsFormatException,
|
||||
reason: '$words',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
77
tool/release_push.dart
Normal 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}';
|
||||
}
|
||||
46
tool/src/release/release_push.dart
Normal file
46
tool/src/release/release_push.dart
Normal 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);
|
||||
59
tool/src/release/targets.dart
Normal file
59
tool/src/release/targets.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue