Skip to content

Commit 4bb9563

Browse files
colbymchenryclaude
andauthored
chore(release): refine release-notes extractor (colbymchenry#181)
Three fixes prompted by retroactively unwrapping the 0.7.6 / 0.7.7 / 0.7.9 release notes: - Add `--stdin` mode so the extractor can clean up an existing release body (via `gh release view ... --json body --jq '.body'`) without needing a matching CHANGELOG.md entry. The 0.7.9 release didn't have one — its body had been hand-rolled from the 0.7.8 entry on publish. - Stop treating `+` as a bullet marker. CommonMark allows it, but our CHANGELOG uses literal `+` inline (`MCP config + instructions`) and the script was misreading those as nested bullets. Keep `-`, `*`, and `N.` only. - Preserve fenced code blocks verbatim. The 0.7.6 entry has a triple- backtick ```bash block; the previous pass was joining its lines into one, producing unreadable code. Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 483ec91 commit 4bb9563

1 file changed

Lines changed: 48 additions & 34 deletions

File tree

scripts/extract-release-notes.mjs

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22
/**
3-
* Extract a release-notes block from CHANGELOG.md for a given version,
4-
* then unwrap hard-wrapped paragraphs.
3+
* Extract a release-notes block from CHANGELOG.md for a given version
4+
* (or unwrap text supplied on stdin), then join hard-wrapped paragraphs.
55
*
66
* Why: GitHub renders release-note Markdown with GFM hard breaks, so
77
* every `\n` becomes `<br>`. The CHANGELOG is hard-wrapped at ~75
@@ -13,45 +13,47 @@
1313
* Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats
1414
* newlines as spaces there).
1515
*
16-
* Usage: extract-release-notes.mjs <version>
17-
* e.g. extract-release-notes.mjs 0.7.10
16+
* Usage:
17+
* extract-release-notes.mjs <version> # read CHANGELOG.md
18+
* extract-release-notes.mjs --stdin # read from stdin (any text)
1819
*/
1920

2021
import { readFileSync } from 'fs';
2122

22-
const version = process.argv[2];
23-
if (!version) {
24-
console.error('usage: extract-release-notes.mjs <version>');
23+
const arg = process.argv[2];
24+
if (!arg) {
25+
console.error('usage: extract-release-notes.mjs <version> | --stdin');
2526
process.exit(1);
2627
}
2728

28-
const escaped = version.replace(/\./g, '\\.');
29-
const headerRe = new RegExp(`^## \\[${escaped}\\]`);
30-
const anyHeaderRe = /^## \[/;
31-
32-
const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n');
33-
const start = lines.findIndex((l) => headerRe.test(l));
34-
if (start === -1) {
35-
console.error(`no '## [${version}]' entry found in CHANGELOG.md`);
36-
process.exit(1);
29+
let block;
30+
if (arg === '--stdin') {
31+
block = readFileSync(0, 'utf8').replace(/\r\n?/g, '\n').split('\n');
32+
} else {
33+
const version = arg;
34+
const escaped = version.replace(/\./g, '\\.');
35+
const headerRe = new RegExp(`^## \\[${escaped}\\]`);
36+
const anyHeaderRe = /^## \[/;
37+
const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n');
38+
const start = lines.findIndex((l) => headerRe.test(l));
39+
if (start === -1) {
40+
console.error(`no '## [${version}]' entry found in CHANGELOG.md`);
41+
process.exit(1);
42+
}
43+
const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l));
44+
block = lines.slice(start, after === -1 ? lines.length : after);
3745
}
38-
const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l));
39-
const block = lines.slice(start, after === -1 ? lines.length : after);
4046

41-
// Find the indent of the most recent list item; a continuation line
42-
// whose indent is GREATER than that belongs to that item, otherwise
43-
// it might belong to an ancestor item further up the stack.
44-
//
45-
// Track a stack of `{ indent: number }` frames so we can attach a
46-
// continuation to the right ancestor. This correctly handles the
47-
// post-nested-list continuation pattern:
47+
// Track a stack of `{ indent: number }` frames so a continuation line
48+
// can attach to the right ancestor. Handles the post-nested-list
49+
// continuation pattern:
4850
//
4951
// - top-level
5052
// - nested
5153
// back to top-level <- 2-space indent, joins the top-level bullet
5254
const out = [];
53-
let buf = ''; // pending list-item text being built
54-
let stack = []; // [{ indent: number }] open list items
55+
let buf = '';
56+
let stack = [];
5557

5658
function flushBuf() {
5759
if (buf !== '') {
@@ -65,9 +67,27 @@ function leadingSpaces(s) {
6567
return m ? m[1].length : 0;
6668
}
6769

68-
const listItemRe = /^(\s*)([-*+]|\d+\.)\s+/;
70+
// Bullets: `-`, `*`, `digit.` only. `+` is intentionally excluded — the
71+
// CHANGELOG uses literal `+` inline (`config + instructions`) and we
72+
// don't want to misread those as nested bullets.
73+
const listItemRe = /^(\s*)([-*]|\d+\.)\s+/;
74+
const fenceRe = /^\s*```/;
75+
76+
let inFence = false;
6977

7078
for (const line of block) {
79+
// Fenced code blocks: pass through verbatim, no joining.
80+
if (fenceRe.test(line)) {
81+
flushBuf();
82+
stack = [];
83+
out.push(line);
84+
inFence = !inFence;
85+
continue;
86+
}
87+
if (inFence) {
88+
out.push(line);
89+
continue;
90+
}
7191
if (/^\s*$/.test(line)) {
7292
flushBuf();
7393
out.push('');
@@ -91,21 +111,15 @@ for (const line of block) {
91111
continue;
92112
}
93113
if (/^\s/.test(line)) {
94-
// Continuation. Pop any list frames deeper than this indent — the
95-
// continuation belongs to the nearest enclosing list item.
96114
const indent = leadingSpaces(line);
97115
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
98-
// Closes the deeper item — its buffered text is already in `buf`
99-
// belonging to the most recent flush. We need to flush before
100-
// re-buffering for the ancestor item.
101116
flushBuf();
102117
stack.pop();
103118
}
104119
const trimmed = line.replace(/^\s+/, '');
105120
buf = buf === '' ? trimmed : `${buf} ${trimmed}`;
106121
continue;
107122
}
108-
// Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`)
109123
flushBuf();
110124
stack = [];
111125
out.push(line);

0 commit comments

Comments
 (0)