|
273 | 273 | * them is printed in the residue rather than left as an absence — a schedule |
274 | 274 | * this tool cannot narrow is a fact the reader is owed, not one to keep quiet. |
275 | 275 | * |
| 276 | + * ## The SCHEDULED-only routing question, measured and DEFERRED (#14899) |
| 277 | + * |
| 278 | + * The section above says what CI's trigger CAN answer. This one records what |
| 279 | + * a card asked it next, and why the answer was to ship a reading rather than a |
| 280 | + * rule. The card: the derivation names `node scripts/pm/check-half-states.mjs` |
| 281 | + * — a live board sweep — for any diff carrying a changeset, on the reading |
| 282 | + * that its only caller is a `schedule`-triggered workflow, so CI never runs it |
| 283 | + * on a PR while the dispatch protocol tells a dev to run every printed |
| 284 | + * command. The proposed general repair was a third withholding class beside |
| 285 | + * the CI-measured and value-bearing ones: "scheduled-only, not a PR gate". |
| 286 | + * |
| 287 | + * Measured first, from the workflow text at fa8c1963 (2026-09-04): |
| 288 | + * |
| 289 | + * .github/workflows/*.yml 30 |
| 290 | + * declaring `schedule:` 15 |
| 291 | + * …of those, contributing a discovered check family 8 |
| 292 | + * discovered families 252 |
| 293 | + * reaching a scheduled workflow at all 21 |
| 294 | + * reached ONLY through scheduled workflows 10 |
| 295 | + * SCHEDULED-ONLY — reached by no PR-time trigger 0 |
| 296 | + * |
| 297 | + * All ten candidates dissolve on the same fact, and it is a deliberate repo |
| 298 | + * posture rather than an accident: every patrol here declares a |
| 299 | + * `pull_request:` trigger with a `paths:` filter naming its own script, so |
| 300 | + * "changes to the patrol itself get exercised before they merge" |
| 301 | + * (`half-state-patrol.yml`'s own comment, and the same words in |
| 302 | + * `required-set-patrol.yml`, `release-coverage-patrol.yml` and |
| 303 | + * `merged-branch-reaper.yml`). The card's own specimen is one of those ten: |
| 304 | + * `half-state-patrol.yml` already carried that trigger on the day the card was |
| 305 | + * filed. Three of the ten are `validate-deps.yml`'s, including |
| 306 | + * `check:override-consistency` — a gate every dependency card must run. So |
| 307 | + * "its only source workflow declares a schedule" is not close to "no PR runs |
| 308 | + * it", and a class keyed on that predicate would have withheld gates a dev |
| 309 | + * owes. |
| 310 | + * |
| 311 | + * The reading does not turn on where the PR-time line is drawn either: |
| 312 | + * narrowing `PR_TIME_TRIGGER_EVENTS` to `pull_request` alone leaves the same |
| 313 | + * zero, because every one of the ten is reached through a `pull_request` |
| 314 | + * trigger specifically. |
| 315 | + * |
| 316 | + * ⛔ So the class is NOT shipped: an empty classification is a capability with |
| 317 | + * nothing in it, the speculative-capability shape `extractTriggerPaths`' |
| 318 | + * `paths-ignore:` boundary already refuses two sections down — "when one does |
| 319 | + * and its families matter, model it then". The cost the card measured (3m09s |
| 320 | + * of a dev's wall clock, plus shared API quota) is separately gone: #15083 |
| 321 | + * classified that invocation VALUE-BEARING off its `$PROVENANCE` argv, so it |
| 322 | + * left `--commands` without any scheduled-only rule existing. |
| 323 | + * |
| 324 | + * What ships is the reading, and that is the load-bearing half. A deferral is |
| 325 | + * only honest while its population stays empty, and nothing was watching that: |
| 326 | + * `declaredTriggerEvents` re-takes the measurement from the workflow text on |
| 327 | + * every `--self-test`, so the day a family really is scheduled-only the pin |
| 328 | + * reds on the PR that creates it. Two exits from there, and the pin's own case |
| 329 | + * names both: give the workflow the `pull_request` paths trigger every patrol |
| 330 | + * here already carries, or ship the class this section defers. ⛔ Neither exit |
| 331 | + * is "edit the pin's expectation" — the zero is a reading, not a roster. |
| 332 | + * |
276 | 333 | * ## Why a declaration can only NARROW, and what that guarantee costs (#12842) |
277 | 334 | * |
278 | 335 | * `declaredInheritedPopulation` refuses any path its own module does not |
@@ -1563,6 +1620,120 @@ export function declaresPullRequestTrigger(workflowText) { |
1563 | 1620 | return false; |
1564 | 1621 | } |
1565 | 1622 |
|
| 1623 | +/** |
| 1624 | + * Every event a workflow's `on:` block DECLARES, in declaration order. |
| 1625 | + * |
| 1626 | + * The third of the three narrow `on:` walkers, kept beside the two above so |
| 1627 | + * the trio cannot drift: `extractTriggerPaths` reads one event's `paths:`, |
| 1628 | + * `declaresPullRequestTrigger` answers one event's presence, and this one |
| 1629 | + * answers WHICH events there are. All three walk indentation rather than |
| 1630 | + * parse YAML, for the reason `extractTriggerPaths` states — this script is |
| 1631 | + * dependency-free by design. |
| 1632 | + * |
| 1633 | + * ## Why it exists when NO derivation consumes it (#14899) |
| 1634 | + * |
| 1635 | + * It is the instrument for the scheduled-only measurement in the header, and |
| 1636 | + * the self-test's live block is its only caller. That is deliberate and it is |
| 1637 | + * the whole shape of what shipped: the classification the card proposed has |
| 1638 | + * ZERO members on this tree, so shipping it would be a capability with nothing |
| 1639 | + * in it, and the header records why. What a deferral needs to be safe is a |
| 1640 | + * reading that goes loud the day the population stops being empty — and a |
| 1641 | + * reading needs an instrument. ⛔ Do not delete this as unused: its caller is |
| 1642 | + * the pin, and deleting it deletes the detection that makes the deferral |
| 1643 | + * honest rather than merely convenient. |
| 1644 | + * |
| 1645 | + * ## The three `on:` spellings, all read |
| 1646 | + * |
| 1647 | + * - the mapping (`on:` then ` pull_request:` on its own line) — every |
| 1648 | + * workflow in this tree today; |
| 1649 | + * - the flow sequence (`on: [push, pull_request]`); |
| 1650 | + * - the bare scalar (`on: push`), and the block sequence under it. |
| 1651 | + * |
| 1652 | + * The YAML 1.1 spelling is read too: an unquoted `on` is the BOOLEAN `true` to |
| 1653 | + * a 1.1 parser, so `'on':`, `"on":` and a literal `true:` are all the same key |
| 1654 | + * — the same three spellings the two walkers above already accept. |
| 1655 | + * |
| 1656 | + * ## The boundaries, each with the direction it fails in |
| 1657 | + * |
| 1658 | + * - Only keys at the FIRST indentation level inside `on:` are events. A |
| 1659 | + * `paths:`/`types:`/`branches:` under an event is not one, and neither is |
| 1660 | + * a `schedule:` under `jobs:` — a walk that scooped either would report |
| 1661 | + * events a workflow does not declare, and this reader's whole use is to |
| 1662 | + * decide that a workflow has NO PR-time trigger. A fabricated event fails |
| 1663 | + * in the safe direction (it can only ever REMOVE a family from the |
| 1664 | + * scheduled-only class); a missed one fails in the loud direction (a false |
| 1665 | + * member, caught by the reader of the pin). Both are pinned below. |
| 1666 | + * - `workflow_call` is reported as declared and is NOT a PR-time event: a |
| 1667 | + * reusable workflow runs with its caller's event, so the caller is where |
| 1668 | + * the question is answered. No family in this tree reaches one. |
| 1669 | + */ |
| 1670 | +export function declaredTriggerEvents(workflowText) { |
| 1671 | + const out = []; |
| 1672 | + const add = (name) => { |
| 1673 | + const clean = unquoteScalar(name); |
| 1674 | + if (clean !== '' && !out.includes(clean)) out.push(clean); |
| 1675 | + }; |
| 1676 | + let inOn = false; |
| 1677 | + let eventIndent = -1; |
| 1678 | + for (const line of workflowText.split('\n')) { |
| 1679 | + const trimmed = line.trim(); |
| 1680 | + if (trimmed === '' || trimmed.startsWith('#')) continue; |
| 1681 | + const indent = /^[ \t]*/.exec(line)[0].length; |
| 1682 | + if (indent === 0) { |
| 1683 | + // A new top-level key closes whatever we were inside — the same reset |
| 1684 | + // the two walkers above make, and what keeps a `jobs:` decoy out. |
| 1685 | + const on = /^(?:on|'on'|"on"|true):\s*(.*)$/.exec(trimmed); |
| 1686 | + inOn = Boolean(on); |
| 1687 | + eventIndent = -1; |
| 1688 | + if (on && on[1].trim() !== '') { |
| 1689 | + const flow = flowSequenceItems(on[1]); |
| 1690 | + if (flow.length > 0) for (const item of flow) add(item); |
| 1691 | + else add(on[1].trim()); |
| 1692 | + // An inline value IS the whole declaration; nothing indented under it. |
| 1693 | + inOn = false; |
| 1694 | + } |
| 1695 | + continue; |
| 1696 | + } |
| 1697 | + if (!inOn) continue; |
| 1698 | + // The first indented line fixes the level events live at. Anything deeper |
| 1699 | + // belongs to an event, not to `on:`. |
| 1700 | + if (eventIndent === -1) eventIndent = indent; |
| 1701 | + if (indent !== eventIndent) continue; |
| 1702 | + const item = /^-\s*(.*)$/.exec(trimmed); |
| 1703 | + if (item) { |
| 1704 | + add(item[1]); |
| 1705 | + continue; |
| 1706 | + } |
| 1707 | + const key = /^([A-Za-z_][A-Za-z0-9_]*):/.exec(trimmed); |
| 1708 | + if (key) add(key[1]); |
| 1709 | + } |
| 1710 | + return out; |
| 1711 | +} |
| 1712 | + |
| 1713 | +/** |
| 1714 | + * The events that put a workflow in front of a PULL REQUEST — the predicate |
| 1715 | + * the scheduled-only measurement subtracts by (#14899). |
| 1716 | + * |
| 1717 | + * `pull_request` and `pull_request_target` run on the PR itself; `merge_group` |
| 1718 | + * runs on the speculative merge the queue builds out of it; `push` runs on the |
| 1719 | + * result of landing it. All four judge a diff a dev wrote, which is the |
| 1720 | + * question — `schedule`, `workflow_dispatch`, `workflow_run` and |
| 1721 | + * `workflow_call` judge a board, a human's button, another run, or a caller. |
| 1722 | + * |
| 1723 | + * ⚠️ The set is deliberately WIDE, and the header records that the measured |
| 1724 | + * answer does not depend on it: narrowing it to `pull_request` alone leaves |
| 1725 | + * the scheduled-only count at zero, because every family this tree reaches |
| 1726 | + * through a scheduled workflow is also reached through a `pull_request` one. |
| 1727 | + * A wide set can only ever UNDER-report the class, which is the direction that |
| 1728 | + * fails quietly — so the pin below asserts the narrow reading too. |
| 1729 | + */ |
| 1730 | +export const PR_TIME_TRIGGER_EVENTS = Object.freeze([ |
| 1731 | + 'pull_request', |
| 1732 | + 'pull_request_target', |
| 1733 | + 'merge_group', |
| 1734 | + 'push', |
| 1735 | +]); |
| 1736 | + |
1566 | 1737 | /** |
1567 | 1738 | * A job's steps, each as `{ name, if: <text|null>, text }`, with the original |
1568 | 1739 | * indentation kept so every extractor above reads a step exactly as it reads a |
@@ -14574,6 +14745,150 @@ function selfTest() { |
14574 | 14745 | t('the flow-sequence spelling is read too', extractTriggerPaths("on:\n pull_request:\n paths: ['a/**', \"b/c\"]\n").join('|') === 'a/**|b/c'); |
14575 | 14746 | t('pull_request_target is not mistaken for pull_request', extractTriggerPaths("on:\n pull_request_target:\n paths:\n - 'x/**'\n").length === 0); |
14576 | 14747 |
|
| 14748 | + // ── The SCHEDULED-ONLY routing question, measured and answered ZERO (#14899) |
| 14749 | + // |
| 14750 | + // The card: the derivation named `node scripts/pm/check-half-states.mjs` — |
| 14751 | + // a live board sweep — for any diff carrying a changeset, on the reading |
| 14752 | + // that its only caller is a `schedule`-triggered workflow. Two things were |
| 14753 | + // measured against the tree instead of accepted: |
| 14754 | + // |
| 14755 | + // 1. `half-state-patrol.yml` DOES declare a `pull_request:` trigger, with |
| 14756 | + // a `paths:` filter naming the sweeper and the workflow — it already |
| 14757 | + // did on the day the card was filed. So the specimen was never a |
| 14758 | + // workflow no PR runs; it is a patrol that exercises itself on the PRs |
| 14759 | + // that change it, the posture every patrol in this tree keeps. |
| 14760 | + // 2. Across the whole tree, the number of discovered families whose |
| 14761 | + // source workflows ALL lack a PR-time trigger is ZERO — and it stays |
| 14762 | + // zero under the narrowest reading of "PR-time" as well. |
| 14763 | + // |
| 14764 | + // So the classification the card proposed has no members, and shipping it |
| 14765 | + // would be a capability with nothing in it. What ships instead is this pin: |
| 14766 | + // the reading is re-taken from the workflow text on every run, so the |
| 14767 | + // deferral goes loud the day the population stops being empty. ⛔ The cases |
| 14768 | + // below are the whole remedy for that day — they are not a roster to edit |
| 14769 | + // when one reds. See the header section of the same name for the exits. |
| 14770 | + const wfDirLive = nodePath.join(ROOT, '.github/workflows'); |
| 14771 | + const eventsWf = [ |
| 14772 | + 'name: Fixture', |
| 14773 | + '# a comment before the on: block', |
| 14774 | + 'on:', |
| 14775 | + ' schedule:', |
| 14776 | + " - cron: '37 1,7,13,19 * * *'", |
| 14777 | + ' workflow_dispatch: {}', |
| 14778 | + ' # a comment between events', |
| 14779 | + ' pull_request:', |
| 14780 | + ' types: [opened, synchronize]', |
| 14781 | + ' paths:', |
| 14782 | + " - 'scripts/pm/check-half-states.mjs'", |
| 14783 | + // A decoy at an event's OWN depth-plus-one: a key under `pull_request:` is |
| 14784 | + // not an event, however event-shaped its name. |
| 14785 | + ' push:', |
| 14786 | + ' branches: [main]', |
| 14787 | + 'jobs:', |
| 14788 | + ' sweep:', |
| 14789 | + // A decoy under `jobs:`, the shape a walk without the top-level reset eats. |
| 14790 | + ' merge_group:', |
| 14791 | + ' never: read', |
| 14792 | + '', |
| 14793 | + ].join('\n'); |
| 14794 | + const fixtureEvents = declaredTriggerEvents(eventsWf); |
| 14795 | + t('the on: mapping\'s events are read in declaration order', fixtureEvents.join('|') === 'schedule|workflow_dispatch|pull_request'); |
| 14796 | + t('a key nested UNDER an event is not an event, however event-shaped its name', !fixtureEvents.includes('push')); |
| 14797 | + t('a decoy event under jobs: is not read', !fixtureEvents.includes('merge_group')); |
| 14798 | + t('an event\'s own sub-keys never enter the list', !fixtureEvents.includes('types') && !fixtureEvents.includes('paths') && !fixtureEvents.includes('branches')); |
| 14799 | + t('the flow-sequence spelling is read', declaredTriggerEvents('on: [push, pull_request]\njobs: {}\n').join('|') === 'push|pull_request'); |
| 14800 | + t('the bare-scalar spelling is read', declaredTriggerEvents('on: push\njobs: {}\n').join('|') === 'push'); |
| 14801 | + t('the block-sequence spelling is read', declaredTriggerEvents('on:\n - push\n - schedule\njobs: {}\n').join('|') === 'push|schedule'); |
| 14802 | + // The YAML 1.1 coercion the two walkers above already accept: unquoted `on` |
| 14803 | + // is the boolean `true`, so all three spellings name the same key. |
| 14804 | + t('the quoted and YAML-1.1 spellings of the key are all read', ["'on'", '"on"', 'true'].every((k) => declaredTriggerEvents(`${k}:\n schedule:\n - cron: '0 1 * * *'\n`).join('|') === 'schedule')); |
| 14805 | + t('a workflow declaring no on: block yields an empty list, not a fabricated event', declaredTriggerEvents('name: X\njobs: {}\n').length === 0); |
| 14806 | + |
| 14807 | + // The same reader against REAL `on:` blocks, read from the tree rather than |
| 14808 | + // pasted: a quoted copy of a workflow is a second revision of it waiting to |
| 14809 | + // rot, which is what this whole file refuses. |
| 14810 | + const eventsOfWorkflow = new Map(); |
| 14811 | + for (const f of readdirSync(wfDirLive).filter((x) => /\.ya?ml$/.test(x))) { |
| 14812 | + eventsOfWorkflow.set(f, declaredTriggerEvents(readFileSync(nodePath.join(wfDirLive, f), 'utf8'))); |
| 14813 | + } |
| 14814 | + t( |
| 14815 | + '⭐ the card\'s own specimen declares a pull_request trigger beside its schedule — half-state-patrol.yml is not a workflow no PR runs', |
| 14816 | + ['schedule', 'workflow_dispatch', 'pull_request'].every((e) => (eventsOfWorkflow.get('half-state-patrol.yml') ?? []).includes(e)), |
| 14817 | + ); |
| 14818 | + t( |
| 14819 | + 'and a genuinely scheduled-only workflow reads as one, so the predicate is not answering `pull_request` to everything (stale.yml)', |
| 14820 | + (eventsOfWorkflow.get('stale.yml') ?? []).join('|') === 'schedule|workflow_dispatch', |
| 14821 | + ); |
| 14822 | + |
| 14823 | + // The live half. Fixtures cannot prove the tree has no scheduled-only |
| 14824 | + // family; this reads it. |
| 14825 | + const reachesPRTime = (workflows, prTime = PR_TIME_TRIGGER_EVENTS) => |
| 14826 | + [...workflows].some((wf) => (eventsOfWorkflow.get(wf) ?? []).some((e) => prTime.includes(e))); |
| 14827 | + const isScheduled = (wf) => (eventsOfWorkflow.get(wf) ?? []).includes('schedule'); |
| 14828 | + const triggerFamilies = [...discoverFamilies().byCheck.values()]; |
| 14829 | + const scheduledWorkflows = [...eventsOfWorkflow.keys()].filter(isScheduled); |
| 14830 | + const scheduledContributors = scheduledWorkflows.filter((wf) => triggerFamilies.some((e) => e.workflows.has(wf))); |
| 14831 | + const fromScheduled = triggerFamilies.filter((e) => [...e.workflows].some(isScheduled)); |
| 14832 | + const scheduledOnly = triggerFamilies.filter((e) => !reachesPRTime(e.workflows) && [...e.workflows].every(isScheduled)); |
| 14833 | + // Non-vacuity, both halves — a zero over an empty sweep is a broken |
| 14834 | + // instrument wearing a clean result's clothes, which is #4690's shape. |
| 14835 | + t( |
| 14836 | + `the live tree really declares ${scheduledWorkflows.length} schedule-triggered workflow(s), so the sweep below has a population`, |
| 14837 | + scheduledWorkflows.length > 0, |
| 14838 | + ); |
| 14839 | + t( |
| 14840 | + `…and ${scheduledContributors.length} of them really contribute discovered families (${fromScheduled.length} famil(ies)), so the zero below is a reading`, |
| 14841 | + scheduledContributors.length > 0 && fromScheduled.length > 0, |
| 14842 | + ); |
| 14843 | + t( |
| 14844 | + `⭐ ZERO of the ${triggerFamilies.length} discovered families is SCHEDULED-ONLY — every one reaches a workflow that declares a PR-time` |
| 14845 | + + ' event, so no board sweep is routed into a per-PR gate list. If this reds, a scheduled-only family has ARRIVED: give its' |
| 14846 | + + ' workflow the pull_request paths trigger every patrol here already carries, or ship the withheld class the header defers', |
| 14847 | + scheduledOnly.length === 0, |
| 14848 | + ); |
| 14849 | + t( |
| 14850 | + '…and the reading does not depend on how wide PR-time is drawn: narrowing it to `pull_request` alone leaves the same zero', |
| 14851 | + triggerFamilies.filter((e) => !reachesPRTime(e.workflows, ['pull_request']) && [...e.workflows].every(isScheduled)).length === 0, |
| 14852 | + ); |
| 14853 | + // The complement, so the zero above cannot be the union quietly hiding a |
| 14854 | + // member: a family reached by NO PR-time event at all must come from a |
| 14855 | + // workflow that declares no `schedule` either. Today that is `cut-rc.yml`, |
| 14856 | + // the human release lane, which is `workflow_dispatch`-only. |
| 14857 | + const noPRTime = triggerFamilies.filter((e) => !reachesPRTime(e.workflows)); |
| 14858 | + t( |
| 14859 | + `the complement agrees: all ${noPRTime.length} famil(ies) reached by no PR-time event at all come from workflows that declare no schedule`, |
| 14860 | + noPRTime.every((e) => [...e.workflows].every((wf) => !isScheduled(wf))), |
| 14861 | + ); |
| 14862 | + // The control the card's ruling names: a family from a scheduled workflow |
| 14863 | + // that ALSO declares a PR-time trigger keeps the class it already had. The |
| 14864 | + // card's own specimen is the subject — it is withheld from `--commands` by |
| 14865 | + // the value-bearing class (#15083) and by nothing else, which is why the |
| 14866 | + // 3m09s it measured is gone without any scheduled-only rule existing. |
| 14867 | + const sweepEntry = triggerFamilies.find((e) => e.check.startsWith('scripts/pm/check-half-states.mjs')); |
| 14868 | + t( |
| 14869 | + 'the card\'s specimen is still discovered, still reached only through its patrol, and still classified VALUE-BEARING — not withheld for being scheduled', |
| 14870 | + Boolean(sweepEntry) |
| 14871 | + && [...sweepEntry.workflows].join('|') === 'half-state-patrol.yml' |
| 14872 | + && isScheduled('half-state-patrol.yml') |
| 14873 | + && reachesPRTime(sweepEntry.workflows) |
| 14874 | + && Boolean(sweepEntry.notRunnable) |
| 14875 | + && !sweepEntry.ciOnly, |
| 14876 | + ); |
| 14877 | + // And the half this card must NOT move: the OFFLINE self-test lint.yml runs |
| 14878 | + // on every PR stays a runnable command. It is the same script's other |
| 14879 | + // spelling, and a rule keyed on the sweeper's name rather than on the |
| 14880 | + // workflow text — the per-script exclusion the ruling refused — would have |
| 14881 | + // taken this one with it. |
| 14882 | + const offlineHalf = triggerFamilies.find((e) => e.check === 'check:pm-half-states'); |
| 14883 | + t( |
| 14884 | + 'and the offline half CI runs on every PR is untouched — check:pm-half-states reaches lint.yml, carries neither withholding class, and still renders a runnable command', |
| 14885 | + Boolean(offlineHalf) |
| 14886 | + && offlineHalf.workflows.has('lint.yml') |
| 14887 | + && reachesPRTime(offlineHalf.workflows) |
| 14888 | + && !offlineHalf.ciOnly |
| 14889 | + && !offlineHalf.notRunnable, |
| 14890 | + ); |
| 14891 | + |
14577 | 14892 | // ── The population a job `if:` names one hop away (#12956) ──────────────── |
14578 | 14893 | // |
14579 | 14894 | // The card: an `.objectui-sha` diff derived NO pin-critical gate, because |
|
0 commit comments