chore(release): refine release-notes extractor (#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) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-19 12:05:59 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 483ec9171c
commit 4bb95639ca
+48 -34
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Extract a release-notes block from CHANGELOG.md for a given version, * Extract a release-notes block from CHANGELOG.md for a given version
* then unwrap hard-wrapped paragraphs. * (or unwrap text supplied on stdin), then join hard-wrapped paragraphs.
* *
* Why: GitHub renders release-note Markdown with GFM hard breaks, so * Why: GitHub renders release-note Markdown with GFM hard breaks, so
* every `\n` becomes `<br>`. The CHANGELOG is hard-wrapped at ~75 * every `\n` becomes `<br>`. The CHANGELOG is hard-wrapped at ~75
@@ -13,45 +13,47 @@
* Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats * Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats
* newlines as spaces there). * newlines as spaces there).
* *
* Usage: extract-release-notes.mjs <version> * Usage:
* e.g. extract-release-notes.mjs 0.7.10 * extract-release-notes.mjs <version> # read CHANGELOG.md
* extract-release-notes.mjs --stdin # read from stdin (any text)
*/ */
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
const version = process.argv[2]; const arg = process.argv[2];
if (!version) { if (!arg) {
console.error('usage: extract-release-notes.mjs <version>'); console.error('usage: extract-release-notes.mjs <version> | --stdin');
process.exit(1); process.exit(1);
} }
const escaped = version.replace(/\./g, '\\.'); let block;
const headerRe = new RegExp(`^## \\[${escaped}\\]`); if (arg === '--stdin') {
const anyHeaderRe = /^## \[/; block = readFileSync(0, 'utf8').replace(/\r\n?/g, '\n').split('\n');
} else {
const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n'); const version = arg;
const start = lines.findIndex((l) => headerRe.test(l)); const escaped = version.replace(/\./g, '\\.');
if (start === -1) { const headerRe = new RegExp(`^## \\[${escaped}\\]`);
console.error(`no '## [${version}]' entry found in CHANGELOG.md`); const anyHeaderRe = /^## \[/;
process.exit(1); const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n');
const start = lines.findIndex((l) => headerRe.test(l));
if (start === -1) {
console.error(`no '## [${version}]' entry found in CHANGELOG.md`);
process.exit(1);
}
const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l));
block = lines.slice(start, after === -1 ? lines.length : after);
} }
const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l));
const block = lines.slice(start, after === -1 ? lines.length : after);
// Find the indent of the most recent list item; a continuation line // Track a stack of `{ indent: number }` frames so a continuation line
// whose indent is GREATER than that belongs to that item, otherwise // can attach to the right ancestor. Handles the post-nested-list
// it might belong to an ancestor item further up the stack. // continuation pattern:
//
// Track a stack of `{ indent: number }` frames so we can attach a
// continuation to the right ancestor. This correctly handles the
// post-nested-list continuation pattern:
// //
// - top-level // - top-level
// - nested // - nested
// back to top-level <- 2-space indent, joins the top-level bullet // back to top-level <- 2-space indent, joins the top-level bullet
const out = []; const out = [];
let buf = ''; // pending list-item text being built let buf = '';
let stack = []; // [{ indent: number }] open list items let stack = [];
function flushBuf() { function flushBuf() {
if (buf !== '') { if (buf !== '') {
@@ -65,9 +67,27 @@ function leadingSpaces(s) {
return m ? m[1].length : 0; return m ? m[1].length : 0;
} }
const listItemRe = /^(\s*)([-*+]|\d+\.)\s+/; // Bullets: `-`, `*`, `digit.` only. `+` is intentionally excluded — the
// CHANGELOG uses literal `+` inline (`config + instructions`) and we
// don't want to misread those as nested bullets.
const listItemRe = /^(\s*)([-*]|\d+\.)\s+/;
const fenceRe = /^\s*```/;
let inFence = false;
for (const line of block) { for (const line of block) {
// Fenced code blocks: pass through verbatim, no joining.
if (fenceRe.test(line)) {
flushBuf();
stack = [];
out.push(line);
inFence = !inFence;
continue;
}
if (inFence) {
out.push(line);
continue;
}
if (/^\s*$/.test(line)) { if (/^\s*$/.test(line)) {
flushBuf(); flushBuf();
out.push(''); out.push('');
@@ -91,13 +111,8 @@ for (const line of block) {
continue; continue;
} }
if (/^\s/.test(line)) { if (/^\s/.test(line)) {
// Continuation. Pop any list frames deeper than this indent — the
// continuation belongs to the nearest enclosing list item.
const indent = leadingSpaces(line); const indent = leadingSpaces(line);
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
// Closes the deeper item — its buffered text is already in `buf`
// belonging to the most recent flush. We need to flush before
// re-buffering for the ancestor item.
flushBuf(); flushBuf();
stack.pop(); stack.pop();
} }
@@ -105,7 +120,6 @@ for (const line of block) {
buf = buf === '' ? trimmed : `${buf} ${trimmed}`; buf = buf === '' ? trimmed : `${buf} ${trimmed}`;
continue; continue;
} }
// Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`)
flushBuf(); flushBuf();
stack = []; stack = [];
out.push(line); out.push(line);