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:
1 parent
ec0849eb01
commit
638a30e16c
22 files changed
+1637
-58
No files matched your search
@@ -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
@@ -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
@@ -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"
|
||||
@@ -13,3 +13,4 @@ dependencies:
|
||||
web_socket_channel: ^3.0.0
|
||||
dev_dependencies:
|
||||
lints: ^5.0.0
|
||||
test: ^1.25.0
|
||||
+31
-14
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user