From ddc9edb731f9aeec64ea6ec4eb55fd31c5e5ba90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 08:38:01 +0000 Subject: [PATCH] test(ci): fixture the no-major changeset guard and wire it into the exemption-free self-test step (#6923) `scripts/check-changeset-no-major.mjs` was the one member of the changeset-gate family with no `--self-test`, which is why PR #6917 could not wire it into `lint.yml` alongside the other two. Refactor it into a pure `judge()` plus a pure `render()` (verdict -> lines + exit code), add 70 fixtures, and add the script to `check:changeset-gate-self-tests`. The fixtures were measured against `@changesets/parse@0.4.3` rather than reasoned about, and that measurement found a real divergence: the parser required its fence on line 1, so a changeset opening with a blank line declared nothing to this guard while changesets honoured its `major` and would have promoted the whole lockstep group. Both sibling parsers already skipped leading blanks, and all three carry a comment claiming the three read the same block -- so this was also the one place that comment was false. Fixed, and pinned. The enforcing half is still unexecuted on CI: `.changeset/pre.json` is `"mode": "pre"`, so the real scan takes the exemption branch on every run. What changed is that `enforce` is now produced by fixtures on every PR, in a job with no label exemption. Fixtured is not executed, and the header says so. Also corrects `check-empty-changeset.mjs`, whose consumer assertion stated in its own message that this script is "deliberately absent: it has no `--self-test` to run" -- true when #6917 wrote it, false as of this commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn --- package.json | 2 +- scripts/check-changeset-no-major.mjs | 620 ++++++++++++++++++++++++--- scripts/check-empty-changeset.mjs | 13 +- 3 files changed, 580 insertions(+), 55 deletions(-) diff --git a/package.json b/package.json index 2f78a79ed9..735d95c9a5 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "check:prerelease-pins": "node scripts/check-prerelease-pin-watch.mjs --self-test && node scripts/check-prerelease-pin-watch.mjs", "check:empty-changeset": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-empty-changeset.mjs", "check:adr-0087-registration": "node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-adr-0087-registration.mjs", - "check:changeset-gate-self-tests": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-adr-0087-registration.mjs --self-test", + "check:changeset-gate-self-tests": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-changeset-no-major.mjs --self-test", "check:override-consistency": "node scripts/check-override-consistency.mjs --self-test && node scripts/check-override-consistency.mjs", "check:release-notes": "node scripts/check-release-notes.mjs", "check:release-body": "node scripts/release-github-releases.mjs --self-test", diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index 6eeaa8e070..e05ad2a591 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -3,6 +3,7 @@ * Launch-window guard: rejects any changeset that declares a `major` bump. * * Run: node scripts/check-changeset-no-major.mjs + * node scripts/check-changeset-no-major.mjs --self-test # verify the checker itself * * WHY THIS EXISTS * --------------- @@ -36,15 +37,104 @@ * * The script intentionally has zero third-party dependencies so it can run in * minimal CI environments before `pnpm install`. + * + * ## What `--self-test` covers, and what it does NOT (#6923) + * + * Read this before trusting a green tick from this file. The self-test is new; + * the enforcing half it fixtures is still, on CI, unexecuted. + * + * COVERED — every decision this file makes, driven through the pure `judge()` + * on synthetic corpora: the frontmatter dialects, the pre-mode/exit-mode + * switch in BOTH directions, the unreadable-`pre.json` fallthrough, and the + * rendered text of the offenders report. + * + * NOT COVERED — the CI path. `.changeset/pre.json` says `"mode": "pre"`, so + * the real scan below takes the exemption branch and exits 0 on every run; + * the `enforce` verdict has never been produced by a CI invocation of this + * script and still is not after #6923. What changed is that it is now + * produced by fixtures on every PR, in a job with no label exemption + * (`check:changeset-gate-self-tests`, lint.yml's ESLint job — #6509/PR #6917). + * Fixtured is not the same as executed, and this note exists so the next + * reader does not read one as the other. + * + * ## The frontmatter dialects, measured against the real parser + * + * `majorPackagesIn` is a hand-written parser standing in for `@changesets/parse` + * (which is a third-party dep this file may not take). Standing in for it is + * only sound where the two agree, so they were compared rather than assumed — + * `@changesets/parse@0.4.3`, the version this repo resolves, on 2026-08-09: + * + * input | @changesets/parse | this file + * -----------------------------------|-------------------|------------------ + * "@objectstack/spec": major | major | caught + * '@objectstack/spec': major | major | caught + * docs: major (unquoted) | major | caught + * @objectstack/spec: major (unquoted)| THROWS invalid YAML | caught (harmless) + * CRLF line endings | major | caught + * a leading blank line before `---` | major | caught (see below) + * "@objectstack/spec": MAJOR | THROWS invalid type | caught (harmless) + * no closing `---` fence | THROWS missing fm | caught (harmless) + * "@objectstack/spec": major # note | major | MISSED (see below) + * + * Rows marked "harmless" are this file being STRICTER than changesets on a file + * changesets refuses outright: the guard names a major in a changeset that could + * never version anything. That direction costs an author one confusing message + * about a file that is already broken. The opposite direction is the one that + * matters, because it is silent. + * + * LEADING BLANK LINES (fixed in #6923). This parser used to require the fence on + * line 1 (`if (lines[0]?.trim() !== '---') return []`), so a changeset opening + * with one blank line declared, to this guard, nothing at all — while changesets + * honoured its `major` and promoted the whole lockstep group. Both sibling + * parsers (`check-empty-changeset.mjs`'s `declaredBumpsIn`, + * `check-adr-0087-registration.mjs`'s `parseChangeset`) already skipped leading + * blanks, and all three carry a comment saying the three read the same block — + * so this was also the one place that comment was false. It now skips them too. + * + * TRAILING YAML COMMENTS are still missed, and that is a KNOWN GAP recorded + * rather than implied: the entry regex ends `([A-Za-z]+)\s*$`, so + * `"@objectstack/spec": major # keep` matches nothing, while changesets reads it + * as a major. All three parsers in this family share the regex and therefore the + * gap, with a different consequence in each, so closing it is a family-wide + * change and not this file's to make alone. Filed as #7004; the fixture below + * pins the CURRENT behaviour so that closing it turns this file red on purpose + * rather than by surprise. + * + * ## RESIDUAL: an unreadable `.changeset/` still exits 0 + * + * `readChangesets()` returns `null` when the directory cannot be read at all, + * and `judge()` turns that into `no-changeset-dir` → exit 0. That is the #4690 + * shape — a gate that could not read its input reporting as "no violations" — + * and it is pinned below as current behaviour, not endorsed. It is recorded + * rather than fixed here because flipping it is a behaviour change to the + * enforcing half on the eve of the window where that half re-arms. The refactor + * does make it *distinguishable*: `no-changeset-dir` (could not read) and + * `clean` (read, found nothing) are now separate verdicts, which is the + * prerequisite for changing it. Filed as #7006. + * + * ## RESIDUAL: the enforcing half judges the STOCK, not the PR's diff + * + * Unrelated to the fixtures, measured by them, and deliberately not changed + * here. This script reads the whole `.changeset` directory with no branch + * point, so its verdict is a function of what main carries. At `changeset pre + * exit` there are 171 major-declaring changesets still on disk (measured at + * `d3e53f2d8`; pre-mode `changeset version` records them in `pre.json` rather + * than deleting them), and every unlabelled PR open in the window between that + * exit and the final `changeset version` would go red listing files it never + * touched. That is #6129's direction by another route, and what to do about it + * decides what this guard MEANS — whether it polices the author or the tree — + * so it is a contract call. Filed as #7005. */ -import { readFileSync, readdirSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(__dirname, '..'); -const changesetDir = resolve(repoRoot, '.changeset'); +const REPO_ROOT = resolve(__dirname, '..'); + +// ── Frontmatter ────────────────────────────────────────────────────────────── /** * Extract the YAML frontmatter block (between the first two `---` fences) and @@ -53,75 +143,501 @@ const changesetDir = resolve(repoRoot, '.changeset'); * A frontmatter line looks like: "@objectstack/spec": major * (single or double quotes, any surrounding whitespace). * + * The entry regex is deliberately the SAME shape `check-empty-changeset.mjs` + * and `check-adr-0087-registration.mjs` use. Three gates reading one block must + * agree on what counts as a declaration, or one of them is judging a different + * file than it appears to. See the dialect table in the header for where they + * agree with `@changesets/parse` and where they do not. + * * @param {string} text * @returns {string[]} */ -function majorPackagesIn(text) { +export function majorPackagesIn(text) { const lines = text.split(/\r?\n/); - if (lines[0]?.trim() !== '---') return []; + let i = 0; + while (i < lines.length && lines[i].trim() === '') i++; // tolerate leading blank lines + if (lines[i]?.trim() !== '---') return []; + const majors = []; - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - if (line.trim() === '---') break; // end of frontmatter + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].trim() === '---') break; // end of frontmatter // "": | '': | : - const m = /^\s*["']?([^"':]+)["']?\s*:\s*([A-Za-z]+)\s*$/.exec(line); + const m = /^\s*["']?([^"':]+)["']?\s*:\s*([A-Za-z]+)\s*$/.exec(lines[j]); if (m && m[2].toLowerCase() === 'major') majors.push(m[1].trim()); } return majors; } -let entries; -try { - entries = readdirSync(changesetDir); -} catch { - console.log('No .changeset directory found — nothing to check.'); - process.exit(0); +// ── The judgement ──────────────────────────────────────────────────────────── + +/** + * Decide what this run should do. + * + * Pure: every input is an argument, so `--self-test` exercises the real decision + * instead of a parallel imitation of it. That matters more here than in most of + * the family, because the branch this returns `enforce` from cannot be reached + * by ANY invocation of the real scan while the repo is in pre-mode. + * + * The verdicts, and the order they are decided in (the order is itself contract: + * a clean tree in pre-mode prints the ordinary tick, never the RC notice): + * + * no-changeset-dir the directory could not be read at all -> exit 0 (see RESIDUAL) + * clean read it, no `major` declared anywhere -> exit 0 + * exempt majors pending, but pre-mode is active -> exit 0 + notices + * enforce majors pending, pre-mode is NOT active -> exit 1 + * + * `pre` is whatever `.changeset/pre.json` parsed to, or `null` when it is + * absent, unreadable or malformed. All three of those collapse to `enforce`, + * which is the safe direction: an exemption is a licence to promote every + * package in the repo to a new major, and handing one out because a file could + * not be read is the #4690 anti-pattern pointed at the release train. + * + * @param {{ + * changesets: Map< string, string > | null, + * pre: { mode?: string, tag?: string } | null, + * }} input + * @returns {{ verdict: string, offenders: { file: string, majors: string[] }[], tag: string | null }} + */ +export function judge({ changesets, pre }) { + if (!changesets) return { verdict: 'no-changeset-dir', offenders: [], tag: null }; + + const offenders = []; + for (const [name, text] of changesets) { + const majors = majorPackagesIn(text); + if (majors.length) offenders.push({ file: `.changeset/${name}`, majors }); + } + + if (offenders.length === 0) return { verdict: 'clean', offenders: [], tag: null }; + if (pre?.mode === 'pre') return { verdict: 'exempt', offenders, tag: pre.tag ?? 'unknown' }; + return { verdict: 'enforce', offenders, tag: null }; +} + +// ── Reporting ──────────────────────────────────────────────────────────────── + +/** + * Render a verdict into the lines this script prints and the code it exits with. + * + * Separated from `judge` and from `console` so the self-test can assert the + * MESSAGE, not merely the exit code. On the day the enforcing half re-arms, its + * report is the only thing standing between a curator and a whole-stack major, + * and "exits 1" does not tell anyone which file to look at. + * + * @param {ReturnType< typeof judge >} result + * @returns {{ exitCode: number, stdout: string[], stderr: string[] }} + */ +export function render(result) { + const stdout = []; + const stderr = []; + + switch (result?.verdict) { + case 'no-changeset-dir': + stdout.push('No .changeset directory found — nothing to check.'); + return { exitCode: 0, stdout, stderr }; + + case 'clean': + stdout.push('✓ No `major` bumps in pending changesets.'); + return { exitCode: 0, stdout, stderr }; + + // RC exemption: in Changesets pre-release mode a `major` only yields a + // `X.0.0-.N` pre-release — the intended product of an RC window — and + // nothing final ships until `changeset pre exit`. Surface the pending majors + // for the RC curator, but do not fail. The guard re-arms once pre-mode + // exits: `changeset pre exit` rewrites pre.json's mode to `"exit"` + // (@changesets/pre@2.0.2, changesets-pre.cjs.js:117), which is not `pre`. + case 'exempt': + stdout.push( + `✓ Changesets is in pre-release mode (tag: ${result.tag}) — ` + + '`major` bumps are the expected product of an RC window; skipping the no-major guard.', + ); + for (const { file, majors } of result.offenders) { + stdout.push(`::notice file=${file}::pending major in ${file}: ${majors.join(', ')}`); + } + return { exitCode: 0, stdout, stderr }; + + case 'enforce': + stderr.push('⛔ Changeset(s) declare a `major` bump.\n'); + for (const { file, majors } of result.offenders) { + stderr.push(` ${file}`); + for (const pkg of majors) stderr.push(` - ${pkg}: major`); + } + stderr.push( + '\nEvery publishable package is in the Changesets `fixed` (lockstep) group, so a single\n' + + '`major` promotes the ENTIRE monorepo to a new major version. During the launch window\n' + + 'ship breaking changes as `minor` instead (they do not burn a major version number).\n' + + '\n' + + 'If a whole-stack major release is genuinely intended, add the `allow-major` label to\n' + + 'the PR to skip this check.', + ); + return { exitCode: 1, stdout, stderr }; + + default: + // Unreachable by construction, and exiting 1 anyway. A checker that cannot + // classify its own verdict has verified nothing, and the one thing it must + // not do is print a tick (#4690). + stderr.push( + `⛔ internal: check-changeset-no-major produced an unknown verdict ${JSON.stringify(result?.verdict ?? null)}. ` + + 'A guard that cannot classify its own input has verified nothing.', + ); + return { exitCode: 1, stdout, stderr }; + } +} + +// ── Reading the real tree ──────────────────────────────────────────────────── + +/** + * Every changeset in `/.changeset`, keyed by file name. + * + * `null` — never an empty Map — when the directory cannot be read, so the two + * facts stay distinguishable downstream. `README.md` is documentation, never a + * changeset. + * + * An individual file that cannot be read is deliberately NOT caught: it throws, + * which is loud. Swallowing it would drop a changeset from the scan and report + * the remainder as a pass. + * + * @param {string} root + * @returns {Map< string, string > | null} + */ +export function readChangesets(root) { + const dir = join(root, '.changeset'); + let entries; + try { + entries = readdirSync(dir); + } catch { + return null; + } + const changesets = new Map(); + for (const name of entries) { + if (!name.endsWith('.md') || name === 'README.md') continue; + changesets.set(name, readFileSync(join(dir, name), 'utf8')); + } + return changesets; } -const offenders = []; -for (const name of entries) { - if (!name.endsWith('.md') || name === 'README.md') continue; - const file = join(changesetDir, name); - const majors = majorPackagesIn(readFileSync(file, 'utf8')); - if (majors.length) offenders.push({ file: `.changeset/${name}`, majors }); +/** + * `/.changeset/pre.json`, or `null` when it is absent, unreadable or not + * JSON. All three collapse to the same thing for `judge`: no exemption. + * + * @param {string} root + * @returns {{ mode?: string, tag?: string } | null} + */ +export function readPre(root) { + try { + return JSON.parse(readFileSync(join(root, '.changeset', 'pre.json'), 'utf8')); + } catch { + return null; + } } -if (offenders.length === 0) { - console.log('✓ No `major` bumps in pending changesets.'); - process.exit(0); +// ── The scan ───────────────────────────────────────────────────────────────── + +function main() { + const result = judge({ changesets: readChangesets(REPO_ROOT), pre: readPre(REPO_ROOT) }); + const { exitCode, stdout, stderr } = render(result); + for (const line of stdout) console.log(line); + for (const line of stderr) console.error(line); + process.exit(exitCode); } -// RC exemption: in Changesets pre-release mode a `major` only yields a -// `X.0.0-.N` pre-release — the intended product of an RC window — and -// nothing final ships until `changeset pre exit`. Surface the pending majors -// for the RC curator, but do not fail. The guard re-arms once pre-mode exits. -try { - const pre = JSON.parse(readFileSync(join(changesetDir, 'pre.json'), 'utf8')); - if (pre?.mode === 'pre') { - console.log( - `✓ Changesets is in pre-release mode (tag: ${pre.tag ?? 'unknown'}) — ` + - '`major` bumps are the expected product of an RC window; skipping the no-major guard.', +// ── Self-test ──────────────────────────────────────────────────────────────── + +function selfTest() { + const failures = []; + let checked = 0; + const assert = (condition, description) => { + checked += 1; + if (!condition) failures.push(description); + }; + + /** A corpus of changesets, as `judge` wants it. */ + const corpus = (files) => new Map(Object.entries(files)); + + const MAJOR = '---\n"@objectstack/spec": major\n---\n\nbody\n'; + const MINOR = '---\n"@objectstack/spec": minor\n---\n\nbody\n'; + + /** + * A dialect that must be CAUGHT, asserted against a control that differs by + * exactly the dialect under test. + * + * The paired control is the point. A synthetic fixture has no anchor to go + * stale, but it has the same failure mode by another route: a typo in the + * fixture text yields a file that declares nothing, and "declares nothing" + * satisfies every negative assertion for the wrong reason. So each negative + * below states which positive it differs from, and each positive is asserted + * to name the package — never merely to be non-empty. + */ + const caught = (label, text, expected) => { + const majors = majorPackagesIn(text); + assert( + majors.length === expected.length && expected.every((p) => majors.includes(p)), + `parser: ${label} ⇒ ${JSON.stringify(expected)} — got ${JSON.stringify(majors)}`, + ); + }; + + // ── The three quoting dialects the header names ─────────────────────────── + caught('a double-quoted name', MAJOR, ['@objectstack/spec']); + caught('a single-quoted name', "---\n'@objectstack/spec': major\n---\n\nbody\n", ['@objectstack/spec']); + caught('an unquoted name', '---\ndocs: major\n---\n\nbody\n', ['docs']); + caught('CRLF line endings', '---\r\n"@objectstack/spec": major\r\n---\r\n\r\nbody\r\n', ['@objectstack/spec']); + caught('mixed quoting in one block', '---\n"@objectstack/a": major\n\'@objectstack/b\': major\n---\n\nbody\n', [ + '@objectstack/a', + '@objectstack/b', + ]); + caught('a major among non-majors', '---\n"@objectstack/a": patch\n"@objectstack/b": major\n"@objectstack/c": minor\n---\n\nbody\n', [ + '@objectstack/b', + ]); + + // Case-insensitive, because the comparison is `.toLowerCase() === 'major'`. + // Measured: @changesets/parse THROWS on these rather than accepting them, so + // catching them is this file being stricter on a file that cannot version + // anything — a message about an already-broken file, never a missed major. + caught('an uppercase MAJOR', '---\n"@objectstack/spec": MAJOR\n---\n\nbody\n', ['@objectstack/spec']); + caught('a capitalised Major', '---\n"@objectstack/spec": Major\n---\n\nbody\n', ['@objectstack/spec']); + + // ── THE FIX (#6923): a leading blank line ───────────────────────────────── + // Predicted direction on reverse verification: restoring the old + // `lines[0]?.trim() !== '---'` turns exactly these two red. Measured with + // @changesets/parse@0.4.3: both of these DO release a major, so a miss here is + // a whole-stack major promoted past a guard that printed a tick. + caught('a leading blank line before the fence', '\n' + MAJOR, ['@objectstack/spec']); + caught('two leading blank lines', '\n\n' + MAJOR, ['@objectstack/spec']); + caught('a leading blank line, single-quoted', "\n---\n'@objectstack/spec': major\n---\n\nbody\n", ['@objectstack/spec']); + + // ── What must NOT be caught, each paired with its control ───────────────── + assert(majorPackagesIn(MINOR).length === 0, 'parser: a `minor` bump is not a major'); + assert(majorPackagesIn('---\n"@objectstack/spec": patch\n---\n\nbody\n').length === 0, 'parser: a `patch` bump is not a major'); + // Control for both: the SAME text with `major` in the bump slot is caught, so + // the two assertions above cannot be passing because the fixture parses as + // nothing at all. + assert(majorPackagesIn(MAJOR).length === 1, "parser: control — the same shape with `major` IS caught (so the two negatives above are about the bump word, not a broken fixture)"); + + // The word `major` after the closing fence is prose, not a declaration. Same + // control discipline: the identical entry ABOVE the fence is caught. + const bodyOnly = '---\n"@objectstack/spec": minor\n---\n\nThis is a major rewrite.\n"@objectstack/other": major\n'; + assert(majorPackagesIn(bodyOnly).length === 0, 'parser: an entry-shaped line in the BODY is not a declaration'); + assert( + majorPackagesIn('---\n"@objectstack/spec": minor\n"@objectstack/other": major\n---\n\nThis is a major rewrite.\n').length === 1, + 'parser: control — the same line INSIDE the fence is caught (so the body assertion is about position, not about the line)', + ); + + assert(majorPackagesIn('no fence at all\n"@objectstack/spec": major\n').length === 0, 'parser: a file with no opening fence declares nothing'); + assert(majorPackagesIn('').length === 0, 'parser: an empty file declares nothing'); + assert(majorPackagesIn('---\n---\n\nbody\n').length === 0, 'parser: an empty frontmatter block declares nothing'); + assert( + majorPackagesIn('---\n- @objectstack/spec major\nsome prose\n---\n\nbody\n').length === 0, + 'parser: lines that are not `: ` are not declarations', + ); + + // KNOWN GAP, pinned as current behaviour rather than endorsed. Measured: + // @changesets/parse@0.4.3 reads this as a real major. The entry regex ends + // `([A-Za-z]+)\s*$`, so the trailing comment defeats it — in all three parsers + // of this family, which is why closing it is not this file's change to make + // alone. When it IS closed, this assertion goes red on purpose: flip it, do + // not delete it. + assert( + majorPackagesIn('---\n"@objectstack/spec": major # keep\n---\n\nbody\n').length === 0, + 'parser: KNOWN GAP (#7004) — a trailing YAML comment hides a major from this parser (changesets reads it as a major); flip this when the family-wide regex is fixed, never delete it', + ); + + // ── The exemption switch, in BOTH directions ────────────────────────────── + // This is the half that no CI run has ever executed. Everything below drives + // it directly. + const pending = corpus({ 'a.md': MAJOR, 'b.md': MINOR }); + + const exempt = judge({ changesets: pending, pre: { mode: 'pre', tag: 'rc' } }); + assert(exempt.verdict === 'exempt', `pre-mode with a pending major ⇒ exempt — got ${exempt.verdict}`); + assert(exempt.offenders.length === 1 && exempt.offenders[0].file === '.changeset/a.md', 'the exempt verdict still names the offender, so the RC curator can see it'); + assert(render(exempt).exitCode === 0, 'pre-mode exits 0'); + assert( + render(exempt).stdout.some((l) => l.includes('pre-release mode (tag: rc)')) && + render(exempt).stdout.some((l) => l === '::notice file=.changeset/a.md::pending major in .changeset/a.md: @objectstack/spec'), + 'pre-mode prints the RC notice AND one ::notice per offender', + ); + assert(render(exempt).stderr.length === 0, 'pre-mode writes nothing to stderr — it is not a complaint'); + assert(judge({ changesets: pending, pre: { mode: 'pre' } }).tag === 'unknown', 'a pre.json with no tag reports the tag as `unknown` rather than `undefined`'); + + // THE ENFORCING HALF. `changeset pre exit` rewrites mode to `"exit"` + // (@changesets/pre@2.0.2), so this exact input is the shape of the first run + // after the window closes. + const enforced = judge({ changesets: pending, pre: { mode: 'exit' } }); + assert(enforced.verdict === 'enforce', `mode "exit" with a pending major ⇒ enforce — got ${enforced.verdict}`); + assert(render(enforced).exitCode === 1, 'the enforcing half exits 1 — the whole point of the guard, and unreached on CI while the repo is in pre-mode'); + assert( + render(enforced).stderr.some((l) => l.includes('⛔ Changeset(s) declare a `major` bump.')) && + render(enforced).stderr.includes(' .changeset/a.md') && + render(enforced).stderr.includes(' - @objectstack/spec: major'), + 'the offenders report names every offending file and every package in it', + ); + assert( + render(enforced).stderr.some((l) => l.includes('allow-major')), + 'the offenders report names the `allow-major` escape hatch — a red with no route out is a wall, not a gate', + ); + assert( + !render(enforced).stderr.some((l) => l.includes('.changeset/b.md')), + 'a `minor`-only changeset is not listed as an offender', + ); + + // Every other reading of pre.json is also "no exemption". An exemption is a + // licence to major the whole repo; it is granted only by an explicit + // `"mode": "pre"`, never by an absence. + for (const [label, pre] of [ + ['no pre.json at all', null], + ['pre.json that did not parse', null], + ['pre.json with no mode key', {}], + ['mode: exit', { mode: 'exit' }], + ['mode: some future spelling', { mode: 'paused' }], + ['mode: PRE (wrong case)', { mode: 'PRE' }], + ]) { + assert(judge({ changesets: pending, pre }).verdict === 'enforce', `${label} ⇒ enforce, never an exemption`); + } + + // ── Order of operations is contract ─────────────────────────────────────── + const cleanInPre = judge({ changesets: corpus({ 'a.md': MINOR }), pre: { mode: 'pre', tag: 'rc' } }); + assert(cleanInPre.verdict === 'clean', 'no majors in pre-mode ⇒ the ordinary tick, not the RC notice'); + assert( + render(cleanInPre).stdout.length === 1 && render(cleanInPre).stdout[0] === '✓ No `major` bumps in pending changesets.', + 'a clean tree prints exactly one line and never mentions the RC window', + ); + assert(judge({ changesets: corpus({ 'a.md': MINOR }), pre: { mode: 'exit' } }).verdict === 'clean', 'no majors outside pre-mode ⇒ clean'); + assert(judge({ changesets: corpus({}), pre: null }).verdict === 'clean', 'an empty .changeset directory ⇒ clean (read it, found nothing)'); + + // ── Missing input is distinguishable from empty input (#4690) ───────────── + const unreadable = judge({ changesets: null, pre: { mode: 'exit' } }); + assert(unreadable.verdict === 'no-changeset-dir', 'a directory that could not be read is its OWN verdict, not `clean`'); + assert( + render(unreadable).exitCode === 0, + 'RESIDUAL pinned, not endorsed: an unreadable .changeset still exits 0 (#4690 shape). Filed as #7006 — flip this assertion together with the behaviour, never alone', + ); + assert(render({ verdict: 'something-new' }).exitCode === 1, 'an unknown verdict exits 1 — a guard that cannot classify itself prints no tick'); + assert(render(undefined).exitCode === 1, 'no verdict at all exits 1'); + + // ── The readers actually reach the real tree ────────────────────────────── + // The phantom-pass risk specific to THIS script: `readChangesets` resolves + // `.changeset` from the script's own location, and if that resolution ever + // broke, every run would return `no-changeset-dir` and print a tick forever. + { + const real = readChangesets(REPO_ROOT); + assert(real instanceof Map, 'reader: the real .changeset directory is reachable from this script (a null here is a permanent silent pass)'); + assert(real !== null && real.size > 0, `reader: the real .changeset directory is non-empty — got ${real === null ? 'null' : real.size} entries`); + assert(real !== null && !real.has('README.md'), 'reader: .changeset/README.md is documentation, never a changeset'); + assert(existsSync(join(REPO_ROOT, '.changeset', 'README.md')), 'reader: control — that README really exists, so the exclusion above is exercised rather than vacuous'); + assert(real !== null && [...real.keys()].every((k) => k.endsWith('.md')), 'reader: only .md files are read (pre.json and config.json are not changesets)'); + const realPre = readPre(REPO_ROOT); + assert(realPre !== null && typeof realPre === 'object', 'reader: the real .changeset/pre.json is readable and parses'); + } + + const empty = mkdtempSync(join(tmpdir(), 'changeset-no-major-')); + try { + assert(readChangesets(empty) === null, 'reader: a root with no .changeset directory reads as null, never as an empty Map'); + assert(readPre(empty) === null, 'reader: an absent pre.json reads as null (⇒ no exemption)'); + mkdirSync(join(empty, '.changeset'), { recursive: true }); + assert(readChangesets(empty) instanceof Map && readChangesets(empty).size === 0, 'reader: an existing but empty .changeset directory reads as an empty Map'); + writeFileSync(join(empty, '.changeset', 'pre.json'), '{ not json'); + assert(readPre(empty) === null, 'reader: a malformed pre.json reads as null (⇒ no exemption), never as a partial object'); + writeFileSync(join(empty, '.changeset', 'pre.json'), '{"mode":"pre","tag":"rc"}'); + assert(readPre(empty)?.mode === 'pre', 'reader: control — a well-formed pre.json DOES parse, so the two nulls above are about the input, not a broken reader'); + writeFileSync(join(empty, '.changeset', 'x.md'), MAJOR); + writeFileSync(join(empty, '.changeset', 'README.md'), MAJOR); + const scanned = readChangesets(empty); + assert(scanned.size === 1 && scanned.has('x.md'), 'reader: end to end, a real directory yields exactly its changesets'); + assert(judge({ changesets: scanned, pre: readPre(empty) }).verdict === 'exempt', 'end to end: a real directory + a real pre.json reach the exemption'); + writeFileSync(join(empty, '.changeset', 'pre.json'), '{"mode":"exit"}'); + assert(judge({ changesets: readChangesets(empty), pre: readPre(empty) }).verdict === 'enforce', 'end to end: the same directory with mode "exit" reaches the enforcing half'); + } finally { + rmSync(empty, { recursive: true, force: true }); + } + + // ── The wiring: these fixtures must actually run on every PR ────────────── + // + // Same shape and the same honesty as check-empty-changeset's consumer block + // (#6509): assertions are only as real as the step that runs them, and a gate + // nobody invokes is #4690's phantom check with extra ceremony. + // + // This file is the third member of that family and the last to be wired. The + // two halves it pins are DIFFERENT places on purpose: + // + // * the SELF-TEST runs in lint.yml's ESLint job, which has no PR-level + // exemption — that is what #6509/PR #6917 built the step for; + // * the REAL SCAN stays in pr-automation.yml, because its `allow-major` and + // `skip-changeset` exemptions are deliberate. Moving the real scan into + // lint.yml would silently revoke the escape hatch the offenders report + // tells authors to use. + // + // RESIDUAL, recorded rather than implied: this block is run BY the step it + // pins, so a PR deleting both the step and this script is not caught here. + // That is a deletion plainly visible in a `.github/**` diff rather than a + // silent no-op. + { + const uncommented = (text) => text.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n'); + + const lintPath = join(REPO_ROOT, '.github/workflows/lint.yml'); + assert(existsSync(lintPath), 'wiring: .github/workflows/lint.yml must exist — it is where this self-test runs unconditionally (#6509)'); + const lintYaml = existsSync(lintPath) ? readFileSync(lintPath, 'utf8') : ''; + + const lintJobStart = lintYaml.indexOf('\n lint:'); + const lintJobEnd = lintYaml.indexOf('\n typecheck:'); + const lintJob = uncommented(lintJobStart === -1 ? '' : lintYaml.slice(lintJobStart, lintJobEnd === -1 ? undefined : lintJobEnd)); + // The anti-vacuous-green guard #6983 wrote down: an anchor that stops + // matching yields an empty slice, and every assertion below it would then be + // judging an empty string and passing for the wrong reason, permanently and + // silently. So the slice is asserted to have found something first. + assert(lintJob.length > 0, 'wiring: the `lint:` job could not be sliced out of lint.yml — its anchors went stale, and every assertion below would judge an empty string'); + + const steps = lintJob.split(/\n(?= - name: )/); + const wired = steps.filter((s) => /run: pnpm check:changeset-gate-self-tests\b/.test(s)); + assert(wired.length === 1, `wiring: lint.yml's ESLint job must run \`pnpm check:changeset-gate-self-tests\` exactly once (found ${wired.length})`); + assert( + wired.every((s) => !/^\s*if:/m.test(s)), + 'wiring: that step must carry NO `if:` — whatever a condition reads is a way for a PR to arrange that these fixtures do not run on it, which is #6509 itself', ); - for (const { file, majors } of offenders) { - console.log(`::notice file=${file}::pending major in ${file}: ${majors.join(', ')}`); + + const pkgPath = join(REPO_ROOT, 'package.json'); + assert(existsSync(pkgPath), 'wiring: the repository root package.json must exist — it carries the script lint.yml runs'); + let wiring = ''; + try { + wiring = JSON.parse(existsSync(pkgPath) ? readFileSync(pkgPath, 'utf8') : '{}').scripts?.['check:changeset-gate-self-tests'] ?? ''; + } catch { + wiring = ''; } - process.exit(0); + assert( + /check-changeset-no-major\.mjs --self-test/.test(wiring), + 'wiring: `check:changeset-gate-self-tests` must run `check-changeset-no-major.mjs --self-test` — the step in lint.yml is only as real as the script it resolves to (#6923)', + ); + assert( + !/check-changeset-no-major\.mjs(?! --self-test)/.test(wiring), + 'wiring: `check:changeset-gate-self-tests` must invoke this file ONLY with `--self-test` — the real scan belongs in pr-automation.yml, where its `allow-major` exemption is', + ); + + // The real scan's home. If this moves or vanishes, the guard stops guarding + // and nothing else in the repo would say so. + const prAutomationPath = join(REPO_ROOT, '.github/workflows/pr-automation.yml'); + assert(existsSync(prAutomationPath), 'wiring: .github/workflows/pr-automation.yml must exist — it is where the REAL scan runs'); + const prAutomation = uncommented(existsSync(prAutomationPath) ? readFileSync(prAutomationPath, 'utf8') : ''); + assert( + /run: node scripts\/check-changeset-no-major\.mjs\s*$/m.test(prAutomation), + 'wiring: pr-automation.yml must still invoke the real scan (`node scripts/check-changeset-no-major.mjs`) — the self-test fixtures replace none of the enforcement', + ); + assert( + !/check-changeset-no-major\.mjs/.test(uncommented(lintYaml)), + 'wiring: lint.yml must NOT invoke this script directly — the self-test reaches it through `check:changeset-gate-self-tests`, and a real scan here would bypass the `allow-major` escape hatch its own error message prescribes', + ); } -} catch { - // No readable `.changeset/pre.json` → not in pre-mode → fall through to the guard. + + if (failures.length > 0) { + console.error(`✗ check-changeset-no-major --self-test — ${failures.length} failure(s)\n`); + for (const failure of failures) console.error(` • ${failure}`); + process.exit(1); + } + console.log( + `✓ check-changeset-no-major --self-test: ${checked} assertions ` + + '(frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #4690 reader pins + the wiring).', + ); } -console.error('⛔ Changeset(s) declare a `major` bump.\n'); -for (const { file, majors } of offenders) { - console.error(` ${file}`); - for (const pkg of majors) console.error(` - ${pkg}: major`); +// ── main ───────────────────────────────────────────────────────────────────── + +if (process.argv.includes('--self-test')) { + selfTest(); +} else { + main(); } -console.error( - '\nEvery publishable package is in the Changesets `fixed` (lockstep) group, so a single\n' + - '`major` promotes the ENTIRE monorepo to a new major version. During the launch window\n' + - 'ship breaking changes as `minor` instead (they do not burn a major version number).\n' + - '\n' + - 'If a whole-stack major release is genuinely intended, add the `allow-major` label to\n' + - 'the PR to skip this check.', -); -process.exit(1); diff --git a/scripts/check-empty-changeset.mjs b/scripts/check-empty-changeset.mjs index c7bfdebf0c..dd097af68a 100644 --- a/scripts/check-empty-changeset.mjs +++ b/scripts/check-empty-changeset.mjs @@ -1021,10 +1021,19 @@ function selfTest() { ); assert( /check-adr-0087-registration\.mjs --self-test/.test(wiring), - 'consumer: `check:changeset-gate-self-tests` must run `check-adr-0087-registration.mjs --self-test` too -- both checkers live in the same exempted job and both were unwired by it (#6509). `check-changeset-no-major.mjs` is deliberately absent: it has no `--self-test` to run.', + 'consumer: `check:changeset-gate-self-tests` must run `check-adr-0087-registration.mjs --self-test` too -- both checkers live in the same exempted job and both were unwired by it (#6509).', ); + // The third member of the family, added in #6923. It was absent from this + // step until then for a stated reason -- it had no `--self-test` to run -- + // and that reason expired the moment it grew one. Its REAL scan stays in + // pr-automation.yml (its `allow-major` escape hatch lives there), so what + // joins this step is the self-test half only, exactly like the other two. assert( - !/check-(?:empty-changeset|adr-0087-registration)\.mjs(?! --self-test)/.test(wiring), + /check-changeset-no-major\.mjs --self-test/.test(wiring), + 'consumer: `check:changeset-gate-self-tests` must run `check-changeset-no-major.mjs --self-test` as well -- it is the third checker of this family, and its fixtures land in the same exemption-free job (#6923)', + ); + assert( + !/check-(?:empty-changeset|adr-0087-registration|changeset-no-major)\.mjs(?! --self-test)/.test(wiring), 'consumer: every invocation in `check:changeset-gate-self-tests` must carry `--self-test` -- chaining a real scan into the lint job is the #6129 direction this split exists to avoid', ); }