diff --git a/ui/scripts/__tests__/check-lockfile-dedupe.test.ts b/ui/scripts/__tests__/check-lockfile-dedupe.test.ts index 4c83ff8bb0d..76149895df4 100644 --- a/ui/scripts/__tests__/check-lockfile-dedupe.test.ts +++ b/ui/scripts/__tests__/check-lockfile-dedupe.test.ts @@ -118,10 +118,10 @@ import { DEDUPE_SENTINEL } from '../check-lockfile-dedupe.mjs'; import { FLOORS, TEST_FILE } from '../check-test-path-roots.mjs'; import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; import { maskComments } from '../js-comment-mask.mjs'; -import { pullRequestTrigger, readWorkflows, repoRoot, subscribesMergeGroup } from './workflow-checks.js'; +import { rootWorkflow, rootWorkflowFiles, rootWorkflowsDir, withoutComments } from './helpers/root-ci'; +import { repoRoot } from './workflow-checks.js'; const SCRIPT = 'scripts/check-lockfile-dedupe.mjs'; -const WORKFLOW = 'lockfile-dedupe.yml'; const CONTEXT = 'Lockfile Dedupe Check'; /** This file, by its own path — a rename may not silently drop what it pins. */ @@ -177,16 +177,19 @@ interface LiveScan { * * Two carriers, one population (objectui#9693): the workflows, which say what a * job runs, and the files those jobs execute — every tracked test file plus the - * vitest runtime modules. Workflow comment LINES are stripped by `readWorkflows`; - * source comments are blanked by `scripts/js-comment-mask.mjs`, the tree's one - * answer to "comment, or code?", so the paragraphs in this very file explaining - * the hazard cannot count as the hazard. + * vitest runtime modules. The workflows are the root `.github/workflows/`, the + * only ones GitHub runs under the monorepo (objectstack#25712), read with their + * comment LINES stripped; `ui/.github/workflows/` runs nowhere, so a live + * reading there is no reading at all. Source comments are blanked by + * `scripts/js-comment-mask.mjs`, the tree's one answer to "comment, or code?", + * so the paragraphs in this very file explaining the hazard cannot count as the + * hazard. */ function scanForLiveReading(): LiveScan { - const workflows = readWorkflows(); + const workflows = rootWorkflowFiles(); const sites = workflows - .filter((workflow) => takesLiveReading(workflow.lines.join('\n'))) - .map((workflow) => `.github/workflows/${workflow.file}`); + .filter((file) => takesLiveReading(withoutComments(rootWorkflow(file)))) + .map((file) => path.relative(repoRoot, path.join(rootWorkflowsDir, file))); const population = trackedFiles().filter((file) => TEST_FILE.test(file) || VITEST_RUNTIME.test(file)); for (const file of population) { @@ -208,16 +211,22 @@ function scanForLiveReading(): LiveScan { /** * The only places a live reading may be taken, and why each may: * - * - the path-filtered, non-required workflow this gate was built around; * - this file, which drives the checker ONLY through the `PATH` stub above — * every run asserts `pnpmArgv`, so a leg that reached the real pnpm reds * rather than going quiet, which is what makes this exemption checkable * instead of trusted. * + * The second entry this list carried, objectui's path-filtered + * `lockfile-dedupe.yml`, left with objectstack#25712: it sits under + * `ui/.github/workflows/`, which runs nowhere. It is row 18 of the + * objectstack#25697 workflow roster, "kept, follow-up port", and the port adds + * its root workflow back here. Until then the equality below has no workflow + * member, so its workflow half rests on the read floor alone. + * * ⛔ Nothing else belongs here. An addition is a live, registry-dependent * reading inside a REQUIRED context — objectui#9562, again. */ -const LIVE_READING_SITES = [`.github/workflows/${WORKFLOW}`, THIS_FILE].sort(); +const LIVE_READING_SITES = [THIS_FILE]; /** * Real `pnpm@10.31.0` output, same capture objectui#8333 took and the checker's @@ -499,23 +508,21 @@ describe('--report-only — the mode objectui#9562 ruled (letter A, comment 5717 }); }); -describe('the lockfile-dedupe gate is wired the way its header claims', () => { - const workflows = readWorkflows(); - const workflow = workflows.find((w) => w.file === WORKFLOW); - /** The workflow with comment lines stripped — prose may not satisfy wiring. */ - const body = () => workflow!.lines.join('\n'); - - it('has a workflow that runs the checker', () => { - expect(workflow, `${WORKFLOW} not found — the gate would never run`).toBeDefined(); - // Comment-stripped throughout: a header comment mentioning the script must - // not be able to satisfy a wiring assertion about what the job RUNS. - expect(body()).toContain(`node ${SCRIPT}`); - expect(body()).toContain(`name: ${CONTEXT}`); - }); +describe('the lockfile-dedupe gate is fenced and classified the way its header claims', () => { + // Retired with objectstack#25712: `has a workflow that runs the checker`, the + // `--report-only` pin, `is path-filtered, and the filter lists the gate’s own + // runtime closure`, `declares no merge_group trigger` and `uses the shared + // pnpm setup rather than a bare corepack call`. Each read objectui's + // `lockfile-dedupe.yml`, which runs nowhere under the monorepo: GitHub runs + // the root `.github/workflows/` alone. That workflow is row 18 of the + // objectstack#25697 workflow roster, "kept, follow-up port", re-aimed at the + // one root `pnpm-lock.yaml`; the port brings these pins back against its root + // workflow, by path. it('is the only place the LIVE reading runs, in workflows AND in what they run (objectui#9562, objectui#9693)', () => { - // The live, registry-dependent execution belongs to one path-filtered - // workflow and nowhere else. ⭐ "Nowhere else" is the whole assertion, and + // The live, registry-dependent execution belongs to this file's stubbed legs + // and, once roster row 18 is ported, to one path-filtered root workflow: + // nowhere else. ⭐ "Nowhere else" is the whole assertion, and // it used to be measured over the workflows alone — while the way a // required job actually grew a `pnpm dedupe`, the time it happened, was // through a TEST FILE, which is not a workflow. So the population is both @@ -531,14 +538,15 @@ describe('the lockfile-dedupe gate is wired the way its header claims', () => { expect(scan.workflows, 'the workflow read collapsed').toBeGreaterThan(20); expect(scan.population.length, 'the source read collapsed').toBeGreaterThan(FLOORS.testFiles); // …and it reached this file's PEERS and the runtime modules, not just the - // two files the expectation names — a scan that only ever looked at itself + // file the expectation names — a scan that only ever looked at itself // would satisfy the equality below exactly as well. expect(scan.population.filter((f) => f.startsWith('scripts/__tests__/') && f !== THIS_FILE).length).toBeGreaterThan(50); expect(scan.population).toContain('vitest.setup.base.ts'); - // The equality is also the positive control for both halves: one member is - // a workflow and the other a test file, so a carrier that stopped being - // read goes red here rather than going quiet. + // The equality was also the positive control for both halves while one + // member was a workflow and the other a test file. Until the row-18 port + // restores the workflow member, the workflow half rests on the floor above + // (see `LIVE_READING_SITES`). expect(scan.sites, 'a live, registry-dependent reading inside a REQUIRED context — objectui#9562, again').toEqual( LIVE_READING_SITES, ); @@ -568,37 +576,6 @@ describe('the lockfile-dedupe gate is wired the way its header claims', () => { expect(takesLiveReading(maskComments('const dedupe = new Set();'))).toBe(false); }); - it('passes `--report-only`, which is where objectui#9562’s ruling actually lands', () => { - // Comment-stripped: the header explains the mode at length, and prose may - // not satisfy wiring. The flag on the step is the entire deliverable — the - // script supports both modes, so the workflow is what chooses one. - expect(body(), 'the gate is blocking again — objectui#9562, undone').toContain( - `node ${SCRIPT} --report-only`, - ); - }); - - it('is path-filtered, and the filter lists the gate’s own runtime closure', () => { - const trigger = pullRequestTrigger(workflow!); - expect(trigger.subscribes, 'the gate must run on pull requests').toBe(true); - expect(trigger.filtered, 'a path filter is what keeps it out of the REQUIRED set').toBe(true); - // Without these the checker can ship having never run once, and the wiring - // bug then surfaces on somebody else's next lockfile pull request. - for (const file of ['pnpm-lock.yaml', `.github/workflows/${WORKFLOW}`, SCRIPT, 'scripts/invoked-as.mjs']) { - expect(body(), `${file} missing from the path filter`).toContain(file); - } - }); - - it('declares no merge_group trigger, per objectui#3523', () => { - // A path-filtered, non-required gate that subscribes the queue stalls it - // until the ruleset timeout. - expect(subscribesMergeGroup(workflow!)).toBe(false); - }); - - it('uses the shared pnpm setup rather than a bare corepack call', () => { - expect(body()).toContain('bash scripts/ci-setup-pnpm.sh'); - expect(body()).not.toMatch(/^\s*run:.*corepack/m); - }); - it('stays an OPTIONAL context, because the JOB still gates even though its verdict does not', () => { // ⚠️ The load-bearing assertion, and objectui#9562 did NOT move it: the // ruling relaxed the VERDICT, not the job. A failed checkout, a broken diff --git a/ui/scripts/__tests__/check-lockfile-integrity.test.ts b/ui/scripts/__tests__/check-lockfile-integrity.test.ts index eb4448e1d39..fc111c05f43 100644 --- a/ui/scripts/__tests__/check-lockfile-integrity.test.ts +++ b/ui/scripts/__tests__/check-lockfile-integrity.test.ts @@ -24,7 +24,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { lockfilePath } from '../workspace-root.mjs'; -import { readWorkflows, repoRoot } from './workflow-checks'; +import { repoRoot } from './workflow-checks'; import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; import { compareLockfiles, @@ -190,19 +190,14 @@ describe('cause 2 — a declared dependency forks with no version moving (object }); }); -describe('the gate is wired, and wired the way the dispatch reserved', () => { - const workflow = readWorkflows().find((w) => w.file === 'lockfile-integrity.yml'); - - it('exists and runs the gate', () => { - expect(workflow, '.github/workflows/lockfile-integrity.yml is missing').toBeTruthy(); - expect(workflow?.text).toContain('node scripts/check-lockfile-integrity.mjs'); - }); - - it('checks out deep enough to have a merge base', () => { - // A shallow checkout has no merge base and the script exits 2 rather than - // guessing — which would be a check that reports nothing and looks fine. - expect(workflow?.text).toContain('fetch-depth: 0'); - }); +describe('the gate is classified the way the dispatch reserved', () => { + // `exists and runs the gate` and `checks out deep enough to have a merge + // base` retired with objectstack#25712. They read objectui's + // `lockfile-integrity.yml`, which runs nowhere under the monorepo: GitHub + // runs the root `.github/workflows/` alone. That workflow is row 19 of the + // objectstack#25697 workflow roster, "kept, follow-up port", re-aimed at the + // one root `pnpm-lock.yaml`; the port brings both pins back against the root + // job that runs the gate. it('⛔ is classified as NOT a blocking context (the maintainer floor)', () => { expect(Object.keys(NOT_A_GATE)).toContain(CHECK_NAME); diff --git a/ui/scripts/__tests__/ci-setup-pnpm-wiring.test.ts b/ui/scripts/__tests__/ci-setup-pnpm-wiring.test.ts index 72167290cab..5032398e339 100644 --- a/ui/scripts/__tests__/ci-setup-pnpm-wiring.test.ts +++ b/ui/scripts/__tests__/ci-setup-pnpm-wiring.test.ts @@ -4,8 +4,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { jobBlock, rootCi, UI_JOBS, withoutComments } from './helpers/root-ci'; + const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); -const WORKFLOW_DIR = path.join(ROOT, '.github', 'workflows'); const SETUP = 'scripts/ci-setup-pnpm.sh'; /** @@ -62,15 +63,6 @@ const SETUP = 'scripts/ci-setup-pnpm.sh'; * which is the failure mode this repository has recorded more than once. */ -/** Every workflow file, as `{ file, yaml }`. */ -function workflows(): { file: string; yaml: string }[] { - return fs - .readdirSync(WORKFLOW_DIR) - .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) - .sort() - .map((file) => ({ file, yaml: fs.readFileSync(path.join(WORKFLOW_DIR, file), 'utf8') })); -} - /** * Every `run:` step body in a piece of workflow YAML — single-line and block * scalar alike, and nothing else. @@ -119,89 +111,38 @@ describe('the shared CI pnpm setup is wired everywhere pnpm is used', () => { expect(run.stdout).toContain('self-test OK'); }); - it('no workflow runs corepack outside the shared script', () => { - const files = workflows(); - expect(files.length, 'no workflow files found — this whole file would be vacuous').toBeGreaterThan(0); - - const offenders = files - .filter(({ yaml }) => runSteps(yaml).some((s) => /\bcorepack\b/.test(s))) - .map(({ file }) => file); - - expect( - offenders, - 'these workflows invoke corepack directly instead of `bash ' + - SETUP + - '`. The download it performs is a registry read that has reddened a REQUIRED check ' + - 'before any gate logic ran (objectui#8099); route it through the shared script so the ' + - 'retry and the self-describing step name apply here too.', - ).toEqual([]); - - // Anti-vacuity for the reader above: the comment-only mentions must still be - // THERE, or this assertion is passing because the corpus emptied out. - const commentOnly = files.filter( - ({ yaml }) => /\bcorepack\b/.test(yaml) && !runSteps(yaml).some((s) => /\bcorepack\b/.test(s)), - ); - expect( - commentOnly.map((w) => w.file), - 'the two comment-only corepack mentions must stay visible to this scan and stay unjudged — ' + - 'if they are gone, re-derive whether this rule still reads what it claims to read', - ).toEqual(['changelog.yml', 'dependabot-auto-merge.yml']); - }); - - it('every workflow that invokes pnpm also runs the shared setup', () => { - const files = workflows(); - - const usesPnpm = files.filter(({ yaml }) => runSteps(yaml).some((s) => /\bpnpm\b/.test(s))); - expect( - usesPnpm.length, - 'no workflow appears to invoke pnpm, which cannot be true — the step reader has drifted', - ).toBeGreaterThan(0); + // `no workflow runs corepack outside the shared script`, `every workflow that + // invokes pnpm also runs the shared setup` and `every job that calls the + // setup script checks the repository out first` retired with + // objectstack#25712. They swept objectui's workflows under + // `ui/.github/workflows/` for calls to this script, and those run nowhere + // under the monorepo: GitHub runs the root `.github/workflows/` alone. What + // objectui's CI became is the four root `* UI` jobs (objectstack#25697), + // which set pnpm up through the root composite action + // `./.github/actions/setup-pnpm`, objectstack's answer to the same Corepack + // fetch. The describe below asks the same three questions of those jobs, by + // path. This script and its self-test stay; no root job calls it. +}); - const missing = usesPnpm - .filter(({ yaml }) => !runSteps(yaml).some((s) => s.includes(SETUP))) - .map(({ file }) => file); +describe('the root ui jobs set pnpm up through the root shared setup', () => { + const SHARED = 'uses: ./.github/actions/setup-pnpm'; + const jobs = Object.values(UI_JOBS).map((key) => ({ key, block: withoutComments(jobBlock(rootCi(), key)) })); - expect( - missing, - `these workflows run pnpm without calling \`bash ${SETUP}\` first. pnpm is not on the runner: ` + - 'the first invocation downloads it from registry.npmjs.org, and unrouted that download lands ' + - "inside whichever step happens to touch pnpm first — including `actions/setup-node`'s own " + - 'cache probe, where a red is hardest to attribute (objectui#8099).', - ).toEqual([]); + it.each(jobs)('$key runs no corepack outside the shared setup', ({ block }) => { + expect(runSteps(block).filter((s) => /\bcorepack\b/.test(s))).toEqual([]); }); - it('every job that calls the setup script checks the repository out first', () => { - const files = workflows().filter(({ yaml }) => yaml.includes(SETUP)); - expect(files.length, `no workflow references ${SETUP} — the sweep was undone`).toBeGreaterThan(0); - - const bad: string[] = []; - for (const { file, yaml } of files) { - const lines = yaml.split('\n'); - // Job blocks: two-space keys inside the `jobs:` mapping. - const jobsAt = lines.findIndex((l) => /^jobs:[ \t]*$/.test(l)); - expect(jobsAt, `${file} must have a top-level \`jobs:\` mapping`).toBeGreaterThan(-1); - - const starts: number[] = []; - for (let i = jobsAt + 1; i < lines.length; i++) { - if (/^ {2}[a-z0-9][a-z0-9-]*:[ \t]*$/.test(lines[i])) starts.push(i); - } - starts.push(lines.length); - - for (let s = 0; s + 1 < starts.length; s++) { - const block = lines.slice(starts[s], starts[s + 1]); - const setupAt = block.findIndex((l) => l.includes(SETUP)); - if (setupAt === -1) continue; - const checkoutAt = block.findIndex((l) => /uses:\s*actions\/checkout@/.test(l)); - if (checkoutAt === -1 || checkoutAt > setupAt) { - bad.push(`${file} :: ${block[0].trim()}`); - } - } - } + it.each(jobs)('$key checks the repository out, then sets pnpm up, before its first pnpm', ({ block }) => { + const checkoutAt = block.search(/uses:\s*actions\/checkout@/); + const setupAt = block.indexOf(SHARED); + expect(checkoutAt, 'the job checks nothing out').toBeGreaterThan(-1); + expect(setupAt, `the job has no \`${SHARED}\` step after its checkout`).toBeGreaterThan(checkoutAt); + const firstPnpm = runSteps(block).find((s) => /\bpnpm\b/.test(s)); + expect(firstPnpm, 'the job runs no pnpm at all, so this reading has drifted').toBeDefined(); expect( - bad, - `${SETUP} is a file in this repository, so a job that runs it before \`actions/checkout\` ` + - 'dies with "No such file or directory" — a failure that reads nothing like a toolchain problem.', - ).toEqual([]); + block.indexOf(firstPnpm!.split('\n')[0]), + 'the job runs pnpm before the shared setup has put it on the runner', + ).toBeGreaterThan(setupAt); }); }); diff --git a/ui/scripts/__tests__/dependabot-merge-gate.test.ts b/ui/scripts/__tests__/dependabot-merge-gate.test.ts index 8dd6f369035..378f8f46819 100644 --- a/ui/scripts/__tests__/dependabot-merge-gate.test.ts +++ b/ui/scripts/__tests__/dependabot-merge-gate.test.ts @@ -13,12 +13,6 @@ import { renderVerdict, waitForGate, } from '../dependabot-merge-gate.mjs'; -import { - type Workflow, - producedCheckNames, - pullRequestTrigger, - readWorkflows, -} from './workflow-checks.js'; /** * objectui#4973 — `dependabot-auto-merge.yml` merged a pull request 8m20s before @@ -66,11 +60,16 @@ import { * request and can therefore be waited for), and every optional context must come * from one that has such a filter (which is the entire reason it cannot be * required). Move a filter and the bucket, not just the comment, goes red. + * + * ⚠️ Both halves retired with objectstack#25712. They read objectui's + * `pull_request` workflows under `ui/.github/workflows/`, which run nowhere + * under the monorepo, and this gate's one caller, `dependabot-auto-merge.yml`, + * is one of them. The note above the bucket describe at the end of this file + * says who brings them back. */ const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, '..', '..'); -const workflowDir = path.join(repoRoot, '.github', 'workflows'); // ── The incident, as GitHub recorded it ────────────────────────────────────── // PR #4959, head SHA 31745d8b6805dc829c05660a9592e92ca537bc3b, merged by @@ -153,9 +152,10 @@ function allGreenSnapshot() { * produces (the partition assertion below). So the reconciliation is this one * dated list, read by exactly one assertion. * - * ⛔ It is not an escape hatch. The control beneath that assertion re-derives - * the produced set and fails if any name here IS still produced, so a live - * unclassified context cannot be parked in it. + * ⛔ It is not an escape hatch. Its control, which re-derived the produced set + * and failed if any name here was still produced, retired with the partition + * in objectstack#25712 (the note above the bucket describe at the end of this + * file), so nothing checks an addition here until that control is back. */ const RENAMED_SINCE_4959 = [ 'Test (shard 1/4)', @@ -299,20 +299,10 @@ describe('the #4959 counterfactual: this gate stops the merge that happened', () ...Object.keys(NOT_A_GATE), ]); - const produced = producedCheckNames(); - - // The control for the exemption: a name may only sit in - // `RENAMED_SINCE_4959` while the repository really has stopped producing - // it. Without this, parking a live unclassified context in that list would - // silently shrink the gate — the failure mode this whole file is about. - for (const name of RENAMED_SINCE_4959) { - expect( - produced.has(name), - `${name} is exempted as renamed-since-#4959, but a pull_request workflow still produces ` + - `it — classify it in one of the three buckets instead of exempting it`, - ).toBe(false); - } - + // The control for the exemption retired with objectstack#25712. It derived + // the produced set from objectui's `pull_request` workflows, which run + // nowhere under the monorepo; the note above the bucket describe at the end + // of this file says who brings it back. for (const run of INCIDENT_4959) { if (RENAMED_SINCE_4959.includes(run.name)) continue; expect(classified).toContain(run.name); @@ -522,75 +512,25 @@ describe('the refusal is legible', () => { }); }); -// ── The buckets versus the workflows that actually produce the checks ──────── +// ── The buckets' boundary and their reasons ────────────────────────────────── // -// The parser these assertions run on now lives in `./workflow-checks.ts`, so -// that `merge-queue-reporting.test.ts` can derive its `merge_group` floor from -// the same answer instead of parsing the workflows a second time (objectui#6160). - -describe('the declared buckets partition what a pull request actually produces', () => { - const workflows = readWorkflows().filter((workflow) => pullRequestTrigger(workflow).subscribes); - const produced = producedCheckNames(); - - it('found the workflows and the shard matrix (the parser still parses)', () => { - expect(workflows.map((w) => w.file)).toContain('ci.yml'); - expect([...produced.keys()]).toEqual( - expect.arrayContaining(['Test (shard 1/16)', 'Test (shard 16/16)', 'Test', 'Lint', 'dependabot', 'label']), - ); - // No unexpanded template survived the matrix expansion. - for (const name of produced.keys()) expect(name).not.toContain('${{'); - }); - - it('classifies every produced check exactly once', () => { - const buckets = [ - ...REQUIRED_CONTEXTS.map((name) => [name, 'required'] as const), - ...Object.keys(OPTIONAL_CONTEXTS).map((name) => [name, 'optional'] as const), - ...Object.keys(NOT_A_GATE).map((name) => [name, 'not-a-gate'] as const), - ]; - - const seen = new Map(); - const duplicated: string[] = []; - for (const [name, bucket] of buckets) { - if (seen.has(name)) duplicated.push(`${name} (${seen.get(name)} + ${bucket})`); - seen.set(name, bucket); - } - expect(duplicated).toEqual([]); - - const unclassified = [...produced.keys()].filter((name) => !seen.has(name)); - expect( - unclassified, - `these checks run on every pull request but the gate does not classify them: ${unclassified.join(', ')}`, - ).toEqual([]); - - const unproduced = [...seen.keys()].filter((name) => !produced.has(name)); - expect( - unproduced, - `the gate names checks that no pull-request workflow produces (renamed or deleted?): ${unproduced.join(', ')}`, - ).toEqual([]); - }); - - it('requires only contexts whose workflow carries NO path filter', () => { - const filtered = new Map( - readWorkflows().map((workflow) => [workflow.file, pullRequestTrigger(workflow).filtered]), - ); - - for (const name of REQUIRED_CONTEXTS) { - const file = produced.get(name); - expect(file, `${name} is required but nothing produces it`).toBeTruthy(); - expect( - filtered.get(file as string), - `${name} is required, but ${file} filters its pull_request trigger by path — it cannot be waited for on every PR (#3523)`, - ).toBe(false); - } - }); - - it('marks optional exactly those blocking contexts whose workflow IS path-filtered', () => { - for (const name of Object.keys(OPTIONAL_CONTEXTS)) { - const file = produced.get(name); - expect(pullRequestTrigger(readWorkflows().find((w) => w.file === file) as Workflow).filtered).toBe(true); - } - }); - +// `found the workflows and the shard matrix (the parser still parses)`, +// `classifies every produced check exactly once`, `requires only contexts whose +// workflow carries NO path filter` and `marks optional exactly those blocking +// contexts whose workflow IS path-filtered` retired with objectstack#25712, +// together with the exemption control in the counterfactual describe. They +// partitioned the three buckets against the check names objectui's +// `pull_request` workflows produce, read through `./workflow-checks.ts` from +// `ui/.github/workflows/`. That directory runs nowhere under the monorepo: +// GitHub runs the root `.github/workflows/` alone, and those produce other +// names (`Lint UI`, `Test UI (n/16)`). The buckets still hold objectui's names +// because the gate's one caller, `dependabot-auto-merge.yml`, runs nowhere +// either: it is row 9 of the objectstack#25697 workflow roster (→ sub-issue 4), +// and no root workflow merges Dependabot pull requests. Sub-issue 4 decides the +// monorepo's Dependabot merge. A port re-derives the buckets from the root +// workflows and brings these pins back against them, by path. + +describe('the declared buckets state their boundary and their reasons', () => { it('writes down the check-runs-only boundary, so the scope is not inferred from the endpoint', () => { // A third-party commit STATUS (this repository has one: `Vercel`) is not a // check run and is not read by the gate. That boundary has to be stated @@ -608,51 +548,11 @@ describe('the declared buckets partition what a pull request actually produces', }); }); -describe('the workflow cannot merge without consulting the gate', () => { - const workflow = fs.readFileSync(path.join(workflowDir, 'dependabot-auto-merge.yml'), 'utf8'); - const steps = workflow.split(/^ {6}- name: /m).slice(1); - - it('has no `gh pr merge` that is not behind a green gate', () => { - const merging = steps.filter((step) => /gh pr merge/.test(step)); - - expect(merging.length).toBeGreaterThan(0); - for (const step of merging) { - expect(step, 'a `gh pr merge` step must be conditioned on the gate verdict').toMatch( - /if:\s*steps\.gate\.outputs\.gate == 'green'/, - ); - } - }); - - it('does not approve before the gate is green either', () => { - for (const step of steps.filter((step) => /gh pr review --approve/.test(step))) { - expect(step).toMatch(/if:\s*steps\.gate\.outputs\.gate == 'green'/); - } - }); - - it('runs the gate script and tells it which SHA to judge', () => { - expect(workflow).toContain('node scripts/dependabot-merge-gate.mjs'); - expect(workflow).toMatch(/HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha }}/); - // Pins the merge to the judged SHA: a push that lands during the wait must - // not be enqueued on the strength of the previous commit's checks. - expect(workflow).toContain('--match-head-commit'); - }); - - it('grants `checks: read`, without which the gate 403s', () => { - expect(workflow).toMatch(/^\s*checks: read$/m); - }); - - it('fails the job when the gate refuses, so the PR carries a red check', () => { - expect(workflow).toMatch(/steps\.gate\.outputs\.gate != 'green'/); - }); - - it('keeps the semver policy the issue did not ask to change', () => { - expect(workflow).toContain('version-update:semver-patch'); - expect(workflow).toContain('version-update:semver-minor'); - expect(workflow).toMatch(/steps\.metadata\.outputs\.update-type == 'version-update:semver-major'/); - }); - - // No merge-driver assertion here any more (objectui#6369). It existed to hold - // this workflow to the row `ci-cd-pipeline.md` pinned for it; that row is gone, - // because the only merge this workflow performs is server-side `gh pr merge` - // and a local driver cannot participate in one. -}); +// `the workflow cannot merge without consulting the gate` retired with +// objectstack#25712. Its six cases read objectui's `dependabot-auto-merge.yml`: +// the merge and approve steps behind a green gate, `HEAD_SHA` and +// `--match-head-commit`, `checks: read`, the red job on a refusal, and the +// semver policy. That workflow runs nowhere under the monorepo, and no root +// workflow merges Dependabot pull requests. It is row 9 of the +// objectstack#25697 workflow roster (→ sub-issue 4); a port brings these pins +// back against the root workflow that runs this gate, by path. diff --git a/ui/scripts/__tests__/doc-version-claims.test.ts b/ui/scripts/__tests__/doc-version-claims.test.ts index c15b33ec1e5..4e6ca427faa 100644 --- a/ui/scripts/__tests__/doc-version-claims.test.ts +++ b/ui/scripts/__tests__/doc-version-claims.test.ts @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { rootWorkflowsDir } from './helpers/root-ci'; + /** * objectui#3697: nothing in this repository looks at a version literal written * into documentation prose. @@ -1652,7 +1654,11 @@ const skeletonChecks: SkeletonCheck[] = KNOWN_CLAIMS.flatMap((entry) => { /** * The directory that IS this repository's CI, and the anchor source for every - * `workflowVersionKey` entry above (objectui#6400). + * `workflowVersionKey` entry above (objectui#6400): the root `.github/workflows/`, the only + * workflows GitHub runs under the monorepo. Until objectstack#25712 this read objectui's + * own, under `ui/.github/workflows/`, which runs nowhere; the spellings the notes in this + * file count (`'22.x'` in most lanes, `'22'` in `half-state-patrol.yml`) are that + * directory's. objectstack#25712 measured every root declaration at `'22'`. * * Not a scan root and never one: no claim is ever recorded FROM these files. They are read * only as the truth a doc claim is measured against, exactly as `pluginDeclaredRanges` @@ -1660,7 +1666,7 @@ const skeletonChecks: SkeletonCheck[] = KNOWN_CLAIMS.flatMap((entry) => { * this tree already states machine-readably, so a reviewer should not be the thing that * re-reads it. */ -const WORKFLOWS_DIR = '.github/workflows'; +const WORKFLOWS_DIR = path.relative(repoRoot, rootWorkflowsDir); /** One `: ` declaration found in one workflow file. */ interface WorkflowDeclaration { @@ -1761,6 +1767,10 @@ function workflowVersionDeclarations( * the census cross-check. Should `parseWorkflowVersionLine` ever stop recognising a * spelling that is in the tree, these two lists stop being the same list and the assertion * says so, rather than the anchor quietly shrinking by one lane. + * + * It skips whole-line YAML comments, which declare nothing: the root workflows quote a + * commented setup example in `half-state-patrol.yml`'s usage block, and counting that + * would read the parser's correct silence on it as a lost lane (objectstack#25712). */ function workflowKeyMentions( key: string, @@ -1772,6 +1782,7 @@ function workflowKeyMentions( for (const name of workflowFiles(dir)) { const lines = fs.readFileSync(path.join(dir, name), 'utf8').split('\n'); for (let i = 0; i < lines.length; i++) { + if (/^\s*#/.test(lines[i])) continue; if (re.test(lines[i])) found.push({ file: name, line: i + 1 }); } } diff --git a/ui/scripts/__tests__/labeler-package-coverage.test.ts b/ui/scripts/__tests__/labeler-package-coverage.test.ts deleted file mode 100644 index c8a24c0c9d5..00000000000 --- a/ui/scripts/__tests__/labeler-package-coverage.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse as parseYaml } from 'yaml'; - -/** - * objectui#7746: `.github/labeler.yml` had no `package: fields` entry, so every PR - * touching `packages/fields` went unlabelled. Measured on PR #7621 (merged - * 2026-09-05), which changes `packages/fields/src/index.tsx` and - * `packages/fields/src/__tests__/datetime-compact-style-7443.test.tsx`: it carries - * exactly `package: core`, `package: components` and `tests` — no fields label. - * - * The path labels are not decoration here: they are how a sweeping seat finds the PRs - * that touch a package. A package whose PRs never get labelled is a package whose PRs - * are invisible to any query keyed on `package: *` — and such a query returns a - * confident, short, wrong answer rather than an error. - * - * `fields` was not the only one. Enumerating `packages/*` against the config found - * **16 of 40** directories drawing no label at all, against a file that named - * `package: *` for exactly four of them (`core`, `types`, `react`, `components`). - * Twenty of the remaining directories are `plugin-*`, covered by the `plugin` family, - * and `data-objectstack` is covered by `data-adapter` — which is why the assertion - * below is "at least one label", not "a `package: *` label": this config runs three - * parallel families keyed on directory prefix, and demanding a `package:` entry for a - * plugin would invent a convention beside the ones the file already has. - * - * That is the shape this file exists to stop, and the reason it is a gate rather than a - * bigger config edit: **nothing reads `.github/labeler.yml` except the action that - * consumes it.** A missing entry produces no error, no failed run and no diff — only a - * successful workflow applying a slightly wrong set of labels, which no one has a reason - * to inspect. A config gap that no gate watches is exactly how this one survived to be - * found by a human reviewer noticing an odd label set on an unrelated PR. - * - * Three directions are pinned, because the config can drift from `packages/` both ways - * and the workflow can drift out from under both: - * - * 1. **Add a package, forget the label.** The #7746 direction. - * 2. **Delete a package, forget the rule.** The mirror. It was live when this file - * landed: the `designer` rule targeted `packages/designer`, whose `package.json` - * was deleted in `21396ca4d`, so it had matched nothing since — carried here as a - * NAMED exemption while objectui#7771 decided delete-vs-retarget. That card ruled - * **delete**: an unmeasured successor relationship (`packages/plugin-designer`) - * does not buy a retarget, which would silently change what the `designer` label - * means relative to every historical PR carrying it. The rule left the config and - * its exemption row left this file, so the direction below is now enforced with no - * waiver standing between it and the config. `UNRESOLVABLE_GLOB_EXEMPT` is the one - * place that says whether any waiver is outstanding; ⛔ do not restate it here. - * 3. **Add rules for labels that do not exist yet, then drop the permission that - * lets them be created.** See the workflow assertion at the bottom of this file. - * - * Deliberately NOT pinned: anything about the repository's live label registry. Labels - * exist server-side, this test reads the checked-in tree, and a gate that cannot see its - * subject would be the "declared but never enforced" shape one level up. The registry - * drifts objectui#7771 records (`plugin: chatbot` registered with no rule, `plugin-view` - * spelled outside its family's convention) are therefore invisible here, on purpose. - */ -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const labelerPath = path.join(repoRoot, '.github/labeler.yml'); -const workflowPath = path.join(repoRoot, '.github/workflows/labeler.yml'); -const packagesDir = path.join(repoRoot, 'packages'); - -/** - * Packages that intentionally draw no `package: *` label, each with the reason. - * - * An exemption LIST rather than a rule ("skip private packages") on purpose: the list is - * reviewable in a diff and forces a sentence per entry, where a rule would silently - * absorb every future private package — including one that should have been labelled. - * The same idiom `DOCUMENTATION_EXEMPT` uses in `ci-cd-pipeline-doc.test.ts`. - * - * Both entries below are `private: true` and were left out because whether the - * maintainer WANTS unpublished packages in the `package: *` taxonomy is a question this - * gate must not answer by itself. Adding either one is a one-line config change plus - * deleting its row here. - */ -const UNLABELLED_BY_DESIGN = new Map([ - [ - 'test-support', - '`@object-ui/test-support` is `private: true` — an internal test helper that is ' + - 'never published, so it has no consumer-facing surface a `package: *` sweep would ' + - 'be looking for.', - ], - [ - 'vscode-extension', - 'Published as the VS Code extension `object-ui` (`private: true`), not as an ' + - '`@object-ui/*` npm package — it sits outside the scope the label family names.', - ], -]); - -/** - * `packages/`-rooted globs allowed to match no existing directory, each with the reason. - * Empty is the healthy state; an entry here is a live defect with a card, not a waiver. - */ -const UNRESOLVABLE_GLOB_EXEMPT = new Map([]); - -/** - * Glob constructs this file models. Anything else makes the test THROW rather than quietly - * mis-answer: an unmodelled construct would make a real rule read as "matches nothing", - * which is the same silent-wrong-answer failure the whole file is about. Fail closed — - * a new glob shape should cost someone a deliberate edit here. - */ -const UNMODELLED_GLOB_SYNTAX = /[?[\]{}()!+@]/; - -function globToRegExp(glob: string): RegExp { - if (UNMODELLED_GLOB_SYNTAX.test(glob)) { - throw new Error( - `.github/labeler.yml uses a glob construct this gate does not model: ${glob}\n` + - 'Extend globToRegExp() in this file (and its lit control) before adding it, so ' + - 'the rule is not silently read as matching nothing.', - ); - } - - let source = ''; - let i = 0; - while (i < glob.length) { - const char = glob[i]; - if (char === '*') { - if (glob[i + 1] === '*') { - if (glob[i + 2] === '/') { - // `**/` spans zero or more whole path segments. - source += '(?:[^/]+/)*'; - i += 3; - } else { - source += '.*'; - i += 2; - } - } else { - // A single `*` never crosses a path separator. - source += '[^/]*'; - i += 1; - } - } else { - source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&'); - i += 1; - } - } - return new RegExp(`^${source}$`); -} - -/** Every glob in the config, keyed by the label it draws. */ -function readLabelerRules(): Map { - const config: unknown = parseYaml(fs.readFileSync(labelerPath, 'utf8')); - if (config === null || typeof config !== 'object') { - throw new Error('.github/labeler.yml did not parse to a mapping'); - } - - const rules = new Map(); - for (const [label, clauses] of Object.entries(config as Record)) { - const globs: string[] = []; - for (const clause of clauses as unknown[]) { - for (const matchers of Object.values(clause as Record)) { - for (const matcher of matchers as unknown[]) { - for (const value of Object.values(matcher as Record)) { - globs.push(...(Array.isArray(value) ? (value as string[]) : [value as string])); - } - } - } - } - rules.set(label, globs); - } - return rules; -} - -/** Directory names under `packages/`, which are the population the labels must cover. */ -function readPackageDirs(): string[] { - return fs - .readdirSync(packagesDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); -} - -const rules = readLabelerRules(); -const packageDirs = readPackageDirs(); - -/** - * A non-test source file, which is what a package's PRs actually change. Deliberately - * NOT `README.md` or `package.json`: both are matched by repo-wide rules - * (`documentation`, `dependencies`), so probing with either would report coverage that - * the per-package families do not actually provide. The path need not exist on disk — - * the subject here is the config, not the tree. - */ -function probePathFor(pkg: string): string { - return `packages/${pkg}/src/index.ts`; -} - -function labelsDrawnBy(filePath: string): string[] { - const drawn: string[] = []; - for (const [label, globs] of rules) { - if (globs.some((glob) => globToRegExp(glob).test(filePath))) drawn.push(label); - } - return drawn; -} - -describe('.github/labeler.yml package coverage', () => { - /** - * The lit control. Every "package X draws nothing" reading below is only worth as much - * as the query that produced it, and a matcher that silently matched nothing would make - * this whole file pass while asserting the opposite of the truth. `core` is in the - * config, so it must come back lit — if this fails, no other result here means anything. - */ - it('draws the expected label for a package that IS in the config (lit control)', () => { - expect(labelsDrawnBy(probePathFor('core'))).toContain('package: core'); - expect(labelsDrawnBy(probePathFor('plugin-kanban'))).toEqual( - expect.arrayContaining(['plugin', 'plugin: kanban']), - ); - expect(labelsDrawnBy(probePathFor('data-objectstack'))).toContain('data-adapter'); - // The negative half of the control: the matcher must not match everything. - expect(labelsDrawnBy('README.md')).not.toContain('package: core'); - }); - - it('gives every package under packages/ at least one label', () => { - const unlabelled = packageDirs - .filter((pkg) => !UNLABELLED_BY_DESIGN.has(pkg)) - .filter((pkg) => labelsDrawnBy(probePathFor(pkg)).length === 0); - - expect( - unlabelled, - `These packages draw no label at all, so their PRs are invisible to any sweep ` + - `keyed on \`package: *\` (objectui#7746):\n` + - unlabelled.map((pkg) => ` - ${pkg}`).join('\n') + - `\n\nAdd an entry to .github/labeler.yml in the shape the file already uses:\n` + - ` 'package: NAME':\n - changed-files:\n - any-glob-to-any-file: 'packages/NAME/**/*'\n` + - `\nIf the package should NOT be labelled, add it to UNLABELLED_BY_DESIGN in this ` + - `file with the reason — the exemption is reviewable, skipping the gate is not.`, - ).toEqual([]); - }); - - it('never keeps a packages/ rule pointing at a directory that does not exist', () => { - const existing = new Set(packageDirs); - const dangling: string[] = []; - - for (const [label, globs] of rules) { - for (const glob of globs) { - if (!glob.startsWith('packages/')) continue; - const segment = glob.split('/')[1]; - // `packages/**/README.md` and friends are repo-wide rules, not package rules. - if (segment === undefined || segment === '**') continue; - if (UNRESOLVABLE_GLOB_EXEMPT.has(glob)) continue; - - const segmentMatcher = globToRegExp(segment); - if (![...existing].some((dir) => segmentMatcher.test(dir))) { - dangling.push(`${label} -> ${glob}`); - } - } - } - - expect( - dangling, - `These labeler rules target a directory that no longer exists under packages/, so ` + - `they can never fire — the mirror of objectui#7746, and just as silent:\n` + - dangling.map((entry) => ` - ${entry}`).join('\n'), - ).toEqual([]); - }); - - it('spells each `package: X` label to match the directory its glob targets', () => { - const mismatched: string[] = []; - for (const [label, globs] of rules) { - const named = /^package: (.+)$/.exec(label); - if (!named) continue; - const expected = `packages/${named[1]}/**/*`; - if (!globs.includes(expected)) mismatched.push(`${label} -> ${globs.join(', ')}`); - } - - expect( - mismatched, - `A \`package: X\` label whose glob does not target \`packages/X/**/*\` labels the ` + - `wrong package, which reads as coverage while pointing somewhere else:\n` + - mismatched.map((entry) => ` - ${entry}`).join('\n'), - ).toEqual([]); - }); - - it('keeps both exemption lists honest', () => { - for (const [pkg, reason] of UNLABELLED_BY_DESIGN) { - expect( - packageDirs, - `UNLABELLED_BY_DESIGN names ${pkg}, which is no longer a directory under packages/ — drop it`, - ).toContain(pkg); - expect( - reason.length, - `UNLABELLED_BY_DESIGN[${pkg}] must carry a real justification`, - ).toBeGreaterThan(20); - } - - const allGlobs = new Set([...rules.values()].flat()); - for (const [glob, reason] of UNRESOLVABLE_GLOB_EXEMPT) { - expect( - [...allGlobs], - `UNRESOLVABLE_GLOB_EXEMPT names ${glob}, which .github/labeler.yml no longer ` + - `contains — the defect it waives is fixed, so drop the row`, - ).toContain(glob); - expect( - reason.length, - `UNRESOLVABLE_GLOB_EXEMPT[${glob}] must carry a real justification`, - ).toBeGreaterThan(20); - } - }); - - /** - * objectui#10012: no rule named `content/**`, the published docs site, so a PR that - * touched only that tree drew no label at all — the package gap above, one tree over. - * - * The assertion names `documentation` rather than "at least one label" on purpose. The - * workflow runs with `sync-labels: true`, and actions/labeler v7's `src/labeler.ts` - * deletes from the PR's existing labels every label this config names whose globs match - * nothing (its `else if (syncLabels)` branch) — so `documentation` hung by hand on such a - * PR comes off on the next push. Drawing some other label would leave that intact. - * - * One tree, not a population: whether every top-level tree must draw a label is a - * decision objectui#10012 left open, and this assertion does not take it. - * - * The probe page does not exist, deliberately — as with `probePathFor`, the subject is - * the config. A real page here would also enrol this file in - * `scripts/markdown-test-inputs.mjs` as a reader of that page, which it is not. - */ - it('labels a change to the published docs tree `documentation`', () => { - expect(labelsDrawnBy('content/docs/guide/labeler-coverage-probe.mdx')).toContain( - 'documentation', - ); - }); - - /** - * The third direction, and the one with no other witness in the tree. - * - * `pull-requests: write` only lets actions/labeler add labels that ALREADY EXIST; - * creating one it has never seen needs `issues: write` (v7 README "Recommended - * Permissions", and the error its `src/labeler.ts` raises on a 403). Twelve of the - * fourteen labels #7746 added did not exist in this repository's registry when the - * rules landed, and the action applies the whole set in ONE `setLabels` call — so - * dropping this permission does not degrade to "the new label is missing", it makes - * the call fail and the PR get NO labels at all. - * - * Parsed with the YAML library rather than grepped, so a `permissions:` block that - * only DISCUSSES `issues: write` in a comment cannot satisfy it. - */ - it('keeps the labeler workflow able to create labels this config names', () => { - const workflow = parseYaml(fs.readFileSync(workflowPath, 'utf8')) as { - permissions?: Record; - }; - - expect( - workflow.permissions?.issues, - `.github/workflows/labeler.yml must grant \`issues: write\`. Without it, the first ` + - `PR matching a rule whose label is not already in the repository's label registry ` + - `fails the action's single setLabels call and receives NO labels at all — a ` + - `regression strictly worse than the objectui#7746 coverage gap this config closed. ` + - `If the permission is deliberately dropped, every \`package: *\` label named in ` + - `.github/labeler.yml must first be created in the repository by hand.`, - ).toBe('write'); - - expect( - workflow.permissions?.['pull-requests'], - '.github/workflows/labeler.yml must still grant `pull-requests: write` — `issues: write` ' + - 'covers creating a label, not attaching it to a pull request.', - ).toBe('write'); - }); -}); diff --git a/ui/scripts/__tests__/merge-queue-reporting.test.ts b/ui/scripts/__tests__/merge-queue-reporting.test.ts index e65c9803f64..b544e53beff 100644 --- a/ui/scripts/__tests__/merge-queue-reporting.test.ts +++ b/ui/scripts/__tests__/merge-queue-reporting.test.ts @@ -3,9 +3,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; -import { producedCheckNames, readWorkflows, subscribesMergeGroup } from './workflow-checks.js'; - /** * objectui#3523 — the merge queue was enforced and validated nothing. * @@ -45,9 +42,13 @@ import { producedCheckNames, readWorkflows, subscribesMergeGroup } from './workf * Deliberately NOT asserted: which contexts are actually required. That lives in * repository settings, which no test in this repo can read and no agent may * change. + * + * ⚠️ objectstack#25712 retired every assertion here that read a workflow: they + * read objectui's, which sit under `ui/.github/workflows/` and run nowhere under + * the monorepo. The note above the CI page section below lists them and the root + * gates that carry both holes now. That section reads no workflow and stays. */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const workflowDir = path.join(repoRoot, '.github/workflows'); const DOC = 'content/docs/guide/ci-cd-pipeline.md'; const doc = fs.readFileSync(path.join(repoRoot, DOC), 'utf8'); @@ -55,8 +56,8 @@ const doc = fs.readFileSync(path.join(repoRoot, DOC), 'utf8'); * `filename -> why this workflow must subscribe merge_group`. * * Hand-maintained, and it carries the REASONS only — since objectui#6160 it no - * longer decides the MEMBERSHIP. `DERIVED_MERGE_GROUP_FLOOR` below does that, - * out of `REQUIRED_CONTEXTS`. + * longer decides the MEMBERSHIP. The floor derived out of `REQUIRED_CONTEXTS` + * did that until objectstack#25712 retired it (the note below). * * The header this replaced said the set could not be derived at all, because * "may this context be required?" is a property of the repository's settings and @@ -69,12 +70,11 @@ const doc = fs.readFileSync(path.join(repoRoot, DOC), 'utf8'); * anyone acted on that it was seven against ten, because PR #6159 added its own * entry by hand without closing the class (objectui#6160). * - * Two mechanical honesty checks keep what is left of it true: an entry naming a - * workflow that no longer exists fails, so the list cannot rot into a - * comfortable fiction the way a stale count does (#3261); and an entry naming a - * workflow that produces no required context fails, so this map can only ever be - * a SUBSET of the derived floor and the derivation cannot silently narrow past - * it. + * Its two mechanical honesty checks (an entry naming a workflow that no longer + * exists failed; an entry naming a workflow that produces no required context + * failed) retired with objectstack#25712, with the floor they held it under: + * both read objectui's workflows. The CI page section below still reads the + * map, as the list the page points at. */ const MUST_SUBSCRIBE_MERGE_GROUP = new Map([ ['ci.yml', 'produces Type Check, Build & E2E, Test (the shard aggregator) and Changeset Fixed Group Check'], @@ -107,428 +107,29 @@ const MUST_SUBSCRIBE_MERGE_GROUP = new Map([ ], ]); -/** - * The floor, DERIVED — objectui#6160. - * - * ## What it is protecting - * - * `main` sits behind an enforced merge queue (#3243). A context the repository - * REQUIRES, produced by a workflow that does not subscribe `merge_group`, never - * reports on the queue build — and a required check that never reports does not - * FAIL the queue, it STALLS it until the ruleset's 60-minute status-check - * timeout. That is the whole reason a floor exists here at all: objectui#3523, - * where nothing subscribed, the queue's required set could therefore only be - * empty, and #3503 / #3510 / #3516 merged on 2026-08-07 with `Type Check` at - * `conclusion=failure`. - * - * ## Why it derives instead of being listed - * - * `REQUIRED_CONTEXTS` is the other place this repository writes down "this check - * is blocking and reports on every pull request". Every name in it is a check a - * maintainer may put in the required set, so every workflow producing one is - * exactly a workflow that must subscribe `merge_group`. Reading the membership - * off that list instead of off a second hand-kept one means a gate added to - * `REQUIRED_CONTEXTS` is inside this floor the moment it is added, with nobody - * having to remember a second file. - * - * The check names come from `REQUIRED_CONTEXTS`; the name -> workflow-file - * mapping is `producedCheckNames()`, the same parser - * `dependabot-merge-gate.test.ts` partitions its buckets with. One parser, so - * the two files cannot disagree about which workflow produces what. - * - * ## The precondition, and why it is pinned rather than assumed - * - * A derived floor is an improvement only while it is a SUPERSET of what the hand - * map named. A derivation that quietly narrows would read as more coverage while - * asserting less — this card's own defect, one level up. So the containment is - * itself an assertion below ("loses nothing the hand-maintained map named"), and - * so is the resolvability of every `REQUIRED_CONTEXTS` name: a renamed check that - * resolves to no workflow drops that workflow out of the floor silently, which is - * the same narrowing wearing a different hat. - * - * Measured on this tree when the derivation landed: every one of the map's - * entries produces a `REQUIRED_CONTEXTS` check (nothing is lost) and every - * workflow producing one subscribes `merge_group` (the floor is satisfiable - * today, and no workflow YAML needed changing — the missing thing was the - * assertion, not the trigger). - */ -function deriveMergeGroupFloor(): { byWorkflow: Map; unresolved: string[] } { - const produced = producedCheckNames(); - const byWorkflow = new Map(); - const unresolved: string[] = []; - - for (const context of REQUIRED_CONTEXTS) { - const file = produced.get(context); - if (!file) { - unresolved.push(context); - continue; - } - byWorkflow.set(file, [...(byWorkflow.get(file) ?? []), context]); - } - - return { byWorkflow, unresolved }; -} - -const DERIVED_MERGE_GROUP_FLOOR = deriveMergeGroupFloor(); - -/** Workflows whose path filtering had to move from the trigger into the jobs. */ -const FILTER_MOVED_INTO_JOBS = ['ci.yml', 'lint.yml']; - -const read = (file: string): string => fs.readFileSync(path.join(workflowDir, file), 'utf8'); - -/** - * A workflow's YAML with whole-line comments removed. Required, not cosmetic: - * every one of these files discusses `paths-ignore`, `merge_group` and the - * incident above in prose, and a scan that counted the prose would report - * triggers and filters that no file has. - */ -function withoutComments(yaml: string): string { - return yaml - .split('\n') - .filter((line) => !/^\s*#/.test(line)) - .join('\n'); -} - -/** A top-level block (`on:`, `jobs:`) up to the next top-level key. */ -function topLevelBlock(yaml: string, key: string): string { - const at = yaml.search(new RegExp(`^${key}:`, 'm')); - expect(at, `the workflow must still have a top-level \`${key}:\``).toBeGreaterThan(-1); - const rest = yaml.slice(at); - const firstLineEnd = rest.indexOf('\n') + 1; - const after = rest.slice(firstLineEnd); - const next = after.search(/^[A-Za-z]/m); - return next === -1 ? rest : rest.slice(0, firstLineEnd + next); -} - -/** One two-space child of `on:` / `jobs:`, up to the next child at that indent. */ -function nestedBlock(block: string, key: string): string { - const at = block.search(new RegExp(`^ {2}${key}:`, 'm')); - if (at === -1) return ''; - const rest = block.slice(at); - const firstLineEnd = rest.indexOf('\n') + 1; - if (firstLineEnd === 0) return rest; - const after = rest.slice(firstLineEnd); - const next = after.search(/^ {2}\S/m); - return next === -1 ? rest : rest.slice(0, firstLineEnd + next); -} - -/** `- 'pattern'` entries — the shape `paths` / `paths-ignore` lists are written in. */ -const quotedEntries = (block: string): string[] => - [...block.matchAll(/^\s*-\s*'([^']+)'/gm)].map((m) => m[1]); - -/** `':(exclude,glob)pattern'` pathspecs — the shape the in-job gate is written in. */ -const excludePathspecs = (yaml: string): string[] => - [...yaml.matchAll(/':\(exclude,glob\)([^']+)'/g)].map((m) => m[1]); - -/** - * Every line capturing a `git diff` into `CHANGED` — one per gate, trimmed. - * - * Lines rather than whole commands, because the fail-open decision is made - * entirely by how the capture OPENS: `if ! CHANGED=$(…` is what turns a nonzero - * `git diff` into `should_run=true`. A capture continued over further lines - * (every one of these is) still contributes exactly one entry, so the count is - * the number of gates. - * - * Callers must strip comments first (`withoutComments`) — both workflows now - * discuss this exact shape in prose, and counting the prose would report gates - * no job has. - */ -const diffCaptureLines = (yaml: string): string[] => - yaml - .split('\n') - .filter((line) => /CHANGED=\$\(\s*git diff/.test(line)) - .map((line) => line.trim()); - -/** - * `file -> the jobs whose diff capture must fail open`. - * - * Hand-maintained for the same reason as `MUST_SUBSCRIBE_MERGE_GROUP`, and used - * only as a FLOOR: the assertion below checks the shape of every capture it - * finds, so a sixth gate is covered the moment it is written, while the floor is - * what makes DELETING one red. Without it, the shape check would pass an empty - * list — green because nothing was produced, not because anything is right. - */ -const FAIL_OPEN_GATES = new Map([ - // `type-check`, `test` and `e2e` share `id: relevant` (objectui#3523 / PR - // #3722); `docs` has its own `id: docs-changes` and predates them (#3450), - // which is how it kept the fail-CLOSED spelling until objectui#3723. - ['ci.yml', ['type-check', 'test', 'e2e', 'docs']], - ['lint.yml', ['lint']], -]); - -describe('every requirable context reports on a merge-queue build (#3523 step 1)', () => { - it('subscribes merge_group in each workflow that produces one', () => { - const missing = [...MUST_SUBSCRIBE_MERGE_GROUP.keys()].filter( - (file) => !/^ {2}merge_group:/m.test(topLevelBlock(withoutComments(read(file)), 'on')), - ); - - expect( - missing, - `These workflows produce a check the repository can require, but do not subscribe ` + - `\`merge_group\`:\n` + - missing.map((f) => ` - ${f} (${MUST_SUBSCRIBE_MERGE_GROUP.get(f)})`).join('\n') + - `\n\nThe merge queue is ENFORCED here (#3243). A required context that does not report ` + - `on a queue build does not fail the queue, it stalls it until the ruleset's 60-minute ` + - `status-check timeout — and while nothing at all subscribes, the queue's required set ` + - `can only be empty, so it rebuilds each PR on the current \`main\` and merges it ` + - `unvalidated. That is not hypothetical: #3503 / #3510 / #3516 merged on 2026-08-07 with ` + - `\`Type Check\` at conclusion=failure (objectui#3523).`, - ).toEqual([]); - }); - - it('keeps the list honest — every name in it is a workflow that exists', () => { - const files = new Set(fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml'))); - for (const [name, reason] of MUST_SUBSCRIBE_MERGE_GROUP) { - expect(files, `MUST_SUBSCRIBE_MERGE_GROUP names ${name}, which no longer exists — drop it`).toContain(name); - expect(reason.length, `MUST_SUBSCRIBE_MERGE_GROUP[${name}] must say which context it produces`).toBeGreaterThan(20); - } - }); - - it('runs the test suite on a queue build, not only on pull requests', () => { - // `if: github.event_name == 'pull_request'` predates the queue and was - // correct while the third event could not happen. Left alone it would make - // the last validation before `main` the ONLY one that skips every shard. - const jobs = topLevelBlock(withoutComments(read('ci.yml')), 'jobs'); - const test = nestedBlock(jobs, 'test'); - expect(test, 'ci.yml must still define a `test:` job').not.toEqual(''); - expect( - test, - `ci.yml's \`test\` job is restricted to \`pull_request\`, so a merge_group build runs no ` + - `tests at all. Write the exclusion as "not push" instead — \`test-coverage\` is the push ` + - `lane, and the queue build is the last check before \`main\` (objectui#3523).`, - ).not.toMatch(/^\s*if: github\.event_name == 'pull_request'\s*$/m); - }); -}); - -describe('the merge_group floor derives itself from REQUIRED_CONTEXTS (#6160)', () => { - const { byWorkflow, unresolved } = DERIVED_MERGE_GROUP_FLOOR; - - it('resolves every required context to the workflow that produces it', () => { - // Non-vacuity guard, and it comes first because the two assertions after it - // are both quantified over `byWorkflow`: a derivation that resolved nothing - // would make them pass while asserting nothing at all — green because - // nothing was produced, the failure mode this whole file is about. - expect( - unresolved, - `These names are in \`REQUIRED_CONTEXTS\` but no \`pull_request\` workflow produces a check ` + - `by that name:\n` + - unresolved.map((name) => ` - ${name}`).join('\n') + - `\n\nEach one is a workflow silently dropped OUT of the merge_group floor below — the ` + - `derivation cannot require a subscription of a workflow it failed to identify. Usually a ` + - `renamed job: fix the name on whichever side is stale. (\`dependabot-merge-gate.test.ts\` ` + - `fails on the same drift from the other direction — there it means the gate waits forever ` + - `for a context nothing creates.)`, - ).toEqual([]); - - expect( - byWorkflow.size, - `The derivation resolved no workflows at all, so the floor below is empty and asserts ` + - `nothing. Either \`REQUIRED_CONTEXTS\` was emptied or \`producedCheckNames()\` stopped ` + - `parsing \`.github/workflows\`.`, - ).toBeGreaterThan(0); - }); - - it('requires merge_group of every workflow that produces a required context', () => { - const workflows = new Map(readWorkflows().map((workflow) => [workflow.file, workflow])); - - const missing = [...byWorkflow.keys()].filter((file) => { - const workflow = workflows.get(file); - return !workflow || !subscribesMergeGroup(workflow); - }); - - expect( - missing, - `These workflows produce a context \`REQUIRED_CONTEXTS\` declares blocking, but do not ` + - `subscribe \`merge_group\`:\n` + - missing.map((f) => ` - ${f} (produces ${byWorkflow.get(f)?.join(', ')})`).join('\n') + - `\n\nThe merge queue is ENFORCED here (#3243). A required context that does not report on ` + - `a queue build does not fail the queue, it STALLS it until the ruleset's 60-minute ` + - `status-check timeout — every queued pull request burns an hour and fails with nothing ` + - `red to point at. Add \`merge_group:\` to the workflow's \`on:\` block; it is a pure ` + - `addition and changes nothing about pull requests (objectui#3523 step 1, where the ` + - `opposite state let #3503 / #3510 / #3516 merge with \`Type Check\` at ` + - `conclusion=failure).\n\nIf the context genuinely cannot be required, it does not belong ` + - `in \`REQUIRED_CONTEXTS\` — move it to \`OPTIONAL_CONTEXTS\` or \`NOT_A_GATE\` in ` + - `\`scripts/dependabot-merge-gate.mjs\` with the reason, rather than weakening this floor.`, - ).toEqual([]); - }); - - it('loses nothing the hand-maintained map named', () => { - // The precondition of the whole derivation, pinned rather than assumed: the - // derived floor must CONTAIN what the hand-maintained map named, or swapping - // one for the other would look like more coverage while asserting less. - // - // It fails in the direction that matters. Adding a workflow to the map that - // produces no `REQUIRED_CONTEXTS` check reads as "the map now covers - // something the derivation does not" — which is the two declarations - // crossing, and is exactly what may not happen quietly. - const lost = [...MUST_SUBSCRIBE_MERGE_GROUP.keys()].filter((file) => !byWorkflow.has(file)); - - expect( - lost, - `MUST_SUBSCRIBE_MERGE_GROUP names these workflows, but none of them produces a check listed ` + - `in \`REQUIRED_CONTEXTS\`:\n` + - lost.map((f) => ` - ${f} (${MUST_SUBSCRIBE_MERGE_GROUP.get(f)})`).join('\n') + - `\n\nSince objectui#6160 the floor is DERIVED from \`REQUIRED_CONTEXTS\`, and this map ` + - `carries only the reasons. So the two declarations have just crossed: either the check ` + - `belongs in \`REQUIRED_CONTEXTS\` (add it there — that is what puts the workflow inside ` + - `the floor), or it is not requirable and the entry here is claiming a floor nothing ` + - `enforces (drop it). Leaving it is the one option that is not available: it would be a ` + - `hand-maintained membership again, the thing that fell behind twice before anyone noticed.`, - ).toEqual([]); - }); -}); - -describe('every context reports on every pull request (#3523 step 2)', () => { - it.each(FILTER_MOVED_INTO_JOBS)('%s filters no pull request at the trigger', (file) => { - const pullRequest = nestedBlock(topLevelBlock(withoutComments(read(file)), 'on'), 'pull_request'); - expect(pullRequest, `${file} must still trigger on \`pull_request\``).not.toEqual(''); - - for (const key of ['paths-ignore', 'paths']) { - expect( - pullRequest, - `${file} filters its \`pull_request\` trigger by \`${key}\`. That skips the WHOLE ` + - `workflow — GitHub has no per-job path filter — so on a PR whose files all match, the ` + - `contexts this file produces are never created. A required check that is never created ` + - `leaves the PR pending forever and fails a queue build on the 60-minute timeout, which ` + - `is why they could not be required at all before objectui#3523 (#3509 measured a ` + - `docs-only PR starting zero of them). Put the path decision in the jobs instead, the ` + - `way \`ci.yml\`'s \`docs\` job has since #3450.`, - ).not.toMatch(new RegExp(`^\\s*${key}:`, 'm')); - } - }); - - it.each(FILTER_MOVED_INTO_JOBS)('%s keeps ONE ignore list, on the push trigger', (file) => { - // The push lane keeps its `paths-ignore` — branch protection and the merge - // queue judge pull requests and queue builds, never pushes to `main`, so - // filtering there costs nothing and saves a full run on every docs merge. - // That makes it the single authored home for the list, and the in-job gates - // are held to it here rather than being four hand-synced copies (#3261's - // lesson: a hand-copied enumeration drifts by construction). - const push = nestedBlock(topLevelBlock(withoutComments(read(file)), 'on'), 'push'); - const declared = quotedEntries(push); - expect( - declared.length, - `${file}'s \`push\` trigger no longer declares \`paths-ignore\` entries — the in-job gates ` + - `below have nothing left to be checked against.`, - ).toBeGreaterThan(0); - - const inJob = [...new Set(excludePathspecs(read(file)))]; - expect( - inJob.length, - `${file} declares no \`:(exclude,glob)…\` pathspec, so no job short-circuits and the ` + - `expensive steps now run on every docs-only PR. objectui#3523 moved the filter into the ` + - `jobs; it did not delete it.`, - ).toBeGreaterThan(0); - - expect( - [...inJob].sort(), - `${file}'s in-job exclusion list has drifted from the \`paths-ignore\` on its \`push\` ` + - `trigger. The two must stay identical: the trigger is what the in-job gate replaced for ` + - `pull requests, and any difference silently means PRs and pushes are judged by different ` + - `rules (objectui#3523).`, - ).toEqual([...new Set(declared)].sort()); - }); - - it.each(FILTER_MOVED_INTO_JOBS)('%s gates every step after the gate, in every gated job', (file) => { - const jobs = topLevelBlock(withoutComments(read(file)), 'jobs'); - const keys = [...jobs.matchAll(/^ {2}([a-z0-9][a-z0-9-]*):[ \t]*$/gm)].map((m) => m[1]); - expect(keys.length, `the ${file} \`jobs:\` parse returned implausibly few keys`).toBeGreaterThan(0); - - const gatedJobs = keys.filter((key) => /^\s*id: relevant$/m.test(nestedBlock(jobs, key))); - expect( - gatedJobs.length, - `${file} has no job carrying the \`id: relevant\` short-circuit. Removing it does not make ` + - `the gate stricter — it makes every expensive step run on every docs-only PR ` + - `(objectui#3523).`, - ).toBeGreaterThan(0); - - for (const key of gatedJobs) { - const block = nestedBlock(jobs, key); - const after = block.slice(block.search(/^\s*id: relevant$/m)); - // Step boundaries inside a job body: ` - name: …`. - const steps = after.split(/^ {6}- name: /m).slice(1); - expect(steps.length, `${file}'s \`${key}\` job has no steps after its gate`).toBeGreaterThan(0); - - const ungated = steps - .filter((step) => !step.includes('steps.relevant.outputs.should_run')) - .map((step) => step.split('\n')[0].trim()); - - expect( - ungated, - `${file}'s \`${key}\` job runs these steps regardless of its own short-circuit:\n` + - ungated.map((s) => ` - ${s}`).join('\n') + - `\n\nEvery step after \`id: relevant\` must carry ` + - `\`if: steps.relevant.outputs.should_run == 'true'\` (combined with its own condition ` + - `where it already has one). A step that ignores the gate turns a docs-only PR into a ` + - `full install-and-build, which is the cost objectui#3523 kept while moving the filter.`, - ).toEqual([]); - } - }); - - it('fails OPEN: EVERY gate that cannot compute the diff runs everything', () => { - // The direction matters more than the code. objectstack#4928 named this the - // filter contract after the opposite spelling — a diff whose failure was - // swallowed into an empty result — produced a fully green pull request with - // no job having run and no red signal anywhere. - // - // This assertion used to be a single `toMatch` per FILE, which is why it - // reported green while `ci.yml`'s `docs` gate was still fail-CLOSED - // (objectui#3723): the three gates PR #3722 added satisfied the regex on - // ci.yml's behalf, and a whole-file match cannot tell four captures from - // three plus a swallow. It is now per CAPTURE, so each gate answers for - // itself and a fifth spelled the closed way cannot hide behind the others. - for (const file of FILTER_MOVED_INTO_JOBS) { - const expected = FAIL_OPEN_GATES.get(file) ?? []; - const captures = diffCaptureLines(withoutComments(read(file))); - - expect( - captures.length, - `${file} declares ${captures.length} \`CHANGED=$(git diff …)\` captures, fewer than the ` + - `${expected.length} gates that must have one (${expected.join(', ')}). A gate whose diff ` + - `capture is gone either no longer filters at all, or filters by some other means this ` + - `test cannot check the direction of — and with none left, the shape assertion below ` + - `passes an empty list (objectui#3523, objectui#3723).`, - ).toBeGreaterThanOrEqual(expected.length); - - const failClosed = captures.filter((line) => !line.startsWith('if ! CHANGED=$(git diff')); - expect( - failClosed, - `${file} captures a \`git diff\` without letting its failure mean RUN:\n` + - failClosed.map((line) => ` - ${line}`).join('\n') + - `\n\nEvery gate must open its capture as \`if ! CHANGED=$(git diff …); then\` and treat ` + - `the failure branch as should_run=true. Swallowing the failure instead — the ` + - `\`2>/dev/null || echo ""\` this file's \`docs\` job used until objectui#3723 — makes ` + - `"the diff could not be computed" indistinguishable from "nothing changed", so a ` + - `shallow checkout or a malformed sha skips the job's real work and still reports ` + - `success. When the filter cannot tell, it must RUN (objectstack#4928).`, - ).toEqual([]); - } - }); - - it('fails OPEN in effect, not only in shape: no gate swallows its diff failure', () => { - // `if !` is necessary but not sufficient. `if ! CHANGED=$(git diff … || echo - // "")` reads as fail-open and is not: `|| echo ""` makes the command succeed - // whatever git did, so the failure branch becomes unreachable and the gate is - // fail-CLOSED again with the safe spelling wrapped around it. Same for - // `2>/dev/null`, which additionally deletes git's own explanation of what - // went wrong from the run log — the one diagnostic a future reader gets. - for (const file of FILTER_MOVED_INTO_JOBS) { - const code = withoutComments(read(file)); - for (const [shape, why] of [ - [/\|\|\s*echo\s*""/, '`|| echo ""` makes the capture succeed even when git failed'], - [/2>\s*\/dev\/null/, '`2>/dev/null` hides why git failed'], - ] as const) { - expect( - code, - `${file} still contains ${why}. Both halves of the fail-CLOSED spelling objectui#3723 ` + - `removed must stay out: the gate's failure branch has to be reachable, and the run ` + - `log has to say what happened (objectstack#4928).`, - ).not.toMatch(shape); - } - } - }); -}); +// Retired with objectstack#25712: step 1 (`subscribes merge_group in each +// workflow that produces one`, `keeps the list honest — every name in it is a +// workflow that exists` and `runs the test suite on a queue build, not only on +// pull requests`); the floor derived from `REQUIRED_CONTEXTS` (objectui#6160: +// `deriveMergeGroupFloor()` and its three cases, which resolved each context +// through `./workflow-checks.ts`); and step 2 (`filters no pull request at the +// trigger`, `keeps ONE ignore list, on the push trigger`, `gates every step +// after the gate, in every gated job` and both fail-OPEN cases, on objectui's +// `ci.yml` and `lint.yml`). Each read objectui's workflows from +// `ui/.github/workflows/`, which runs nowhere under the monorepo: GitHub runs +// the root `.github/workflows/` alone. +// +// The root gates carry both #3523 holes for the contexts the repository +// requires today, `Lint UI` and `Build & E2E UI` among them. +// `scripts/check-required-contexts.mjs` (`check:required-contexts`, in the root +// `Lint · CI, release & engine gates` job) derives from the root registry and +// holds the workflow of every required context to a `merge_group:` trigger (its +// assertion 6) and to a `pull_request` trigger with no path filter (its +// assertion 7). The root `ci.yml` makes its path decision in its `filter` job +// instead, and `check:workflow-status-functions`, in the same job, holds every +// job-level `if:` that reads a `needs.*.outputs` value to a status function, +// the half of the objectstack#4928 filter contract that keeps a dead filter +// from skipping to green. /** * The page's copy of the same list (objectui#4154). @@ -543,10 +144,12 @@ describe('every context reports on every pull request (#3523 step 2)', () => { * The page now points at `MUST_SUBSCRIBE_MERGE_GROUP` instead of copying it, and * these assertions keep it that way. The honest caveat, recorded rather than * hidden: this pins a copy to a copy, since the map is itself hand-maintained. - * What makes that trade worth taking is that the map is read by an assertion — - * a member that stops subscribing fails the first test in this file, and a member - * that stops existing fails the second — while prose is read by no one until it - * has already misled someone. + * What made that trade worth taking was that the map was read by an assertion: + * a member that stopped subscribing failed the first test in this file, and a + * member that stopped existing failed the second. Both read objectui's + * workflows and retired with objectstack#25712 (the note above), so this now + * pins the page to a map no other assertion reads, while prose is read by no + * one until it has already misled someone. * * Two paragraphs, two different rules, because they are two different kinds of * claim: @@ -613,16 +216,12 @@ const namesWorkflow = (text: string, file: string): boolean => describe('ci-cd-pipeline.md does not keep its own copy of the subscriber list (#4154)', () => { /** - * Every workflow the page may not enumerate: the map's entries AND the derived - * floor's, which since objectui#6160 is the larger set. Scanning only the map - * would have let the page name the subscribers the map does not — the same - * drift this describe exists to stop, hiding in the gap the map had already - * fallen behind by. + * Every workflow the page may not enumerate: the map's entries. Until + * objectstack#25712 this also took the derived floor's, the larger set since + * objectui#6160; that floor read objectui's workflows and retired with the + * assertions above, so the map is the one list left to scan. */ - const CURRENT_SUBSCRIBERS = new Set([ - ...MUST_SUBSCRIBE_MERGE_GROUP.keys(), - ...DERIVED_MERGE_GROUP_FLOOR.byWorkflow.keys(), - ]); + const CURRENT_SUBSCRIBERS = new Set(MUST_SUBSCRIBE_MERGE_GROUP.keys()); it('names no current subscriber outside the dated #3523 paragraph', () => { const offenders: string[] = []; diff --git a/ui/scripts/__tests__/workflow-cache-save-bound.test.ts b/ui/scripts/__tests__/workflow-cache-save-bound.test.ts deleted file mode 100644 index c0bf0c7dc8d..00000000000 --- a/ui/scripts/__tests__/workflow-cache-save-bound.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse as parseYaml } from 'yaml'; - -/** - * objectui#7048 — every cache save in this repository is bounded and non-fatal, - * not just `ci.yml`'s `type-check` one. - * - * ## Why a second file beside `turbo-cache-save-bound.test.ts` - * - * That file is about ONE INCIDENT and one job: the 2026-08-26 merge-group build - * of #6571, where all 21 real steps of `Type Check` passed and then the - * runner-generated `Post Turbo Cache` spent 13m09s (789s) in the upload, hit the - * job's `timeout-minutes: 20`, and turned an all-green pull request into a - * `cancelled` check the merge queue could not tell from a failure. Its - * assertions are sized against that job's numbers and it should stay that way. - * - * This file is about the POPULATION. When #6577's fix landed, seven more - * combined `actions/cache@v6` uses across five workflows carried the identical - * exposure — that was the repository's DEFAULT SPELLING for caching, not an edge - * case on one job — and nothing in the tree noticed. So the subject here is the - * rule rather than the site: enumerate every workflow file, and require of every - * cache save what #6577 proved a cache save must have. - * - * ## The ordering, which is the whole point - * - * The verdict is recorded by the checking steps; everything after them is - * bookkeeping, and bookkeeping must never discard an answer the gate already - * produced. Combined `actions/cache` declares `main: dist/restore/index.js` plus - * `post: dist/save/index.js`, so its save is a step the RUNNER generates at job - * end and NO workflow syntax attaches `timeout-minutes` or `continue-on-error` - * to it. Splitting into `actions/cache/restore` + a trailing - * `actions/cache/save` is not a style preference — it is the only way those two - * keys can exist at all, and it is the route `actions/cache`'s own `save-always` - * deprecation text points at. - * - * ## Why it needs a pin: reverting is invisible - * - * Fold any split back into one `actions/cache` step and nothing goes red. The - * cache still works, every run still passes, and the next transient upload stall - * ejects the next green pull request. Until objectui#7270 three of these jobs - * declared no `timeout-minutes` at all, so the same stall held a required - * context open against GitHub's 360-minute default instead; those three now - * carry ceilings derived from their own run distributions, and the last pins in - * this file are what keep them. That is this repository's recurring "looks like - * enforcement, isn't" class (objectui#3009, #3181, #3494). - * - * ## The ceilings outgrew the cache (objectui#7956) - * - * objectui#7270 bounded the CACHED half of that exposure; objectui#7956 measured - * the other six jobs, which cache nothing at all, and closed them the same way. - * So the last three pins in this file are no longer about caching — they are - * about job ceilings, which is why they live beside the cache rules rather than - * in a new file: both are "a key whose deletion is invisible in every run", and - * splitting them would give a future reader two places to look for one rule. - * - * Those six divided in two, and BOTH halves are pinned, because both are - * decisions someone made: - * - * - Four had a distribution to derive from, and carry a ceiling with the - * derivation written beside the key. - * - Two did not, and carry NO ceiling ON PURPOSE, with the reason written - * beside the job. Pinning an ABSENCE reads oddly until you notice the - * failure mode it catches: someone tidying up the inconsistency by copying - * `20` onto them, which is precisely the inherited number objectui#7048 - * fences and objectui#7956's triage forbade. - * - * ⚠️ That second half is now ONE job, not two. `stale.yml::stale` was the other - * one, and objectui#8548 deleted the whole workflow under enforce-or-remove: - * eight months, 236 runs, zero successes, zero consumers. Its entry left the - * `accepted` table in that same commit, because a pin naming a job that no - * longer exists fails on the lookup and says nothing about ceilings. - * ⛔ Do not read the shrink as the rule weakening — `changelog.yml::changelog` - * still holds it, and the case it catches (copying `20` onto an unmeasured job) - * is unchanged. ⭐ A job leaving this table by being DELETED and a job leaving - * it by acquiring a derived ceiling are opposite events; only the second one - * moves an entry into the `derived` table above. - * - * ## Deliberately NOT asserted - * - * - That the RESTORE halves are bounded. A restore stall fails BEFORE any - * verdict exists — a gate that did not run, which is honest — rather than a - * recorded verdict discarded, and bounding it would force a cold check under - * the same ceiling. - * - Any specific bound. Each site's number is derived from that site's own - * measured healthy save and written into the workflow comment beside it; - * objectui#7048 fences copying one site's number to another. What is asserted - * is that a bound EXISTS and that it cannot crowd out the checking steps. - */ - -const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); -const WORKFLOW_DIR = path.join(ROOT, '.github/workflows'); - -interface Step { - name?: string; - uses?: string; - run?: string; - id?: string; - if?: string; - 'timeout-minutes'?: number; - 'continue-on-error'?: boolean; - with?: Record; -} -interface Job { - 'timeout-minutes'?: number; - steps?: Step[]; -} - -const workflowFiles = fs - .readdirSync(WORKFLOW_DIR) - .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) - .sort(); - -interface JobEntry { - file: string; - jobKey: string; - job: Job; - steps: Step[]; -} - -const allJobs: JobEntry[] = workflowFiles.flatMap((file) => { - const parsed = parseYaml(fs.readFileSync(path.join(WORKFLOW_DIR, file), 'utf8')) as { - jobs?: Record; - }; - return Object.entries(parsed?.jobs ?? {}).map(([jobKey, job]) => ({ - file, - jobKey, - job, - steps: job.steps ?? [], - })); -}); - -const usesPrefix = (step: Step, prefix: string) => (step.uses ?? '').startsWith(prefix); -const where = (e: JobEntry, s: Step) => `${e.file} :: job \`${e.jobKey}\` :: step \`${s.name ?? '(unnamed)'}\``; - -describe('every workflow cache save is bounded and non-fatal (objectui#7048)', () => { - it('reads a plausible population — the scan below is not vacuous', () => { - // The enumeration is the axis that failed when this card was first filed: - // the population was inherited from one file's grep instead of derived - // across the directory, and undercounted by more than 3x. A control that - // only proves the PROBE works cannot catch that; this one is on the - // population itself. - expect( - workflowFiles.length, - `implausibly few workflow files found under ${WORKFLOW_DIR} — every assertion below would ` + - 'pass while reading nothing', - ).toBeGreaterThan(20); - expect(allJobs.length, 'implausibly few jobs parsed out of those workflow files').toBeGreaterThan(20); - }); - - it('caches through the split form only — never the combined action', () => { - const offenders = allJobs.flatMap((e) => - e.steps.filter((s) => /^actions\/cache@/.test(s.uses ?? '')).map((s) => where(e, s)), - ); - - expect( - offenders, - 'these steps use the COMBINED `actions/cache` action:\n' + - offenders.map((o) => ` - ${o}`).join('\n') + - '\n\nIts save runs as a runner-generated POST step, which cannot carry `timeout-minutes` ' + - 'or `continue-on-error` — so an upload stall runs the job into its ceiling and cancels a ' + - 'gate that had already passed (objectui#6577), or, in a job with no declared ceiling, ' + - "holds it against GitHub's 360-minute default. Use `actions/cache/restore` plus a bounded, " + - '`continue-on-error` `actions/cache/save` placed after the last command step.', - ).toEqual([]); - }); - - it('actually caches something — the rule above is not passing on an empty set', () => { - // Without this, deleting every cache step in the repository would make the - // assertion above green. The rule is "saves are bounded", not "there are no - // saves". - const saves = allJobs.flatMap((e) => e.steps.filter((s) => usesPrefix(s, 'actions/cache/save@'))); - expect( - saves.length, - 'no workflow in this repository saves a cache any more. If that is deliberate, this whole ' + - 'file is obsolete and should be deleted with the reason recorded — not left passing on an ' + - 'empty set.', - ).toBeGreaterThan(5); - }); - - it('bounds every save, and lets none of them speak for the code', () => { - const unbounded: string[] = []; - const fatal: string[] = []; - - for (const e of allJobs) { - for (const s of e.steps.filter((x) => usesPrefix(x, 'actions/cache/save@'))) { - if (typeof s['timeout-minutes'] !== 'number') unbounded.push(where(e, s)); - if (s['continue-on-error'] !== true) fatal.push(where(e, s)); - } - } - - expect( - unbounded, - 'these cache saves declare no `timeout-minutes`:\n' + - unbounded.map((o) => ` - ${o}`).join('\n') + - '\n\nThe bound is the only thing standing between a stalled upload and the job ceiling, ' + - 'and the job ceiling reports `cancelled` — a gate that says nothing at all (objectui#6577). ' + - "Derive the number from that site's own measured healthy save; do not copy another site's.", - ).toEqual([]); - - expect( - fatal, - 'these cache saves do not declare `continue-on-error: true`:\n' + - fatal.map((o) => ` - ${o}`).join('\n') + - '\n\nWithout it the bound only trades a `cancelled` gate for a red one. A cache that ' + - 'failed to upload costs the next run some time and says nothing whatsoever about the code ' + - 'under test, so it must not be allowed to speak for it.', - ).toEqual([]); - }); - - it('runs every save AFTER its job’s last command step — bookkeeping follows the verdict', () => { - const hoisted: string[] = []; - - for (const e of allJobs) { - const lastCommand = e.steps.map((s) => s.run !== undefined).lastIndexOf(true); - if (lastCommand === -1) continue; // no commands in this job: nothing to protect - for (const s of e.steps.filter((x) => usesPrefix(x, 'actions/cache/save@'))) { - if (e.steps.indexOf(s) < lastCommand) { - hoisted.push(`${where(e, s)} (runs before \`${e.steps[lastCommand].name ?? '(unnamed)'}\`)`); - } - } - } - - expect( - hoisted, - 'these cache saves run BEFORE their job’s last command step:\n' + - hoisted.map((o) => ` - ${o}`).join('\n') + - '\n\nThe verdict is recorded by the command steps; everything after them is bookkeeping, ' + - 'and that ordering is what makes a save safe to bound and safe to lose. A save hoisted ' + - 'above a checking step is bookkeeping that can still delay, and be blamed for, an answer ' + - 'that has not been produced yet (objectui#6577).', - ).toEqual([]); - }); - - it('still caches the same thing the combined step did', () => { - const broken: string[] = []; - - for (const e of allJobs) { - const restores = e.steps.filter((s) => usesPrefix(s, 'actions/cache/restore@')); - for (const save of e.steps.filter((s) => usesPrefix(s, 'actions/cache/save@'))) { - const restore = restores.find((r) => r.with?.path === save.with?.path); - if (!restore) { - broken.push(`${where(e, save)}: no \`actions/cache/restore\` in this job reads path \`${save.with?.path}\``); - continue; - } - if (save.with?.key !== restore.with?.key) { - broken.push(`${where(e, save)}: writes key \`${save.with?.key}\` but the restore asks for \`${restore.with?.key}\` — every run would miss`); - } - if (!restore.id) { - broken.push(`${where(e, restore)}: needs an \`id\` so the save can read its \`cache-hit\` output`); - continue; - } - const guard = `steps.${restore.id}.outputs.cache-hit != 'true'`; - if (!(save.if ?? '').includes(guard)) { - broken.push(`${where(e, save)}: must skip on an exact primary-key hit — \`${guard}\`. The combined action did this internally; after the split it is the workflow’s job, and without it every re-run re-uploads a cache that already exists.`); - } - } - } - - expect(broken, 'the split has silently changed what gets cached:\n' + broken.map((o) => ` - ${o}`).join('\n')).toEqual([]); - }); - - it('never lets bookkeeping crowd out the checking steps under a declared ceiling', () => { - // Generalises the type-check pin's invariant to jobs with more than one - // cache: it is the SUM of a job's save bounds that competes with its - // verdict path for the ceiling, not any single bound. - const crowded: string[] = []; - - for (const e of allJobs) { - const ceiling = e.job['timeout-minutes']; - if (typeof ceiling !== 'number') continue; - const bounds = e.steps - .filter((s) => usesPrefix(s, 'actions/cache/save@')) - .map((s) => s['timeout-minutes'] ?? 0); - if (bounds.length === 0) continue; - const total = bounds.reduce((a, b) => a + b, 0); - if (total * 2 > ceiling) { - crowded.push( - `${e.file} :: job \`${e.jobKey}\`: cache saves may run for ${total} of the job's ${ceiling} minutes (bounds: ${bounds.join(' + ')})`, - ); - } - } - - expect( - crowded, - 'bookkeeping can starve the verdict path of its ceiling here:\n' + - crowded.map((o) => ` - ${o}`).join('\n') + - '\n\nA bound that large can still cancel the job — which is the defect, not the fix.', - ).toEqual([]); - }); - - it('does not raise the ceilings of the jobs this card bounded — the ruled-out non-fix', () => { - // Ruled out on objectui#6577 and again on #7048: a larger ceiling buys a - // longer hang and still ends in `cancelled`, and lifting a gate's ceiling - // weakens the gate. Raising one for some UNRELATED reason is a decision - // someone may need to make — make it deliberately, and move this pin in the - // same commit, rather than discovering later that the fix was undone by it. - const ceilings: Record = { - 'ci.yml::e2e': 30, - 'ci.yml::docs': 15, - 'live-e2e.yml::live-e2e': 40, - }; - - for (const [key, max] of Object.entries(ceilings)) { - const [file, jobKey] = key.split('::'); - const entry = allJobs.find((e) => e.file === file && e.jobKey === jobKey); - expect(entry, `${file} must still define a \`${jobKey}:\` job`).toBeDefined(); - expect( - entry!.job['timeout-minutes'], - `the \`${jobKey}\` job ceiling in ${file} moved. Raising it does not fix a hung cache save — ` + - 'the hang just runs longer and the gate still reports `cancelled` (objectui#6577).', - ).toBeLessThanOrEqual(max); - } - }); - - it('bounds the three jobs objectui#7270 found running under the 360-minute default', () => { - // Presence and bound in one assertion, because the two ways this can be - // undone need the same fix site and neither is visible in a run. - // - // - Delete the key and the job silently returns to GitHub's 360-minute - // default. Nothing goes red: a healthy job never approaches any - // ceiling, so the only symptom is the next transient hang holding a - // required context for six hours and then reporting `cancelled` — a - // verdict the merge queue cannot tell from `failure` (objectui#5304, - // objectui#6577 are the two times this repository has paid it). - // - Raise the number and objectui#7048's fence is crossed in the other - // direction: a larger ceiling buys a longer hang and still ends in - // `cancelled`. - // - // Each number is DERIVED FOR ITS OWN JOB from that job's measured run - // distribution, and the derivation is written beside the key in the - // workflow — window, sample size, min/median/p95/max, and the rule. - // objectui#7048 fences copying `ci.yml`'s 10/15/20/30/40, so the three - // values here are not a shared constant and two of them being equal is a - // coincidence of the arithmetic. Pinning them means a future change to one - // has to move the derivation beside it in the same commit. - // - // The third was `changeset-release.yml::release` (40). objectstack#25745 - // deleted that workflow when this tree became the `ui/` workspace of - // objectstack and took that repository's one release lane, which is why this - // table lists two keys and not three. ⛔ Re-adding it does not restore a pin — - // the lookup below would fail on a workflow that is gone. - const derived: Record = { - 'lint.yml::lint': 25, - 'performance-budget.yml::bundle-analysis': 25, - }; - - const missing: string[] = []; - const raised: string[] = []; - - for (const [key, ceiling] of Object.entries(derived)) { - const [file, jobKey] = key.split('::'); - const entry = allJobs.find((e) => e.file === file && e.jobKey === jobKey); - expect(entry, `${file} must still define a \`${jobKey}:\` job`).toBeDefined(); - - const declared = entry!.job['timeout-minutes']; - if (typeof declared !== 'number') { - missing.push(`${file} :: job \`${jobKey}\``); - } else if (declared > ceiling) { - raised.push(`${file} :: job \`${jobKey}\` declares ${declared}, derived ${ceiling}`); - } - } - - expect( - missing, - 'these jobs declare no job-level `timeout-minutes`, so their only backstop is GitHub\'s ' + - '360-minute default again:\n' + - missing.map((o) => ` - ${o}`).join('\n') + - '\n\nTwo of them are required contexts and one is the publish lane. Restore the key ' + - 'TOGETHER WITH the derivation comment beside it — a ceiling with no recorded provenance ' + - 'is the next reader\'s excuse to guess at it (objectui#7270).', - ).toEqual([]); - - expect( - raised, - 'these job ceilings are above the value derived for them:\n' + - raised.map((o) => ` - ${o}`).join('\n') + - '\n\nRaising a ceiling does not fix a hang — it buys a longer one and the gate still ' + - 'reports `cancelled` (objectui#6577, objectui#7048). If a job genuinely got slower, ' + - 're-derive it from a fresh distribution, rewrite the comment beside the key, and move ' + - 'this pin in the same commit.', - ).toEqual([]); - }); - - it('bounds the four jobs objectui#7956 derived a ceiling for', () => { - // The uncached half of the same exposure. objectui#7270 scoped itself to - // jobs that cache; these six cache nothing, so the cache-save hazard above - // is absent and what they carried was the generic one: GitHub's 360-minute - // default and a `cancelled` verdict at the end of it. - // - // ⚠️ NONE of these four produces a required status check — re-measured on - // the `main` ruleset (`Lint`, `Type Check`, `Build & E2E`, the four `Test - // (shard n/4)`, `Build Docs`, `Changeset Declaration`). So objectui#7270's - // shared-serial-queue stakes do not carry over, and the numbers below are - // not carried over either. - // - // ⚠️ All four are 20 and that is NOT a shared constant, which is the exact - // thing objectui#7048 fences and objectui#7956's triage forbade in writing. - // Each was derived from its own sample by objectui#7270's rule — the - // smallest round number that is both >= 3x that job's max and >= that job's - // max + 15min — and the additive term dominates for every job whose slowest - // run is under 7.5 minutes, so the rule lands every sub-minute job on the - // same rung. Their measured maxima differ (43s, 31s, 13s, 12s); their - // ceilings coincide. The derivation beside each key is what makes that - // checkable, and moving any one of these numbers means rewriting the - // derivation beside it in the same commit. - // - // The fourth was `changeset-release.yml::lane` (20), deleted with its - // workflow by objectstack#25745, which is why this table lists three keys. ⛔ - // Re-adding it does not restore a pin — the lookup below would fail on a - // workflow that is gone. - const derived: Record = { - 'check-links.yml::check-links': 20, - 'cross-repo-issue-closer.yml::close-foreign-issues': 20, - 'labeler.yml::label': 20, - }; - - const missing: string[] = []; - const raised: string[] = []; - - for (const [key, ceiling] of Object.entries(derived)) { - const [file, jobKey] = key.split('::'); - const entry = allJobs.find((e) => e.file === file && e.jobKey === jobKey); - expect(entry, `${file} must still define a \`${jobKey}:\` job`).toBeDefined(); - - const declared = entry!.job['timeout-minutes']; - if (typeof declared !== 'number') { - missing.push(`${file} :: job \`${jobKey}\``); - } else if (declared > ceiling) { - raised.push(`${file} :: job \`${jobKey}\` declares ${declared}, derived ${ceiling}`); - } - } - - expect( - missing, - 'these jobs declare no job-level `timeout-minutes`, so their only backstop is GitHub\'s ' + - '360-minute default again:\n' + - missing.map((o) => ` - ${o}`).join('\n') + - '\n\nNone of them blocks a pull request, so nothing goes red when this key disappears — ' + - 'the only symptom is the next wedged run holding a runner for six hours. Restore the key ' + - 'TOGETHER WITH the derivation comment beside it; a ceiling with no recorded provenance is ' + - "the next reader's excuse to guess at it (objectui#7270, objectui#7956).", - ).toEqual([]); - - expect( - raised, - 'these job ceilings are above the value derived for them:\n' + - raised.map((o) => ` - ${o}`).join('\n') + - '\n\nRaising a ceiling does not fix a hang — it buys a longer one and the gate still ' + - 'reports `cancelled` (objectui#6577, objectui#7048). If a job genuinely got slower, ' + - 're-derive it from a fresh distribution, rewrite the comment beside the key, and move ' + - 'this pin in the same commit.', - ).toEqual([]); - }); - - it('keeps the job objectui#7956 could not measure UNBOUNDED, with its reason recorded', () => { - // The other half of objectui#7956, and the half that is easy to undo by - // being helpful. Four of its six jobs got a ceiling; this one did not, - // because it has no distribution that measures the job doing its work: - // - // - `changelog.yml::changelog` — `total_count: 0` runs, ever. It is - // dispatch-only and has never been dispatched. (Control on the same - // endpoint: `changeset-release.yml` answers with thousands, so the zero - // is a reading and not a broken query.) - // - // objectui#7956 left TWO jobs here. The second was `stale.yml::stale` — 234 - // completed runs, 0 successful, over eight months, every sampled one failing - // in `Set up job` before the marketplace action started, so its 1-4 seconds - // measured how fast the job failed to begin rather than any work it did. - // objectui#8548 deleted that workflow outright (enforce-or-remove: zero - // consumers, zero successes), which is why this table lists one key and not - // two. ⛔ Re-adding `stale.yml::stale` here does not restore a pin — the - // lookup below would fail on a workflow that is gone. - // - // A number invented for it would look derived and be a guess, and a - // ceiling under a job's honest slowest run converts a working job into a - // permanently red one — objectui#7048's fence, and objectstack#16173 is the - // live counter-example (a distribution mis-estimated by ~2.6x killed a test - // shard while the rollup read green). - // - // So this pin asserts an ABSENCE plus a RECORD, and the record is the point: - // the failure it catches is a future tidy-up that copies `20` off the four - // jobs above onto these two. Adding a real ceiling here is welcome — derive - // it from runs that did the work, write the derivation beside the key, and - // move the entry up into the `derived` table above in the same commit. - const accepted = ['changelog.yml::changelog']; - - const bounded: string[] = []; - const undocumented: string[] = []; - - for (const key of accepted) { - const [file, jobKey] = key.split('::'); - const entry = allJobs.find((e) => e.file === file && e.jobKey === jobKey); - expect(entry, `${file} must still define a \`${jobKey}:\` job`).toBeDefined(); - - if (typeof entry!.job['timeout-minutes'] === 'number') { - bounded.push(`${file} :: job \`${jobKey}\` now declares ${entry!.job['timeout-minutes']}`); - } - - // The decision has to be READABLE at the job, not only in a merged pull - // request. Two stable tokens rather than a wording: the default being - // accepted, and the card that accepted it. - const prose = fs - .readFileSync(path.join(WORKFLOW_DIR, file), 'utf8') - .split('\n') - .filter((line) => /^\s*#/.test(line)) - .join('\n'); - if (!prose.includes('360') || !prose.includes('objectui#7956')) { - undocumented.push(`${file} (looked for \`360\` and \`objectui#7956\` in its comments)`); - } - } - - expect( - bounded, - 'these jobs were deliberately left at the 360-minute default and now declare a ceiling:\n' + - bounded.map((o) => ` - ${o}`).join('\n') + - '\n\nIf that number was DERIVED from runs in which the job actually did its work, this is ' + - 'good news: write the derivation beside the key, move the entry into the `derived` table ' + - 'above, and delete it from here — in this same commit. If it was copied from the four jobs ' + - 'in that table, it is the inherited ceiling objectui#7048 fences: a number that looks ' + - "derived, sits over a cost nobody has measured, and reds a working job the first time it " + - 'runs long (objectui#7956).', - ).toEqual([]); - - expect( - undocumented, - 'these jobs run under the 360-minute default with nothing beside them saying so on purpose:\n' + - undocumented.map((o) => ` - ${o}`).join('\n') + - '\n\nAn unbounded job that records WHY is a decision; one that records nothing is an ' + - 'oversight, and the two are indistinguishable to the next reader — which is how ' + - 'objectui#7956 came to be filed in the first place.', - ).toEqual([]); - }); -}); diff --git a/ui/scripts/markdown-test-inputs.mjs b/ui/scripts/markdown-test-inputs.mjs index 53d83147a97..d63a1c16211 100644 --- a/ui/scripts/markdown-test-inputs.mjs +++ b/ui/scripts/markdown-test-inputs.mjs @@ -1014,15 +1014,6 @@ export const ADJUDICATED = new Map([ walker: 'markdown-tree', }, ], - // Reads no markdown: the `README.md` literal names a repo-wide labeler rule, not a file it opens. - [ - 'scripts/__tests__/labeler-package-coverage.test.ts', - { - reads: [], - notRead: ['README.md'], - walker: 'not-markdown: package directories under `packages/`', - }, - ], [ 'scripts/__tests__/layered-read-declared-path-4016.test.ts', { @@ -1062,7 +1053,6 @@ export const ADJUDICATED = new Map([ 'scripts/__tests__/merge-queue-reporting.test.ts', { reads: ['content/docs/guide/ci-cd-pipeline.md'], - walker: 'not-markdown: `.github/workflows/*.yml`', }, ], [