Skip to content

Commit a26776d

Browse files
ci: lint.yml's scheduled run skips the battery when a green push run already covers the sha (3 of 3) (#27085)
Fixes #26071 Clause-②: no Metadata-protocol: none Ruling-ref: 6416984681 ## 维护者速读 - 改了什么:`lint.yml` 每 4 小时的定时运行新增第一个 job `schedule-gate`:同一 commit 上已有跑完且全绿的 push 运行时,其余 14 个 job(12 条 lane 和 2 个汇总)全部跳过;PR、合并队列、push、手动触发的行为不变。 - 为什么改:这是裁决 A 的第 (i) 步((ii)(iii) 已在 #26512、#26570 落地)。定时运行测的是 main 的 head,而这个 commit 的 push 运行已经跑过同一套门禁。实测最近 13 次定时运行:push 运行最后都是绿的,但定时运行启动时已经跑完的只有 4 次。所以按裁决的写法,大约 4/13 会跳过,其余照常全量跑。 - 风险与回滚:门禁 job 一律偏向「照跑」:API 报错、403、读不懂返回、push 还在跑,都按「不跳过」处理;门禁 job 本身崩溃,各 lane 也照常运行。新增的风险面是 `check-required-contexts.mjs` 的一条接线断言,现在多接受一种精确拼写。回滚:revert 本 PR。 - 先看:`.github/workflows/lint.yml` 里的 `schedule-gate` job 和两个汇总 job(`lint`、`typecheck`)的 `if:`;`scripts/check-required-contexts.mjs` 的 `pinLanesCarryNoArrangeableIf`。 - 两分钟验证:`pnpm check:required-contexts && pnpm check:workflow-status-functions && pnpm check:pm-expected-skips`,应看到三组 ✓。合并后在 Actions 里看下一次 `Lint & Type Check` 定时运行:要么只有 `Schedule gate` 一个 job 在跑、其余显示 skipped,要么(push 还没跑完时)全量运行。 - 需要你做的:这是 Tier H(改 `.github/workflows/**`)。请审阅并 Approve,之后由席位落地。 ## What changes Step (i) of ruling 6416984681 (letter A), the last of the card's three serial PRs. Steps (ii) and (iii) landed as #26512 (`3fcafcd3`) and #26570 (`9bfd6a62`). - **New first job `schedule-gate`** (`Schedule gate · skip when a green push run covers the commit`). Job-level `if: github.event_name == 'schedule'`, `permissions: actions: read` only, no checkout, `timeout-minutes: 10` (the file's job-wall floor). It runs one step, `gh api repos/REPO/actions/workflows/lint.yml/runs?event=push&branch=main&head_sha=SHA&status=completed&per_page=10`, and keeps only runs with `event == push`, `status == completed`, `conclusion == success` and `head_sha == github.sha`. It sets `skip=true` only on a positive match. Every other outcome sets `skip=false`: no run, a red run, a run on another sha, a failed read (403 included), or a body that does not parse. The step has no `set -e`, so a read failure cannot fail the job. If the job crashes anyway, its output reads empty and the lanes still run. - **The 12 lanes** each gain the same two lines, `needs: [schedule-gate]` and `if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}`. On any event other than `schedule` the gate job is skipped and its output is the empty string. `!cancelled()` runs a job past a skipped or failed need, so each lane runs as before. This is ci.yml's existing `!cancelled() && needs.filter.outputs.*` idiom. No lane body changed: a yaml-parsed comparison against `HEAD` finds every job identical apart from `needs`/`if`. - **The two required aggregates** (`Lint & Repo Gates`, `TypeScript Type Check`) keep `always()`. Each gains one schedule-only conjunct: `${{ always() && !(github.event_name == 'schedule' && contains(needs.*.result, 'skipped')) }}`. On any other event that conjunct is true, so `always()` decides exactly as before, and a skipped required context can never appear on a PR or a queue build. Their `needs:` and rosters are unchanged, so `check:aggregator-roster` and their run-time roster verdicts are unaffected. - **`on:` comment block** now describes the skip, how it fails closed, and why ci.yml's schedule is not gated (there `push` tests the affected set only). The `schedule:` cron, the other triggers and the `concurrency:` key are byte-identical. - **`scripts/check-required-contexts.mjs`**: the wiring assertion "the lane hosting the required-context pin must carry NO job-level `if:`" went red, because the pin lane (`lint-ci-engine`) now carries the skip. It now admits exactly one shape and still refuses every other `if:`. That shape is the lane's byte-exact skip `if:`, plus `needs:` on `schedule-gate`, plus a `schedule-gate` job whose `if:` is byte-exactly `github.event_name == 'schedule'`. There are six new self-test cases in both directions, and the battery floor rises from 13 to 23, its measured count (17 before, plus the 6). `REQUIRED_CONTEXTS` is untouched. Job-by-job, before → after. Before this PR every job ran on every event. | job | push | pull_request | merge_group | workflow_dispatch | schedule, green push run already completed on the sha | schedule, none (or still running, red, unreadable) | |:--|:--|:--|:--|:--|:--|:--| | `schedule-gate` (new) | skipped | skipped | skipped | skipped | runs, `skip=true` | runs, `skip=false` | | 12 lanes (`Lint · *`, `Type Check · *`) | runs → runs | runs → runs | runs → runs | runs → runs | runs → **skipped** | runs → runs | | `Lint & Repo Gates` (required) | runs → runs | runs → runs | runs → runs | runs → runs | runs → **skipped** | runs → runs | | `TypeScript Type Check` (required) | runs → runs | runs → runs | runs → runs | runs → runs | runs → **skipped** | runs → runs | ## Tests All local readings are on head `8fd56eab` (the final commit, after merging `origin/main` `5cba8fc2`). <details><summary>Derived gate union: 61 of 61 exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; 2 paths vs merge base `5cba8fc2f`, 186 changed lines) derived 61 commands. All 61 ran with exit codes captured before any pipe, all 0. Among them: `check:required-contexts`, `check:workflow-status-functions` (58 job-level `if:`, 29 read `needs.*.outputs.*`, all naming a status function), `check:pm-expected-skips` (206 self-test cases), `check:aggregator-roster`, `check:ci-filter-parity`, `check:shard-attestation`, `check:select-gate-families`, `node scripts/ci/scheduled-full-run.mjs --self-test`, `check:nul-bytes`, and `check:pm-dispatch-gates` (fast tier, 1935 cases). `node scripts/pm/dispatch-gates.mjs --ran FILE --repo objectstack-ai/objectstack`: `61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of them is 3)`. The first derivation, on the lint.yml-only commit, named 42 families. 41 were green and `check:required-contexts` was red with `wiring: the lane hosting the required-context pin must carry NO job-level if:`, which led to the gate change above. </details> <details><summary>Reverse verification on the live file (committed first, trap-restored)</summary> Each mutation was made through `node scripts/ablation-replace.mjs` (anchor hit 1 → 0, blob changed), run against `node scripts/check-required-contexts.mjs --self-test`, and restored with `git checkout HEAD -- ABSOLUTE_PATH` under an `EXIT INT TERM` trap. - Leg A: the `schedule-gate` job's `if:` widened to `github.event_name != 'merge_group'` (the gate could now say skip on a PR): exit 1, `wiring: the lane hosting the required-context pin must carry NO job-level if: other than the scheduled-run skip`. - Leg B: the pin lane's `if:` respelled `needs.schedule-gate.outputs.skip == 'false'`: exit 1, same finding. - Leg C: the battery floor raised past its count: `registered 23 case(s), below its pinned floor of 999`. That shows the six new cases register, and the floor is now 23. - Restore proof: `git hash-object` of both files equals the `HEAD` blob, `git diff HEAD` and `git status --porcelain` are empty, and the self-test exits 0 again. </details> <details><summary>The gate step's behaviour, offline and live</summary> - Offline: the step's `run:` body was extracted from the parsed YAML and driven with a fake `gh` that applies the real `--jq` filter through `jq`. Results: green push run on the sha → `skip=true` (it picks the success run past a cancelled one on the same sha); red → `false`; success on another sha → `false`; empty listing → `false`; HTTP 403 → `false` (`the runs API did not answer (… HTTP 403)`); non-JSON body → `false`. The step exits 0 in all six cases. - Live: the step's exact query, read against the 3 newest scheduled runs' shas, returns `total_count=1` with one green push run each; an all-zero sha returns `total_count 0`. - YAML: `yaml` parse with no errors; `on` keys, `on.schedule` and `concurrency` are identical to `HEAD`. `actionlint`: NOT MEASURED, because it is not on PATH in this container. - `ui/scripts/__tests__/merge-queue-reporting.test.ts`, the one test file naming the edited gate script (in prose), ran under the verify lock: `Test Files 1 passed (1)`, `Tests 4 passed (4)`, `VERDICT command-exit 0`. - No package source is touched, so no package build, test or typecheck is owed. `test:repo`, `pnpm lint` and whole-tree scans are CI's. No dogfood run: there is no `!` in the title and no `(narrowing)` arm. </details> ## Acceptance notes - **Premise P1, corrected by measurement.** The card measured that each of the 12 newest scheduled runs repeated a commit whose push run ended `success`, and that still holds for all 13 newest. But the ruled mechanism skips only on a **completed** green push run, checked when the scheduled run starts, and that holds for 4 of 13: `f708cf22`, `6390fe52`, `f143cd88`, `05f7758f`. In the other 9, the push run of the same commit finished 8 to 18 minutes after the scheduled run started (each push run's last job `completed_at` vs the schedule's `run_started_at`, 2026-10-26T06:25Z → 2026-10-28T04:14Z). A scheduled run starts late past its cron slot and `main` merges many times a day, so the head it tests is often a commit whose push run is still in flight. Those 9 run the full battery exactly as today; nothing is lost, only less is saved. Waiting for an in-progress push run would have skipped all 13, but that is a runner-spend shape the ruling did not name, so it is raised to the seat rather than built here. - **What a skipped run reports.** The gate job succeeds and every other job is skipped, so the run's conclusion is expected to be `success`. The first post-merge scheduled run is the measurement. `scheduled-full-run.mjs --release-gate` reads the newest `schedule`/`push` run on the nearest commit. On a skipped commit that is either this run or the green push run of the same sha, so both answer `success`, as the battery did. `scheduled-full-run-card.yml` files only on `failure`/`timed_out` and is unaffected. - **Live-state reads.** Gates in `lint-ci-engine` that read GitHub state (`issues: read`, `pull-requests: read`) are not re-read at schedule time on a skipped run. The next merge's push run re-reads them. - **Scope.** The edit to `scripts/check-required-contexts.mjs` sits outside the claim's literal file surface ("a gate that reads that leg by name") but inside the dispatch's instruction to re-point what asserts on the changed shape. It touches only the wiring predicate and its self-test; `REQUIRED_CONTEXTS` is unchanged. No other gate read the shape: the 61-family union is green. - The file's ⭐ WHERE A GATE LIVES block still says every job runs on every `main` run. The scheduled skip is the one exception, stated in the `schedule-gate` header. The block itself was left unedited, per the scope fence. - Region-disjoint from open draft #27067 (typecheck-workspace hunks near the turbo summarize lines): a local `git merge-tree --write-tree` of this head with `b782ee2d` exits 0 and writes a tree. `lint.yml` is not routed to the custom merge driver. ## Verification - Tier H (`.github/workflows/**`): the PR stays draft until an authorized approval. The seat lands it; nothing here readies, arms or enqueues it. - CI on this PR runs the whole `lint.yml` (no paths filter). On `pull_request` the new `Schedule gate` check-run reads `skipped`, which `check-expected-skips` admits as `non-pr-event schedule; derived`. Every lane and both aggregates run as before. --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYmhU5KEj1nukYhp1Tg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc87dfb commit a26776d

2 files changed

Lines changed: 177 additions & 9 deletions

File tree

‎.github/workflows/lint.yml‎

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,25 @@ on:
2727
merge_group:
2828
# ── THE SCHEDULED FULL RUN (#16467), the half this file owns ─────────────
2929
# Every 4 hours since #25771 (maintainer ruling Q6 = C), the same cadence as
30-
# ci.yml's half and for the reason stated there; no step changed.
30+
# ci.yml's half and for the reason stated there.
31+
#
32+
# ⭐ IT SKIPS WHEN THE PUSH RUN ALREADY COVERED THE COMMIT (#26071, ruling
33+
# 6416984681, letter A (i)). A scheduled run tests `main`'s head, and the
34+
# push run of that same commit ran the same battery over the same tree: on
35+
# `push` the selector keeps every family and the workspace type check takes
36+
# the whole universe, exactly as on `schedule`. Measured on 2026-10-28: all 12
37+
# newest scheduled runs repeated a commit whose push run had already ended
38+
# `success`. So the `schedule-gate` job (first under `jobs:`) asks the runs
39+
# API for a completed `push` run of THIS workflow on `github.sha` that
40+
# concluded `success`; when there is one, every lane skips and the two
41+
# aggregates skip with them, and the run concludes `success` with one ran
42+
# job. ⛔ It fails CLOSED toward running: no answer, an error, a 403 or a
43+
# body it cannot read all mean "run the battery". Only `schedule` is gated:
44+
# on `push`, `pull_request`, `merge_group` and `workflow_dispatch` the gate
45+
# job is skipped, its output reads empty, and every lane runs as before.
46+
# ci.yml's scheduled run is NOT gated: there `push` tests the affected set
47+
# only, and the schedule is the full battery.
48+
#
3149
# The card's ruling is that the scheduled run covers BOTH required-context
3250
# files, not just ci.yml, and the reason is measured rather than symmetric:
3351
# the push-on-`main` run of THIS workflow is the only post-merge full-battery
@@ -42,7 +60,8 @@ on:
4260
#
4361
# The selector already treats every event that is neither `merge_group` nor
4462
# `pull_request` as "run every family", so this trigger alone restores the
45-
# full battery here — no step in this file changes.
63+
# full battery here — no step in this file changes. That same sentence is
64+
# why the gate above may skip it: the push run of the commit already ran it.
4665
#
4766
# ⛔ A NEW TRIGGER KEY, not a widening of `push:`; see ci.yml's `on:` block.
4867
schedule:
@@ -197,6 +216,61 @@ concurrency:
197216

198217
jobs:
199218

219+
schedule-gate:
220+
# ── THE SCHEDULED RUN'S SKIP (#26071, ruling 6416984681, letter A (i)) ───
221+
# The why is the `on:` block's ⭐ note. This job runs on `schedule` only, and
222+
# every other job in this file needs it and carries the same `if:`:
223+
#
224+
# if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
225+
#
226+
# On every other event this job is SKIPPED, a skipped job's outputs read as
227+
# the empty string, and `!cancelled()` lets a job run past a skipped (or a
228+
# failed) need, so each lane runs exactly as it did before this job existed.
229+
# The two aggregates keep `always()` and add one schedule-only conjunct:
230+
# they skip only on a scheduled run whose lanes skipped, never on a PR or a
231+
# queue build, where a skipped required context would read as green.
232+
#
233+
# ⛔ FAIL CLOSED TOWARD RUNNING. Only a positive answer skips: a completed
234+
# `push` run of THIS workflow on `github.sha`, concluded `success`, read
235+
# from the runs API. A failed read, a 403, a body that does not parse, no
236+
# such run, or a push run still in progress all leave `skip=false`, and a
237+
# crash of this job leaves the output empty, which also runs the lanes.
238+
name: Schedule gate · skip when a green push run covers the commit
239+
if: github.event_name == 'schedule'
240+
runs-on: ubuntu-latest
241+
# The job-wall rule's floor (see the `lint` job): one API read, no checkout.
242+
timeout-minutes: 10
243+
permissions:
244+
actions: read
245+
outputs:
246+
skip: ${{ steps.probe.outputs.skip }}
247+
steps:
248+
- name: Look for a completed green push run on this commit
249+
id: probe
250+
env:
251+
GH_TOKEN: ${{ github.token }}
252+
OS_GATE_SHA: ${{ github.sha }}
253+
run: |
254+
set -uo pipefail
255+
skip=false
256+
reason="no completed push run of this workflow on $OS_GATE_SHA concluded success"
257+
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" \
258+
--jq '.workflow_runs[] | select(.event == "push" and .status == "completed" and .conclusion == "success") | "\(.head_sha) \(.id)"' \
259+
2> "$RUNNER_TEMP/schedule-gate.err"); then
260+
while read -r sha id; do
261+
if [ -n "$sha" ] && [ "$sha" = "$OS_GATE_SHA" ]; then
262+
skip=true
263+
reason="push run $id on $OS_GATE_SHA concluded success: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$id"
264+
break
265+
fi
266+
done <<< "$green"
267+
else
268+
reason="the runs API did not answer ($(head -c 300 "$RUNNER_TEMP/schedule-gate.err" | tr '\n' ' ')), so the battery runs"
269+
fi
270+
echo "skip=$skip" >> "$GITHUB_OUTPUT"
271+
echo "schedule-gate: skip=$skip -- $reason"
272+
echo "schedule-gate: skip=$skip -- $reason" >> "$GITHUB_STEP_SUMMARY"
273+
200274
lint:
201275
# ⚠️ This `name:` IS the required-status-check context in repository
202276
# Settings → Rulesets (a job's check-run name is its `name:`), so it is
@@ -300,7 +374,12 @@ jobs:
300374
# skipped required context is an absence, which reads as green in every
301375
# checks list and never blocks the queue. The `typecheck` job carries the
302376
# same contract and says so at length.
303-
if: always()
377+
# The one conjunct after it is the scheduled run's skip (`schedule-gate`):
378+
# on `schedule` alone, lanes that skipped mean the gate found a green push
379+
# run on the commit, and this aggregate skips with them rather than reding
380+
# over lanes that were told not to run. On every other event the
381+
# conjunct is true and `always()` alone decides, as before.
382+
if: ${{ always() && !(github.event_name == 'schedule' && contains(needs.*.result, 'skipped')) }}
304383
runs-on: ubuntu-latest
305384
# ⭐ JOB WALL — measured, and the rule for every job in this file. No step in
306385
# this workflow runs under `scripts/run-with-stall-guard.mjs` (if one ever
@@ -429,6 +508,8 @@ jobs:
429508
# test-double contract gate, the two heaviest always-on gates, side by
430509
# side.
431510
name: Lint · ESLint
511+
needs: [schedule-gate]
512+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
432513
runs-on: ubuntu-latest
433514
# JOB WALL = CHECKOUT BUDGET (20, on the checkout step) + BODY BUDGET, the
434515
# scheme `typecheck-source-gates` argues for a full-history checkout. Body
@@ -511,6 +592,8 @@ jobs:
511592
# shared-module self-tests, the PM tooling self-tests, the Claude hook
512593
# guards, the docs authoring gates and both censuses.
513594
name: Lint · repo & tooling gates
595+
needs: [schedule-gate]
596+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
514597
runs-on: ubuntu-latest
515598
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`'
516599
# scheme. Body after the checkout, rebuilt per run from the 70 runs the
@@ -1934,6 +2017,8 @@ jobs:
19342017
# symbol anchors, and the product-code guards (CLI, error codes, routes,
19352018
# logging, the objectui pin).
19362019
name: Lint · docs & code guards
2020+
needs: [schedule-gate]
2021+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
19372022
runs-on: ubuntu-latest
19382023
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`'
19392024
# scheme. Body after the checkout, rebuilt per run from the 70 runs the
@@ -2953,6 +3038,8 @@ jobs:
29533038
# them), the engine and packaging gates, and the issue-citation gate,
29543039
# which is why this lane alone reads issues and pull requests.
29553040
name: Lint · CI, release & engine gates
3041+
needs: [schedule-gate]
3042+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
29563043
runs-on: ubuntu-latest
29573044
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`'
29583045
# scheme. Body after the checkout, rebuilt per run from the 70 runs the
@@ -4090,6 +4177,8 @@ jobs:
40904177
# argument is in this lane) and pays its setup even when the selector
40914178
# skips everything in it: about a minute of runner, not of wall.
40924179
name: Lint · scoped · query-options erasure
4180+
needs: [schedule-gate]
4181+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
40934182
runs-on: ubuntu-latest
40944183
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`'
40954184
# scheme. Body after the checkout, rebuilt per run from the 70 runs the
@@ -4246,6 +4335,8 @@ jobs:
42464335
# entry-guard sweep and the comment-mask corpus walk. The slot-lookup
42474336
# ratchet is the gate the checkout step's `fetch-depth: 0` note names.
42484337
name: Lint · scoped · slot lookup & corpus
4338+
needs: [schedule-gate]
4339+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
42494340
runs-on: ubuntu-latest
42504341
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`'
42514342
# scheme. Body after the checkout, rebuilt per run from the 70 runs the
@@ -4390,6 +4481,8 @@ jobs:
43904481
# self-test, the self-test workflow-command gate and the verify-lock
43914482
# entry-point self-test.
43924483
name: Lint · scoped · tooling self-tests
4484+
needs: [schedule-gate]
4485+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
43934486
runs-on: ubuntu-latest
43944487
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`'
43954488
# scheme. Body after the checkout, rebuilt per run from the 70 runs the
@@ -4576,6 +4669,8 @@ jobs:
45764669
# `schedule` and `push` both tiers, the slow tier's daily home under the
45774670
# ruling (`scripts/pm/check-dispatch-gates.mjs` decides, from the event).
45784671
name: Lint · PM dispatch-gates
4672+
needs: [schedule-gate]
4673+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
45794674
runs-on: ubuntu-latest
45804675
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET, `typecheck-source-gates`'
45814676
# scheme. Body after the checkout, rebuilt per run from the 70 runs the
@@ -4722,6 +4817,8 @@ jobs:
47224817
# missing or stale one. Build-dependent gates belong in the lanes below.
47234818
typecheck-source-gates:
47244819
name: Type Check · source gates
4820+
needs: [schedule-gate]
4821+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
47254822
runs-on: ubuntu-latest
47264823
# JOB WALL — why every job here has one and the rule (about 2× the measured
47274824
# maximum, a 5-minute grain, never below 10, never above the queue's
@@ -5411,6 +5508,8 @@ jobs:
54115508
# runs the `typecheck` task family and so the only one that can seed it.
54125509
typecheck-workspace:
54135510
name: Type Check · workspace
5511+
needs: [schedule-gate]
5512+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
54145513
runs-on: ubuntu-latest
54155514
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET; the split, its window, the
54165515
# checkout numbers and what the aggregate reads: `typecheck-source-gates`.
@@ -5656,6 +5755,8 @@ jobs:
56565755
# semantics are untouched: same script, same flags, same command.
56575756
typecheck-debt:
56585757
name: Type Check · debt ledger
5758+
needs: [schedule-gate]
5759+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
56595760
runs-on: ubuntu-latest
56605761
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET; the split, its window, the
56615762
# checkout numbers and what the aggregate reads: `typecheck-source-gates`.
@@ -5858,6 +5959,8 @@ jobs:
58585959
# api-surface family, the skill/docs example compilers and the i18n gates.
58595960
typecheck-consumers:
58605961
name: Type Check · consumer gates
5962+
needs: [schedule-gate]
5963+
if: ${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}
58615964
runs-on: ubuntu-latest
58625965
# JOB WALL = CHECKOUT BUDGET (20) + BODY BUDGET; the split, its window, the
58635966
# checkout numbers and what the aggregate reads: `typecheck-source-gates`.
@@ -6387,8 +6490,9 @@ jobs:
63876490
# required context is not a red — it is an absence, which reads as green in
63886491
# every checks list and never blocks the queue. The same contract
63896492
# ci.yml's `test-gate` documents. With `always()` the job runs on every
6390-
# outcome and the verdict below is what decides.
6391-
if: always()
6493+
# outcome and the verdict below is what decides. Its one schedule-only
6494+
# conjunct is the `lint` job's, for the reason stated there.
6495+
if: ${{ always() && !(github.event_name == 'schedule' && contains(needs.*.result, 'skipped')) }}
63926496
runs-on: ubuntu-latest
63936497
timeout-minutes: 10
63946498
permissions:

‎scripts/check-required-contexts.mjs‎

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
235235
'the recognizer itself, in BOTH directions': 10,
236236
'…and the NARROW recognizer beside it, in both directions': 6,
237237
'is the pin WIRED into the required job?': 3,
238-
'the wiring recognizer, in BOTH directions': 13,
238+
'the wiring recognizer, in BOTH directions': 23,
239239
});
240240

241241
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -991,6 +991,36 @@ function jobsCarriedByLint(doc) {
991991
return carried;
992992
}
993993

994+
// The ONE job-level `if:` a lane hosting the pin may carry: lint.yml's scheduled-run skip
995+
// (#26071, ruling 6416984681, letter A (i)). The lane needs `schedule-gate`, a job that runs
996+
// on `schedule` only; on every other event that job is skipped, its output reads empty, and
997+
// `!cancelled()` runs the lane past it — so no PR, queue build or push can arrange that the
998+
// pin does not run on it without editing one of these two byte-exact spellings, which is
999+
// what the wiring assertion then refuses. Any other `if:` on the lane is still refused.
1000+
const SCHEDULE_GATE_JOB = 'schedule-gate';
1001+
const SCHEDULE_GATE_JOB_IF = "github.event_name == 'schedule'";
1002+
const SCHEDULE_SKIP_LANE_IF = "${{ !cancelled() && needs.schedule-gate.outputs.skip != 'true' }}";
1003+
1004+
/**
1005+
* Whether every lane hosting the pin carries either no job-level `if:` or exactly the
1006+
* scheduled-run skip, read from one parsed lint.yml. The `lint` aggregate itself is exempt by
1007+
* construction: its `if: always()` (plus its schedule-only conjunct) is what makes it report.
1008+
*
1009+
* @param {unknown} doc a parsed lint.yml
1010+
* @returns {boolean}
1011+
*/
1012+
function pinLanesCarryNoArrangeableIf(doc) {
1013+
const gate = doc && typeof doc === 'object' && doc.jobs && typeof doc.jobs === 'object' ? doc.jobs[SCHEDULE_GATE_JOB] : undefined;
1014+
const gateIsScheduleOnly = !!gate && typeof gate === 'object' && gate.if === SCHEDULE_GATE_JOB_IF;
1015+
return jobsCarriedByLint(doc)
1016+
.filter(([, job]) => pinSteps(job).length > 0)
1017+
.every(([id, job]) => {
1018+
if (id === 'lint' || !Object.prototype.hasOwnProperty.call(job, 'if')) return true;
1019+
const needs = Array.isArray(job.needs) ? job.needs.map(String) : typeof job.needs === 'string' ? [job.needs] : [];
1020+
return gateIsScheduleOnly && job.if === SCHEDULE_SKIP_LANE_IF && needs.includes(SCHEDULE_GATE_JOB);
1021+
});
1022+
}
1023+
9941024
/**
9951025
* Judge a registry against already-parsed workflows.
9961026
*
@@ -3277,9 +3307,11 @@ async function selfTest() {
32773307
// …and, since the pin moved into a lane (#22991), neither may the LANE: a job-level `if:` is
32783308
// the same arrangement one level up, and it is the one a step-level read cannot see. The
32793309
// aggregate itself is exempt by construction — its `if: always()` is what makes it report.
3310+
// One spelling is admitted, the scheduled-run skip (`pinLanesCarryNoArrangeableIf`, #26071):
3311+
// it can skip the lane on `schedule` alone, never on a PR or a queue build.
32803312
assert(
3281-
hosts.every((h) => h.id === 'lint' || !Object.prototype.hasOwnProperty.call(h.job, 'if')),
3282-
'wiring: the lane hosting the required-context pin must carry NO job-level `if:` — a skipped lane is a pin that did not run',
3313+
pinLanesCarryNoArrangeableIf(lintDoc),
3314+
'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',
32833315
);
32843316

32853317
// ── the wiring recognizer, in BOTH directions ────────────────────────────
@@ -3358,8 +3390,9 @@ async function selfTest() {
33583390
);
33593391
// (q) ⭐ #22991: the pin in a LANE of the `lint` aggregate — declared AND needed — is wired.
33603392
// The checked-in lint.yml is this shape; the fixture pins the reading apart from it.
3361-
const aggregateFixture = ({ declare, need, laneIf = null }) =>
3393+
const aggregateFixture = ({ declare, need, laneIf = null, gateIf = null, laneNeedsGate = false }) =>
33623394
lintFixture(
3395+
...(gateIf ? [' schedule-gate:', ` if: ${gateIf}`, ' runs-on: ubuntu-latest', ' steps:', ' - run: echo skip=false'] : []),
33633396
' lint:',
33643397
' name: Lint & Repo Gates',
33653398
...(need ? [' needs: [lint-lane]'] : []),
@@ -3370,6 +3403,7 @@ async function selfTest() {
33703403
' - name: Verify every lint lane succeeded',
33713404
' run: node verify.mjs',
33723405
' lint-lane:',
3406+
...(laneNeedsGate ? [' needs: [schedule-gate]'] : []),
33733407
...(laneIf ? [` if: ${laneIf}`] : []),
33743408
' runs-on: ubuntu-latest',
33753409
' steps:',
@@ -3401,6 +3435,36 @@ async function selfTest() {
34013435
'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',
34023436
);
34033437
}
3438+
// (u) #26071: the live no-`if:` predicate itself, in BOTH directions. The scheduled-run skip
3439+
// is admitted only as the whole shape — the lane's byte-exact `if:`, its `needs:` on the gate,
3440+
// and a gate job that runs on `schedule` alone; any one half missing is refused again.
3441+
{
3442+
const skip = { declare: true, need: true, laneIf: SCHEDULE_SKIP_LANE_IF, gateIf: SCHEDULE_GATE_JOB_IF, laneNeedsGate: true };
3443+
assert(
3444+
pinLanesCarryNoArrangeableIf(parse(aggregateFixture({ declare: true, need: true }))),
3445+
'wiring: a pin lane with NO job-level `if:` passes the live predicate (#26071 control)',
3446+
);
3447+
assert(
3448+
pinLanesCarryNoArrangeableIf(parse(aggregateFixture(skip))),
3449+
'wiring: the scheduled-run skip (lane `if:` + `needs: [schedule-gate]` + a schedule-only gate) is admitted on the pin lane (#26071)',
3450+
);
3451+
assert(
3452+
!pinLanesCarryNoArrangeableIf(parse(aggregateFixture({ declare: true, need: true, laneIf: "github.event_name != 'pull_request'" }))),
3453+
'wiring: any other job-level `if:` on the pin lane is still refused (#26071)',
3454+
);
3455+
assert(
3456+
!pinLanesCarryNoArrangeableIf(parse(aggregateFixture({ ...skip, gateIf: "github.event_name != 'merge_group'" }))),
3457+
'wiring: the skip spelling is refused when the gate job also runs on a PR — there it could say skip (#26071)',
3458+
);
3459+
assert(
3460+
!pinLanesCarryNoArrangeableIf(parse(aggregateFixture({ ...skip, gateIf: null }))),
3461+
'wiring: the skip spelling is refused when no `schedule-gate` job exists to bound it (#26071)',
3462+
);
3463+
assert(
3464+
!pinLanesCarryNoArrangeableIf(parse(aggregateFixture({ ...skip, laneNeedsGate: false }))),
3465+
'wiring: the skip spelling is refused when the lane does not need the gate it reads (#26071)',
3466+
);
3467+
}
34043468
// (n) the genuine article, spelled the way lint.yml spells it.
34053469
assert(
34063470
pinWiredIn(lintJobFixture(' - name: Required-context name pin', ` run: pnpm ${PIN_SCRIPT}`)),

0 commit comments

Comments
 (0)