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