From 7a0e452b073e89c2883edbdf2705b59778c383c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 04:13:36 +0000 Subject: [PATCH 1/3] fix(ci): run the changeset family's --self-test in lint.yml, out of reach of skip-changeset (#6509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-empty-changeset.mjs` and `check-adr-0087-registration.mjs` were called from exactly one place in the repository: pr-automation.yml's `changeset-check` job, which a PR carrying `skip-changeset` is exempted from wholesale. A PR that edits a CI-internal script is the textbook case for that label, so a PR editing these two checkers was routinely the PR that skipped their own fixtures. The self-test halves are pure static checks with no merge-base dependency, so they move to lint.yml's ESLint job as one unconditional step. The real scans stay in `changeset-check`, which is where $MERGE_BASE exists — running them in a job with no branch point would be #6129 in the false-RED direction. The `chunks.length === 5` job invariant is untouched. `check-empty-changeset.mjs`'s consumer block gains 11 assertions pinning the new wiring: the step exists exactly once, carries no `if:`, lint.yml reads no label and carries no paths filter, the gates are invoked only through the self-test-only pnpm script, and that script covers both checkers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn --- .github/workflows/lint.yml | 42 ++++++++++ package.json | 1 + scripts/check-empty-changeset.mjs | 124 ++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 12dd33a4f6..4f9b289e23 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -345,6 +345,48 @@ jobs: - name: objectui pin-changeset digest guard run: pnpm check:objectui-changeset + # Changeset-family gate self-tests (#6509). The SELF-TEST halves only — + # the real scans stay in pr-automation.yml's `changeset-check`, and the + # split is the whole point of this step. + # + # Why they moved here. `check-empty-changeset.mjs` and + # `check-adr-0087-registration.mjs` were called from exactly one place in + # the repository: the `changeset-check` job, which a PR carrying + # `skip-changeset` is exempted from WHOLESALE (job-level `if:` plus the two + # per-step label reads). And "this PR edits a CI-internal script" is the + # textbook `skip-changeset` case — such a PR releases nothing, so by the + # workflow's own prescription it takes the label. The consequence is the + # exact inversion of #4690: a PR that edits these two checkers is the PR + # most likely to skip their own fixtures. + # + # Not hypothetical, and not this card's own reasoning either. PR #6876 + # (#5620, merged) added FIVE consumer assertions to + # `check-empty-changeset.mjs` that never executed once on its own CI: it + # carried `skip-changeset`, so on run 31289894461 the step + # `Reject an empty-frontmatter changeset added by this PR` — the only thing + # that runs `--self-test` — reports `conclusion: skipped`. Every assertion + # in that PR was verified locally and by nothing else. + # + # This step is deliberately UNCONDITIONAL. No `if:`, no label read, no + # paths filter: an exemption is precisely what the two self-tests must not + # have, or the gap simply moves. That property is not left to prose — + # `check-empty-changeset.mjs`'s own consumer block asserts this step's + # shape (present, unguarded, self-test halves only), so removing or + # conditioning it reds the very check it was removing. + # + # Why the SELF-TEST halves only, and not `pnpm check:empty-changeset`: + # that script chains the real scan, whose verdict is a function of the + # PR's DIFF and therefore needs `$MERGE_BASE`. This job has no branch + # point, so a real scan here would fall back to reading stock — which is + # #6129, "judge the author for what main gained while their PR was open", + # in the direction of a false RED. The self-test halves have no such + # dependency: measured, both pass with REPO_ROOT pointing at a directory + # that is not a git repository at all. They build their own throwaway + # repos in $TMPDIR and read two files (this workflow and + # pr-automation.yml). ~0.8s + ~5.0s. + - name: Changeset-family gate self-tests + run: pnpm check:changeset-gate-self-tests + # Release-notes drift guard: the platform is one version-locked train, so # every released @objectstack/spec major must have a curated, navigable # release page at content/docs/releases/v.mdx. Catches the gap that diff --git a/package.json b/package.json index ab687e12f1..9c7afa81ea 100644 --- a/package.json +++ b/package.json @@ -67,6 +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: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-empty-changeset.mjs b/scripts/check-empty-changeset.mjs index 9e5e9d742a..c7bfdebf0c 100644 --- a/scripts/check-empty-changeset.mjs +++ b/scripts/check-empty-changeset.mjs @@ -905,6 +905,130 @@ function selfTest() { ); } + // ── The second consumer: where THIS SELF-TEST runs (#6509) ─────────────── + // + // Everything above pins the job that runs the REAL scan. This block pins the + // job that runs the self-test, and it exists because for a long time they + // were the same job -- which made the assertions above skippable by a label. + // + // The defect, stated once. `changeset-check` is exempt WHOLESALE when a PR + // carries `skip-changeset`, and a PR that edits a CI-internal script is the + // textbook case for that label (it releases nothing; the workflow itself + // prescribes the label for exactly that). So a PR editing this file was + // routinely the PR that skipped this file's own fixtures. Measured specimen, + // not a worry: PR #6876 (#5620, merged) added the five `allow-major` + // assertions above and NONE of them executed on its own CI -- it carried the + // label, and on run 31289894461 the step that runs `--self-test` reports + // `conclusion: skipped`. Both directions of that gap are real, and they are + // not symmetric: a BROKEN assertion is merely deferred onto the next + // unlabelled PR (an unrelated author eats the red), while a DELETED one is + // silent forever, because nothing afterwards remembers it existed. + // + // The fix is wiring, so the fixture for it has to read the wiring. Without + // this block `lint.yml`'s step could be deleted tomorrow with every + // assertion above still green -- the same "phantom check" shape (#4690) this + // whole family is written against. + // + // What is pinned is the property that closes the gap, not the step's prose: + // the self-test halves run in a job NO PR-level exemption reaches, and the + // merge-base-dependent halves stay out of it. + // + // RESIDUAL, recorded rather than implied. This assertion is run BY the step + // it pins, so a PR that deletes that step AND carries `skip-changeset` is + // still not caught -- both places that would have run it are gone in the + // same diff. That is a strictly smaller hole than the one it replaces (which + // swallowed EVERY labelled PR, including one that merely edits an + // assertion), it is a deletion visible in a `.github/**` diff rather than a + // silent no-op, and closing it entirely would need a gate outside this + // family asserting this family's wiring, which is a coupling with its own + // cost. Stated so the next reader inherits the fact and not a false sense of + // closure. + { + const lintPath = join(REPO_ROOT, '.github/workflows/lint.yml'); + const lintPresent = existsSync(lintPath); + assert(lintPresent, 'consumer: .github/workflows/lint.yml must exist -- it is where this self-test runs unconditionally (#6509)'); + const lintYaml = lintPresent ? readFileSync(lintPath, 'utf8') : ''; + const uncommented = (text) => text.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n'); + + // Scoped to the `lint` job: `typecheck` is a separate required job with a + // separate purpose, and a step landing there instead would be a different + // fact than the one asserted here. + const lintJobStart = lintYaml.indexOf('\n lint:'); + const lintJobEnd = lintYaml.indexOf('\n typecheck:'); + const lintJob = lintJobStart === -1 ? '' : lintYaml.slice(lintJobStart, lintJobEnd === -1 ? undefined : lintJobEnd); + const lintSteps = lintJob.split(/\n(?= - name: )/).map(uncommented); + const wiredSteps = lintSteps.filter((s) => /run: pnpm check:changeset-gate-self-tests\b/.test(s)); + assert( + wiredSteps.length === 1, + `consumer: the ESLint job of lint.yml must run \`pnpm check:changeset-gate-self-tests\` exactly once (found ${wiredSteps.length}) -- that step is the only place these two checkers' fixtures are executed on a PR carrying \`skip-changeset\` (#6509)`, + ); + // The load-bearing half. A conditioned step is the defect again with one + // more hop: whatever the condition reads, it is a way for a PR to arrange + // that this self-test does not run on it. + assert( + wiredSteps.every((s) => !/^\s*if:/m.test(s)), + 'consumer: the changeset-family self-test step in lint.yml must carry NO `if:` -- an exemptable self-test is #6509 itself, and the exemption it must not have is the one that hid PR #6876\'s five assertions', + ); + // Same property, one level up: a label read anywhere in this workflow + // would mean some step of it can be waived by the author of the PR under + // test. + assert( + !/skip-changeset/.test(uncommented(lintYaml)), + 'consumer: lint.yml must not read the `skip-changeset` label anywhere -- the whole point of running the self-tests here is that this workflow has no PR-level exemption', + ); + // And one level up again: "runs on every PR" is what "unconditional" + // means in practice. A `paths:` filter is a condition written in the + // trigger instead of in an `if:`, and it would silently restore the gap + // for every PR whose file list misses the glob. + const onBlock = uncommented((lintYaml.match(/\non:\n([\s\S]*?)\n(?=[A-Za-z_])/) ?? ['', ''])[1]); + assert( + /^\s+pull_request:/m.test(onBlock), + 'consumer: lint.yml must keep its `pull_request:` trigger -- a self-test that does not run on pull requests is not wired at all (#6509)', + ); + assert( + !/\bpaths(-ignore)?\s*:/.test(onBlock), + 'consumer: lint.yml must carry no `paths:`/`paths-ignore:` filter -- a path-filtered trigger is an exemption written one level up, and this self-test may not have one (#6509)', + ); + + // The other half of the split: the merge-base-dependent scans stay out of + // this job. `check:empty-changeset` / `check:adr-0087-registration` chain + // the REAL scan, whose verdict is a function of the PR's diff; this job + // has no branch point, so running one here reads stock and reports main's + // drift against the author -- #6129 in the false-RED direction. + const strayScans = uncommented(lintYaml) + .split('\n') + .filter((l) => /check-empty-changeset\.mjs|check-adr-0087-registration\.mjs|pnpm check:empty-changeset\b|pnpm check:adr-0087-registration\b/.test(l)); + assert( + strayScans.length === 0, + `consumer: lint.yml must invoke these gates ONLY through \`pnpm check:changeset-gate-self-tests\` (found ${strayScans.length} direct invocation(s)) -- the real scans need $MERGE_BASE and this job has no branch point, which is #6129 in the false-RED direction`, + ); + + // What that pnpm script actually is. The step above is a name; this is the + // thing the name resolves to, and it is where "self-test halves only" and + // "BOTH of them" are actually true or false. + const pkgPath = join(REPO_ROOT, 'package.json'); + const pkgPresent = existsSync(pkgPath); + assert(pkgPresent, 'consumer: the repository root package.json must exist -- it carries the script lint.yml runs'); + let wiring = ''; + try { + wiring = JSON.parse(pkgPresent ? readFileSync(pkgPath, 'utf8') : '{}').scripts?.['check:changeset-gate-self-tests'] ?? ''; + } catch { + wiring = ''; + } + assert( + /check-empty-changeset\.mjs --self-test/.test(wiring), + 'consumer: `check:changeset-gate-self-tests` must run `check-empty-changeset.mjs --self-test` -- the step in lint.yml is only as real as the script it resolves to', + ); + 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.', + ); + assert( + !/check-(?:empty-changeset|adr-0087-registration)\.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', + ); + } + // ── Parser unit rows ───────────────────────────────────────────────────── assert(isEmptyDeclaration('---\n---\n\nbody\n'), 'parser: the canonical empty shape is empty'); assert(isEmptyDeclaration('\n---\n\n---\n\nbody\n'), 'parser: blank lines around/inside the fence stay empty'); From 56f701810e1593372c2c9412becb3f967907e618 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 04:19:36 +0000 Subject: [PATCH 2/3] =?UTF-8?q?TEMP=20ABLATION=20(reverted=20in=20the=20ne?= =?UTF-8?q?xt=20commit)=20=E2=80=94=20reverse-verification=20for=20#6509?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weakens the live allow-major matcher in pr-automation.yml from `grep -qxF` to `grep -qF`, i.e. reintroduces exactly the #5620-class defect that PR #6876's consumer assertions were added to catch — and which never executed once on #6876's own CI, because that PR carried skip-changeset. This PR carries skip-changeset too. Predicted direction: `Check Changeset` skips its self-test steps again, and the NEW lint.yml step goes RED. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn --- .github/workflows/pr-automation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index 4bf52ad4db..2b341d01dc 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -798,7 +798,7 @@ jobs: exit 0 fi echo "Labels on PR #$PR_NUMBER right now: ${LABELS:-(none)}" - if grep -qxF 'allow-major' <<<"$LABELS"; then + if grep -qF 'allow-major' <<<"$LABELS"; then echo "::notice::'allow-major' is on PR #$PR_NUMBER (read live, not from the event payload), so a whole-stack major is intended here and the launch-window guard stands aside." echo 'allow=true' >> "$GITHUB_OUTPUT" else From 0290e77471dbf0df8eee8e5f1e45a5775ef3e99b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 04:21:27 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Revert=20the=20temporary=20ablation=20?= =?UTF-8?q?=E2=80=94=20restore=20the=20whole-line=20allow-major=20matcher?= =?UTF-8?q?=20(#6509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores `grep -qxF 'allow-major'` in pr-automation.yml, undoing 56f701810. That commit existed only to prove on CI that the new lint.yml step goes RED on a PR carrying skip-changeset; run 31294323300 is the evidence and this returns the branch to the intended state. pr-automation.yml is now byte-identical to origin/main. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn --- .github/workflows/pr-automation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml index 2b341d01dc..4bf52ad4db 100644 --- a/.github/workflows/pr-automation.yml +++ b/.github/workflows/pr-automation.yml @@ -798,7 +798,7 @@ jobs: exit 0 fi echo "Labels on PR #$PR_NUMBER right now: ${LABELS:-(none)}" - if grep -qF 'allow-major' <<<"$LABELS"; then + if grep -qxF 'allow-major' <<<"$LABELS"; then echo "::notice::'allow-major' is on PR #$PR_NUMBER (read live, not from the event payload), so a whole-stack major is intended here and the launch-window guard stands aside." echo 'allow=true' >> "$GITHUB_OUTPUT" else