diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 23d576664c1..73b29ceea96 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,7 +27,25 @@ on: merge_group: # ── THE SCHEDULED FULL RUN (#16467), the half this file owns ───────────── # Every 4 hours since #25771 (maintainer ruling Q6 = C), the same cadence as - # ci.yml's half and for the reason stated there; no step changed. + # ci.yml's half and for the reason stated there. + # + # ⭐ IT SKIPS WHEN THE PUSH RUN ALREADY COVERED THE COMMIT (#26071, ruling + # 6416984681, letter A (i)). A scheduled run tests `main`'s head, and the + # push run of that same commit ran the same battery over the same tree: on + # `push` the selector keeps every family and the workspace type check takes + # the whole universe, exactly as on `schedule`. Measured on 2026-10-28: all 12 + # newest scheduled runs repeated a commit whose push run had already ended + # `success`. So the `schedule-gate` job (first under `jobs:`) asks the runs + # API for a completed `push` run of THIS workflow on `github.sha` that + # concluded `success`; when there is one, every lane skips and the two + # aggregates skip with them, and the run concludes `success` with one ran + # job. ⛔ It fails CLOSED toward running: no answer, an error, a 403 or a + # body it cannot read all mean "run the battery". Only `schedule` is gated: + # on `push`, `pull_request`, `merge_group` and `workflow_dispatch` the gate + # job is skipped, its output reads empty, and every lane runs as before. + # ci.yml's scheduled run is NOT gated: there `push` tests the affected set + # only, and the schedule is the full battery. + # # The card's ruling is that the scheduled run covers BOTH required-context # files, not just ci.yml, and the reason is measured rather than symmetric: # the push-on-`main` run of THIS workflow is the only post-merge full-battery @@ -42,7 +60,8 @@ on: # # The selector already treats every event that is neither `merge_group` nor # `pull_request` as "run every family", so this trigger alone restores the - # full battery here — no step in this file changes. + # full battery here — no step in this file changes. That same sentence is + # why the gate above may skip it: the push run of the commit already ran it. # # ⛔ A NEW TRIGGER KEY, not a widening of `push:`; see ci.yml's `on:` block. schedule: @@ -197,6 +216,61 @@ concurrency: jobs: + schedule-gate: + # ── THE SCHEDULED RUN'S SKIP (#26071, ruling 6416984681, letter A (i)) ─── + # The why is the `on:` block's ⭐ note. This job runs on `schedule` only, and + # every other job in this file needs it and carries the same `if:`: + # + # if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} + # + # On every other event this job is SKIPPED, a skipped job's outputs read as + # the empty string, and `!cancelled()` lets a job run past a skipped (or a + # failed) need, so each lane runs exactly as it did before this job existed. + # The two aggregates keep `always()` and add one schedule-only conjunct: + # they skip only on a scheduled run whose lanes skipped, never on a PR or a + # queue build, where a skipped required context would read as green. + # + # ⛔ FAIL CLOSED TOWARD RUNNING. Only a positive answer skips: a completed + # `push` run of THIS workflow on `github.sha`, concluded `success`, read + # from the runs API. A failed read, a 403, a body that does not parse, no + # such run, or a push run still in progress all leave `skip=false`, and a + # crash of this job leaves the output empty, which also runs the lanes. + name: Schedule gate · skip when a green push run covers the commit + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + # The job-wall rule's floor (see the `lint` job): one API read, no checkout. + timeout-minutes: 10 + permissions: + actions: read + outputs: + skip: ${{ steps.probe.outputs.skip }} + steps: + - name: Look for a completed green push run on this commit + id: probe + env: + GH_TOKEN: ${{ github.token }} + OS_GATE_SHA: ${{ github.sha }} + run: | + set -uo pipefail + skip=false + reason="no completed push run of this workflow on $OS_GATE_SHA concluded success" + if green=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/lint.yml/runs?event=push&branch=main&head_sha=$OS_GATE_SHA&status=completed&per_page=10" \ + --jq '.workflow_runs[] | select(.event == "push" and .status == "completed" and .conclusion == "success") | "\(.head_sha) \(.id)"' \ + 2> "$RUNNER_TEMP/schedule-gate.err"); then + while read -r sha id; do + if [ -n "$sha" ] && [ "$sha" = "$OS_GATE_SHA" ]; then + skip=true + reason="push run $id on $OS_GATE_SHA concluded success: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$id" + break + fi + done <<< "$green" + else + reason="the runs API did not answer ($(head -c 300 "$RUNNER_TEMP/schedule-gate.err" | tr '\n' ' ')), so the battery runs" + fi + echo "skip=$skip" >> "$GITHUB_OUTPUT" + echo "schedule-gate: skip=$skip -- $reason" + echo "schedule-gate: skip=$skip -- $reason" >> "$GITHUB_STEP_SUMMARY" + lint: # ⚠️ This `name:` IS the required-status-check context in repository # Settings → Rulesets (a job's check-run name is its `name:`), so it is @@ -300,7 +374,12 @@ jobs: # skipped required context is an absence, which reads as green in every # checks list and never blocks the queue. The `typecheck` job carries the # same contract and says so at length. - if: always() + # The one conjunct after it is the scheduled run's skip (`schedule-gate`): + # on `schedule` alone, lanes that skipped mean the gate found a green push + # run on the commit, and this aggregate skips with them rather than reding + # over lanes that were told not to run. On every other event the + # conjunct is true and `always()` alone decides, as before. + if: ${{ always() && !(github.event_name == 'schedule' && contains(needs.*.result, 'skipped')) }} runs-on: ubuntu-latest # ⭐ JOB WALL — measured, and the rule for every job in this file. No step in # this workflow runs under `scripts/run-with-stall-guard.mjs` (if one ever @@ -429,6 +508,8 @@ jobs: # test-double contract gate, the two heaviest always-on gates, side by # side. name: Lint · ESLint + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20, on the checkout step) + BODY BUDGET, the # scheme `typecheck-source-gates` argues for a full-history checkout. Body @@ -511,6 +592,8 @@ jobs: # shared-module self-tests, the PM tooling self-tests, the Claude hook # guards, the docs authoring gates and both censuses. name: Lint · repo & tooling gates + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' # scheme. Body after the checkout, rebuilt per run from the 70 runs the @@ -1934,6 +2017,8 @@ jobs: # symbol anchors, and the product-code guards (CLI, error codes, routes, # logging, the objectui pin). name: Lint · docs & code guards + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' # scheme. Body after the checkout, rebuilt per run from the 70 runs the @@ -2953,6 +3038,8 @@ jobs: # them), the engine and packaging gates, and the issue-citation gate, # which is why this lane alone reads issues and pull requests. name: Lint · CI, release & engine gates + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' # scheme. Body after the checkout, rebuilt per run from the 70 runs the @@ -4090,6 +4177,8 @@ jobs: # argument is in this lane) and pays its setup even when the selector # skips everything in it: about a minute of runner, not of wall. name: Lint · scoped · query-options erasure + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' # scheme. Body after the checkout, rebuilt per run from the 70 runs the @@ -4246,6 +4335,8 @@ jobs: # entry-guard sweep and the comment-mask corpus walk. The slot-lookup # ratchet is the gate the checkout step's `fetch-depth: 0` note names. name: Lint · scoped · slot lookup & corpus + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' # scheme. Body after the checkout, rebuilt per run from the 70 runs the @@ -4390,6 +4481,8 @@ jobs: # self-test, the self-test workflow-command gate and the verify-lock # entry-point self-test. name: Lint · scoped · tooling self-tests + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' # scheme. Body after the checkout, rebuilt per run from the 70 runs the @@ -4576,6 +4669,8 @@ jobs: # `schedule` and `push` both tiers, the slow tier's daily home under the # ruling (`scripts/pm/check-dispatch-gates.mjs` decides, from the event). name: Lint · PM dispatch-gates + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`' # scheme. Body after the checkout, rebuilt per run from the 70 runs the @@ -4722,6 +4817,8 @@ jobs: # missing or stale one. Build-dependent gates belong in the lanes below. typecheck-source-gates: name: Type Check · source gates + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL — why every job here has one and the rule (about 2× the measured # maximum, a 5-minute grain, never below 10, never above the queue's @@ -5411,6 +5508,8 @@ jobs: # runs the `typecheck` task family and so the only one that can seed it. typecheck-workspace: name: Type Check · workspace + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET; the split, its window, the # checkout numbers and what the aggregate reads: `typecheck-source-gates`. @@ -5656,6 +5755,8 @@ jobs: # semantics are untouched: same script, same flags, same command. typecheck-debt: name: Type Check · debt ledger + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET; the split, its window, the # checkout numbers and what the aggregate reads: `typecheck-source-gates`. @@ -5858,6 +5959,8 @@ jobs: # api-surface family, the skill/docs example compilers and the i18n gates. typecheck-consumers: name: Type Check · consumer gates + needs: [schedule-gate] + if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }} runs-on: ubuntu-latest # JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET; the split, its window, the # checkout numbers and what the aggregate reads: `typecheck-source-gates`. @@ -6387,8 +6490,9 @@ jobs: # required context is not a red — it is an absence, which reads as green in # every checks list and never blocks the queue. The same contract # ci.yml's `test-gate` documents. With `always()` the job runs on every - # outcome and the verdict below is what decides. - if: always() + # outcome and the verdict below is what decides. Its one schedule-only + # conjunct is the `lint` job's, for the reason stated there. + if: ${{ always() && !(github.event_name == 'schedule' && contains(needs.*.result, 'skipped')) }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 8f85c619e3c..d8f43759a4d 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -235,7 +235,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the recognizer itself, in BOTH directions': 10, '…and the NARROW recognizer beside it, in both directions': 6, 'is the pin WIRED into the required job?': 3, - 'the wiring recognizer, in BOTH directions': 13, + 'the wiring recognizer, in BOTH directions': 23, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -991,6 +991,36 @@ function jobsCarriedByLint(doc) { return carried; } +// The ONE job-level `if:` a lane hosting the pin may carry: lint.yml's scheduled-run skip +// (#26071, ruling 6416984681, letter A (i)). The lane needs `schedule-gate`, a job that runs +// on `schedule` only; on every other event that job is skipped, its output reads empty, and +// `!cancelled()` runs the lane past it — so no PR, queue build or push can arrange that the +// pin does not run on it without editing one of these two byte-exact spellings, which is +// what the wiring assertion then refuses. Any other `if:` on the lane is still refused. +const SCHEDULE_GATE_JOB = 'schedule-gate'; +const SCHEDULE_GATE_JOB_IF = "github.event_name == 'schedule'"; +const SCHEDULE_SKIP_LANE_IF = "${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}"; + +/** + * Whether every lane hosting the pin carries either no job-level `if:` or exactly the + * scheduled-run skip, read from one parsed lint.yml. The `lint` aggregate itself is exempt by + * construction: its `if: always()` (plus its schedule-only conjunct) is what makes it report. + * + * @param {unknown} doc a parsed lint.yml + * @returns {boolean} + */ +function pinLanesCarryNoArrangeableIf(doc) { + const gate = doc && typeof doc === 'object' && doc.jobs && typeof doc.jobs === 'object' ? doc.jobs[SCHEDULE_GATE_JOB] : undefined; + const gateIsScheduleOnly = !!gate && typeof gate === 'object' && gate.if === SCHEDULE_GATE_JOB_IF; + return jobsCarriedByLint(doc) + .filter(([, job]) => pinSteps(job).length > 0) + .every(([id, job]) => { + if (id === 'lint' || !Object.prototype.hasOwnProperty.call(job, 'if')) return true; + const needs = Array.isArray(job.needs) ? job.needs.map(String) : typeof job.needs === 'string' ? [job.needs] : []; + return gateIsScheduleOnly && job.if === SCHEDULE_SKIP_LANE_IF && needs.includes(SCHEDULE_GATE_JOB); + }); +} + /** * Judge a registry against already-parsed workflows. * @@ -3277,9 +3307,11 @@ async function selfTest() { // …and, since the pin moved into a lane (#22991), neither may the LANE: a job-level `if:` is // the same arrangement one level up, and it is the one a step-level read cannot see. The // aggregate itself is exempt by construction — its `if: always()` is what makes it report. + // One spelling is admitted, the scheduled-run skip (`pinLanesCarryNoArrangeableIf`, #26071): + // it can skip the lane on `schedule` alone, never on a PR or a queue build. assert( - hosts.every((h) => h.id === 'lint' || !Object.prototype.hasOwnProperty.call(h.job, 'if')), - 'wiring: the lane hosting the required-context pin must carry NO job-level `if:` — a skipped lane is a pin that did not run', + pinLanesCarryNoArrangeableIf(lintDoc), + 'wiring: the lane hosting the required-context pin must carry NO job-level `if:` other than the scheduled-run skip — a skipped lane is a pin that did not run', ); // ── the wiring recognizer, in BOTH directions ──────────────────────────── @@ -3358,8 +3390,9 @@ async function selfTest() { ); // (q) ⭐ #22991: the pin in a LANE of the `lint` aggregate — declared AND needed — is wired. // The checked-in lint.yml is this shape; the fixture pins the reading apart from it. - const aggregateFixture = ({ declare, need, laneIf = null }) => + const aggregateFixture = ({ declare, need, laneIf = null, gateIf = null, laneNeedsGate = false }) => lintFixture( + ...(gateIf ? [' schedule-gate:', ` if: ${gateIf}`, ' runs-on: ubuntu-latest', ' steps:', ' - run: echo skip=false'] : []), ' lint:', ' name: Lint & Repo Gates', ...(need ? [' needs: [lint-lane]'] : []), @@ -3370,6 +3403,7 @@ async function selfTest() { ' - name: Verify every lint lane succeeded', ' run: node verify.mjs', ' lint-lane:', + ...(laneNeedsGate ? [' needs: [schedule-gate]'] : []), ...(laneIf ? [` if: ${laneIf}`] : []), ' runs-on: ubuntu-latest', ' steps:', @@ -3401,6 +3435,36 @@ async function selfTest() { 'wiring: a job-level `if:` on the lane hosting the pin is SEEN by the hosts read (#22991) — the live no-`if:` assertion depends on it', ); } + // (u) #26071: the live no-`if:` predicate itself, in BOTH directions. The scheduled-run skip + // is admitted only as the whole shape — the lane's byte-exact `if:`, its `needs:` on the gate, + // and a gate job that runs on `schedule` alone; any one half missing is refused again. + { + const skip = { declare: true, need: true, laneIf: SCHEDULE_SKIP_LANE_IF, gateIf: SCHEDULE_GATE_JOB_IF, laneNeedsGate: true }; + assert( + pinLanesCarryNoArrangeableIf(parse(aggregateFixture({ declare: true, need: true }))), + 'wiring: a pin lane with NO job-level `if:` passes the live predicate (#26071 control)', + ); + assert( + pinLanesCarryNoArrangeableIf(parse(aggregateFixture(skip))), + 'wiring: the scheduled-run skip (lane `if:` + `needs: [schedule-gate]` + a schedule-only gate) is admitted on the pin lane (#26071)', + ); + assert( + !pinLanesCarryNoArrangeableIf(parse(aggregateFixture({ declare: true, need: true, laneIf: "github.event_name != 'pull_request'" }))), + 'wiring: any other job-level `if:` on the pin lane is still refused (#26071)', + ); + assert( + !pinLanesCarryNoArrangeableIf(parse(aggregateFixture({ ...skip, gateIf: "github.event_name != 'merge_group'" }))), + 'wiring: the skip spelling is refused when the gate job also runs on a PR — there it could say skip (#26071)', + ); + assert( + !pinLanesCarryNoArrangeableIf(parse(aggregateFixture({ ...skip, gateIf: null }))), + 'wiring: the skip spelling is refused when no `schedule-gate` job exists to bound it (#26071)', + ); + assert( + !pinLanesCarryNoArrangeableIf(parse(aggregateFixture({ ...skip, laneNeedsGate: false }))), + 'wiring: the skip spelling is refused when the lane does not need the gate it reads (#26071)', + ); + } // (n) the genuine article, spelled the way lint.yml spells it. assert( pinWiredIn(lintJobFixture(' - name: Required-context name pin', ` run: pnpm ${PIN_SCRIPT}`)),