From 483ec9171c5600d44bd7f0f1e2ad977460903bb3 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 19 May 2026 11:42:22 -0500 Subject: [PATCH] chore(release): unwrap CHANGELOG paragraphs for GitHub Release notes (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub renders release-note Markdown with GFM hard breaks, so every `\n` becomes `
`. The CHANGELOG is hard-wrapped at ~75 chars for readable diffs, which renders as awkward visible line breaks on the release page (see https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10). Add `scripts/extract-release-notes.mjs` to extract a version block and join indented continuation lines into a single line per bullet. Nested list items, headings, and link references are preserved. `scripts/release.sh` now uses this helper instead of the inline awk extractor — repo-level CHANGELOG.md viewing is unaffected because CommonMark there treats newlines as spaces. Also fix the 0.7.10 entry: "Two underlying fixes" -> "Three", "Rust file-/level" broken hyphen, and move the closes/credit line above the nested list so it doesn't strand as a top-level paragraph. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 ++-- scripts/extract-release-notes.mjs | 116 ++++++++++++++++++++++++++++++ scripts/release.sh | 12 ++-- 3 files changed, 128 insertions(+), 15 deletions(-) create mode 100755 scripts/extract-release-notes.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 30937cd..28f07d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,24 +42,23 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). …) accept `module::symbol` (Rust / C++ / Ruby), `Module.symbol` (TS / JS / Python), and `module/symbol` (path-style) — multi-level forms (`crate::configurator::stage_apply::run`) and Rust path - prefixes (`crate`, `super`, `self`) are handled. Two underlying - fixes: + prefixes (`crate`, `super`, `self`) are handled. Closes + [#173](https://github.com/colbymchenry/codegraph/issues/173). Thanks + to [@joselhurtado](https://github.com/joselhurtado) for the detailed + reproduction. Three underlying fixes: - The FTS5 query builder now treats `::` as a token separator instead of stripping it to nothing, so `stage_apply::run` no longer collapses to the unsearchable `stage_applyrun`. - `matchesSymbol` falls back to a file-path containment check when - `qualifiedName` doesn't carry the module hierarchy (Rust file- - level functions, Python free functions in a package): a `run` - in `src/configurator/stage_apply.rs` now matches + `qualifiedName` doesn't carry the module hierarchy (Rust + file-level functions, Python free functions in a package): a + `run` in `src/configurator/stage_apply.rs` now matches `stage_apply::run` because `stage_apply` appears as a path segment. - Qualified lookups that don't match the qualifier no longer fall through to fuzzy text matches — `stage_apply::nonexistent_fn` returns `null` instead of resolving to an unrelated `rollback` in the same file. - Closes [#173](https://github.com/colbymchenry/codegraph/issues/173). - Thanks to [@joselhurtado](https://github.com/joselhurtado) for the - detailed reproduction. [0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10 diff --git a/scripts/extract-release-notes.mjs b/scripts/extract-release-notes.mjs new file mode 100755 index 0000000..3bcf7f3 --- /dev/null +++ b/scripts/extract-release-notes.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * Extract a release-notes block from CHANGELOG.md for a given version, + * then unwrap hard-wrapped paragraphs. + * + * Why: GitHub renders release-note Markdown with GFM hard breaks, so + * every `\n` becomes `
`. The CHANGELOG is hard-wrapped at ~75 + * chars for readable diffs, which then renders as awkward visible + * line breaks on the release page. This script joins indented + * continuation lines into a single line per bullet so the GFM + * renderer produces clean paragraphs. + * + * Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats + * newlines as spaces there). + * + * Usage: extract-release-notes.mjs + * e.g. extract-release-notes.mjs 0.7.10 + */ + +import { readFileSync } from 'fs'; + +const version = process.argv[2]; +if (!version) { + console.error('usage: extract-release-notes.mjs '); + 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); +} +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: +// +// - top-level +// - nested +// back to top-level <- 2-space indent, joins the top-level bullet +const out = []; +let buf = ''; // pending list-item text being built +let stack = []; // [{ indent: number }] open list items + +function flushBuf() { + if (buf !== '') { + out.push(buf); + buf = ''; + } +} + +function leadingSpaces(s) { + const m = s.match(/^(\s*)/); + return m ? m[1].length : 0; +} + +const listItemRe = /^(\s*)([-*+]|\d+\.)\s+/; + +for (const line of block) { + if (/^\s*$/.test(line)) { + flushBuf(); + out.push(''); + continue; + } + if (/^#/.test(line)) { + flushBuf(); + stack = []; + out.push(line); + continue; + } + const itemMatch = line.match(listItemRe); + if (itemMatch) { + flushBuf(); + const indent = itemMatch[1].length; + while (stack.length > 0 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + stack.push({ indent }); + buf = line; + continue; + } + 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); + 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(); + stack.pop(); + } + const trimmed = line.replace(/^\s+/, ''); + buf = buf === '' ? trimmed : `${buf} ${trimmed}`; + continue; + } + // Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`) + flushBuf(); + stack = []; + out.push(line); +} +flushBuf(); + +process.stdout.write(out.join('\n')); +if (!out[out.length - 1]?.endsWith('\n')) process.stdout.write('\n'); diff --git a/scripts/release.sh b/scripts/release.sh index da6bdae..9edf846 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -30,13 +30,11 @@ if ! grep -q "^## \[${VERSION}\]" CHANGELOG.md; then exit 1 fi -NOTES=$(awk -v v="${VERSION}" ' - /^## \[/ { - if (p) exit - if ($0 ~ "^## \\[" v "\\]") p = 1 - } - p -' CHANGELOG.md) +# Extract notes with paragraph unwrapping — GitHub Releases render with +# GFM hard-breaks, so the CHANGELOG's hard-wrapped lines would show as +# visible `
` breaks otherwise. The helper joins continuation lines +# into a single line per bullet. +NOTES=$(node scripts/extract-release-notes.mjs "${VERSION}") if [ -z "${NOTES}" ]; then echo "error: failed to extract changelog notes for ${VERSION}" >&2