Skip to content

Commit 483ec91

Browse files
colbymchenryclaude
andauthored
chore(release): unwrap CHANGELOG paragraphs for GitHub Release notes (colbymchenry#180)
GitHub renders release-note Markdown with GFM hard breaks, so every `\n` becomes `<br>`. 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) <[email protected]>
1 parent fb8fb0e commit 483ec91

3 files changed

Lines changed: 128 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,23 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4242
…) accept `module::symbol` (Rust / C++ / Ruby), `Module.symbol`
4343
(TS / JS / Python), and `module/symbol` (path-style) — multi-level
4444
forms (`crate::configurator::stage_apply::run`) and Rust path
45-
prefixes (`crate`, `super`, `self`) are handled. Two underlying
46-
fixes:
45+
prefixes (`crate`, `super`, `self`) are handled. Closes
46+
[#173](https://github.com/colbymchenry/codegraph/issues/173). Thanks
47+
to [@joselhurtado](https://github.com/joselhurtado) for the detailed
48+
reproduction. Three underlying fixes:
4749
- The FTS5 query builder now treats `::` as a token separator
4850
instead of stripping it to nothing, so `stage_apply::run` no
4951
longer collapses to the unsearchable `stage_applyrun`.
5052
- `matchesSymbol` falls back to a file-path containment check when
51-
`qualifiedName` doesn't carry the module hierarchy (Rust file-
52-
level functions, Python free functions in a package): a `run`
53-
in `src/configurator/stage_apply.rs` now matches
53+
`qualifiedName` doesn't carry the module hierarchy (Rust
54+
file-level functions, Python free functions in a package): a
55+
`run` in `src/configurator/stage_apply.rs` now matches
5456
`stage_apply::run` because `stage_apply` appears as a path
5557
segment.
5658
- Qualified lookups that don't match the qualifier no longer fall
5759
through to fuzzy text matches — `stage_apply::nonexistent_fn`
5860
returns `null` instead of resolving to an unrelated `rollback`
5961
in the same file.
60-
Closes [#173](https://github.com/colbymchenry/codegraph/issues/173).
61-
Thanks to [@joselhurtado](https://github.com/joselhurtado) for the
62-
detailed reproduction.
6362

6463
[0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10
6564

scripts/extract-release-notes.mjs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Extract a release-notes block from CHANGELOG.md for a given version,
4+
* then unwrap hard-wrapped paragraphs.
5+
*
6+
* Why: GitHub renders release-note Markdown with GFM hard breaks, so
7+
* every `\n` becomes `<br>`. The CHANGELOG is hard-wrapped at ~75
8+
* chars for readable diffs, which then renders as awkward visible
9+
* line breaks on the release page. This script joins indented
10+
* continuation lines into a single line per bullet so the GFM
11+
* renderer produces clean paragraphs.
12+
*
13+
* Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats
14+
* newlines as spaces there).
15+
*
16+
* Usage: extract-release-notes.mjs <version>
17+
* e.g. extract-release-notes.mjs 0.7.10
18+
*/
19+
20+
import { readFileSync } from 'fs';
21+
22+
const version = process.argv[2];
23+
if (!version) {
24+
console.error('usage: extract-release-notes.mjs <version>');
25+
process.exit(1);
26+
}
27+
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);
37+
}
38+
const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l));
39+
const block = lines.slice(start, after === -1 ? lines.length : after);
40+
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:
48+
//
49+
// - top-level
50+
// - nested
51+
// back to top-level <- 2-space indent, joins the top-level bullet
52+
const out = [];
53+
let buf = ''; // pending list-item text being built
54+
let stack = []; // [{ indent: number }] open list items
55+
56+
function flushBuf() {
57+
if (buf !== '') {
58+
out.push(buf);
59+
buf = '';
60+
}
61+
}
62+
63+
function leadingSpaces(s) {
64+
const m = s.match(/^(\s*)/);
65+
return m ? m[1].length : 0;
66+
}
67+
68+
const listItemRe = /^(\s*)([-*+]|\d+\.)\s+/;
69+
70+
for (const line of block) {
71+
if (/^\s*$/.test(line)) {
72+
flushBuf();
73+
out.push('');
74+
continue;
75+
}
76+
if (/^#/.test(line)) {
77+
flushBuf();
78+
stack = [];
79+
out.push(line);
80+
continue;
81+
}
82+
const itemMatch = line.match(listItemRe);
83+
if (itemMatch) {
84+
flushBuf();
85+
const indent = itemMatch[1].length;
86+
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
87+
stack.pop();
88+
}
89+
stack.push({ indent });
90+
buf = line;
91+
continue;
92+
}
93+
if (/^\s/.test(line)) {
94+
// Continuation. Pop any list frames deeper than this indent — the
95+
// continuation belongs to the nearest enclosing list item.
96+
const indent = leadingSpaces(line);
97+
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.
101+
flushBuf();
102+
stack.pop();
103+
}
104+
const trimmed = line.replace(/^\s+/, '');
105+
buf = buf === '' ? trimmed : `${buf} ${trimmed}`;
106+
continue;
107+
}
108+
// Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`)
109+
flushBuf();
110+
stack = [];
111+
out.push(line);
112+
}
113+
flushBuf();
114+
115+
process.stdout.write(out.join('\n'));
116+
if (!out[out.length - 1]?.endsWith('\n')) process.stdout.write('\n');

scripts/release.sh

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,11 @@ if ! grep -q "^## \[${VERSION}\]" CHANGELOG.md; then
3030
exit 1
3131
fi
3232

33-
NOTES=$(awk -v v="${VERSION}" '
34-
/^## \[/ {
35-
if (p) exit
36-
if ($0 ~ "^## \\[" v "\\]") p = 1
37-
}
38-
p
39-
' CHANGELOG.md)
33+
# Extract notes with paragraph unwrapping — GitHub Releases render with
34+
# GFM hard-breaks, so the CHANGELOG's hard-wrapped lines would show as
35+
# visible `<br>` breaks otherwise. The helper joins continuation lines
36+
# into a single line per bullet.
37+
NOTES=$(node scripts/extract-release-notes.mjs "${VERSION}")
4038

4139
if [ -z "${NOTES}" ]; then
4240
echo "error: failed to extract changelog notes for ${VERSION}" >&2

0 commit comments

Comments
 (0)