Changelog and What's new: CHANGELOG.md cut into each release, shown in the update band, a What's new card and once after updating

CHANGELOG.md keeps an Unreleased section (user-facing changes add a line in
the same commit); tool/release.sh cuts it into the version, the manifest
gets a structured "changes" field next to the one-line "notes" older
apps read, and the Gitea release text is that section. The app's update
band shows the top items, What's new opens a card (Added / Changed /
Fixed), and the first start after an update shows it once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 02:11:37 +03:00
1 parent ec0849eb01
commit 638a30e16c
22 files changed
+1637 -58

No files matched your search

+35 -2
View File
@@ -78,18 +78,50 @@ const _stagingDir = '.update-staging';
const _backupDir = '.update-backup';
const _downloadFile = '.update-download';
/// What a release changed, in the words of CHANGELOG.md: the manifest's
/// `changes` ({"added": [...], "changed": [...], "fixed": [...]}).
/// Manifests from before it have only `notes`.
class ReleaseChanges {
final List<String> added;
final List<String> changed;
final List<String> fixed;
const ReleaseChanges({this.added = const [], this.changed = const [], this.fixed = const []});
/// Null for anything that is not a map, or one with no items; entries
/// that are not strings (or blank) are dropped, unknown keys ignored.
static ReleaseChanges? fromJson(Object? json) {
if (json is! Map) return null;
List<String> list(String key) => List.unmodifiable([
if (json[key] case final List l)
for (final item in l)
if (item is String && item.trim().isNotEmpty) item.trim(),
]);
final c = ReleaseChanges(added: list('added'), changed: list('changed'), fixed: list('fixed'));
return c.isEmpty ? null : c;
}
Map<String, Object?> toJson() => {'added': added, 'changed': changed, 'fixed': fixed};
bool get isEmpty => added.isEmpty && changed.isEmpty && fixed.isEmpty;
int get length => added.length + changed.length + fixed.length;
}
class UpdateManifest {
final String version;
final int protocol;
final String url;
final String? sha256;
/// One line for every app (1.2.x and 1.3.x show only this).
final String notes;
/// Null in manifests from before the changelog (show [notes] then).
final ReleaseChanges? changes;
const UpdateManifest(
{required this.version,
required this.protocol,
required this.url,
this.sha256,
required this.notes});
required this.notes,
this.changes});
// `assets` maps platform → archive URL. Top-level `url` is the Linux
// tarball, kept for builds from before `assets` existed (<= 0.2.1).
@@ -115,7 +147,8 @@ class UpdateManifest {
protocol: protocol,
url: url,
sha256: sha is String && sha.isNotEmpty ? sha : null,
notes: (json['notes'] as String?) ?? '');
notes: json['notes'] is String ? json['notes'] as String : '',
changes: ReleaseChanges.fromJson(json['changes']));
}
// Newer wire protocol: this build can't join servers running it.
+41
View File
@@ -190,6 +190,47 @@ void main() {
isTrue);
});
test('manifest changes: structured lists, notes kept, old manifests without', () {
final base = {
'version': '1.3.3',
'protocol': RelayConstants.protoVersion,
'url': 'https://x/y.tar.gz',
'notes': 'One line for older apps.',
};
final old = UpdateManifest.fromJson(base);
expect(old.changes, isNull);
expect(old.notes, 'One line for older apps.');
final m = UpdateManifest.fromJson({
...base,
'changes': {
'added': ['Danmaku style notifications', ' ', 7],
'fixed': ['Various bug fixes'],
'later': ['unknown kinds are ignored'],
},
});
expect(m.notes, 'One line for older apps.');
expect(m.changes!.added, ['Danmaku style notifications']);
expect(m.changes!.changed, isEmpty);
expect(m.changes!.fixed, ['Various bug fixes']);
expect(m.changes!.length, 2);
expect(ReleaseChanges.fromJson(m.changes!.toJson())!.toJson(), m.changes!.toJson());
// Junk or empty `changes` (and a non-string `notes`) do not break the manifest.
for (final junk in [null, 'text', [], {}, {'added': 'no list'}]) {
expect(UpdateManifest.fromJson({...base, 'changes': junk}).changes, isNull);
}
expect(UpdateManifest.fromJson({...base, 'notes': 3}).notes, '');
});
test('the repo manifest parses, and keeps a one-line string notes for 1.2.x/1.3.x', () {
final json = jsonDecode(File('../../updates/manifest.json').readAsStringSync()) as Map<String, Object?>;
// Older apps read `(json['notes'] as String?)`: it must stay a string.
expect(json['notes'], isA<String>());
expect((json['notes'] as String).contains('\n'), isFalse);
expect(UpdateManifest.fromJson(json, platform: 'linux-x64').version, json['version']);
});
test('manifest picks the asset for this platform', () {
final json = {
'version': '0.2.2',