Skip to content

Commit 7fd4786

Browse files
authored
Merge pull request #38 from levelcodeai/tools/draft-release-notes
tools: draft RELEASE-NOTES.md from the release range
2 parents 2a8a161 + 3519403 commit 7fd4786

1 file changed

Lines changed: 218 additions & 0 deletions

File tree

scripts/draft-release-notes.mjs

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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 { spawnSync } from 'node:child_process';
29+
import { readdirSync, writeFileSync, existsSync } from 'node:fs';
30+
import { join } from 'node:path';
31+
32+
const die = (msg) => { console.error('draft-release-notes: ' + msg); process.exit(1); };
33+
34+
// Anchor EVERYTHING to the repo root, never to the invocation directory. Run from scripts/ with a cwd
35+
// -based root and the tool does not fail — it reports "0 suites … all green", because extensions/ is
36+
// simply not there to look in, and --write drops RELEASE-NOTES.md into the subdirectory. A wrong answer
37+
// delivered confidently is the one failure mode this tool must not have.
38+
const bootstrap = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8' });
39+
if (bootstrap.status !== 0) { die('not inside a git repository'); }
40+
const REPO = bootstrap.stdout.trim();
41+
42+
// git via argv, never a shell string. A tag name is repo-controlled but still UNTRUSTED input here:
43+
// it is discovered at runtime and then used to build a revision range, so passing it through a shell
44+
// would make a tag containing metacharacters an injection. spawnSync with an array cannot be quoted
45+
// out of. (RELEASE_TAG below is the second half of that defence: shape, not just escaping.)
46+
const git = (...args) => {
47+
const r = spawnSync('git', args, { cwd: REPO, encoding: 'utf8' });
48+
if (r.status !== 0) { die(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`); }
49+
return r.stdout.trim();
50+
};
51+
52+
// The ONLY tag shape this tool will measure a release against.
53+
const RELEASE_TAG = /^v\d+\.\d+\.\d+$/;
54+
55+
// ---- arguments ---------------------------------------------------------------------------------
56+
57+
const args = process.argv.slice(2);
58+
const write = args.includes('--write');
59+
const version = args.find((a) => !a.startsWith('--'));
60+
if (!version) { die('usage: node scripts/draft-release-notes.mjs <version> [--write] e.g. 0.9.2'); }
61+
if (!/^\d+\.\d+\.\d+$/.test(version)) { die(`"${version}" is not a bare semver (expected e.g. 0.9.2, no leading v)`); }
62+
63+
const tag = 'v' + version;
64+
if (git('tag', '--list', tag)) {
65+
die(`${tag} already exists. Notes are written BEFORE tagging, so the tag contains them.`);
66+
}
67+
68+
// ---- the range ---------------------------------------------------------------------------------
69+
70+
// Newest existing RELEASE tag — filtered by shape, not merely by the 'v*' glob. A stray tag like
71+
// v0.9.2-rc1 or v-wip sorts into that glob and would silently produce the wrong range (and therefore
72+
// the wrong compare link and the wrong commit list), which is a subtler failure than any injection.
73+
const prevTag = git('tag', '--list', 'v*', '--sort=-v:refname')
74+
.split('\n').map((t) => t.trim()).filter((t) => RELEASE_TAG.test(t))[0];
75+
if (!prevTag) { die('no previous vX.Y.Z release tag found — cannot compute a range or a compare link'); }
76+
77+
const dirty = git('status', '--porcelain').split('\n').filter((l) => l && !l.startsWith('??'));
78+
const warnings = [];
79+
if (dirty.length) {
80+
warnings.push(`working tree has ${dirty.length} uncommitted change(s) — the notes may describe code that is not in the tag`);
81+
}
82+
83+
// %x1f separates fields, %x1e separates records: commit subjects contain almost anything else.
84+
const raw = git('log', '--format=%H%x1f%s%x1f%an%x1e', `${prevTag}..HEAD`);
85+
const commits = raw.split('\x1e').map((r) => r.trim()).filter(Boolean).map((r) => {
86+
const [hash, subject, author] = r.split('\x1f');
87+
return { hash: hash.slice(0, 7), subject, author };
88+
});
89+
if (!commits.length) { die(`no commits between ${prevTag} and HEAD — nothing to release`); }
90+
91+
// ---- classification ----------------------------------------------------------------------------
92+
//
93+
// Conventional-commit type decides the SECTION, not whether the change matters — that is your call.
94+
// Merge commits are dropped (their PR title is already carried by the squashed/branch commits), but
95+
// their PR numbers are collected so the draft can cite them.
96+
97+
// PR numbers arrive in one of two shapes depending on the merge strategy, and a tool that only knows
98+
// one of them silently reports "no PRs" on a repo that squash-merges. Collect both:
99+
// merge commit -> "Merge pull request #37 from ..."
100+
// squash commit -> "feat(ai): auto-open the browser (#35)"
101+
const prNumbers = new Set();
102+
const isMerge = (c) => {
103+
const m = /^Merge pull request #(\d+)/.exec(c.subject);
104+
if (m) { prNumbers.add(m[1]); return true; }
105+
return /^Merge branch /.test(c.subject);
106+
};
107+
const notePrInSubject = (c) => {
108+
const m = /\(#(\d+)\)\s*$/.exec(c.subject);
109+
if (m) { prNumbers.add(m[1]); }
110+
};
111+
112+
const typeOf = (subject) => (/^(\w+)(\([^)]*\))?!?:/.exec(subject) || [])[1] || 'other';
113+
const USER_FACING = new Set(['feat', 'fix', 'perf', 'revert']);
114+
const INTERNAL = new Set(['ci', 'build', 'chore', 'test', 'docs', 'refactor', 'style']);
115+
116+
const kept = commits.filter((c) => !isMerge(c));
117+
kept.forEach(notePrInSubject); // squash-merge repos carry the PR in the subject, not a merge commit
118+
const features = kept.filter((c) => typeOf(c.subject) === 'feat');
119+
const fixes = kept.filter((c) => ['fix', 'perf', 'revert'].includes(typeOf(c.subject)));
120+
const excluded = kept.filter((c) => INTERNAL.has(typeOf(c.subject)));
121+
const unclassified = kept.filter((c) => !USER_FACING.has(typeOf(c.subject)) && !INTERNAL.has(typeOf(c.subject)));
122+
123+
// ---- test coverage, measured rather than recalled -----------------------------------------------
124+
//
125+
// Runs the same suites the release gate runs and reads each one's own reported count. If a suite
126+
// fails, that is a release blocker, not a footnote — say so loudly and exit non-zero.
127+
128+
function measureSuites() {
129+
const extRoot = join(REPO, 'extensions');
130+
if (!existsSync(extRoot)) { return { suites: [], failed: [] }; }
131+
const suites = [];
132+
const failed = [];
133+
for (const ext of readdirSync(extRoot)) {
134+
const testDir = join(extRoot, ext, 'test');
135+
if (!existsSync(testDir)) { continue; }
136+
for (const file of readdirSync(testDir).filter((f) => f.endsWith('.test.js'))) {
137+
const rel = join('extensions', ext, 'test', file);
138+
const run = spawnSync('node', [rel], { cwd: REPO, encoding: 'utf8' });
139+
if (run.status !== 0) { failed.push(rel); continue; }
140+
const m = /(\d+) tests? passed/.exec(run.stdout || '');
141+
// Keyed by RELATIVE PATH: two extensions may legitimately both have catalog.test.js, and a
142+
// basename would make the "biggest suites" list ambiguous about which one it means.
143+
suites.push({ file: rel, cases: m ? Number(m[1]) : null });
144+
}
145+
}
146+
return { suites, failed };
147+
}
148+
149+
const { suites, failed } = measureSuites();
150+
if (failed.length) {
151+
die(`these suites FAIL — fix before drafting notes:\n ${failed.join('\n ')}`);
152+
}
153+
// Zero suites is not "all green", it is "nothing was measured" — and the two must never render the
154+
// same. Anchoring REPO above should make this unreachable; it stays as the backstop, because the cost
155+
// of being wrong here is a release note that certifies coverage nobody ran.
156+
if (!suites.length) {
157+
die(`found no test suites under ${join(REPO, 'extensions')} — refusing to draft notes that would claim "all green" without measuring anything`);
158+
}
159+
const totalCases = suites.reduce((n, s) => n + (s.cases || 0), 0);
160+
// A suite whose summary line we could not parse contributes 0, so the total would quietly UNDER-report
161+
// coverage while sounding authoritative. Say which number we actually have.
162+
const uncounted = suites.filter((s) => s.cases == null);
163+
const biggest = [...suites].sort((a, b) => (b.cases || 0) - (a.cases || 0)).slice(0, 3);
164+
165+
// ---- render -------------------------------------------------------------------------------------
166+
167+
// Set -> sorted array. (A Set has .size, not .length; reading .length silently yields undefined,
168+
// which is how the PR list quietly emptied itself the first time.)
169+
const prList = [...prNumbers].sort((a, b) => Number(a) - Number(b));
170+
171+
const bullet = (c) => `- \`${c.hash}\` ${c.subject}`;
172+
const section = (title, list) => (list.length ? `\n### ${title}\n${list.map(bullet).join('\n')}\n` : '');
173+
174+
const draft = `# LevelCode v${version}
175+
176+
<!-- TODO one sentence: what does this release GIVE someone? Lead with the change they will notice,
177+
not the biggest diff. Two features is a fine release; say so plainly. -->
178+
179+
## Highlights
180+
${section('Candidates — feat (write these up, or move them down / delete)', features)}${section('Candidates — fix/perf/revert (usually "Under the hood", unless a user hit the bug)', fixes)}
181+
<!-- TODO For each thing you keep: say what it does, then the ONE non-obvious property a user should
182+
know (a bound, a tradeoff, a thing it deliberately will not do). That sentence is the whole
183+
value of hand-writing these. -->
184+
185+
## Under the hood
186+
187+
<!-- TODO implementation notes worth a curious reader's time. -->
188+
189+
## Test coverage
190+
191+
- **${suites.length} suites** across the bundled extensions — all green.
192+
- ${totalCases} cases counted${uncounted.length ? ` across ${suites.length - uncounted.length} of them; ${uncounted.length} suite(s) did not report a count (${uncounted.map((s) => s.file).join(', ')}), so the real total is higher` : ' in total'}.
193+
${biggest.map((s) => `- \`${s.file}\`${s.cases != null ? ` (${s.cases} cases)` : ''} — <!-- TODO what does it guard? -->`).join('\n')}
194+
195+
**Full changelog:** https://github.com/levelcodeai/levelcode/compare/${prevTag}...${tag}
196+
197+
<!-- ============================================================================================
198+
EVERYTHING BELOW IS SCAFFOLDING — delete it before committing.
199+
200+
Range: ${prevTag}..HEAD (${commits.length} commits, ${kept.length} after dropping merges)
201+
PRs merged: ${prList.length ? prList.map((n) => '#' + n).join(', ') : '(none detected)'}
202+
${warnings.length ? '\n WARNINGS:\n' + warnings.map((w) => ' - ' + w).join('\n') + '\n' : ''}
203+
EXCLUDED as internal — check this list; anything user-visible in here belongs above:
204+
${excluded.length ? excluded.map((c) => ` ${c.hash} ${c.subject}`).join('\n') : ' (none)'}
205+
${unclassified.length ? '\n UNCLASSIFIED (no conventional-commit type) — decide for each:\n' + unclassified.map((c) => ` ${c.hash} ${c.subject}`).join('\n') + '\n' : ''}
206+
Deliberate omissions are fine, but they should be CHOSEN. v0.9.1 left out an undocumented
207+
command on purpose because publishing it would have defeated it.
208+
============================================================================================ -->
209+
`;
210+
211+
if (write) {
212+
writeFileSync(join(REPO, 'RELEASE-NOTES.md'), draft);
213+
console.error(`draft-release-notes: wrote RELEASE-NOTES.md for ${tag} (${prevTag}..HEAD)`);
214+
console.error(' Fill in the TODOs, delete the scaffolding block, then commit BEFORE tagging.');
215+
if (warnings.length) { warnings.forEach((w) => console.error(' WARNING: ' + w)); }
216+
} else {
217+
process.stdout.write(draft);
218+
}

0 commit comments

Comments
 (0)