Skip to content

Commit c56670c

Browse files
ndemiancclaude
andcommitted
tools: draft RELEASE-NOTES.md from the release range
Automates the half of release notes that is a computer's job, and deliberately refuses the other half. WHAT IT FILLS IN (previously looked up by hand, every release): - the previous tag, the commit range, and the compare URL - commits grouped by conventional-commit type, with the PRs they came from - test coverage MEASURED by running the suites and reading each one's own count, rather than recalled — a stale count is a small lie nobody catches WHAT IT WILL NOT DO: write the prose. Choosing which 2 of 15 commits a user actually cares about, and how to frame them, is judgement. v0.9.2 needed the sentence "credits are a change of unit, not of price" — no commit subject contains that, and inventing it from subjects is how release notes become unreadable. The draft marks those spots TODO instead of guessing. IT SHOWS ITS WORKING. Every commit classified as internal is listed under "excluded" in the scaffolding block. A tool that silently drops commits is worse than no tool: you cannot review an omission you never see. Verified against the real v0.9.1..HEAD range — it excluded exactly what I had excluded by hand (the CI Playwright fix and the notes commits) and showed all three. Guardrails: rejects a missing/non-semver version, refuses a version whose tag already exists (notes are written BEFORE tagging so the tag contains them), warns on a dirty tree, and EXITS NON-ZERO if any suite fails — a red suite is a release blocker, not a footnote. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8156a37 commit c56670c

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

scripts/draft-release-notes.mjs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* LevelCode — draft RELEASE-NOTES.md for a release.
3+
*
4+
* Usage: node scripts/draft-release-notes.mjs 0.9.2 [--write]
5+
* (--write replaces RELEASE-NOTES.md; without it the draft goes to stdout)
6+
*
7+
* WHY THIS EXISTS
8+
*
9+
* Writing release notes has two halves, and only one of them is a computer's job.
10+
*
11+
* The FACTS are: which commits are in the range, which PRs they came from, what the previous tag
12+
* was, how many test suites there are and how many cases each holds, and the compare URL. Every one
13+
* of those was previously looked up by hand for each release, which is exactly the kind of thing
14+
* that gets misremembered — a stale test count or a compare link pointing at the wrong tag is a
15+
* small lie that nobody catches.
16+
*
17+
* The PROSE is: which two of fourteen commits actually matter to a user, what to lead with, and how
18+
* to frame a change so it is not misread (v0.9.2 had to say "credits are a change of unit, not of
19+
* price" — no commit subject contains that). This script does NOT attempt that, on purpose. A
20+
* changelog auto-generated from commit subjects is the reason most release notes go unread.
21+
*
22+
* So: this fills in everything factual and leaves clearly-marked TODOs where judgement is required.
23+
*
24+
* IT ALSO SHOWS ITS WORKING. Every commit it classifies as internal is listed under "excluded" in
25+
* the draft. A tool that silently drops commits is worse than no tool — you cannot review an
26+
* omission you never see. Delete that section once you have checked it.
27+
*--------------------------------------------------------------------------------------------*/
28+
import { execSync, spawnSync } from 'node:child_process';
29+
import { readdirSync, writeFileSync, existsSync } from 'node:fs';
30+
import { join } from 'node:path';
31+
32+
const REPO = process.cwd();
33+
const sh = (cmd) => execSync(cmd, { cwd: REPO, encoding: 'utf8' }).trim();
34+
const die = (msg) => { console.error('draft-release-notes: ' + msg); process.exit(1); };
35+
36+
// ---- arguments ---------------------------------------------------------------------------------
37+
38+
const args = process.argv.slice(2);
39+
const write = args.includes('--write');
40+
const version = args.find((a) => !a.startsWith('--'));
41+
if (!version) { die('usage: node scripts/draft-release-notes.mjs <version> [--write] e.g. 0.9.2'); }
42+
if (!/^\d+\.\d+\.\d+$/.test(version)) { die(`"${version}" is not a bare semver (expected e.g. 0.9.2, no leading v)`); }
43+
44+
const tag = 'v' + version;
45+
if (sh('git tag --list ' + tag)) {
46+
die(`${tag} already exists. Notes are written BEFORE tagging, so the tag contains them.`);
47+
}
48+
49+
// ---- the range ---------------------------------------------------------------------------------
50+
51+
// Newest existing release tag, which is what this release is measured against.
52+
const prevTag = sh("git tag --list 'v*' --sort=-v:refname").split('\n').filter(Boolean)[0];
53+
if (!prevTag) { die('no previous v* tag found — cannot compute a range or a compare link'); }
54+
55+
const dirty = sh('git status --porcelain').split('\n').filter((l) => l && !l.startsWith('??'));
56+
const warnings = [];
57+
if (dirty.length) {
58+
warnings.push(`working tree has ${dirty.length} uncommitted change(s) — the notes may describe code that is not in the tag`);
59+
}
60+
61+
// %x1f separates fields, %x1e separates records: commit subjects contain almost anything else.
62+
const raw = sh(`git log --format=%H%x1f%s%x1f%an%x1e ${prevTag}..HEAD`);
63+
const commits = raw.split('\x1e').map((r) => r.trim()).filter(Boolean).map((r) => {
64+
const [hash, subject, author] = r.split('\x1f');
65+
return { hash: hash.slice(0, 7), subject, author };
66+
});
67+
if (!commits.length) { die(`no commits between ${prevTag} and HEAD — nothing to release`); }
68+
69+
// ---- classification ----------------------------------------------------------------------------
70+
//
71+
// Conventional-commit type decides the SECTION, not whether the change matters — that is your call.
72+
// Merge commits are dropped (their PR title is already carried by the squashed/branch commits), but
73+
// their PR numbers are collected so the draft can cite them.
74+
75+
const prNumbers = [];
76+
const isMerge = (c) => {
77+
const m = /^Merge pull request #(\d+)/.exec(c.subject);
78+
if (m) { prNumbers.push(m[1]); return true; }
79+
return /^Merge branch /.test(c.subject);
80+
};
81+
82+
const typeOf = (subject) => (/^(\w+)(\([^)]*\))?!?:/.exec(subject) || [])[1] || 'other';
83+
const USER_FACING = new Set(['feat', 'fix', 'perf', 'revert']);
84+
const INTERNAL = new Set(['ci', 'build', 'chore', 'test', 'docs', 'refactor', 'style']);
85+
86+
const kept = commits.filter((c) => !isMerge(c));
87+
const features = kept.filter((c) => typeOf(c.subject) === 'feat');
88+
const fixes = kept.filter((c) => ['fix', 'perf', 'revert'].includes(typeOf(c.subject)));
89+
const excluded = kept.filter((c) => INTERNAL.has(typeOf(c.subject)));
90+
const unclassified = kept.filter((c) => !USER_FACING.has(typeOf(c.subject)) && !INTERNAL.has(typeOf(c.subject)));
91+
92+
// ---- test coverage, measured rather than recalled -----------------------------------------------
93+
//
94+
// Runs the same suites the release gate runs and reads each one's own reported count. If a suite
95+
// fails, that is a release blocker, not a footnote — say so loudly and exit non-zero.
96+
97+
function measureSuites() {
98+
const extRoot = join(REPO, 'extensions');
99+
if (!existsSync(extRoot)) { return { suites: [], failed: [] }; }
100+
const suites = [];
101+
const failed = [];
102+
for (const ext of readdirSync(extRoot)) {
103+
const testDir = join(extRoot, ext, 'test');
104+
if (!existsSync(testDir)) { continue; }
105+
for (const file of readdirSync(testDir).filter((f) => f.endsWith('.test.js'))) {
106+
const rel = join('extensions', ext, 'test', file);
107+
const run = spawnSync('node', [rel], { cwd: REPO, encoding: 'utf8' });
108+
if (run.status !== 0) { failed.push(rel); continue; }
109+
const m = /(\d+) tests? passed/.exec(run.stdout || '');
110+
suites.push({ file, cases: m ? Number(m[1]) : null });
111+
}
112+
}
113+
return { suites, failed };
114+
}
115+
116+
const { suites, failed } = measureSuites();
117+
if (failed.length) {
118+
die(`these suites FAIL — fix before drafting notes:\n ${failed.join('\n ')}`);
119+
}
120+
const totalCases = suites.reduce((n, s) => n + (s.cases || 0), 0);
121+
const biggest = [...suites].sort((a, b) => (b.cases || 0) - (a.cases || 0)).slice(0, 3);
122+
123+
// ---- render -------------------------------------------------------------------------------------
124+
125+
const bullet = (c) => `- \`${c.hash}\` ${c.subject}`;
126+
const section = (title, list) => (list.length ? `\n### ${title}\n${list.map(bullet).join('\n')}\n` : '');
127+
128+
const draft = `# LevelCode v${version}
129+
130+
<!-- TODO one sentence: what does this release GIVE someone? Lead with the change they will notice,
131+
not the biggest diff. Two features is a fine release; say so plainly. -->
132+
133+
## Highlights
134+
${section('Candidates — feat (write these up, or move them down / delete)', features)}${section('Candidates — fix/perf (usually "Under the hood", unless a user hit the bug)', fixes)}
135+
<!-- TODO For each thing you keep: say what it does, then the ONE non-obvious property a user should
136+
know (a bound, a tradeoff, a thing it deliberately will not do). That sentence is the whole
137+
value of hand-writing these. -->
138+
139+
## Under the hood
140+
141+
<!-- TODO implementation notes worth a curious reader's time. -->
142+
143+
## Test coverage
144+
145+
- **${suites.length} suites** across the bundled extensions, ${totalCases} cases in total — all green.
146+
${biggest.map((s) => `- \`${s.file}\`${s.cases != null ? ` (${s.cases} cases)` : ''} — <!-- TODO what does it guard? -->`).join('\n')}
147+
148+
**Full changelog:** https://github.com/levelcodeai/levelcode/compare/${prevTag}...${tag}
149+
150+
<!-- ============================================================================================
151+
EVERYTHING BELOW IS SCAFFOLDING — delete it before committing.
152+
153+
Range: ${prevTag}..HEAD (${commits.length} commits, ${kept.length} after dropping merges)
154+
PRs merged: ${prNumbers.length ? prNumbers.map((n) => '#' + n).join(', ') : '(none detected)'}
155+
${warnings.length ? '\n WARNINGS:\n' + warnings.map((w) => ' - ' + w).join('\n') + '\n' : ''}
156+
EXCLUDED as internal — check this list; anything user-visible in here belongs above:
157+
${excluded.length ? excluded.map((c) => ` ${c.hash} ${c.subject}`).join('\n') : ' (none)'}
158+
${unclassified.length ? '\n UNCLASSIFIED (no conventional-commit type) — decide for each:\n' + unclassified.map((c) => ` ${c.hash} ${c.subject}`).join('\n') + '\n' : ''}
159+
Deliberate omissions are fine, but they should be CHOSEN. v0.9.1 left out an undocumented
160+
command on purpose because publishing it would have defeated it.
161+
============================================================================================ -->
162+
`;
163+
164+
if (write) {
165+
writeFileSync(join(REPO, 'RELEASE-NOTES.md'), draft);
166+
console.error(`draft-release-notes: wrote RELEASE-NOTES.md for ${tag} (${prevTag}..HEAD)`);
167+
console.error(' Fill in the TODOs, delete the scaffolding block, then commit BEFORE tagging.');
168+
if (warnings.length) { warnings.forEach((w) => console.error(' WARNING: ' + w)); }
169+
} else {
170+
process.stdout.write(draft);
171+
}

0 commit comments

Comments
 (0)