|
| 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