build(release): what's new drafted from the commits' Whats-New trailers, other apps' commits left out

The drafts came from the commits' subjects, which are written for
developers: each of the releases of 4 Nov 2026 (Todo 0.2.0, Train 0.3.0,
Learn 0.24.0) listed every commit since the last, Meditate's among them
where it touched a shared package, more than twice F-Droid's 500
characters, the German all still to translate, and each had to be written
again whole.

A commit people would notice now says so in its message's trailers,
`Whats-New: <sentence>` once a change and `Whats-New-De: <Satz>` in the
same order, and the draft is those lines alone, the features' first, a
missing translation marked TRANSLATE:. Only where no commit of the
release carries one does it fall back to the subjects. A commit whose
scope names apps (feat(todo,meditate): ...) counts for those alone, any
other for every app it touches. Over a running next.<language>.txt per
app, which parallel branches conflict on at every merge, and over an
assistant drafting at release time (a key, the commits sent to a
provider, a different text each run). ADR 0030, D61.

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-04 20:19:12 +01:00
commit 4239a1de24
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 211 additions and 31 deletions

View file

@ -26,7 +26,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- **Explain every migration's SQL.**
- **Before keeping a framework default, name the alternatives** and recommend one, especially where a choice becomes expensive once users or data exist.
- 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*).
- **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*). A commit whose change people using an app would notice says so for them in its trailers, `Whats-New: <one sentence>` and `Whats-New-De: <its German>`, once a change, short (a release's lines share F-Droid's 500 characters) and in the app's own names for its screens and buttons; the release drafts its *What's new* from them alone (Train's ADR 0030, D61, 4 Nov 2026). A commit scoped to apps (`feat(todo,meditate):`) counts for those alone, a shared package's for every app it touches; a refactor, a build change or a test carries none.
- **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`, 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.
@ -83,7 +83,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]`, 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.
- **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?* (their `Whats-New:` and `Whats-New-De:` trailers, below) 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

View file

@ -1561,6 +1561,19 @@ Strava. What was found, 27 Oct:
commit with the version (the first release's alone, where the pubspec keeps its
version). A run stopped there keeps them for the next, the only untracked files
a release starts with. The dry run prints them, or the drafts.
- **Written with the commit** (the developer's, 4 Nov 2026, on the agent's
recommendation, after three releases' drafts had to be rewritten whole: the
subjects are written for developers, listed other apps' commits touching a
shared package, and left German to translate): a commit people would notice
carries what it changes for them as trailers, `Whats-New: <sentence>` once a
change and `Whats-New-De: <Satz>` in the same order, and the draft is those lines
alone, the features' first, a missing translation marked `TRANSLATE:`; only
where no commit of the release has one does it fall back to the subjects. A
commit whose scope names apps (`feat(todo,meditate):`) counts for those alone,
any other (a shared package's, `feat(app):`) for every app it touches. Over a
running `next.<language>.txt` per app, which parallel branches would conflict on
at every merge, and over an assistant drafting at release time, which needs a
key, sends the commits to a provider and writes differently each run.
- **The same text everywhere**: the English as the Forgejo notes' *What's new*, in
place of the commits' list, and each language as F-Droid's changelog of the build
(`metadata/<app id>/<locale>/changelogs/<build>.txt`, `en-US` and `de`), written

View file

@ -11,16 +11,21 @@ import 'fixtures.dart';
void main() {
test('the drafts list the features, then the fixes, without types, scopes '
'and closing references, as sentences; German marked to translate', () {
final drafts = draftWhatsNew(const [
Commit(hash: 'a', subject: 'fix(sync): no lost rating'),
Commit(hash: 'b', subject: 'docs: why'),
Commit(
hash: 'c',
subject: 'feat(train): tabs folding into the title (ADR 0030, D60)',
),
Commit(hash: 'd', subject: 'perf!: faster library.'),
Commit(hash: 'e', subject: 'chore(release): notes-v0.18.0'),
], first: false);
final drafts = draftWhatsNew(
const [
Commit(hash: 'a', subject: 'fix(sync): no lost rating'),
Commit(hash: 'b', subject: 'docs: why'),
Commit(
hash: 'c',
subject: 'feat(train): tabs folding into the title (ADR 0030, D60)',
),
Commit(hash: 'd', subject: 'perf!: faster library.'),
Commit(hash: 'e', subject: 'chore(release): notes-v0.18.0'),
],
first: false,
app: 'notes',
apps: _apps,
);
expect(drafts['en'], '''
- Tabs folding into the title.
@ -36,11 +41,87 @@ void main() {
test('the first release says so', () {
expect(
draftWhatsNew(const [], first: true)['en'],
draftWhatsNew(const [], first: true, app: 'notes', apps: _apps)['en'],
'- The first release.\n',
);
});
test("commits scoped to other apps don't count; a shared package's do", () {
final drafts = draftWhatsNew(
const [
Commit(hash: 'a', subject: 'feat(diary): the first diary'),
Commit(hash: 'b', subject: 'feat(notes,diary): a lock on both'),
Commit(hash: 'c', subject: 'feat(app): a profile everywhere'),
Commit(hash: 'd', subject: 'fix(deploy): the image builds'),
],
first: false,
app: 'notes',
apps: _apps,
);
expect(drafts['en'], '''
- A lock on both.
- A profile everywhere.
- The image builds.
''');
});
group('the trailers', () {
const commits = [
Commit(
hash: 'a',
subject: 'fix(notes): the sync no longer loses a rating',
body:
'Why it did.\n\n'
'Whats-New: Ratings are kept when syncing.\n'
'Whats-New-De: Bewertungen bleiben beim Synchronisieren.\n'
'Signed-off-by: A <a@example.org>\n',
),
Commit(hash: 'b', subject: 'feat(notes): a refactor nobody sees'),
Commit(
hash: 'c',
subject: 'feat(app): tabs folding into the title (ADR 0030, D60)',
body:
'Whats-New: Tabs fold into the title.\n'
'Whats-New: Search: in every list.\n'
'Whats-New-De: Tabs falten sich in den Titel.\n',
),
Commit(
hash: 'd',
subject: 'feat(diary): a diary',
body: 'Whats-New: A diary.\nWhats-New-De: Ein Tagebuch.\n',
),
];
test("are what's new where a commit has them: the features' first, "
'commits without them left out, those of other apps too', () {
final drafts = draftWhatsNew(
commits,
first: false,
app: 'notes',
apps: _apps,
);
expect(drafts['en'], '''
- Tabs fold into the title.
- Search: in every list.
- Ratings are kept when syncing.
''');
expect(drafts['de'], '''
- Tabs falten sich in den Titel.
- TRANSLATE: Search: in every list.
- Bewertungen bleiben beim Synchronisieren.
''');
});
test('are left alone in the first release', () {
expect(
draftWhatsNew(commits, first: true, app: 'notes', apps: _apps)['de'],
'- TRANSLATE: The first release.\n',
);
});
});
test("a text's changes are its lines starting with '- '", () {
expect(whatsNewChanges('- One\n\n - Two \n'), ['One', 'Two']);
expect(whatsNewList('- One\n\n - Two \n'), '- One\n- Two');
@ -110,3 +191,5 @@ void main() {
});
}
}
const _apps = {'notes', 'diary'};

View file

@ -699,7 +699,15 @@ final class Releaser({
List<Commit> commits, {
required bool first,
}) async {
final drafts = draftWhatsNew(commits, first: first);
final drafts = draftWhatsNew(
commits,
first: first,
app: app.id,
apps: {
for (final entry in Directory(p.join(root.path, 'apps')).listSync())
if (entry is Directory) p.basename(entry.path),
},
);
final files = {
for (final language in whatsNewLanguages)
language: File(p.join(root.path, whatsNewFile(app, version, language))),

View file

@ -26,34 +26,110 @@ String whatsNewFile(AppRelease app, Version version, String language) =>
/// The files' names in the app's folder: `<version>.<language>.txt`.
final whatsNewName = RegExp(r'^(\d+\.\d+\.\d+)\.([a-z]{2,3})\.txt$');
/// The drafts of what's new, by language, from the features and fixes among
/// [commits] (oldest first), the features first: each commit's description
/// without its type, scope and closing references (`(ADR 0030, D60)`),
/// starting with a capital and ending with a full stop. English as the
/// commits say it, every other language the same lines marked with
/// [translateMark]. [first]: the app's first release.
Map<String, String> draftWhatsNew(List<Commit> commits, {required bool first}) {
List<String> changes(String types) => [
/// The trailer of a commit's message that says, in English, what it changes
/// for the people using the app: `Whats-New: <sentence>`, once a change;
/// each other language's is `Whats-New-<Language>:` (`Whats-New-De:`), its
/// lines in the same order. Written as the commit is (decided 4 Nov 2026), so
/// that a release finds its texts ready rather than drafted from subjects
/// written for developers.
const whatsNewTrailer = 'Whats-New';
/// The drafts of what's new in [app], by language, from [commits] (oldest
/// first), those that count for [app]: a commit whose scope names apps
/// ([apps], the ids of every app, `feat(todo,meditate): …`) counts for those
/// alone, any other for every app it touches.
///
/// Where any of them carries [whatsNewTrailer] lines, those lines, in each
/// language, the features' first; a line without its translation is the
/// English marked with [translateMark]. Else, as before the trailers, the
/// features and fixes among them, the features first: each commit's
/// description without its type, scope and closing references
/// (`(ADR 0030, D60)`), starting with a capital and ending with a full stop,
/// every other language the same lines marked. [first]: the app's first
/// release.
Map<String, String> draftWhatsNew(
List<Commit> commits, {
required bool first,
required String app,
required Set<String> apps,
}) {
final counting = [
for (final commit in commits)
if (RegExp('^($types)(\\([^)]*\\))?!?: (.*)').firstMatch(commit.subject)
case final match?)
_sentence(match.group(3)!),
if (_countsFor(commit, app, apps)) commit,
];
final lines = first
? ['The first release.']
: [...changes('feat'), ...changes('fix|perf')];
final ordered = [
...counting.where((commit) => _hasType(commit, 'feat')),
...counting.where((commit) => !_hasType(commit, 'feat')),
];
final written = [for (final commit in ordered) ..._trailerLines(commit)];
final List<Map<String, String>> lines;
if (first) {
lines = [
{whatsNewLanguages.first: 'The first release.'},
];
} else if (written.isNotEmpty) {
lines = written;
} else {
lines = [
for (final types in ['feat', 'fix|perf'])
for (final commit in counting)
if (RegExp('^($types)(\\([^)]*\\))?!?: (.*)')
.firstMatch(commit.subject)
case final match?)
{whatsNewLanguages.first: _sentence(match.group(3)!)},
];
}
return {
for (final language in whatsNewLanguages)
language: [
for (final line in lines)
if (language == whatsNewLanguages.first)
'- $line\n'
if (line[language] case final text?)
'- $text\n'
else
'- $translateMark $line\n',
'- $translateMark ${line[whatsNewLanguages.first]}\n',
].join(),
};
}
/// Whether [commit] counts for [app]: its scope names no app of [apps], or
/// names [app].
bool _countsFor(Commit commit, String app, Set<String> apps) {
final scope = RegExp(r'^[a-z]+\(([^)]*)\)').firstMatch(commit.subject)?[1];
final named = {
for (final part in scope?.split(',') ?? <String>[])
if (apps.contains(part.trim())) part.trim(),
};
return named.isEmpty || named.contains(app);
}
bool _hasType(Commit commit, String type) =>
RegExp('^$type(\\([^)]*\\))?!?:').hasMatch(commit.subject);
/// The [whatsNewTrailer] lines of [commit]'s message, each with its
/// translations by language where given, in the order written.
List<Map<String, String>> _trailerLines(Commit commit) {
final byLanguage = <String, List<String>>{};
for (final line in commit.body.split('\n')) {
final match = RegExp(
'^$whatsNewTrailer(?:-([A-Za-z]{2,3}))?:\\s*(.+?)\\s*\$',
).firstMatch(line);
if (match == null) {
continue;
}
final language = match[1]?.toLowerCase() ?? whatsNewLanguages.first;
(byLanguage[language] ??= []).add(match[2]!);
}
final english = byLanguage[whatsNewLanguages.first] ?? const [];
return [
for (final (index, text) in english.indexed)
{
for (final MapEntry(key: language, value: texts) in byLanguage.entries)
if (index < texts.length) language: texts[index],
whatsNewLanguages.first: text,
},
];
}
String _sentence(String description) {
final text = description
.replaceFirst(RegExp(r'\s*\((ADR|D\d)[^()]*\)\s*$'), '')