diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index 4bf52ad4db..645394a54b 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -822,8 +822,21 @@ jobs: # never the event payload. Its comment block carries the argument: what the # payload read cost, where this diverges from #5580's `skip-changeset` read # and why, the residual it leaves, and what re-arms the whole thing. + # + # `--base` takes the same merge base the two gates above take, for the + # same #6129 reason one defect along (#7005). This script used to read + # the whole `.changeset` directory with no branch point, so its verdict + # was a function of what main carried rather than of what the author + # wrote. That was invisible while pre-mode held and would have become + # visible all at once at `changeset pre exit`: 171 consumed-but-undeleted + # major changesets sit on main until the post-exit `changeset version` + # removes them, so every unlabelled PR open in that window would have + # gone red listing files it never touched, with `allow-major` -- a label + # meaning "a whole-stack major is intended HERE" -- as its only way out. if: >- steps.labels.outputs.skip != 'true' && steps.labels_settled.outputs.skip != 'true' && steps.allow_major.outputs.allow != 'true' - run: node scripts/check-changeset-no-major.mjs + env: + MERGE_BASE: ${{ steps.diffbase.outputs.merge_base }} + run: node scripts/check-changeset-no-major.mjs --base "$MERGE_BASE" diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index dbdd3c0a60..143d9ee291 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -1,9 +1,17 @@ #!/usr/bin/env node /** - * Launch-window guard: rejects any changeset that declares a `major` bump. + * Launch-window guard: a PR may not INTRODUCE a 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 + * Run: node scripts/check-changeset-no-major.mjs --base [--head ] + * node scripts/check-changeset-no-major.mjs # base defaults to origin/main + * node scripts/check-changeset-no-major.mjs --self-test # verify the checker itself + * node scripts/check-changeset-no-major.mjs --list # audit the whole .changeset dir + * + * `--base` names the BRANCH POINT to judge against, not the first commit of the + * diff: the scan always starts at `merge-base(, )`. See "Where the + * diff starts" below — getting this wrong is #6129, and #7005 is this file's own + * instance of it. * * WHY THIS EXISTS * --------------- @@ -20,38 +28,138 @@ * of tribal, so an over-strict `major` marker can never again turn an ordinary * PR into a whole-stack major release by accident. * - * Exits with code 1 (and a clear list of offenders) if any changeset frontmatter - * bumps a package `major`. + * Exits with code 1 (and a clear list of offenders) if the diff introduces a + * changeset that bumps a package `major`. * * RC EXEMPTION: when Changesets is in pre-release mode (`.changeset/pre.json` * with `"mode": "pre"`, entered via `changeset pre enter `), a `major` * bump only ever produces a `X.0.0-.N` PRE-RELEASE version — nothing final * publishes until `changeset pre exit`. Accumulating the next major's breaking * changes is precisely what an RC window is FOR, so this guard stands aside for - * the duration and re-arms automatically once pre-mode is exited. The pending - * majors are still printed (informationally) so the RC curator can eyeball them. + * the duration and re-arms automatically once pre-mode is exited. The majors the + * diff introduces are still printed (informationally) so the RC curator can see + * them go by; the whole pending stock is `--list`. * * ESCAPE HATCH: outside pre-mode, when a major release is genuinely intended, * gate this check off in CI with the `allow-major` PR label (see * `.github/workflows/pr-automation.yml`). * - * The script intentionally has zero third-party dependencies so it can run in - * minimal CI environments before `pnpm install`. + * The script has zero THIRD-PARTY dependencies, so it still runs before + * `pnpm install`. It does now depend on `git` and on being run inside the + * repository with a resolvable base — see "The cost of the branch point" below. + * + * ## Where the diff starts (#7005, and it is #6129 again) + * + * This guard used to read the whole `.changeset` directory (`readdirSync`, no + * branch point) and fail if ANY pending changeset declared a `major`. Its + * verdict was therefore a function of what main carried, not of what the author + * wrote — the same defect `check-empty-changeset.mjs` carries a long note about, + * reached by a different route: not a frozen base ref, but no base ref at all. + * + * It went unnoticed because the enforcing half has never run: the RC exemption + * above stands the guard down for the whole pre-release window. What made it + * urgent is that the window ENDS. Measured on `origin/main` @ `d3e53f2d8`, with + * `pre.json` still at `"mode": "pre"`: + * + * total .changeset/*.md (excl README): 1552 + * FILES declaring a major: 171 + * total major package entries: 222 + * pre.changesets recorded: 1279 + * + * Those files are on disk because pre-mode `changeset version` does not delete + * the changesets it consumes — it records them in `pre.json.changesets` so the + * final release can re-apply them. Only the POST-EXIT `changeset version` + * deletes them. So `changeset pre exit` rewrites the mode to `"exit"` + * (@changesets/pre@2.0.2, `changesets-pre.cjs.js:117`), that commit lands on + * main, and from that moment until the Version PR merges, the stock-scoped guard + * would have failed EVERY unlabelled PR in the repo — each one listing 171 files + * it never touched, with `allow-major` as the only route out. That label's own + * error message says "a whole-stack major release is genuinely intended", which + * is false for a PR fixing a typo, so the escape hatch would have meant + * something different from what it says for the duration of the window. + * + * The fix is the sibling's, deliberately rather than coincidentally: judge only + * what the diff INTRODUCES, starting at `merge-base(base, head)` and never at + * `base` itself. Both halves of #6129's argument apply here unchanged — + * + * - fed a FROZEN commit, every changeset main gained while the PR sat open + * reads as added by this PR; + * - fed a moving BRANCH, a two-dot diff misreads DELETIONS on the base branch + * as additions on this one — and the post-exit `changeset version` deleting + * all 1279 consumed changesets at once is exactly that event. + * + * `merge-base(X, head)` is `X` again whenever `X` is already the branch point, so + * a caller handing over an exact merge base loses nothing by this. + * + * The stock is therefore exempt with no list and no maintenance: "absent-or- + * non-major at the branch point" says it once, where a roster of 171 names would + * be a high-water mark that rots on the first merge (the #5471 shape). + * + * What this does NOT change: at `changeset pre exit` a whole-stack major really + * IS intended, and the release's own Version PR is what carries it. That PR is + * exempt at the job level (`changeset-check` skips `changeset-release/main`), so + * the intended major still lands. What moves is who pays: the release, not the + * author of an unrelated PR. * - * ## What `--self-test` covers, and what it does NOT (#6923) + * ## The three diff rows, and why `M` and `R` are judged rather than skipped * - * 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. + * A added, declares a major at head -> offence + * M declares a major at head, not at the branch point -> offence (majored in place) + * M declares the same major at the branch point -> exempt (stock, prose edited) + * R renamed AND newly declares a major -> offence + * R renamed, same major as at the branch point -> exempt (stock, moved) + * * no major at head -> ok * - * 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. + * Row 2 removes the obvious bypass: taking a stock `minor` changeset and editing + * the bump word to `major` introduces a brand-new whole-stack major — exactly + * the harm — while `--diff-filter=A` alone sees nothing. Row 3 keeps the stock + * exempt when a PR legitimately edits an existing major changeset's prose. + * + * The comparison is per PACKAGE, not per file, so a PR that adds + * `"@objectstack/cli": major` to a changeset already declaring + * `"@objectstack/spec": major` is reported for `@objectstack/cli` alone. The + * report naming only what the PR introduced is the entire point of the card. + * + * `R` is where this file diverges from its two siblings by one letter: + * `check-empty-changeset.mjs` and `check-adr-0087-registration.mjs` both use + * `--diff-filter=AM`. Measured on git 2.43.0, renaming `.changeset/old.md` to + * `.changeset/new.md` while flipping its bump to `major` reports as + * `R075 .changeset/old.md .changeset/new.md` and is dropped entirely by `AM` — + * a silent bypass. `AMR` plus reading the base side at the OLD path closes it + * and costs nothing, because a pure rename compares equal and stays exempt. The + * two siblings have the same hole in their own directions; filed separately + * rather than fixed here, because their fixtures and messages are theirs. + * + * ## The cost of the branch point, stated rather than slipped in + * + * This file used to have no dependency on `git` at all, which let it run in a + * checkout with no history. It now shells out to `git merge-base`, `git diff` + * and `git show`. That is a real reduction in where it can run, and it is the + * price of the fix: "what this PR introduces" is a claim about one side of a + * fork, and there is no way to evaluate it without the fork. The sibling already + * pays exactly this cost for exactly this reason. Zero THIRD-PARTY dependencies + * still holds — `node:child_process` and the `git` binary are both already + * required by the two steps that run beside this one. + * + * A base that cannot be resolved, or that has no merge base with head, exits 1 + * rather than 0 (#4690): a gate that cannot read its input has verified nothing, + * and exiting 0 there reads as "no violations" in every checks list. + * + * ## What `--self-test` covers, and what it does NOT (#6923, still true) + * + * Read this before trusting a green tick from this file. The enforcing half it + * fixtures is still, on CI, unexecuted — and #7005 did not change that. + * + * COVERED — every decision this file makes: the frontmatter dialects, the + * pre-mode/exit-mode switch in BOTH directions, the diff scoping driven + * through real temp git repositories (including a real `refs/pull/N/merge` + * shape with a base branch that keeps moving), 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 + * the real scan below still 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 #7005. What #6923 changed is that it is * 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 @@ -120,32 +228,25 @@ * change. Measured after: 19 shapes changesets ACCEPTS now agree, 0 regressions, * and every surviving difference is on a file changesets throws on. * - * ## 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. + * ## RESOLVED HERE: the unreadable-input residual (#7006) + * + * The stock-scoped version returned a `no-changeset-dir` verdict when + * `readdirSync('.changeset')` failed, and rendered it as exit 0 — the #4690 + * shape, a gate that could not read its input reporting "no violations". #7008 + * pinned that as current behaviour rather than endorsing it, and filed #7006 to + * flip it. + * + * Diff scoping dissolves it rather than fixing it: the enforcing path no longer + * reads the directory at all, so there is no `no-changeset-dir` verdict left to + * exit 0 from. Its replacement is `unreadable-diff`, and that one exits 1. Every + * way the input can now go missing — an unresolvable `--base`, no merge base at + * all, a `git` that fails — is a failure. The self-test assertion #7008 wrote as + * a pin on exit 0 is FLIPPED below, not deleted, so the change of direction is + * visible in the diff. `readChangesets` survives only to serve `--list`, where + * "no .changeset directory" is a report, not a verdict. */ +import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -193,6 +294,128 @@ export function majorPackagesIn(text) { return majors; } +/** `.changeset/README.md` is documentation, never a changeset. */ +const isChangesetFile = (p) => p.startsWith('.changeset/') && p.endsWith('.md') && !p.endsWith('/README.md'); + +// ── git helpers ────────────────────────────────────────────────────────────── +// +// Copied in shape from `check-empty-changeset.mjs` on purpose. Two gates that +// answer "what did this PR introduce" must start their diff at the same commit, +// or one of them is judging a different side of the fork than the other. + +function git(args, cwd, { quiet = false } = {}) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + // `execFileSync` inherits the child's stderr by default. That is right for + // every call here except `git show` on a path that is absent at the rev, + // where "absent" is an ANSWER rather than an error and git's `fatal: path + // ... exists on disk, but not in ` would print on a perfectly ordinary + // run — noise on a gate's output reads as a gate failing. + ...(quiet ? { stdio: ['ignore', 'pipe', 'ignore'] } : {}), + }); +} + +/** File contents at a rev, or `null` when the path does not exist there. */ +function showOrNull(rev, path, cwd) { + try { + return git(['show', `${rev}:${path}`], cwd, { quiet: true }); + } catch { + return null; + } +} + +/** Resolve a ref to a commit sha, or `null`. */ +export function resolveCommit(ref, cwd) { + try { + return git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd).trim() || null; + } catch { + return null; + } +} + +/** + * The commit the diff actually starts at: the merge base of `base` and `head`. + * `null` when the two have no common ancestor — the caller fails on that rather + * than falling back to `base`, see "Where the diff starts" (#6129 / #7005). + * + * @param {string} base + * @param {string} head + * @param {string} cwd + * @returns {string|null} + */ +export function mergeBase(base, head, cwd) { + try { + return git(['merge-base', base, head], cwd).trim() || null; + } catch { + return null; + } +} + +// ── The scan ───────────────────────────────────────────────────────────────── + +/** + * The `major` declarations this diff INTRODUCES, per file. + * + * `base` is the branch point to judge against; the diff itself starts at + * `merge-base(base, head)`, which is what makes the verdict a function of THIS + * side of the fork alone (#6129 / #7005). Resolving it HERE rather than in the + * caller is deliberate: this is the function the self-test drives, and a + * correction that lived in the CLI could be dropped from it without a single + * fixture noticing. + * + * @param {{ cwd: string, base: string, head?: string }} opts + * @returns {{ introduced: { file: string, majors: string[] }[], exempt: string[], base: string }} + * @throws when `base` and `head` have no merge base (#4690: not a pass) + */ +export function scan({ cwd, base, head = 'HEAD' }) { + const from = mergeBase(base, head, cwd); + if (!from) { + throw new Error( + `no merge base between '${base}' and '${head}' — the diff has no trustworthy starting point. ` + + 'Refusing to fall back to the raw base, which is the #6129 defect.', + ); + } + // `AMR`, one letter more than the two siblings: see "The three diff rows" in + // the header for the measured rename bypass `AM` leaves open. + const out = git(['diff', '--name-status', '--diff-filter=AMR', from, head, '--', '.changeset/*.md'], cwd); + + const introduced = []; + const exempt = []; + + for (const line of out.split('\n')) { + if (!line.trim()) continue; + const fields = line.split('\t'); + // `R` is `R\t\t`; `A` and `M` are `\t`. + const status = fields[0][0]; + const file = status === 'R' ? fields[2] : fields[1]; + // What to compare against at the branch point. For `A` this path does not + // exist there and `showOrNull` returns null, which is the right answer; for + // `R` it is the pre-rename name, which is the whole reason `R` is readable. + const basePath = fields[1]; + if (!file || !isChangesetFile(file)) continue; + + const headText = showOrNull(head, file, cwd); + if (headText === null) continue; // vanished under us; nothing to judge + const majors = majorPackagesIn(headText); + if (majors.length === 0) continue; + + // `A` means the path is not at the branch point at all, so there is nothing + // to read and nothing it could already have declared. + const baseText = status === 'A' ? null : showOrNull(from, basePath, cwd); + const already = baseText === null ? [] : majorPackagesIn(baseText); + // Per PACKAGE, not per file: adding a second `major` entry to a changeset + // that already declared one is still introducing that second one. + const added = majors.filter((pkg) => !already.includes(pkg)); + + if (added.length) introduced.push({ file, majors: added }); + else exempt.push(file); + } + + return { introduced, exempt, base: from }; +} + // ── The judgement ──────────────────────────────────────────────────────────── /** @@ -204,12 +427,17 @@ export function majorPackagesIn(text) { * 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): + * a clean diff 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 + * unreadable-diff the diff could not be computed at all -> exit 1 (#4690) + * clean the diff introduces no `major` -> exit 0 + * exempt it introduces one, but pre-mode is active -> exit 0 + notices + * enforce it introduces one, pre-mode is NOT active -> exit 1 + * + * `introduced` is `scan()`'s list — the majors THIS DIFF adds, never the pending + * stock. A `null` there means the scan could not be performed, and unlike the + * `no-changeset-dir` verdict it replaces, it fails (see the #7006 note in the + * header). * * `pre` is whatever `.changeset/pre.json` parsed to, or `null` when it is * absent, unreadable or malformed. All three of those collapse to `enforce`, @@ -218,23 +446,16 @@ export function majorPackagesIn(text) { * not be read is the #4690 anti-pattern pointed at the release train. * * @param {{ - * changesets: Map< string, string > | null, + * introduced: { file: string, majors: 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 }; +export function judge({ introduced, pre }) { + if (!introduced) return { verdict: 'unreadable-diff', offenders: [], tag: null }; + if (introduced.length === 0) return { verdict: 'clean', offenders: [], tag: null }; + if (pre?.mode === 'pre') return { verdict: 'exempt', offenders: introduced, tag: pre.tag ?? 'unknown' }; + return { verdict: 'enforce', offenders: introduced, tag: null }; } // ── Reporting ──────────────────────────────────────────────────────────────── @@ -255,19 +476,25 @@ export function render(result) { const stderr = []; switch (result?.verdict) { - case 'no-changeset-dir': - stdout.push('No .changeset directory found — nothing to check.'); - return { exitCode: 0, stdout, stderr }; + // #7006, resolved by #7005 rather than pinned: the input this gate cannot + // read is now a diff, and a diff it could not compute is a FAILURE. The + // predecessor verdict (`no-changeset-dir`) exited 0 here. + case 'unreadable-diff': + stderr.push( + '⛔ check-changeset-no-major: the diff against the branch point could not be computed, ' + + 'so nothing was verified. Missing input is a failure, never a pass (#4690).', + ); + return { exitCode: 1, stdout, stderr }; case 'clean': - stdout.push('✓ No `major` bumps in pending changesets.'); + stdout.push('✓ This diff introduces no `major` bump.'); 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"` + // nothing final ships until `changeset pre exit`. Surface the introduced + // 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( @@ -275,12 +502,12 @@ export function render(result) { '`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(', ')}`); + stdout.push(`::notice file=${file}::major introduced by this diff in ${file}: ${majors.join(', ')}`); } return { exitCode: 0, stdout, stderr }; case 'enforce': - stderr.push('⛔ Changeset(s) declare a `major` bump.\n'); + stderr.push('⛔ This PR introduces changeset(s) that declare a `major` bump.\n'); for (const { file, majors } of result.offenders) { stderr.push(` ${file}`); for (const pkg of majors) stderr.push(` - ${pkg}: major`); @@ -290,6 +517,10 @@ export function render(result) { '`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' + + 'Only what THIS diff introduces is listed above. The `major` changesets already pending\n' + + 'on the base branch are exempt and must not be cleaned up here — this gate judges diffs,\n' + + 'not stock (#7005). `--list` audits the whole pending directory.\n' + + '\n' + 'If a whole-stack major release is genuinely intended, add the `allow-major` label to\n' + 'the PR to skip this check.', ); @@ -312,13 +543,17 @@ export function render(result) { /** * Every changeset in `/.changeset`, keyed by file name. * + * Serves `--list` only. The judgement above never reads the stock — that was the + * whole of #7005 — so a null here can no longer produce a silent pass; it + * produces a `--list` that says the directory is not there. + * * `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. + * which is loud. Swallowing it would drop a changeset from the audit and report + * the remainder as the whole. * * @param {string} root * @returns {Map< string, string > | null} @@ -354,11 +589,89 @@ export function readPre(root) { } } -// ── The scan ───────────────────────────────────────────────────────────────── +/** + * `--list`: the whole pending `.changeset` directory, majors called out. + * + * This is where the stock view went when the gate stopped judging it. During an + * RC window it is how a curator sees what has accumulated, which used to be a + * side effect of every PR run — 171 `::notice` lines on a PR that introduced + * none of them, well past the 10-annotation cap, on every PR in the repo. + */ +function list() { + const changesets = readChangesets(REPO_ROOT); + if (!changesets) { + console.log('No .changeset directory found.'); + return; + } + let declaring = 0; + for (const name of [...changesets.keys()].sort()) { + const majors = majorPackagesIn(changesets.get(name)); + if (majors.length === 0) continue; + declaring++; + console.log(`major .changeset/${name} (${majors.join(', ')})`); + } + const pre = readPre(REPO_ROOT); + console.log(`\n${changesets.size} pending changeset(s), ${declaring} declaring a major.`); + console.log(`.changeset/pre.json mode: ${pre?.mode ?? '(absent or unreadable)'}`); + console.log('All of the above are EXEMPT for any PR that does not introduce them — this gate judges diffs, not stock (#7005).'); +} + +// ── The scan, on the real tree ─────────────────────────────────────────────── + +function main(argv) { + const readFlag = (name) => { + const i = argv.indexOf(name); + return i === -1 ? null : argv[i + 1]; + }; + const head = readFlag('--head') ?? 'HEAD'; + const requested = readFlag('--base'); -function main() { - const result = judge({ changesets: readChangesets(REPO_ROOT), pre: readPre(REPO_ROOT) }); + let base = null; + let baseLabel = requested; + if (requested) { + base = resolveCommit(requested, REPO_ROOT); + if (!base) { + console.error(`⛔ check-changeset-no-major: --base '${requested}' does not resolve to a commit.`); + console.error(' A gate that cannot read its input has verified nothing, so this is a failure, not a pass (#4690).'); + process.exit(1); + } + } else { + for (const candidate of ['origin/main', 'main']) { + base = resolveCommit(candidate, REPO_ROOT); + if (base) { + baseLabel = candidate; + break; + } + } + if (!base) { + console.error('⛔ check-changeset-no-major: no base to diff against (tried origin/main, main).'); + console.error(' Pass one explicitly: --base . Missing input is a failure, never a pass (#4690).'); + process.exit(1); + } + } + + let scanned = null; + try { + scanned = scan({ cwd: REPO_ROOT, base, head }); + } catch (error) { + console.error(`⛔ check-changeset-no-major: ${error instanceof Error ? error.message : String(error)}`); + } + + const result = judge({ introduced: scanned?.introduced ?? null, pre: readPre(REPO_ROOT) }); const { exitCode, stdout, stderr } = render(result); + + // The starting commit is printed on every verdict, and it is not decoration: + // #6129 hid for as long as it did because nothing in any log said where the + // diff began, so a gate reading the wrong side of a fork looked exactly like a + // gate reading the right one. #7005 is the same fact with no side at all. + if (scanned) { + const touched = scanned.introduced.length + scanned.exempt.length; + console.log(`Diffing ${head} from ${scanned.base.slice(0, 9)} (merge base with ${baseLabel}).`); + if (scanned.exempt.length) { + console.log(`${scanned.exempt.length} of the ${touched} major-declaring changeset(s) in this diff were already declared at the branch point — exempt.`); + } + } + for (const line of stdout) console.log(line); for (const line of stderr) console.error(line); process.exit(exitCode); @@ -374,9 +687,6 @@ function selfTest() { 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'; @@ -503,29 +813,29 @@ function selfTest() { // ── 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 }); + // it directly. `introduced` is now scan()'s output shape, never the stock. + const pending = [{ file: '.changeset/a.md', majors: ['@objectstack/spec'] }]; - const exempt = judge({ changesets: pending, pre: { mode: 'pre', tag: 'rc' } }); - assert(exempt.verdict === 'exempt', `pre-mode with a pending major ⇒ exempt — got ${exempt.verdict}`); + const exempt = judge({ introduced: pending, pre: { mode: 'pre', tag: 'rc' } }); + assert(exempt.verdict === 'exempt', `pre-mode with an introduced 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'), + render(exempt).stdout.some((l) => l === '::notice file=.changeset/a.md::major introduced by this diff 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`'); + assert(judge({ introduced: 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}`); + const enforced = judge({ introduced: pending, pre: { mode: 'exit' } }); + assert(enforced.verdict === 'enforce', `mode "exit" with an introduced 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.some((l) => l.includes('⛔ This PR introduces changeset(s) that 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', @@ -535,8 +845,8 @@ function selfTest() { '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', + render(enforced).stderr.some((l) => l.includes('judges diffs,')) && render(enforced).stderr.some((l) => l.includes('#7005')), + 'the offenders report says the pending stock is exempt — an author told to "remove the major bumps" would otherwise reach for 171 files that are not theirs', ); // Every other reading of pre.json is also "no exemption". An exemption is a @@ -550,36 +860,70 @@ function selfTest() { ['mode: some future spelling', { mode: 'paused' }], ['mode: PRE (wrong case)', { mode: 'PRE' }], ]) { - assert(judge({ changesets: pending, pre }).verdict === 'enforce', `${label} ⇒ enforce, never an exemption`); + assert(judge({ introduced: 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'); + const cleanInPre = judge({ introduced: [], pre: { mode: 'pre', tag: 'rc' } }); + assert(cleanInPre.verdict === 'clean', 'a diff introducing no major, 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', + render(cleanInPre).stdout.length === 1 && render(cleanInPre).stdout[0] === '✓ This diff introduces no `major` bump.', + 'a clean diff 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)'); + assert(judge({ introduced: [], pre: { mode: 'exit' } }).verdict === 'clean', 'a diff introducing no major, outside pre-mode ⇒ clean'); + + // ── #7005 ITSELF, stated as one assertion against the REAL stock ────────── + // + // The card's acceptance criterion, driven on the actual pending directory + // rather than on a synthetic stand-in for it: with N major-declaring + // changesets really on disk and pre-mode really exited, a PR that introduces + // none of them is CLEAN. Before #7005 this exact input was `enforce` and every + // unlabelled PR in the repo went red listing all N. + // + // The control is what keeps it from being vacuous — if the stock ever stops + // containing a major, the assertion below would pass for the wrong reason, so + // the count is asserted non-zero first and named in the message. + { + const realStock = readChangesets(REPO_ROOT); + assert(realStock instanceof Map, 'reader: the real .changeset directory is reachable from this script (it is what `--list` audits)'); + const stockMajors = [...(realStock ?? new Map()).entries()].filter(([, text]) => majorPackagesIn(text).length > 0); + assert( + stockMajors.length > 0, + 'control (#7005): the real .changeset stock must actually contain major-declaring changesets, or the assertion below is green for the wrong reason', + ); + assert( + judge({ introduced: [], pre: { mode: 'exit' } }).verdict === 'clean', + `#7005: ${stockMajors.length} major-declaring changeset(s) are pending on disk and pre-mode is exited, and a PR that introduces none of them is still CLEAN — stock-scoped, this was \`enforce\` for every unlabelled PR in the repo`, + ); + // And the other direction, so the pair cannot both be satisfied by a gate + // that simply stopped enforcing: one introduced major, same stock, is red. + assert( + judge({ introduced: [{ file: '.changeset/mine.md', majors: ['@objectstack/spec'] }], pre: { mode: 'exit' } }).verdict === 'enforce', + '#7005 control: the same exited pre-mode with ONE introduced major is still `enforce` — the fix narrows the gate, it does not disarm it', + ); + const only = render(judge({ introduced: [{ file: '.changeset/mine.md', majors: ['@objectstack/spec'] }], pre: { mode: 'exit' } })); + assert( + only.stderr.includes(' .changeset/mine.md') && !only.stderr.some((l) => stockMajors.some(([name]) => l.includes(name))), + '#7005: the report names ONLY the changeset this diff introduced, never one of the pending stock files', + ); + } - // ── 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`'); + // ── Missing input is a failure, never a pass (#4690 / #7006) ────────────── + const unreadable = judge({ introduced: null, pre: { mode: 'exit' } }); + assert(unreadable.verdict === 'unreadable-diff', 'a diff that could not be computed 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', + render(unreadable).exitCode === 1, + 'FLIPPED by #7005 (was pinned at exit 0 as the #7006 residual): a gate that could not read its input now FAILS. The `no-changeset-dir` verdict it replaced is gone with the directory read', ); + assert(render(unreadable).stdout.length === 0, 'the unreadable verdict prints no tick on stdout — that was the whole of the #4690 shape'); 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. + // ── The readers ────────────────────────────────────────────────────────── + // `readChangesets` no longer feeds the verdict; it feeds `--list`. These pins + // stay because `--list` is now the only stock view a curator has. { 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'); @@ -598,17 +942,255 @@ function selfTest() { 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 diff scoping, on real temp git repositories ─────────────────────── + // + // The sibling's convention (`check-empty-changeset.mjs`), for the sibling's + // reason: this gate's whole subject is now a diff between two commits, so a + // fixture that is not two real commits would be testing an imitation of the + // code path that ships. + + const repos = []; + const initRepo = (prefix) => { + const dir = mkdtempSync(join(tmpdir(), prefix)); + repos.push(dir); + git(['init', '-q', '-b', 'main'], dir); + git(['config', 'user.email', 'selftest@example.invalid'], dir); + git(['config', 'user.name', 'self test'], dir); + git(['config', 'commit.gpgsign', 'false'], dir); + return dir; + }; + const writeInto = (dir, files) => { + for (const [rel, contents] of Object.entries(files)) { + const full = join(dir, rel); + if (contents === null) rmSync(full); + else { + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, contents); + } + } + git(['add', '-A'], dir); + }; + /** @param {Record} baseFiles @param {Record} headFiles */ + const makeRepo = (baseFiles, headFiles) => { + const dir = initRepo('changeset-no-major-scan-'); + writeInto(dir, baseFiles); + git(['commit', '-q', '-m', 'base', '--allow-empty', '--no-gpg-sign'], dir); + const base = git(['rev-parse', 'HEAD'], dir).trim(); + writeInto(dir, headFiles); + git(['commit', '-q', '-m', 'head', '--allow-empty', '--no-gpg-sign'], dir); + return { dir, base }; + }; + + try { + // A stock of pending majors on the base commit, the shape of the real tree + // at `changeset pre exit`. Three rather than 171: the count is irrelevant to + // the property, and a hardcoded 171 would rot on the next merge. The REAL + // count is asserted against the real directory in the #7005 block above. + const STOCK = { + '.changeset/stock-1.md': MAJOR, + '.changeset/stock-2.md': '---\n"@objectstack/cli": major\n---\n\nstock body\n', + '.changeset/stock-3.md': MINOR, + '.changeset/README.md': MAJOR, // documentation, never a changeset + }; + + // THE CARD, end to end. A PR that adds an ordinary `minor` changeset on top + // of a stock full of majors introduces nothing. + { + const { dir, base } = makeRepo(STOCK, { '.changeset/mine.md': MINOR }); + const { introduced, exempt } = scan({ cwd: dir, base }); + assert(introduced.length === 0, `#7005: a PR adding a non-major changeset over a stock of majors introduces nothing — got ${JSON.stringify(introduced)}`); + assert(exempt.length === 0, 'the stock is not even touched by the diff, so it is not "exempt" either — it is simply not read'); + assert(judge({ introduced, pre: { mode: 'exit' } }).verdict === 'clean', '#7005: ... and with pre-mode exited that PR is CLEAN'); + } + + // The control that keeps the row above from being green because the scan + // returns nothing at all: the SAME stock, a PR that does add a major. + { + const { dir, base } = makeRepo(STOCK, { '.changeset/mine.md': MAJOR }); + const { introduced } = scan({ cwd: dir, base }); + assert( + introduced.length === 1 && introduced[0].file === '.changeset/mine.md' && introduced[0].majors.join() === '@objectstack/spec', + `#7005: a PR that DOES add a major is caught, and names only its own file — got ${JSON.stringify(introduced)}`, + ); + assert(judge({ introduced, pre: { mode: 'exit' } }).verdict === 'enforce', '#7005: ... and with pre-mode exited that PR is RED'); + assert(judge({ introduced, pre: { mode: 'pre', tag: 'rc' } }).verdict === 'exempt', '#7005: ... and inside the RC window it is still exempt'); + } + + // Row 2: majored in place. `--diff-filter=A` alone would see nothing here. + { + const { dir, base } = makeRepo({ '.changeset/x.md': MINOR }, { '.changeset/x.md': MAJOR }); + const { introduced } = scan({ cwd: dir, base }); + assert( + introduced.length === 1 && introduced[0].file === '.changeset/x.md', + `a stock changeset edited from minor to major is an offence (the bypass \`--diff-filter=A\` cannot see) — got ${JSON.stringify(introduced)}`, + ); + } + + // Row 3: the same file, prose edited, bump untouched. This is the row that + // keeps the stock exempt for a PR that legitimately touches one of its files. + { + const { dir, base } = makeRepo({ '.changeset/x.md': MAJOR }, { '.changeset/x.md': MAJOR.replace('body', 'a better body') }); + const { introduced, exempt } = scan({ cwd: dir, base }); + assert(introduced.length === 0, `editing the prose of a changeset that ALREADY declared its major introduces nothing — got ${JSON.stringify(introduced)}`); + assert(exempt.join() === '.changeset/x.md', `... and it is reported as exempt rather than silently dropped — got ${JSON.stringify(exempt)}`); + } + + // Per PACKAGE, not per file: the second entry is introduced even though the + // file already declared a major. + { + const two = '---\n"@objectstack/spec": major\n"@objectstack/cli": major\n---\n\nbody\n'; + const { dir, base } = makeRepo({ '.changeset/x.md': MAJOR }, { '.changeset/x.md': two }); + const { introduced } = scan({ cwd: dir, base }); + assert( + introduced.length === 1 && introduced[0].majors.join() === '@objectstack/cli', + `adding a SECOND major to a changeset that already declared one names only the new package — got ${JSON.stringify(introduced)}`, + ); + } + + // The rename rows. Measured on git 2.43.0: this reports as `R` and is + // dropped entirely by the two siblings' `--diff-filter=AM`. + { + const long = '\n\nbody long enough for git to score this as a rename rather than an add plus a delete\n'; + const oldMinor = '---\n"@objectstack/spec": minor\n---' + long; + const newMajor = '---\n"@objectstack/spec": major\n---' + long; + const { dir, base } = makeRepo({ '.changeset/old.md': oldMinor }, { '.changeset/old.md': null, '.changeset/new.md': newMajor }); + const raw = git(['diff', '--name-status', base, 'HEAD', '--', '.changeset/*.md'], dir); + assert( + /^R\d/.test(raw.trim()), + `control: git must really report this as a rename, or the row below is about an ordinary add — got ${JSON.stringify(raw.trim())}`, + ); + const { introduced } = scan({ cwd: dir, base }); + assert( + introduced.length === 1 && introduced[0].file === '.changeset/new.md', + `a changeset renamed AND flipped to major is an offence (\`--diff-filter=AM\` drops it entirely) — got ${JSON.stringify(introduced)}`, + ); + } + { + // The paired control: a PURE rename of a stock major is NOT an offence, + // because the same declaration was already at the branch point. + const long = '\n\nbody long enough for git to score this as a rename rather than an add plus a delete\n'; + const text = '---\n"@objectstack/spec": major\n---' + long; + const { dir, base } = makeRepo({ '.changeset/old.md': text }, { '.changeset/old.md': null, '.changeset/new.md': text }); + const { introduced, exempt } = scan({ cwd: dir, base }); + assert(introduced.length === 0, `control: a pure rename of a stock major introduces nothing — got ${JSON.stringify(introduced)}`); + assert(exempt.join() === '.changeset/new.md', `... and is reported as exempt at its new path — got ${JSON.stringify(exempt)}`); + } + + // `.changeset/README.md` is documentation, even when it is shaped exactly + // like a changeset declaring a major — asserted with its own control, so the + // green cannot be "the diff found nothing at all". + { + const { dir, base } = makeRepo({}, { '.changeset/README.md': MAJOR, '.changeset/mine.md': MINOR }); + const { introduced } = scan({ cwd: dir, base }); + assert(introduced.length === 0, `a major-shaped .changeset/README.md is documentation, not a changeset — got ${JSON.stringify(introduced)}`); + const control = makeRepo({}, { '.changeset/README.md': MAJOR, '.changeset/mine.md': MAJOR }); + assert( + scan({ cwd: control.dir, base: control.base }).introduced.map((o) => o.file).join() === '.changeset/mine.md', + 'control: the identical diff with the major in a NON-README file is caught, so the row above is about the filename and not about an empty diff', + ); + } + + // ── #6129 proper: main drift must not move the verdict ─────────────────── + // + // The CI shape built for real — a base branch that keeps moving after the PR + // forks off it, and the `refs/pull/N/merge` commit GitHub builds from the + // two. Faking it with two linear commits would test an imitation: the whole + // defect lives in the difference between a merge commit's two parents, so + // the fixture has to have two parents. + // + // Predicted direction, written before the run: judged from the merge base, + // the major main gained after the fork is invisible to this PR (0 + // offenders); judged from the moved main tip it would be 0 too — but judged + // as this gate USED to judge, reading the stock at HEAD, it is 1. All three + // are asserted, so the fixture cannot be green because nothing is produced. + { + const dir = initRepo('changeset-no-major-mergeref-'); + writeInto(dir, { '.changeset/stock.md': MINOR }); + git(['commit', '-q', '-m', 'base', '--allow-empty', '--no-gpg-sign'], dir); + const forkPoint = git(['rev-parse', 'HEAD'], dir).trim(); + + git(['checkout', '-q', '-b', 'pr'], dir); + writeInto(dir, { '.changeset/mine.md': MINOR }); + git(['commit', '-q', '-m', 'pr side', '--no-gpg-sign'], dir); + const prTip = git(['rev-parse', 'HEAD'], dir).trim(); + + git(['checkout', '-q', 'main'], dir); + // main gains a whole-stack major while the PR sits open — the release's + // own doing, at `changeset pre exit`, not this author's. + writeInto(dir, { '.changeset/drift-major.md': MAJOR }); + git(['commit', '-q', '-m', 'main drift: the release major', '--no-gpg-sign'], dir); + const mainTip = git(['rev-parse', 'HEAD'], dir).trim(); + + // The merge ref actions/checkout puts at HEAD on a `pull_request` event. + git(['checkout', '-q', '-b', 'mergeref', prTip], dir); + git(['merge', '-q', '--no-ff', '-m', 'merge ref', mainTip], dir); + + assert(mergeBase('main', 'HEAD', dir) !== forkPoint, 'control: on a merge ref the merge base is the MERGED main tip, not the fork point — the fixture is the CI shape, not a linear one'); + + const fromMain = scan({ cwd: dir, base: 'main' }); + assert( + fromMain.introduced.length === 0, + `#6129/#7005: a major MAIN gained while this PR was open is not this PR's — got ${JSON.stringify(fromMain.introduced)}`, + ); + // Judged from the fork point instead, main's drift reads as introduced by + // this PR. That is the defect, pinned so the correction above cannot be + // mistaken for a scan that simply finds nothing. + const fromForkPoint = scan({ cwd: dir, base: forkPoint }); + assert( + fromForkPoint.introduced.length === 1 && fromForkPoint.introduced[0].file === '.changeset/drift-major.md', + `#6129 control: from a FROZEN fork point the same repo blames this PR for main's major — the defect, pinned — got ${JSON.stringify(fromForkPoint.introduced)}`, + ); + // And the stock-scoped reading this card replaces: at HEAD the directory + // contains main's major, so the old gate failed this PR over it. + const stockAtHead = readChangesets(dir); + assert( + [...stockAtHead.entries()].filter(([, t]) => majorPackagesIn(t).length > 0).length === 1, + '#7005 control: the STOCK at HEAD does contain the drift major — which is exactly what the old stock-scoped gate read, and why it reddened this PR', + ); + + // The other half of #6129: a base branch that DELETES. The post-exit + // `changeset version` removes every consumed changeset from main at once, + // and a two-dot diff from the moved tip reads those deletions as this + // branch's additions. + git(['checkout', '-q', 'main'], dir); + writeInto(dir, { '.changeset/drift-major.md': null }); + git(['commit', '-q', '-m', 'main: the Version PR deletes the consumed changesets', '--no-gpg-sign'], dir); + git(['checkout', '-q', 'mergeref'], dir); + const afterDeletion = scan({ cwd: dir, base: 'main' }); + assert( + afterDeletion.introduced.length === 0, + `#6129 DELETED-ON-MAIN: majors deleted on main must not read as introduced by a branch that still carries them — got ${JSON.stringify(afterDeletion.introduced)}`, + ); + } + + // ── Missing input is a failure, never a pass (#4690) ───────────────────── + { + const { dir } = makeRepo({}, { 'a.txt': 'x\n' }); + assert(resolveCommit('definitely-not-a-ref', dir) === null, 'an unresolvable base resolves to null (⇒ exit 1)'); + assert(resolveCommit('HEAD', dir) !== null, 'control — a resolvable ref DOES resolve, so the null above is about the ref, not a broken resolver'); + + const other = initRepo('changeset-no-major-unrelated-'); + writeInto(other, { 'b.txt': 'y\n' }); + git(['commit', '-q', '-m', 'unrelated', '--no-gpg-sign'], other); + const unrelatedSha = git(['rev-parse', 'HEAD'], other).trim(); + git(['fetch', '-q', other, 'main'], dir); + assert(mergeBase(unrelatedSha, 'HEAD', dir) === null, 'unrelated histories have no merge base'); + let threw = false; + try { + scan({ cwd: dir, base: unrelatedSha }); + } catch { + threw = true; + } + assert(threw, '#4690: no merge base is a failure rather than a silent pass — scan throws rather than falling back to the raw base'); + } + } finally { + for (const dir of repos) rmSync(dir, { 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 @@ -621,9 +1203,11 @@ function selfTest() { // * 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. + // `skip-changeset` exemptions are deliberate, AND because after #7005 it + // needs the `$MERGE_BASE` that job derives. Moving it into lint.yml would + // silently revoke the escape hatch the offenders report prescribes, and + // leave it with no branch point to judge against — which is #6129 in the + // false-RED direction, the very thing this card fixed. // // 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. @@ -667,7 +1251,7 @@ function selfTest() { ); 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', + 'wiring: `check:changeset-gate-self-tests` must invoke this file ONLY with `--self-test` — the real scan needs $MERGE_BASE and 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 @@ -676,12 +1260,38 @@ function selfTest() { 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', + /run: node scripts\/check-changeset-no-major\.mjs --base "\$MERGE_BASE"\s*$/m.test(prAutomation), + 'wiring: pr-automation.yml must invoke the real scan with `--base "$MERGE_BASE"` — the self-test fixtures replace none of the enforcement, and a scan with no branch point is #7005 restored', + ); + // FLIPPED by #7005. The predecessor assertion required the BARE invocation + // (`node scripts/check-changeset-no-major.mjs` with nothing after it); that + // spelling is the stock-scoped gate, so it is now the thing forbidden. + // Every `--base` handed to this script, not just the one that exists today: + // a second call site added later with a pinned sha is #6129 again. + const bases = [...prAutomation.matchAll(/check-changeset-no-major\.mjs --base (\S+)/g)].map((m) => m[1]); + assert(bases.length === 1, `wiring: exactly one \`check-changeset-no-major.mjs --base\` call site is expected in the workflow (found ${bases.length})`); + assert( + bases.every((b) => b === '"$MERGE_BASE"'), + `wiring: every \`check-changeset-no-major.mjs --base\` in the workflow must be handed $MERGE_BASE, never a pinned sha (#6129) — got ${JSON.stringify(bases)}`, + ); + assert( + !/node scripts\/check-changeset-no-major\.mjs\s*$/m.test(prAutomation), + 'wiring: the BARE `node scripts/check-changeset-no-major.mjs` is forbidden — with no `--base` it defaults to origin/main, which on a stale checkout is the two-dot reading #6129 rules out, and it was the stock-scoped spelling #7005 removed', + ); + // The step must actually be handed the value it interpolates. `$MERGE_BASE` + // is a shell variable, so a step that spells it without the `env:` key runs + // the scan against an empty string and the gate would fail on every PR. + const majorStep = prAutomation + .split(/\n(?= - name: )/) + .find((s) => /check-changeset-no-major\.mjs --base/.test(s)); + assert(majorStep !== undefined, 'wiring: the step running the real scan could not be sliced out of pr-automation.yml — the assertion below would judge undefined'); + assert( + /MERGE_BASE:\s*\$\{\{\s*steps\.diffbase\.outputs\.merge_base\s*\}\}/.test(majorStep ?? ''), + 'wiring: that step must set `MERGE_BASE: ${{ steps.diffbase.outputs.merge_base }}` in its own `env:` — `$MERGE_BASE` is a shell variable, and a step that never receives it scans against an empty base', ); 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', + '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 and have no branch point to judge against', ); } @@ -692,14 +1302,18 @@ function selfTest() { } 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).', + '(frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #7005 diff scoping over real temp git repos + the #4690 pins + the wiring).', ); } // ── main ───────────────────────────────────────────────────────────────────── -if (process.argv.includes('--self-test')) { +const argv = process.argv.slice(2); + +if (argv.includes('--self-test')) { selfTest(); +} else if (argv.includes('--list')) { + list(); } else { - main(); + main(argv); }