Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions scripts/draft-release-notes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/*---------------------------------------------------------------------------------------------
* LevelCode — draft RELEASE-NOTES.md for a release.
*
* Usage: node scripts/draft-release-notes.mjs 0.9.2 [--write]
* (--write replaces RELEASE-NOTES.md; without it the draft goes to stdout)
*
* WHY THIS EXISTS
*
* Writing release notes has two halves, and only one of them is a computer's job.
*
* The FACTS are: which commits are in the range, which PRs they came from, what the previous tag
* was, how many test suites there are and how many cases each holds, and the compare URL. Every one
* of those was previously looked up by hand for each release, which is exactly the kind of thing
* that gets misremembered — a stale test count or a compare link pointing at the wrong tag is a
* small lie that nobody catches.
*
* The PROSE is: which two of fourteen commits actually matter to a user, what to lead with, and how
* to frame a change so it is not misread (v0.9.2 had to say "credits are a change of unit, not of
* price" — no commit subject contains that). This script does NOT attempt that, on purpose. A
* changelog auto-generated from commit subjects is the reason most release notes go unread.
*
* So: this fills in everything factual and leaves clearly-marked TODOs where judgement is required.
*
* IT ALSO SHOWS ITS WORKING. Every commit it classifies as internal is listed under "excluded" in
* the draft. A tool that silently drops commits is worse than no tool — you cannot review an
* omission you never see. Delete that section once you have checked it.
*--------------------------------------------------------------------------------------------*/
import { spawnSync } from 'node:child_process';
import { readdirSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';

const die = (msg) => { console.error('draft-release-notes: ' + msg); process.exit(1); };

// Anchor EVERYTHING to the repo root, never to the invocation directory. Run from scripts/ with a cwd
// -based root and the tool does not fail — it reports "0 suites … all green", because extensions/ is
// simply not there to look in, and --write drops RELEASE-NOTES.md into the subdirectory. A wrong answer
// delivered confidently is the one failure mode this tool must not have.
const bootstrap = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8' });
if (bootstrap.status !== 0) { die('not inside a git repository'); }
const REPO = bootstrap.stdout.trim();

// git via argv, never a shell string. A tag name is repo-controlled but still UNTRUSTED input here:
// it is discovered at runtime and then used to build a revision range, so passing it through a shell
// would make a tag containing metacharacters an injection. spawnSync with an array cannot be quoted
// out of. (RELEASE_TAG below is the second half of that defence: shape, not just escaping.)
const git = (...args) => {
const r = spawnSync('git', args, { cwd: REPO, encoding: 'utf8' });
if (r.status !== 0) { die(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`); }
return r.stdout.trim();
};

// The ONLY tag shape this tool will measure a release against.
const RELEASE_TAG = /^v\d+\.\d+\.\d+$/;

// ---- arguments ---------------------------------------------------------------------------------

const args = process.argv.slice(2);
const write = args.includes('--write');
const version = args.find((a) => !a.startsWith('--'));
if (!version) { die('usage: node scripts/draft-release-notes.mjs <version> [--write] e.g. 0.9.2'); }
if (!/^\d+\.\d+\.\d+$/.test(version)) { die(`"${version}" is not a bare semver (expected e.g. 0.9.2, no leading v)`); }

const tag = 'v' + version;
if (git('tag', '--list', tag)) {
die(`${tag} already exists. Notes are written BEFORE tagging, so the tag contains them.`);
}

// ---- the range ---------------------------------------------------------------------------------

// Newest existing RELEASE tag — filtered by shape, not merely by the 'v*' glob. A stray tag like
// v0.9.2-rc1 or v-wip sorts into that glob and would silently produce the wrong range (and therefore
// the wrong compare link and the wrong commit list), which is a subtler failure than any injection.
const prevTag = git('tag', '--list', 'v*', '--sort=-v:refname')
.split('\n').map((t) => t.trim()).filter((t) => RELEASE_TAG.test(t))[0];
if (!prevTag) { die('no previous vX.Y.Z release tag found — cannot compute a range or a compare link'); }

const dirty = git('status', '--porcelain').split('\n').filter((l) => l && !l.startsWith('??'));
const warnings = [];
if (dirty.length) {
warnings.push(`working tree has ${dirty.length} uncommitted change(s) — the notes may describe code that is not in the tag`);
}

// %x1f separates fields, %x1e separates records: commit subjects contain almost anything else.
const raw = git('log', '--format=%H%x1f%s%x1f%an%x1e', `${prevTag}..HEAD`);
const commits = raw.split('\x1e').map((r) => r.trim()).filter(Boolean).map((r) => {
const [hash, subject, author] = r.split('\x1f');
return { hash: hash.slice(0, 7), subject, author };
});
if (!commits.length) { die(`no commits between ${prevTag} and HEAD — nothing to release`); }

// ---- classification ----------------------------------------------------------------------------
//
// Conventional-commit type decides the SECTION, not whether the change matters — that is your call.
// Merge commits are dropped (their PR title is already carried by the squashed/branch commits), but
// their PR numbers are collected so the draft can cite them.

// PR numbers arrive in one of two shapes depending on the merge strategy, and a tool that only knows
// one of them silently reports "no PRs" on a repo that squash-merges. Collect both:
// merge commit -> "Merge pull request #37 from ..."
// squash commit -> "feat(ai): auto-open the browser (#35)"
const prNumbers = new Set();
const isMerge = (c) => {
const m = /^Merge pull request #(\d+)/.exec(c.subject);
if (m) { prNumbers.add(m[1]); return true; }
return /^Merge branch /.test(c.subject);
};
const notePrInSubject = (c) => {
const m = /\(#(\d+)\)\s*$/.exec(c.subject);
if (m) { prNumbers.add(m[1]); }
};

const typeOf = (subject) => (/^(\w+)(\([^)]*\))?!?:/.exec(subject) || [])[1] || 'other';
const USER_FACING = new Set(['feat', 'fix', 'perf', 'revert']);
const INTERNAL = new Set(['ci', 'build', 'chore', 'test', 'docs', 'refactor', 'style']);

const kept = commits.filter((c) => !isMerge(c));
kept.forEach(notePrInSubject); // squash-merge repos carry the PR in the subject, not a merge commit
const features = kept.filter((c) => typeOf(c.subject) === 'feat');
const fixes = kept.filter((c) => ['fix', 'perf', 'revert'].includes(typeOf(c.subject)));
const excluded = kept.filter((c) => INTERNAL.has(typeOf(c.subject)));
const unclassified = kept.filter((c) => !USER_FACING.has(typeOf(c.subject)) && !INTERNAL.has(typeOf(c.subject)));

// ---- test coverage, measured rather than recalled -----------------------------------------------
//
// Runs the same suites the release gate runs and reads each one's own reported count. If a suite
// fails, that is a release blocker, not a footnote — say so loudly and exit non-zero.

function measureSuites() {
const extRoot = join(REPO, 'extensions');
if (!existsSync(extRoot)) { return { suites: [], failed: [] }; }
const suites = [];
const failed = [];
for (const ext of readdirSync(extRoot)) {
const testDir = join(extRoot, ext, 'test');
if (!existsSync(testDir)) { continue; }
for (const file of readdirSync(testDir).filter((f) => f.endsWith('.test.js'))) {
const rel = join('extensions', ext, 'test', file);
const run = spawnSync('node', [rel], { cwd: REPO, encoding: 'utf8' });
if (run.status !== 0) { failed.push(rel); continue; }
const m = /(\d+) tests? passed/.exec(run.stdout || '');
// Keyed by RELATIVE PATH: two extensions may legitimately both have catalog.test.js, and a
// basename would make the "biggest suites" list ambiguous about which one it means.
suites.push({ file: rel, cases: m ? Number(m[1]) : null });
}
}
return { suites, failed };
}

const { suites, failed } = measureSuites();
if (failed.length) {
die(`these suites FAIL — fix before drafting notes:\n ${failed.join('\n ')}`);
}
// Zero suites is not "all green", it is "nothing was measured" — and the two must never render the
// same. Anchoring REPO above should make this unreachable; it stays as the backstop, because the cost
// of being wrong here is a release note that certifies coverage nobody ran.
if (!suites.length) {
die(`found no test suites under ${join(REPO, 'extensions')} — refusing to draft notes that would claim "all green" without measuring anything`);
}
const totalCases = suites.reduce((n, s) => n + (s.cases || 0), 0);
// A suite whose summary line we could not parse contributes 0, so the total would quietly UNDER-report
// coverage while sounding authoritative. Say which number we actually have.
const uncounted = suites.filter((s) => s.cases == null);
const biggest = [...suites].sort((a, b) => (b.cases || 0) - (a.cases || 0)).slice(0, 3);

// ---- render -------------------------------------------------------------------------------------

// Set -> sorted array. (A Set has .size, not .length; reading .length silently yields undefined,
// which is how the PR list quietly emptied itself the first time.)
const prList = [...prNumbers].sort((a, b) => Number(a) - Number(b));

const bullet = (c) => `- \`${c.hash}\` ${c.subject}`;
const section = (title, list) => (list.length ? `\n### ${title}\n${list.map(bullet).join('\n')}\n` : '');

const draft = `# LevelCode v${version}

<!-- TODO one sentence: what does this release GIVE someone? Lead with the change they will notice,
not the biggest diff. Two features is a fine release; say so plainly. -->

## Highlights
${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)}
<!-- TODO For each thing you keep: say what it does, then the ONE non-obvious property a user should
know (a bound, a tradeoff, a thing it deliberately will not do). That sentence is the whole
value of hand-writing these. -->

## Under the hood

<!-- TODO implementation notes worth a curious reader's time. -->

## Test coverage

- **${suites.length} suites** across the bundled extensions — all green.
- ${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'}.
${biggest.map((s) => `- \`${s.file}\`${s.cases != null ? ` (${s.cases} cases)` : ''} — <!-- TODO what does it guard? -->`).join('\n')}

**Full changelog:** https://github.com/levelcodeai/levelcode/compare/${prevTag}...${tag}

<!-- ============================================================================================
EVERYTHING BELOW IS SCAFFOLDING — delete it before committing.

Range: ${prevTag}..HEAD (${commits.length} commits, ${kept.length} after dropping merges)
PRs merged: ${prList.length ? prList.map((n) => '#' + n).join(', ') : '(none detected)'}
${warnings.length ? '\n WARNINGS:\n' + warnings.map((w) => ' - ' + w).join('\n') + '\n' : ''}
EXCLUDED as internal — check this list; anything user-visible in here belongs above:
${excluded.length ? excluded.map((c) => ` ${c.hash} ${c.subject}`).join('\n') : ' (none)'}
${unclassified.length ? '\n UNCLASSIFIED (no conventional-commit type) — decide for each:\n' + unclassified.map((c) => ` ${c.hash} ${c.subject}`).join('\n') + '\n' : ''}
Deliberate omissions are fine, but they should be CHOSEN. v0.9.1 left out an undocumented
command on purpose because publishing it would have defeated it.
============================================================================================ -->
`;

if (write) {
writeFileSync(join(REPO, 'RELEASE-NOTES.md'), draft);
console.error(`draft-release-notes: wrote RELEASE-NOTES.md for ${tag} (${prevTag}..HEAD)`);
console.error(' Fill in the TODOs, delete the scaffolding block, then commit BEFORE tagging.');
if (warnings.length) { warnings.forEach((w) => console.error(' WARNING: ' + w)); }
} else {
process.stdout.write(draft);
}