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

+199
View File
@@ -0,0 +1,199 @@
// CHANGELOG.md, read and cut for releases (docs/DEVELOPMENT.md,
// "Changelog"). The file: `## Unreleased`, then `## X.Y.Z` sections newest
// first; each has an optional one-paragraph summary and `### Added` /
// `### Changed` / `### Fixed` lists of `- ` items.
// Usage: dart tool/changelog.dart cut X.Y.Z
// (tool/release.sh: Unreleased becomes X.Y.Z, a fresh empty
// Unreleased on top; fails when Unreleased has no items)
// dart tool/changelog.dart json <X.Y.Z|unreleased>
// (the manifest's {"notes": "...", "changes": {...}})
// dart tool/changelog.dart markdown <X.Y.Z|unreleased> [X.Y.Z]
// (the release page's body; the last argument names the version
// when it is not the section's own)
// dart: imports only — runs without `pub get` (tool/release_publish.dart
// imports it).
import 'dart:convert';
import 'dart:io';
const unreleased = 'Unreleased';
const kinds = ['Added', 'Changed', 'Fixed'];
/// The empty section [cutRelease] puts on top.
const freshUnreleased = '## $unreleased\n\n### Added\n\n### Changed\n\n### Fixed\n';
class ChangelogSection {
/// `Unreleased` or a version.
final String title;
/// The paragraph under the heading, one line ('' when there is none).
final String summary;
/// [kinds] name -> items, in file order.
final Map<String, List<String>> items;
const ChangelogSection(this.title, this.summary, this.items);
List<String> of(String kind) => items[kind] ?? const [];
bool get isEmpty => kinds.every((k) => of(k).isEmpty);
/// One line for apps that read only `notes` (1.2.x, 1.3.x): the summary,
/// else the first items.
String get notes {
if (summary.isNotEmpty) return summary;
final all = [for (final k in kinds) ...of(k)];
final head = all.take(3).join('; ');
return all.length > 3 ? '$head; and more' : head;
}
Map<String, Object?> toManifest() => {
'notes': notes,
'changes': {for (final k in kinds) k.toLowerCase(): of(k)},
};
/// The section as written in CHANGELOG.md under [heading].
String render([String? heading]) {
final b = StringBuffer('## ${heading ?? title}\n');
if (summary.isNotEmpty) b.write('\n$summary\n');
for (final k in kinds) {
if (of(k).isEmpty) continue;
b.write('\n### $k\n\n');
for (final item in of(k)) {
b.write('- $item\n');
}
}
return b.toString();
}
/// The release page's body.
String releaseBody(String version) {
final b = StringBuffer('## Starsync $version\n');
if (summary.isNotEmpty) b.write('\n$summary\n');
for (final k in kinds) {
if (of(k).isEmpty) continue;
b.write('\n### $k\n\n');
for (final item in of(k)) {
b.write('- $item\n');
}
}
b.write('\nIn the app: Update and restart in the band at the top, or Settings → Updates → Check now. '
'New here: download the archive for your system below.\n');
return b.toString();
}
}
class Changelog {
/// Everything before the first `## ` heading.
final String preamble;
final List<ChangelogSection> sections;
const Changelog(this.preamble, this.sections);
/// [title] `unreleased` (any case) or a version; null when missing.
ChangelogSection? section(String title) {
final want = title.toLowerCase();
for (final s in sections) {
if (s.title.toLowerCase() == want) return s;
}
return null;
}
static Changelog parse(String text) {
final lines = const LineSplitter().convert(text);
final pre = StringBuffer();
final sections = <ChangelogSection>[];
String? title;
var summary = <String>[];
var items = <String, List<String>>{};
String? kind;
void close() {
if (title != null) sections.add(ChangelogSection(title, summary.join(' ').trim(), items));
}
for (final (n, raw) in lines.indexed) {
final line = raw.trimRight();
FormatException bad(String why) => FormatException('CHANGELOG.md line ${n + 1}: $why: "$line"');
if (line.startsWith('## ')) {
close();
title = line.substring(3).trim();
if (title.toLowerCase() == unreleased.toLowerCase()) title = unreleased;
summary = [];
items = {};
kind = null;
} else if (title == null) {
pre.writeln(raw);
} else if (line.startsWith('### ')) {
final name = line.substring(4).trim();
kind = kinds.where((k) => k.toLowerCase() == name.toLowerCase()).firstOrNull ??
(throw bad('a section is ${kinds.join(' / ')}'));
if (items.containsKey(kind)) throw bad('$kind twice');
items[kind] = [];
} else if (RegExp(r'^[-+*] ').hasMatch(line)) {
if (kind == null) throw bad('an item needs ${kinds.join(' / ')} above it');
items[kind]!.add(line.substring(2).trim());
} else if (line.isEmpty) {
continue;
} else if (kind != null && raw.startsWith(' ') && items[kind]!.isNotEmpty) {
final list = items[kind]!;
list[list.length - 1] = '${list.last} ${line.trim()}';
} else if (kind == null) {
summary.add(line.trim());
} else {
throw bad('expected a "- " item');
}
}
close();
return Changelog(pre.toString(), sections);
}
/// [version] takes Unreleased's items; a fresh, empty Unreleased goes on
/// top. Throws when Unreleased is missing or empty or [version] is there.
String cutRelease(String version) {
final next = section(unreleased);
if (next == null) throw StateError('CHANGELOG.md has no "## $unreleased" section');
if (next.isEmpty) {
throw StateError('CHANGELOG.md: "## $unreleased" has no items — '
'add what changed for people before cutting $version');
}
if (section(version) != null) throw StateError('CHANGELOG.md already has "## $version"');
final b = StringBuffer(preamble.trimRight())..write('\n\n$freshUnreleased');
for (final s in sections) {
b.write('\n${s.title == unreleased ? s.render(version) : s.render()}');
}
return b.toString();
}
}
Future<void> main(List<String> args) async {
final markdown = args.length == 3 && args[0] == 'markdown';
if ((args.length != 2 && !markdown) || !const ['cut', 'json', 'markdown'].contains(args[0])) {
_die('usage: dart tool/changelog.dart cut X.Y.Z\n'
' dart tool/changelog.dart json <X.Y.Z|unreleased>\n'
' dart tool/changelog.dart markdown <X.Y.Z|unreleased> [X.Y.Z]');
}
final root = File.fromUri(Platform.script).parent.parent.path;
final file = File('$root/CHANGELOG.md');
if (!file.existsSync()) _die('missing ${file.path}');
final Changelog log;
try {
log = Changelog.parse(file.readAsStringSync());
} on FormatException catch (e) {
_die(e.message);
}
final [cmd, which, ...rest] = args;
switch (cmd) {
case 'cut':
if (!RegExp(r'^\d+\.\d+\.\d+$').hasMatch(which)) _die('version must look like 1.3.3, got $which');
try {
file.writeAsStringSync(log.cutRelease(which));
} on StateError catch (e) {
_die(e.message);
}
stdout.writeln('CHANGELOG.md: $unreleased is now $which');
case 'json':
stdout.writeln(jsonEncode((log.section(which) ?? _die('CHANGELOG.md has no "## $which"')).toManifest()));
case 'markdown':
final s = log.section(which) ?? _die('CHANGELOG.md has no "## $which"');
stdout.write(s.releaseBody(rest.firstOrNull ?? which));
}
}
Never _die(String msg) {
stderr.writeln('error: $msg');
exit(1);
}
+11 -3
View File
@@ -10,6 +10,8 @@
# following it (1.2.0-dev.2, ...) until the release moves the pubspec.
# Token: RELEASE_TOKEN, else the file ~/.config/watch-together-release-token
# (a Gitea access token with repository read/write; keep the file 0600).
# The manifest's `changes` (What's new) and the dev release's page are
# CHANGELOG.md's Unreleased.
# Prints the one-time command that moves a stable install onto test builds.
set -euo pipefail
@@ -66,14 +68,20 @@ MANIFEST_PROTO="$(sed -n 's/.*protoMax = \([0-9]*\).*/\1/p' "$ROOT/packages/core
MANIFEST_URL="$BASE_URL/$(basename "$TARBALL")" \
MANIFEST_SHA="$(sha256sum "$TARBALL" | cut -d' ' -f1)" \
MANIFEST_NOTES="Test build: $NOTES" \
MANIFEST_CHANGES="$(dart "$ROOT/tool/changelog.dart" json unreleased 2>/dev/null || echo '{}')" \
python3 -I - "$MANIFEST" <<'EOF'
import json, os, sys
e = os.environ
url = e['MANIFEST_URL']
manifest = {'version': e['MANIFEST_VER'], 'protocol': int(e['MANIFEST_PROTO']),
'url': url, 'sha256': e['MANIFEST_SHA'], 'notes': e['MANIFEST_NOTES'],
'assets': {'linux-x64': url}}
# What's new in a test build: CHANGELOG.md's Unreleased, when it has items.
changes = json.loads(e['MANIFEST_CHANGES']).get('changes') or {}
if any(changes.values()):
manifest['changes'] = changes
with open(sys.argv[1], 'w') as f:
json.dump({'version': e['MANIFEST_VER'], 'protocol': int(e['MANIFEST_PROTO']),
'url': url, 'sha256': e['MANIFEST_SHA'], 'notes': e['MANIFEST_NOTES'],
'assets': {'linux-x64': url}}, f, indent=2)
json.dump(manifest, f, indent=2, ensure_ascii=False)
EOF
if [[ "$PUBLISH" != "1" ]]; then
+243 -3
View File
@@ -1,6 +1,30 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "48e48f73a431eb269eec66d243a63a07ed90ef9417a74047a3b98d52338626ad"
url: "https://pub.dev"
source: hosted
version: "109.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "6d377901d198536b648172974d9d4f277910502cad92193466869cac0ff34990"
url: "https://pub.dev"
source: hosted
version: "14.5.0"
archive:
dependency: transitive
description:
name: archive
sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
args:
dependency: "direct main"
description:
@@ -17,6 +41,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: "89b8801ba49f8b593fbf2d3e59efd0f566c46cd4ba6837ae2610c72b52a0f899"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
collection:
dependency: transitive
description:
@@ -33,6 +81,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: ff89e37c19355194e279d26dafe8e4bece46c9ea63a30be4dc68214f5bc290d3
url: "https://pub.dev"
source: hosted
version: "1.16.0"
crypto:
dependency: transitive
description:
@@ -41,6 +97,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: "404dbf9bce29beafbe9dcb19219447b6f7d49900cd42b8d3b7af102b24508e25"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
glob:
dependency: transitive
description:
name: glob
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
@@ -49,6 +145,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
lints:
dependency: "direct dev"
description:
@@ -65,6 +169,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
meta:
dependency: transitive
description:
@@ -81,6 +193,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
path:
dependency: transitive
description:
@@ -89,6 +217,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c"
url: "https://pub.dev"
source: hosted
version: "1.5.3"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
relic:
dependency: "direct main"
description:
@@ -113,6 +265,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e75e9cf311d7fd712048038cc17c25199c38536c20330d53e7db058b543d5a48
url: "https://pub.dev"
source: hosted
version: "1.4.3"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: "480504af9e5323899c5be26a4f6c2fc4437d50954d39842f0290f4ae733d57b4"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898"
url: "https://pub.dev"
source: hosted
version: "0.10.14"
source_span:
dependency: transitive
description:
@@ -151,14 +351,14 @@ packages:
path: "../packages/core"
relative: true
source: path
version: "0.1.0"
version: "1.3.2"
sync_relay_server:
dependency: "direct main"
description:
path: "../packages/server"
relative: true
source: path
version: "0.1.0"
version: "1.3.2"
term_glyph:
dependency: transitive
description:
@@ -167,6 +367,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "44b2fccf4407bcdb5e105c064c5d6b5e2a640c87761f8b03cdce8cf532d98cfa"
url: "https://pub.dev"
source: hosted
version: "1.33.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "47481c58bb92d0b1d218f0510ff5647e768f8e1256d07077a151fb520923b51d"
url: "https://pub.dev"
source: hosted
version: "0.7.15"
test_core:
dependency: transitive
description:
name: test_core
sha256: "9dec4c178bd61493e1cd2060ffe701fcb61c43da2493e99590fb1b0cc82eb4af"
url: "https://pub.dev"
source: hosted
version: "0.6.21"
typed_data:
dependency: transitive
description:
@@ -183,6 +407,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.3.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
@@ -207,6 +439,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
yaml:
dependency: transitive
description:
@@ -216,4 +456,4 @@ packages:
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.8.0 <4.0.0"
dart: ">=3.11.0 <4.0.0"
+1
View File
@@ -13,3 +13,4 @@ dependencies:
web_socket_channel: ^3.0.0
dev_dependencies:
lints: ^5.0.0
test: ^1.25.0
+31 -14
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env bash
# Cut a release without typing a version anywhere: derives the next
# version (patch by default), bumps all three pubspecs in lockstep,
# writes updates/manifest.json (predictable asset URL — no CI roundtrip
# needed), commits, and tags. Review, then `git push --follow-tags` —
# version (patch by default), turns CHANGELOG.md's Unreleased into that
# version (refuses when it has no items), bumps all three pubspecs in
# lockstep, writes updates/manifest.json (predictable asset URL — no CI
# roundtrip needed; `notes` and `changes` from the changelog), commits,
# and tags. Review, then `git push --follow-tags` —
# CI takes it from there (build, Forgejo release, asset upload).
# Usage: tool/release.sh [patch|minor|major] [--dry-run]
set -euo pipefail
@@ -41,9 +43,25 @@ if git -C "$ROOT" rev-parse "v$VER" >/dev/null 2>&1; then
fi
echo "release will be v$VER ($WHY)"
# What people read (the update band, What's new, the release page) is
# CHANGELOG.md's Unreleased: no items, no release.
CHANGES="$(dart "$ROOT/tool/changelog.dart" json unreleased)"
COUNT="$(python3 -I -c 'import json,sys; print(sum(map(len, json.loads(sys.argv[1])["changes"].values())))' "$CHANGES")"
if [[ "$COUNT" == "0" ]]; then
echo "error: CHANGELOG.md's Unreleased has no items — write what changed for people first" >&2
exit 1
fi
if [[ -z "$(sed -n '/^## Unreleased/,/^### /{/^#/d;p}' "$ROOT/CHANGELOG.md" | tr -d '[:space:]')" ]]; then
echo "warning: Unreleased has no summary line; older apps (which show only notes) get its first items" >&2
fi
echo "changelog for v$VER:"
dart "$ROOT/tool/changelog.dart" markdown unreleased "$VER" | sed 's/^/ /'
if [[ "$DRY" == "1" ]]; then
exit 0
fi
dart "$ROOT/tool/changelog.dart" cut "$VER"
CHANGES="$(dart "$ROOT/tool/changelog.dart" json "$VER")"
for f in $PUBSPECS; do
sed -i -E "s/^version: .*/version: $VER/" "$ROOT/$f"
done
@@ -52,15 +70,10 @@ ORIGIN="$(git -C "$ROOT" remote get-url origin)"
SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')"
REPO="$(echo "${ORIGIN%.git}" | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#')"
PROTO="$(sed -n 's/.*protoMax = \([0-9]*\).*/\1/p' "$ROOT/packages/core/lib/src/constants.dart")"
if [[ -n "$LATEST" && "$LATEST" != "v$VER" ]]; then
NOTES="$(git -C "$ROOT" log -n 20 "$LATEST..HEAD" --format='- %s')"
else
NOTES="Starsync $VER."
fi
export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \
MANIFEST_PROTO="$PROTO" MANIFEST_NOTES="$NOTES" \
MANIFEST_PROTO="$PROTO" MANIFEST_CHANGES="$CHANGES" \
MANIFEST_BASE="$SERVER/$REPO/releases/download/v$VER/starsync-$VER"
python3 - <<'EOF'
python3 -I - <<'EOF'
import json, os
path = os.environ['MANIFEST_FILE']
try:
@@ -69,6 +82,7 @@ try:
except (FileNotFoundError, ValueError):
manifest = {}
base = os.environ['MANIFEST_BASE']
log = json.loads(os.environ['MANIFEST_CHANGES'])
# Asset names must match tool/package_linux.sh / package_windows.ps1.
# `url` stays the Linux tarball for builds that predate `assets` (<= 0.2.1).
assets = {
@@ -80,19 +94,22 @@ manifest.update({
'protocol': int(os.environ['MANIFEST_PROTO']),
'url': assets['linux-x64'],
'assets': assets,
'notes': os.environ['MANIFEST_NOTES'].splitlines()[0] if os.environ['MANIFEST_NOTES'] else '',
# One line: 1.2.x and 1.3.x apps show only this.
'notes': ' '.join(log['notes'].split()) or 'Starsync ' + os.environ['MANIFEST_VER'],
# {"added": [...], "changed": [...], "fixed": [...]}: What's new.
'changes': log['changes'],
})
manifest.pop('sha256', None)
with open(path, 'w') as f:
json.dump(manifest, f, indent=2)
json.dump(manifest, f, indent=2, ensure_ascii=False)
f.write('\n')
EOF
git -C "$ROOT" add $PUBSPECS updates/manifest.json
git -C "$ROOT" add $PUBSPECS updates/manifest.json CHANGELOG.md
if git -C "$ROOT" diff --cached --quiet; then
echo "everything already at $VER — tagging only"
else
git -C "$ROOT" commit -m "Release v$VER" -- $PUBSPECS updates/manifest.json
git -C "$ROOT" commit -m "Release v$VER" -- $PUBSPECS updates/manifest.json CHANGELOG.md
fi
git -C "$ROOT" tag -a -m "Release v$VER" "v$VER"
echo "review with: git show v$VER — then: git push --follow-tags"
+28 -13
View File
@@ -3,7 +3,8 @@
// The Linux and Windows CI jobs both run this, possibly at the same time,
// so a lost create race falls back to the release the other job made.
// The manifest is written by tool/release.sh at release time (predictable
// asset URLs), so this never touches git history beyond reading notes.
// asset URLs). The release page's text is the version's CHANGELOG.md
// section (tool/changelog.dart).
// Usage: dart tool/release_publish.dart vX.Y.Z <archive>
// (run after tool/package_linux.sh or tool/package_windows.ps1)
// dart tool/release_publish.dart dev <archive> <manifest.json>
@@ -13,6 +14,8 @@
import 'dart:convert';
import 'dart:io';
import 'changelog.dart';
Future<void> main(List<String> args) async {
final dev = args.isNotEmpty && args[0] == 'dev';
if (args.length != (dev ? 3 : 2)) {
@@ -42,17 +45,7 @@ Future<void> main(List<String> args) async {
final token = _token();
final api = _apiBase(root);
final prev = _git(root, ['describe', '--tags', '--abbrev=0', '$tag^'],
allowFail: true);
final notes = prev.isEmpty
? 'Starsync $ver.'
: _git(root, [
'log',
'$prev..$tag',
'--format=- %s',
'--invert-grep',
'--grep=^Release v'
]).split('\n').take(20).join('\n');
final body = _changelog(root)?.section(ver)?.releaseBody(ver) ?? 'Starsync $ver.';
final http = HttpClient();
try {
@@ -63,7 +56,7 @@ Future<void> main(List<String> args) async {
rel = await c.post('$api/releases', {
'tag_name': tag,
'name': tag,
'body': 'Starsync $ver\n\n$notes',
'body': body,
'draft': false,
'prerelease': false,
});
@@ -130,6 +123,12 @@ Future<void> _publishDev(String root, List<File> files) async {
}) ??
_die('could not create or find release dev');
final id = rel['id'];
// The page says what the build has: Unreleased.
final ver = jsonDecode(files.last.readAsStringSync())['version'];
final next = _changelog(root)?.section(unreleased);
if (next != null && !next.isEmpty) {
await c.patch('$api/releases/$id', {'body': 'Latest test build; dev builds update from here.\n\n${next.releaseBody('$ver')}'});
}
final old = [for (final a in (rel['assets'] as List? ?? const [])) if (a is Map) a];
Future<void> upload(File f) {
final name = f.uri.pathSegments.last;
@@ -153,6 +152,16 @@ Future<void> _publishDev(String root, List<File> files) async {
}
}
Changelog? _changelog(String root) {
final f = File('$root/CHANGELOG.md');
try {
return Changelog.parse(f.readAsStringSync());
} on Object catch (e) {
stderr.writeln('warning: CHANGELOG.md not used: $e');
return null;
}
}
Never _die(String msg) {
stderr.writeln('error: $msg');
exit(1);
@@ -203,6 +212,12 @@ class _Api {
return jsonDecode(text) as Map<String, Object?>;
}
Future<void> patch(String url, Object json) async {
final (code, text) = await _send('PATCH', url,
body: utf8.encode(jsonEncode(json)), type: 'application/json');
_check('PATCH', url, code, text);
}
Future<void> delete(String url) async {
final (code, text) = await _send('DELETE', url);
_check('DELETE', url, code, text);
+85
View File
@@ -0,0 +1,85 @@
import 'dart:io';
import 'package:test/test.dart';
import '../changelog.dart';
const _sample = '''# Changelog
Intro text.
## Unreleased
Summary of the next one,
over two lines.
### Added
- Danmaku style notifications
+ Bilibili style chat box
that wraps
### Fixed
- Optimized relay
## 1.0.0
### Fixed
- Various bug fixes
''';
void main() {
test('parses sections, summaries, items and wrapped items', () {
final log = Changelog.parse(_sample);
expect(log.sections.map((s) => s.title), ['Unreleased', '1.0.0']);
final next = log.section('unreleased')!;
expect(next.summary, 'Summary of the next one, over two lines.');
expect(next.of('Added'), ['Danmaku style notifications', 'Bilibili style chat box that wraps']);
expect(next.of('Changed'), isEmpty);
expect(next.of('Fixed'), ['Optimized relay']);
expect(next.toManifest(), {
'notes': 'Summary of the next one, over two lines.',
'changes': {
'added': ['Danmaku style notifications', 'Bilibili style chat box that wraps'],
'changed': <String>[],
'fixed': ['Optimized relay'],
},
});
// No summary: the first items.
expect(log.section('1.0.0')!.notes, 'Various bug fixes');
});
test('cut: Unreleased becomes the version, a fresh empty one on top', () {
final cut = Changelog.parse(_sample).cutRelease('1.1.0');
final log = Changelog.parse(cut);
expect(log.sections.map((s) => s.title), ['Unreleased', '1.1.0', '1.0.0']);
expect(log.section('Unreleased')!.isEmpty, isTrue);
expect(log.section('1.1.0')!.of('Added'), hasLength(2));
expect(log.preamble, contains('Intro text.'));
expect(cut, contains(freshUnreleased));
// Nothing new: no release.
expect(() => log.cutRelease('1.2.0'), throwsStateError);
expect(() => Changelog.parse(_sample).cutRelease('1.0.0'), throwsStateError);
});
test('rejects unknown kinds and stray text', () {
expect(() => Changelog.parse('## 1.0.0\n\n### Removed\n\n- x\n'), throwsFormatException);
expect(() => Changelog.parse('## 1.0.0\n\n- no kind\n'), throwsFormatException);
expect(() => Changelog.parse('## 1.0.0\n\n### Fixed\n\nprose\n'), throwsFormatException);
});
test('release body: heading, summary, lists', () {
final body = Changelog.parse(_sample).section('unreleased')!.releaseBody('1.1.0');
expect(body, startsWith('## Starsync 1.1.0\n\nSummary of the next one'));
expect(body, contains('### Added\n\n- Danmaku style notifications\n'));
expect(body, isNot(contains('### Changed')));
});
test("the repo's CHANGELOG.md parses and has an Unreleased section", () {
final log = Changelog.parse(File('../CHANGELOG.md').readAsStringSync());
expect(log.sections.first.title, unreleased);
expect(log.section('1.3.0')!.of('Added'), isNotEmpty);
});
}