Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 84 additions & 18 deletions scripts/pm/dispatch-gates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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;
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.',
];
Expand All @@ -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}`);
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions scripts/pm/dispatch-gates.self-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ import {
firstPartyImportBindings,
firstPartyImportTargets,
followCompositeActions,
fullBatteryOnlyClause,
gateFamilyFiles,
generatedModuleSources,
globCarriesLiteralSuffix,
Expand All @@ -184,8 +185,10 @@ import {
isTypeCheckInvocation,
jobFilterOutputRefs,
jobFilterPopulation,
jobFilteredNotMeasuredLine,
jobFilteredStepLines,
jobFilteredSteps,
jobIfReading,
jobPathPopulations,
joinLineContinuations,
judgedAsPattern,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 <config>` invocation is a lane',
isTypeCheckInvocation('pnpm --filter @objectstack/spec exec tsc --noEmit') && isTypeCheckInvocation('npx tsc -p tsconfig.test.json'));
Expand Down
Loading