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:
parent
581cf1f1cd
commit
7727317c68
4 changed files with 1272 additions and 76 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
109
tool/src/release/whats_new.dart
Normal file
109
tool/src/release/whats_new.dart
Normal 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';
|
||||
Loading…
Reference in a new issue