diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 5dd62afcb7a..b2836327f78 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -1187,6 +1187,15 @@ export function extractTriggerPaths(workflowText) { * of it before the red lands on `main`. Refusing it would drop the console and * dogfood leads this function exists to give. Any other event term, a reordered * clause or one event alone is still refused whole. + * + * ⛔ Stripped is not FORGOTTEN (#24073). The clause decides nothing about paths, + * but it decides WHEN: such a job never runs on the PR or in the merge queue. + * `jobIfReading` below returns that fact beside the refs, and every row a + * path-scheduled job renders carries it to the line the reader reads. Measured + * before it did: a `packages/runtime/src/route-ledger.ts` change derived the + * Dogfood Regression Gate as "scheduled BY YOUR PATHS" with nothing saying the + * PR would never run it, and the first run of its pins was `main`'s push + * battery, red. */ const JOB_IF_STATUS_TERM = /(?:^|\s)!\s*cancelled\(\)\s*&&\s*/g; const JOB_IF_FULL_BATTERY_TERM = @@ -1201,12 +1210,27 @@ const JOB_FILTER_OUTPUT_TERM = * family keeps whatever verdict it had before. */ export function jobFilterOutputRefs(ifExpression) { + return jobIfReading(ifExpression)?.refs ?? null; +} + +/** + * The whole reading of a job `if:` — `{ refs, fullBatteryOnly }`, or null on + * exactly the refusals `jobFilterOutputRefs` makes, because that function IS + * this one with the second half dropped. `fullBatteryOnly` is true when the + * full-battery clause was stripped as the last conjunct: the job runs on + * `main`'s push and hourly runs and NEVER on a `pull_request` or `merge_group` + * event. It is read in the same pass as the refs, from the same text, so the + * population and the event fact cannot describe two different `if:`s. + */ +export function jobIfReading(ifExpression) { if (typeof ifExpression !== 'string') return null; let expr = ifExpression.trim(); const wrapped = /^\$\{\{([\s\S]*)\}\}$/.exec(expr); if (wrapped) expr = wrapped[1].trim(); expr = expr.replace(JOB_IF_STATUS_TERM, ' ').trim(); - expr = expr.replace(JOB_IF_FULL_BATTERY_TERM, '').trim(); + const eventFree = expr.replace(JOB_IF_FULL_BATTERY_TERM, '').trim(); + const fullBatteryOnly = eventFree !== expr; + expr = eventFree; // One balanced outer paren pair at a time — `(A || B)` is the live spelling. for (;;) { if (!expr.startsWith('(') || !expr.endsWith(')')) break; @@ -1227,7 +1251,7 @@ export function jobFilterOutputRefs(ifExpression) { if (!m) return null; refs.push({ job: m[1], output: m[2] }); } - return refs.length ? refs : null; + return refs.length ? { refs, fullBatteryOnly } : null; } /** @@ -1426,12 +1450,15 @@ const EXTGLOB_CONSTRUCT = /[!?*+@]\(/; * The path population a job's `if:` resolves to, or null — the whole of * direction 1, assembled from the three readings above. * - * Returns `{ paths, outputs, dropped }`. `paths` is a UNION: the `if:` is an OR - * of filter outputs, so CI schedules the job when ANY of them matched. + * Returns `{ paths, outputs, dropped, fullBatteryOnly }`. `paths` is a UNION: + * the `if:` is an OR of filter outputs, so CI schedules the job when ANY of them + * matched. `fullBatteryOnly` is `jobIfReading`'s event fact, carried so a row + * built from this population can say WHEN CI schedules it (#24073). */ export function jobFilterPopulation(job, filterStepsByOutputSource) { - const refs = jobFilterOutputRefs(job.if); - if (!refs) return null; + const reading = jobIfReading(job.if); + if (!reading) return null; + const { refs, fullBatteryOnly } = reading; const paths = []; const outputs = []; let dropped = 0; @@ -1450,12 +1477,12 @@ export function jobFilterPopulation(job, filterStepsByOutputSource) { } outputs.push(`${ref.job}.${ref.output}`); } - return paths.length ? { paths, outputs, dropped } : null; + return paths.length ? { paths, outputs, dropped, fullBatteryOnly } : null; } /** * Every job in one workflow whose `if:` resolves to a path population, WITH the - * job's own text: `[{ job, name, text, outputs, paths, dropped }]`. + * job's own text: `[{ job, name, text, outputs, paths, dropped, fullBatteryOnly }]`. * * ⛔ No check filter here, and that is the whole reason this is its own * function. The caller below drops a job that invokes no discoverable check @@ -2811,6 +2838,13 @@ export function alwaysRunSteps(entries) { * of it runs a command no discovered family accounts for. Both halves are * read off the workflow text on every run; nothing here is listed. * + * ⭐ And WHEN it is scheduled (#24073). A row whose job `if:` ended in the + * full-battery clause carries `fullBatteryOnly: true`, read by `jobIfReading` + * in the same pass as the population: CI schedules that job for your path on + * `main`'s push and hourly runs ONLY — never on this PR, never in the merge + * queue. Both renderings print it on the row's own line, so "scheduled BY YOUR + * PATHS" cannot be read as "this PR runs it". + * * ⛔ NOT claimed: that a row is a test, a build or a setup step. * `alwaysRunLines` refuses that classification for its own rows and the reason * carries unchanged — every rule that separates them is a guess about a step's @@ -2896,7 +2930,10 @@ export function jobFilteredSteps(entries, paths) { } if (steps.length === 0) continue; counts.named += 1; - rows.push({ workflow: file, job: job.name, outputs: job.outputs, hits, dropped: job.dropped, steps }); + rows.push({ + workflow: file, job: job.name, outputs: job.outputs, hits, dropped: job.dropped, + fullBatteryOnly: job.fullBatteryOnly === true, steps, + }); } } return { rows, counts }; @@ -11460,6 +11497,37 @@ export function alwaysRunLines(rows, counts) { return lines; } +/** + * The WHEN of a path-scheduled job row, as the one clause both renderings print + * on that row's own line (#24073) — or the empty string when the row's job runs + * on the PR like any other. + * + * Read off `row.fullBatteryOnly`, the fact `jobIfReading` took from the job's + * own `if:`; ⛔ never keyed on a job name, so a job that gains or loses the + * full-battery clause gains or loses the clause here with nothing to update. + * The reader it exists for: a dev whose path schedules the Dogfood Regression + * Gate, told only "scheduled BY YOUR PATHS", inferred that the PR would run its + * pins — and the first run was `main`'s push battery, red. + */ +export function fullBatteryOnlyClause(row) { + if (row?.fullBatteryOnly !== true) return ''; + return '⛔ does NOT run on this PR or in the merge queue: its `if:` excludes the pull_request and merge_group events,' + + " so its first run on this change is `main`'s push battery, AFTER the merge"; +} + +/** + * One path-scheduled job row as `--commands` / `--json` print it on stderr — a + * named reader rather than an inline template so the line a dispatch is built + * from is the line the self-test pins. The WHEN clause sits directly after the + * job name, ahead of the step list, because a long step list is where a + * trailing clause goes unread. + */ +export function jobFilteredNotMeasuredLine(row) { + const when = fullBatteryOnlyClause(row); + return ` ⊘ NOT MEASURED — [${row.workflow} · ${row.job}]${when ? ` ${when} —` : ''} ${row.steps.length} step(s):` + + ` ${row.steps.map((s) => s.step).join(' · ')}`; +} + /** * The path-scheduled CI jobs, rendered (#16285) — printed BELOW the * reconciliation, because everything under that total names something OUTSIDE @@ -11502,8 +11570,9 @@ export function jobFilteredStepLines(rows, counts) { ' ⛔ NOT runnable and ⛔ NOT in --commands: every row is CI\'s own shell in CI\'s environment. They sit OUTSIDE the runnable', ' total above — running every family named there does NOT cover them, which is the gap this block exists to close.', ' ⛔ NOT classified into tests, builds and setup — the same refusal the always-runs tail makes, for the same reason: every rule', - ' that tells them apart is a guess about a step\'s intent. What is claimed for a row is only what was measured — YOUR path puts', - ' this job on the PR, and nothing above names a family for this step. The reader judges the rest.', + ' that tells them apart is a guess about a step\'s intent. What is claimed for a row is only what was measured — YOUR path', + ' schedules this job (on this PR unless its row says it does NOT), and nothing above names a family for this step.', + ' The reader judges the rest.', ' ⇒ The always-runs tail below is the OTHER half of this partition: it reads the jobs CI cannot narrow by path, this one reads', ' the jobs your paths narrow it TO — and a job carrying an `if:` is excluded there, which is every job on this list.', ]; @@ -11516,8 +11585,10 @@ export function jobFilteredStepLines(rows, counts) { for (const row of rows) { const via = row.hits.map((h) => `${h.path} ⇢ '${h.pattern}'`).join('; '); const dropped = row.dropped ? `, ${row.dropped} glob(s) of it untranslatable and dropped` : ''; + const when = fullBatteryOnlyClause(row); lines.push( - ` - [${row.workflow} · ${row.job}] scheduled by ${via} (job \`if:\` reads ${row.outputs.join(', ')}${dropped})`, + ` - [${row.workflow} · ${row.job}] scheduled by ${via} (job \`if:\` reads ${row.outputs.join(', ')}${dropped})` + + `${when ? ` ${when}` : ''}`, ); for (const { step, commands } of row.steps) { lines.push(` · ${step}`); @@ -14346,12 +14417,7 @@ export function machineReadableOutput(mode, { paths, size = null, matchedRows, k ` — ${mode === 'json' ? 'under jobFilteredSteps, not in commands' : 'NOT above'}. They are CI's own shell in CI's environment,` + ' so there is no local invocation to hand you, and running every command on stdout does ⛔ NOT cover them.', ); - for (const row of jobFiltered.rows) { - console.error( - ` ⊘ NOT MEASURED — [${row.workflow} · ${row.job}] ${row.steps.length} step(s):` + - ` ${row.steps.map((s) => s.step).join(' · ')}`, - ); - } + for (const row of jobFiltered.rows) console.error(jobFilteredNotMeasuredLine(row)); console.error(' ⇒ Run without --commands/--json to see each step printed as CI spells it.'); } // ⭐ The SEVENTH thing stdout deliberately omits (#19172) — and the lane this diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 93bfd52fb43..61b7c6843ee 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -162,6 +162,7 @@ import { firstPartyImportBindings, firstPartyImportTargets, followCompositeActions, + fullBatteryOnlyClause, gateFamilyFiles, generatedModuleSources, globCarriesLiteralSuffix, @@ -184,8 +185,10 @@ import { isTypeCheckInvocation, jobFilterOutputRefs, jobFilterPopulation, + jobFilteredNotMeasuredLine, jobFilteredStepLines, jobFilteredSteps, + jobIfReading, jobPathPopulations, joinLineContinuations, judgedAsPattern, @@ -5670,6 +5673,17 @@ export function selfTest({ tier = 'full' } = {}) { '...and the clause in front of the filter output is not the stripped spelling either', jobFilterOutputRefs(`\${{ ${FULL_BATTERY} && needs.f.outputs.a != 'false' }}`) === null, ); + // #24073: stripped is not FORGOTTEN. The reading returns the event fact beside + // the refs it shares with `jobFilterOutputRefs`, and only when it stripped it. + const fullBatteryReading = jobIfReading(`\${{ !cancelled() && needs.filter.outputs.console != 'false' && ${FULL_BATTERY} }}`); + t( + 'jobIfReading: the stripped full-battery clause is RETURNED as fullBatteryOnly, beside the same refs', + fullBatteryReading?.fullBatteryOnly === true && fullBatteryReading?.refs.map((r) => `${r.job}.${r.output}`).join() === 'filter.console', + ); + t( + '...and an if: with no event clause reads fullBatteryOnly FALSE — the fact is read off the text, never defaulted on', + jobIfReading("${{ !cancelled() && needs.filter.outputs.console != 'false' }}")?.fullBatteryOnly === false, + ); const fixturePops = jobPathPopulations(jobFilterWf, 'fixture.yml'); t( @@ -11928,6 +11942,63 @@ export function selfTest({ tier = 'full' } = {}) { outsideBlockCounts(familyReconciliation({ jobFilteredRows: [{}, {}] })).jobFilteredJobs === 2, ); + // ── WHEN a path-scheduled job runs, on the row the reader reads (#24073) ── + // + // The measured case: a `packages/runtime/src/route-ledger.ts` change derived + // the Dogfood Regression Gate as "scheduled BY YOUR PATHS", and nothing said + // its `if:` skips pull_request and merge_group — so the PR never ran the two + // dogfood pins that read that file, and their first run was `main`'s push + // battery, red. Pinned LIVE on ci.yml for both dogfood jobs (they carry the + // same `if:`), against a NEGATIVE control on a job that does run on the PR, + // and with an in-place ABLATION of the carried flag on every row, so the + // statement cannot pass as a string typed per job. If ci.yml moves either job + // back onto the PR, these go red: re-point them to whichever path-scheduled + // job still carries the clause — ⛔ do not delete them. + const ledgerCard = 'packages/runtime/src/route-ledger.ts'; + const ledgerJobs = jobFilteredSteps(liveWorkflows, [ledgerCard]); + const ledgerRow = (prefix) => ledgerJobs.rows.find((r) => r.job.startsWith(prefix)) ?? null; + const NOT_ON_PR = 'does NOT run on this PR or in the merge queue'; + // The plain rendering of ONE row, with counts that satisfy the renderer's own + // refusals, so the ablated copy below is rendered by the same function. + const plainRowLine = (row) => { + if (!row) return ''; + const n = row.steps.length; + const lines = jobFilteredStepLines([row], { covering: 1, named: 1, steps: n, accounted: 0, unaccounted: n, conditionalSteps: 0 }); + return lines.find((l) => l.startsWith(` - [${row.workflow} · ${row.job}]`)) ?? ''; + }; + for (const prefix of ['Dogfood Regression Gate', 'Dogfood Verify CLI']) { + const row = ledgerRow(prefix); + t( + `LIVE: a route-ledger.ts change schedules '${prefix}', and its row carries the full-battery fact read off its own if:`, + row?.fullBatteryOnly === true, + ); + t( + '...its --commands line SAYS the job does not run on this PR or in the merge queue, ahead of the step list', + Boolean(row) && jobFilteredNotMeasuredLine(row).indexOf(NOT_ON_PR) > -1 + && jobFilteredNotMeasuredLine(row).indexOf(NOT_ON_PR) < jobFilteredNotMeasuredLine(row).indexOf(' step(s):'), + ); + t("...and so does its row in the plain rendering, on the row's own line", plainRowLine(row).includes(NOT_ON_PR)); + // ABLATION, in place: the same row with the carried fact stripped. The + // statement must vanish from both lines — rendered from the flag, or not at all. + const stripped = row ? Object.fromEntries(Object.entries(row).filter(([k]) => k !== 'fullBatteryOnly')) : null; + t( + '...ABLATED — the carried fact stripped from the row — the statement disappears from BOTH lines', + Boolean(stripped) + && fullBatteryOnlyClause(stripped) === '' + && !jobFilteredNotMeasuredLine(stripped).includes(NOT_ON_PR) + && plainRowLine(stripped) !== '' + && !plainRowLine(stripped).includes(NOT_ON_PR), + ); + } + const prJobRow = ledgerRow('Build Core'); + t( + 'LIVE NEGATIVE CONTROL: Build Core, scheduled by the same path and run on the PR, carries the fact as FALSE and no statement', + prJobRow?.fullBatteryOnly === false + && !jobFilteredNotMeasuredLine(prJobRow).includes(NOT_ON_PR) + && plainRowLine(prJobRow) !== '' + && !plainRowLine(prJobRow).includes(NOT_ON_PR), + ); + // ── Type-check lanes (#19172): the negatives are the live over-matches a SUBSTRING reading produces here ── t('a `tsc --noEmit` or `-p ` invocation is a lane', isTypeCheckInvocation('pnpm --filter @objectstack/spec exec tsc --noEmit') && isTypeCheckInvocation('npx tsc -p tsconfig.test.json'));