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:
co-authored by
Claude Opus 4.7
parent
483ec9171c
commit
4bb95639ca
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user