feat(app): what's new after an update, drafted by the release tool (ADR 0030, D61)

halkyon_app's WhatsNew reads what each release changed from the app's
assets/whats_new/ (<version>.<language>.txt, English and German, a change
a line): after an update a sheet lists every release since the one the
device showed last, newest first, once; Settings shows the running
version's again, above About. Nothing on a fresh install.

tool/release.dart drafts the missing texts from the commits that count
after "Go on?" (German marked TRANSLATE:), opens them in the editor until
they fit (no mark left, F-Droid's 500 characters) and commits them with
the version. The English is the Forgejo notes' What's new, each language
F-Droid's changelog of the build, written at publishing and copied in by
tool/fdroid_repo.sh. Learn and Train bundle their texts, Learn's 0.22.0
and Train's 0.1.0 written to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-31 19:36:20 +01:00
commit 7727317c68
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 1272 additions and 76 deletions

View file

@ -63,6 +63,10 @@ final class const AppRelease({
/// The pubspec holding the app's version and build.
String get pubspec => '$flutterPackage/pubspec.yaml';
/// The folder of what's new in each release, bundled with the app
/// (`whats_new.dart`).
String get whatsNewDirectory => '$flutterPackage/assets/whats_new';
/// The instance's host, as the release notes name it.
String get instance => Uri.parse(serverUrl).host;

View file

@ -3,10 +3,12 @@ library;
import 'app_release.dart';
import 'version.dart';
import 'whats_new.dart';
/// What the release of [version] of [app] says: what it brings since
/// [previousTag] (the features and fixes among [commits], oldest first, by
/// their descriptions), how to install each file, and how to check them.
/// [previousTag] (what's new in English, [whatsNew], as the app shows it;
/// without, the features and fixes among [commits], oldest first, by their
/// descriptions), how to install each file, and how to check them.
String releaseNotes({
required AppRelease app,
required Version version,
@ -15,6 +17,7 @@ String releaseNotes({
required String certificate,
required String? fdroidFingerprint,
required String signer,
String? whatsNew,
}) {
List<String> changes(String types) => [
for (final commit in commits)
@ -24,7 +27,9 @@ String releaseNotes({
];
final features = changes('feat');
final fixes = changes('fix|perf');
final summary = previousTag == null
final summary = whatsNew != null && whatsNewChanges(whatsNew).isNotEmpty
? "### What's new\n\n${whatsNewList(whatsNew)}"
: previousTag == null
? 'The first release.'
: features.isEmpty && fixes.isEmpty
? 'No features or fixes since $previousTag.'

View file

@ -15,6 +15,7 @@ import 'notes.dart';
import 'relevance.dart';
import 'tags.dart';
import 'version.dart';
import 'whats_new.dart';
/// Why a release stopped, as the tool says it.
final class const ReleaseFailure(final String message) implements Exception {
@ -53,7 +54,15 @@ final class Releaser({
}
_step('Checking the repository');
if ((await _git(['status', '--porcelain'])).trim().isNotEmpty) {
// But for texts of what's new not committed yet: a run stopped while
// they were edited leaves them for the next (checked below).
if (const LineSplitter()
.convert(await _git(['status', '--porcelain']))
.any(
(line) =>
line.trim().isNotEmpty &&
!line.startsWith('?? ${app.whatsNewDirectory}/'),
)) {
_refuse('The working tree has changes.');
}
final branch = await _gitOrNull([
@ -90,6 +99,8 @@ final class Releaser({
final Version version;
final int build;
final String? previous;
// What's new in the release, by language.
final Map<String, String> whatsNew;
if (resumed != null) {
final refusal = resumeRefusal(
app: app,
@ -112,6 +123,13 @@ final class Releaser({
(tag, version, build) = (resumed, current, currentBuild);
previous = await _previousTag('$tag^');
stdout.writeln(' Carrying on with $tag (build $build).');
// Committed with the tag, unless it came before them.
whatsNew = {
for (final language in whatsNewLanguages)
if (File(p.join(root.path, whatsNewFile(app, version, language)))
case final file when file.existsSync())
language: file.readAsStringSync(),
};
} else {
if (await _git(['rev-parse', 'HEAD']) !=
await _git(['rev-parse', 'origin/main'])) {
@ -180,6 +198,16 @@ final class Releaser({
throw const ReleaseFailure('Stopped; nothing changed.');
}
}
whatsNew = await _whatsNew(
version,
previous == null
? const []
: _counting(
await _commits('$previous..HEAD'),
relevant,
).reversed.toList(),
first: previous == null,
);
}
await _checkFlutter();
@ -227,7 +255,23 @@ final class Releaser({
final commits = previous == null
? const <Commit>[]
: _counting(await _commits('$previous..HEAD'), relevant);
_step("What's new it would show");
for (final language in whatsNewLanguages) {
final name = whatsNewFile(app, version, language);
final kept = File(p.join(root.path, name)).existsSync();
stdout
..writeln()
..writeln(' $name${kept ? '' : ' (drafted, to be edited)'}')
..writeln(
(whatsNew[language] ?? '')
.trimRight()
.split('\n')
.map((line) => ' $line')
.join('\n'),
);
}
stdout
..writeln()
..writeln('▸ The notes it would publish')
..writeln()
..writeln(
@ -239,6 +283,7 @@ final class Releaser({
certificate: certificate,
fdroidFingerprint: fdroidFingerprint,
signer: "<the tag's signer>",
whatsNew: whatsNew['en'],
),
)
..writeln();
@ -301,14 +346,27 @@ final class Releaser({
}
if (resumed == null) {
if (previous != null) {
_step('Writing $version+$build into the pubspec');
File(p.join(root.path, app.pubspec)).writeAsStringSync(
pubspec.replaceFirst(
RegExp(r'^version: .*$', multiLine: true),
'version: $version+$build',
),
);
final texts = [
for (final language in whatsNewLanguages)
whatsNewFile(app, version, language),
];
final textsChanged = (await _git([
'status',
'--porcelain',
'--',
...texts,
])).trim().isNotEmpty;
if (previous != null || textsChanged) {
if (previous != null) {
_step('Writing $version+$build into the pubspec');
File(p.join(root.path, app.pubspec)).writeAsStringSync(
pubspec.replaceFirst(
RegExp(r'^version: .*$', multiLine: true),
'version: $version+$build',
),
);
}
await _git(['add', '--', ...texts]);
await _git([
'commit',
'--quiet',
@ -319,6 +377,7 @@ final class Releaser({
'Build $build.',
'--',
app.pubspec,
...texts,
]);
}
await _git(['tag', '-s', tag, '-m', '${app.name} $version']);
@ -436,6 +495,7 @@ final class Releaser({
certificate: certificate,
fdroidFingerprint: fdroidFingerprint,
signer: signer,
whatsNew: whatsNew['en'],
),
);
for (final file in [...files, 'SHA256SUMS', 'SHA256SUMS.asc']) {
@ -448,9 +508,22 @@ final class Releaser({
if (app.fdroid case final fdroid?) {
_step('Publishing the APK in the F-Droid repository');
await _run(fdroid.script, [
p.join(out, apk),
], environment: fdroidEnvironment);
// What's new as F-Droid's changelog of the build, in each language;
// the script copies it into the repository's metadata.
final metadata = p.join(root.path, out, 'fdroid-metadata');
for (final MapEntry(key: language, value: text) in whatsNew.entries) {
File(p.join(metadata, fdroidChangelogFile(app, build, language)))
..parent.createSync(recursive: true)
..writeAsStringSync('${whatsNewList(text)}\n');
}
await _run(
fdroid.script,
[p.join(out, apk)],
environment: {
...fdroidEnvironment,
'HALKYON_FDROID_METADATA': metadata,
},
);
}
// Last, as the Store may hold a revision for review; the releases above
@ -553,10 +626,12 @@ final class Releaser({
'${app.forgejo} takes it for ${app.repository}, that no release of '
'$tag exists there, and that CI is green on ${checked.substring(0, 7)}',
);
if (!resumed && previous != null) {
if (!resumed) {
would(
'write $version+$build into ${app.pubspec} and commit it, signed: '
'chore(release): $tag',
"${previous == null ? 'commit' : 'write $version+$build into '
'${app.pubspec} and commit it with'} what's new in "
'$version (${app.whatsNewDirectory}/$version.*.txt, drafted where '
'missing and edited first), signed: chore(release): $tag',
);
}
if (!resumed) {
@ -591,6 +666,122 @@ final class Releaser({
}
}
/// What's new in [version], by language, from the app's files: drafted
/// from [commits] (oldest first) where missing, then opened in the
/// editor until each is as a release takes it ([whatsNewProblems]); a
/// file kept from an earlier run, and as a release takes it, as it is.
/// The dry run reads them, or the drafts, and writes nothing.
Future<Map<String, String>> _whatsNew(
Version version,
List<Commit> commits, {
required bool first,
}) async {
final drafts = draftWhatsNew(commits, first: first);
final files = {
for (final language in whatsNewLanguages)
language: File(p.join(root.path, whatsNewFile(app, version, language))),
};
String text(String language) => files[language]!.existsSync()
? files[language]!.readAsStringSync()
: drafts[language]!;
if (dryRun) {
return {
for (final language in whatsNewLanguages) language: text(language),
};
}
final strays = [
for (final line in const LineSplitter().convert(
await _git([
'ls-files',
'--others',
'--exclude-standard',
'--',
app.whatsNewDirectory,
]),
))
if (!files.values.any(
(file) => p.equals(file.path, p.join(root.path, line.trim())),
))
line.trim(),
];
if (strays.isNotEmpty) {
throw ReleaseFailure(
"Texts of what's new not committed that aren't $version's: "
'${strays.join(', ')}.',
);
}
var drafted = false;
for (final MapEntry(key: language, value: file) in files.entries) {
if (!file.existsSync()) {
file
..parent.createSync(recursive: true)
..writeAsStringSync(drafts[language]!);
drafted = true;
}
}
List<String> problems() => [
for (final MapEntry(key: language, value: file) in files.entries)
...whatsNewProblems(
whatsNewFile(app, version, language),
file.readAsStringSync(),
),
];
if (drafted || problems().isNotEmpty) {
_step("What's new in $version, to edit");
while (true) {
await _edit([for (final file in files.values) file.path]);
final found = problems();
if (found.isEmpty) {
break;
}
for (final problem in found) {
stdout.writeln(' ✗ $problem');
}
stdout.write('Edit again? [Y/n] ');
if ({'n', 'N'}.contains(stdin.readLineSync()?.trim())) {
throw ReleaseFailure(
'Stopped; nothing committed. The texts stay in '
'${app.whatsNewDirectory} for the next run.',
);
}
}
}
return {
for (final MapEntry(key: language, value: file) in files.entries)
language: file.readAsStringSync(),
};
}
/// Opens [paths] in the developer's editor (`$VISUAL`, else `$EDITOR`,
/// which may carry arguments, as `code --wait`) until it closes; without
/// one, waits until they say they edited them.
Future<void> _edit(List<String> paths) async {
final editor = [
Platform.environment['VISUAL'],
Platform.environment['EDITOR'],
].firstWhere((e) => e != null && e.trim().isNotEmpty, orElse: () => null);
if (editor == null) {
for (final path in paths) {
stdout.writeln(' $path');
}
stdout.write('Edit them, then press Enter. ');
stdin.readLineSync();
return;
}
final process = await Process.start(
'sh',
['-c', '$editor "\$@"', 'editor', ...paths],
mode: ProcessStartMode.inheritStdio,
workingDirectory: root.path,
);
if (await process.exitCode != 0) {
throw ReleaseFailure(
'The editor ($editor) failed; the texts stay in '
'${app.whatsNewDirectory}.',
);
}
}
/// Fails unless the Flutter here is the one CI pins.
Future<void> _checkFlutter() async {
final workflow = File(p.join(root.path, '.forgejo/workflows/ci.yaml'))

View file

@ -0,0 +1,109 @@
/// What's new in a release: the text the app shows after the update
/// (halkyon_app's `WhatsNew`), the release's notes on Forgejo and F-Droid's
/// changelog, one file a release and language in the app's
/// `assets/whats_new/`, drafted here from the commits and edited by the
/// developer (Halkyon Train's ADR 0030, D61).
library;
import 'app_release.dart';
import 'version.dart';
/// The languages each release says what's new in, English first.
const whatsNewLanguages = ['en', 'de'];
/// What marks a line of a draft still to be translated; a text holding it
/// isn't released.
const translateMark = 'TRANSLATE:';
/// The longest changelog F-Droid takes, in characters.
const fdroidChangelogLimit = 500;
/// The file holding what's new in [version] of [app] in [language],
/// relative to the repository's root.
String whatsNewFile(AppRelease app, Version version, String language) =>
'${app.whatsNewDirectory}/$version.$language.txt';
/// 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) => [
for (final commit in commits)
if (RegExp('^($types)(\\([^)]*\\))?!?: (.*)').firstMatch(commit.subject)
case final match?)
_sentence(match.group(3)!),
];
final lines = first
? ['The first release.']
: [...changes('feat'), ...changes('fix|perf')];
return {
for (final language in whatsNewLanguages)
language: [
for (final line in lines)
if (language == whatsNewLanguages.first)
'- $line\n'
else
'- $translateMark $line\n',
].join(),
};
}
String _sentence(String description) {
final text = description
.replaceFirst(RegExp(r'\s*\((ADR|D\d)[^()]*\)\s*$'), '')
.trim();
if (text.isEmpty) {
return text;
}
final capital = text[0].toUpperCase() + text.substring(1);
return RegExp(r'[.!?]$').hasMatch(capital) ? capital : '$capital.';
}
/// The changes [text] lists: its lines starting with `- `, without it, as
/// the app reads them.
List<String> whatsNewChanges(String text) => [
for (final line in text.split('\n'))
if (line.trim() case final line when line.startsWith('- '))
line.substring(2).trim(),
];
/// [text] as the changelog and the notes give it: its changes, one a line.
String whatsNewList(String text) =>
[for (final change in whatsNewChanges(text)) '- $change'].join('\n');
/// What is wrong with [text], the file [name]; empty when nothing is.
List<String> whatsNewProblems(String name, String text) {
final problems = <String>[];
final lines = text.split('\n');
for (final (index, line) in lines.indexed) {
if (line.trim().isNotEmpty && !line.trim().startsWith('- ')) {
problems.add("$name, line ${index + 1}: doesn't start with '- '.");
}
}
if (whatsNewChanges(text).isEmpty) {
problems.add('$name lists no change.');
}
if (text.contains(translateMark)) {
problems.add('$name is still to be translated ($translateMark).');
}
final length = whatsNewList(text).length;
if (length > fdroidChangelogLimit) {
problems.add(
"$name has $length characters, more than F-Droid's "
'$fdroidChangelogLimit.',
);
}
return problems;
}
/// Where F-Droid looks for a changelog in [language] of the build
/// [build] of [app], relative to the repository's metadata folder.
String fdroidChangelogFile(AppRelease app, int build, String language) =>
'${app.applicationId}/${language == 'en' ? 'en-US' : language}/'
'changelogs/$build.txt';