Просмотр исходного кода

chore(release): refine release-notes extractor

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>
Colby McHenry 1 месяц назад
Родитель
Сommit
c6dac429ba
1 измененных файлов с 48 добавлено и 34 удалено
  1. 48 34
      scripts/extract-release-notes.mjs

+ 48 - 34
scripts/extract-release-notes.mjs

@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 #!/usr/bin/env node
 /**
 /**
- * Extract a release-notes block from CHANGELOG.md for a given version,
- * then unwrap hard-wrapped paragraphs.
+ * Extract a release-notes block from CHANGELOG.md for a given version
+ * (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>
- *        e.g. extract-release-notes.mjs 0.7.10
+ * Usage:
+ *   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];
-if (!version) {
-  console.error('usage: extract-release-notes.mjs <version>');
+const arg = process.argv[2];
+if (!arg) {
+  console.error('usage: extract-release-notes.mjs <version> | --stdin');
   process.exit(1);
   process.exit(1);
 }
 }
 
 
-const escaped = version.replace(/\./g, '\\.');
-const headerRe = new RegExp(`^## \\[${escaped}\\]`);
-const anyHeaderRe = /^## \[/;
-
-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);
+let block;
+if (arg === '--stdin') {
+  block = readFileSync(0, 'utf8').replace(/\r\n?/g, '\n').split('\n');
+} else {
+  const version = arg;
+  const escaped = version.replace(/\./g, '\\.');
+  const headerRe = new RegExp(`^## \\[${escaped}\\]`);
+  const anyHeaderRe = /^## \[/;
+  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
-// whose indent is GREATER than that belongs to that item, otherwise
-// it might belong to an ancestor item further up the stack.
-//
-// 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:
+// Track a stack of `{ indent: number }` frames so a continuation line
+// can attach to the right ancestor. 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 stack = [];                              // [{ indent: number }] open list items
+let buf = '';
+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);