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
2 changed files with 211 additions and 31 deletions

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*$'), '')