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
114 changes: 109 additions & 5 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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:
Expand Down
72 changes: 68 additions & 4 deletions scripts/check-required-contexts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 ────────────────────────────
Expand Down Expand Up @@ -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]'] : []),
Expand All @@ -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:',
Expand Down Expand Up @@ -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}`)),
Expand Down
Loading