Skip to content

Commit 6a499a6

Browse files
claude[bot]claude
andauthored
ci: run the heavy workflows on pull_request for any base branch (#16893)
`ci.yml` and `lint.yml` both filtered their `pull_request:` trigger to `branches: [main]`, so a PR whose base is a feature branch started neither workflow. A workflow that does not run publishes no check run at all -- an absence, not a skip -- so all six branch-protection-required contexts (`Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Build Core`, `Dogfood Regression Gate`, `Temporal Conformance`) reported nothing on every such PR. Remove the base filter from `pull_request:` in both files. `push:` keeps its `branches: [main]` filter (it is per-branch, not per-PR; unfiltering it would run on every push to every agent branch), and the `merge_group:` trigger is untouched in both files. Runner cost, measured over 2026-08-09 -> 2026-09-08: 6 of 4000 PRs had a base other than `main` (0.15%), all six the #14478/#15679 stack. Against the window's 7008 pull_request runs per workflow that is ~2.5 extra runs/week per workflow, ~57 job-runs/week across both files; the pessimistic one-run-per-commit bound is 1.6% of current pull_request spend. Small, so the trigger is widened unconditionally rather than gated on a label. Pin: `check-required-contexts.mjs` grows assertion (7c) -- no `branches:` / `branches-ignore:` on the `pull_request:` trigger of any workflow publishing a required context -- beside its existing `paths:` and `types:` guards, with a (7c) self-test battery pinning both directions. The registry it already derives from supplies the context list, so nothing is transcribed. The MEASURED 2026-08-25 (#12211) block in `lint.yml` is preserved verbatim; a note above the trigger records why that merge_group-coverage result does not bind on a `pull_request` base filter. Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44c849c commit 6a499a6

3 files changed

Lines changed: 117 additions & 20 deletions

File tree

.github/workflows/ci.yml

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,33 @@ on:
44
push:
55
branches:
66
- main
7+
# ⭐ NO `branches:` filter here, deliberately (#16482). A `pull_request:`
8+
# trigger filtered to `branches: [main]` does not run at all when the PR's
9+
# base is a feature branch — and a workflow that does not run publishes NO
10+
# check run, which is an ABSENCE, not a skip. Branch protection reads that as
11+
# permanently pending, so every required context this file publishes reports
12+
# nothing on such a PR. Measured on the #14478 stack: six card PRs showed
13+
# 8-11 light checks and ZERO of the six required contexts; the first real
14+
# signal arrived at the trunk PR, after every card was written, reviewed and
15+
# merged. Correctness rested on local runs quoted into PR bodies — a
16+
# discipline, not a gate.
17+
#
18+
# Runner cost of removing the filter, measured 2026-09-08 over the 30 days
19+
# 2026-08-09 → 2026-09-08 (4000 PRs read, `sort=created&direction=desc` paged
20+
# until the window closed): 6 of 4000 PRs had a base other than `main`
21+
# (0.15%), all six the #14478/#15679 stack. At the window's measured 7008
22+
# pull_request runs per workflow, 0.15% is ~10.5 extra runs per workflow per
23+
# 30 days ≈ 2.5/week, or ~57 job-runs/week across both files at 23 job-runs
24+
# per PR event. Even the pessimistic bound — one uncancelled run per one of
25+
# those PRs' 115 commits — is 1.6% of current pull_request spend. Small, so
26+
# the trigger is widened unconditionally rather than gated on a label or on
27+
# "the base is an open PR's head" (#16482 records the arithmetic).
28+
#
29+
# ⛔ Do NOT mirror this widening onto the `push:` trigger above. `push:` is
30+
# per-branch, not per-PR: unfiltering it would run this workflow on every
31+
# push to every agent branch in the repo, which is a different and far larger
32+
# population than the 6 PRs measured here.
733
pull_request:
8-
branches:
9-
- main
1034
# Merge queue: the queue builds each PR as speculatively merged onto the
1135
# current main and only lands it if this workflow is green on that result —
1236
# the race-free version of the "pull main and re-verify before merging"

.github/workflows/lint.yml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,23 @@ on:
44
push:
55
branches:
66
- main
7+
# ⭐ NO `branches:` filter here, deliberately (#16482) — see ci.yml's `on:`
8+
# block for the full rationale and the measured runner cost; both files carry
9+
# required contexts and are widened together, because six contexts split
10+
# across two files are only as reachable as the less reachable file.
11+
#
12+
# ⚠️ The MEASURED 2026-08-25 (#12211) block immediately below does NOT bind
13+
# on this widening, and it is not touched by it. Read what it measured: it is
14+
# a `merge_group` COVERAGE result — "is there a queue-build gap to close" —
15+
# answered NO, plus a ⛔ against adding `merge_group` to the ADVISORY
16+
# workflows. It says nothing about the `pull_request:` trigger's `branches:`
17+
# filter, and its residual is explicitly named as two repository SETTINGS
18+
# (a commit reaching main with no queue build; `strict_required_status_checks
19+
# _policy: false`), "neither reachable from this file". Removing a base-branch
20+
# filter from `pull_request:` changes no merge_group behaviour whatsoever:
21+
# `merge_group:` is a separate trigger key, still present below, and
22+
# `check:required-contexts` assertion 6 fails the build if it ever is not.
723
pull_request:
8-
branches:
9-
- main
1024
# Merge queue (see ci.yml for the full note): required checks must report on
1125
# queue builds or the queue stalls. This workflow has no PR-only steps, so
1226
# the trigger alone is enough.

scripts/check-required-contexts.mjs

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,20 @@
5656
* the time of the audit; that workflow has since been deleted outright —
5757
* see the ⛔ exclusion note below, which outlives it);
5858
* 7. its workflow's `pull_request:` trigger exists and carries no `paths:` /
59-
* `paths-ignore:`, and, if it names `types:` at all, that list is a
60-
* superset of GitHub's default `[opened, synchronize, reopened]`. A
61-
* path-filtered trigger produces NO check run on a PR that misses the
62-
* glob, and naming `types:` REPLACES (never extends) the default set,
63-
* so dropping one of the three from a hand-written list produces NO
64-
* check run on that activity — neither is a skip, both are an absence,
65-
* which is permanent pending (the audit's `Spec property liveness`
66-
* exclusion for the `paths:` half; #8304 for the `types:` half — no
67-
* enrolled workflow names `types:` today, and this guard is what makes
68-
* growing such a list safe).
59+
* `paths-ignore:`, no `branches:` / `branches-ignore:`, and, if it names
60+
* `types:` at all, that list is a superset of GitHub's default
61+
* `[opened, synchronize, reopened]`. A path-filtered trigger produces NO
62+
* check run on a PR that misses the glob; a BASE-filtered trigger
63+
* produces none on a PR based on a branch outside the filter; and naming
64+
* `types:` REPLACES (never extends) the default set, so dropping one of
65+
* the three from a hand-written list produces NO check run on that
66+
* activity — none of the three is a skip, all are an absence, which is
67+
* permanent pending (the audit's `Spec property liveness` exclusion for
68+
* the `paths:` half; #8304 for the `types:` half — no enrolled workflow
69+
* names `types:` today, and this guard is what makes growing such a list
70+
* safe; #16482 for the `branches:` half, where `branches: [main]` on both
71+
* enrolled workflows meant all six required contexts reported NOTHING on
72+
* every PR based on a feature branch).
6973
*
7074
* Plus two whole-registry properties:
7175
*
@@ -216,6 +220,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
216220
'(6) the merge_group trigger': 3,
217221
'(7) a path-filtered pull_request trigger': 4,
218222
'(7b) a `types:` list that drops a GitHub default': 6,
223+
'(7c) a `branches:` filter on the pull_request trigger': 5,
219224
'(9) the shadowing collision, on the live specimen': 3,
220225
'(8) a registry that lists one context twice': 1,
221226
'(10) a `carries` string that embeds a step count': 3,
@@ -234,7 +239,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
234239

235240
// DELETING an entry silences that battery's floor exactly as effectively as
236241
// zeroing it, so the roster's own size is pinned too.
237-
const SELF_TEST_BATTERY_FLOOR = 22;
242+
const SELF_TEST_BATTERY_FLOOR = 23;
238243

239244
// The key an assertion is filed under when no battery is open. It is not a
240245
// declared battery, so it reds by the same set difference rather than silently
@@ -972,6 +977,28 @@ export function judge({ registry, workflows }) {
972977
);
973978
}
974979
}
980+
// (7c) a `branches:` / `branches-ignore:` filter on the pull_request
981+
// trigger. Same permanent-pending wedge as assertion 7's `paths:` guard,
982+
// reached through the OTHER axis of the same trigger: `paths:` decides
983+
// which CHANGES run the workflow, `branches:` decides which BASE
984+
// branches do. A `pull_request: branches: [main]` filter means a PR
985+
// whose base is a feature branch does not start this workflow at all, so
986+
// every required context in this file publishes no check run — an
987+
// ABSENCE, not a skip, which branch protection holds as permanently
988+
// pending. Measured on the #14478 stack (#16482): six card PRs each
989+
// showed 8-11 light checks and ZERO of the six required contexts, and
990+
// the first real signal arrived only at the trunk PR, after every card
991+
// had been written, reviewed and merged.
992+
for (const key of ['branches', 'branches-ignore']) {
993+
if (pr && typeof pr === 'object' && Object.prototype.hasOwnProperty.call(pr, key)) {
994+
problems.push(
995+
`.github/workflows/${file}'s \`pull_request:\` trigger carries \`${key}:\`. A base-filtered trigger does not run at all on a PR ` +
996+
`whose base branch is outside the filter, so it publishes NO check run there — not a skip, an absence — and every required ` +
997+
`context in this file sits permanently pending on every PR based on a feature branch (#16482; the base-branch counterpart to ` +
998+
`assertion 7's \`paths:\` guard above). A required context must report for ANY base.`,
999+
);
1000+
}
1001+
}
9751002
// (7b) a `types:` list that drops one of GitHub's defaults. Naming any
9761003
// `types:` REPLACES the default `[opened, synchronize, reopened]`
9771004
// rather than adding to it, so a hand-restated list that misses one is
@@ -1799,13 +1826,13 @@ async function selfTest() {
17991826
// ── (7) a path-filtered pull_request trigger ─────────────────────────────
18001827
battery('(7) a path-filtered pull_request trigger');
18011828
const pathFiltered = fixture('paths: on ci.yml', 'ci.yml', (s) =>
1802-
s.replace(' pull_request:\n branches:\n - main\n', " pull_request:\n branches:\n - main\n paths:\n - 'packages/**'\n"),
1829+
s.replace(' pull_request:\n', " pull_request:\n paths:\n - 'packages/**'\n"),
18031830
);
18041831
assert(
18051832
pathFiltered.problems.some((p) => p.includes('`paths:`') && p.includes('permanently pending')),
18061833
'a paths-filtered pull_request trigger ⇒ red (it publishes no check run at all, which is not a skip)',
18071834
);
1808-
const noPr = fixture('drop pull_request from ci.yml', 'ci.yml', (s) => s.replace(' pull_request:\n branches:\n - main\n', ''));
1835+
const noPr = fixture('drop pull_request from ci.yml', 'ci.yml', (s) => s.replace(' pull_request:\n', ''));
18091836
assert(noPr.problems.some((p) => p.includes('no `pull_request:` trigger')), 'a required-context workflow with no pull_request trigger ⇒ red');
18101837

18111838
// ── (7b) a `types:` list that drops a GitHub default ──────────────────────
@@ -1819,27 +1846,59 @@ async function selfTest() {
18191846
// asserted green at the top of this self-test.
18201847
battery('(7b) a `types:` list that drops a GitHub default');
18211848
const droppedReopened = fixture('grow a types: list that omits reopened onto ci.yml', 'ci.yml', (s) =>
1822-
s.replace(' pull_request:\n branches:\n - main\n', ' pull_request:\n types: [opened, synchronize]\n branches:\n - main\n'),
1849+
s.replace(' pull_request:\n', ' pull_request:\n types: [opened, synchronize]\n'),
18231850
);
18241851
assert(
18251852
droppedReopened.problems.some((p) => p.includes('ci.yml') && p.includes("omits GitHub's default activity type(s) 'reopened'")),
18261853
"a hand-restated types: list missing 'reopened' ⇒ red, naming the dropped default (#8304)",
18271854
);
18281855
const droppedTwo = fixture('grow a types: list that omits opened and synchronize onto ci.yml', 'ci.yml', (s) =>
1829-
s.replace(' pull_request:\n branches:\n - main\n', ' pull_request:\n types: [reopened]\n branches:\n - main\n'),
1856+
s.replace(' pull_request:\n', ' pull_request:\n types: [reopened]\n'),
18301857
);
18311858
assert(
18321859
droppedTwo.problems.some((p) => p.includes("'opened', 'synchronize'")),
18331860
'dropping two defaults at once ⇒ red naming both, in default order',
18341861
);
18351862
const supersetTypes = fixture('grow a strict-superset types: list onto ci.yml', 'ci.yml', (s) =>
1836-
s.replace(' pull_request:\n branches:\n - main\n', ' pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n branches:\n - main\n'),
1863+
s.replace(' pull_request:\n', ' pull_request:\n types: [opened, synchronize, reopened, ready_for_review]\n'),
18371864
);
18381865
assert(
18391866
supersetTypes.problems.length === 0,
18401867
`a types: list that is a strict superset of the three defaults ⇒ green (got ${JSON.stringify(supersetTypes.problems)})`,
18411868
);
18421869

1870+
// ── (7c) a `branches:` filter on the pull_request trigger ────────────────
1871+
// The defect #16482 closed, pinned in BOTH directions. Until 2026-09-08 both
1872+
// enrolled workflows carried `pull_request: branches: [main]`, so a PR based
1873+
// on a feature branch started NEITHER — and all six required contexts
1874+
// published no check run at all on every such PR. The green half is the
1875+
// checked-in baseline (asserted explicitly below rather than left implicit,
1876+
// because "no key" is exactly the state a one-line edit restores); the red
1877+
// half re-introduces the filter on each file in turn.
1878+
battery('(7c) a `branches:` filter on the pull_request trigger');
1879+
const baseFiltered = fixture('restore branches: [main] on ci.yml', 'ci.yml', (s) =>
1880+
s.replace(' pull_request:\n', ' pull_request:\n branches:\n - main\n'),
1881+
);
1882+
assert(
1883+
baseFiltered.problems.some((p) => p.includes('`branches:`') && p.includes('permanently pending')),
1884+
'a base-filtered pull_request trigger ⇒ red (it publishes no check run on a feature-branch-based PR, which is not a skip)',
1885+
);
1886+
const baseIgnored = fixture('add branches-ignore: to lint.yml', 'lint.yml', (s) =>
1887+
s.replace(' pull_request:\n', " pull_request:\n branches-ignore:\n - 'claude/**'\n"),
1888+
);
1889+
assert(
1890+
baseIgnored.problems.some((p) => p.includes('`branches-ignore:`') && p.includes('permanently pending')),
1891+
'branches-ignore: is the same wedge through the negated key ⇒ red',
1892+
);
1893+
assert(
1894+
baseline.problems.length === 0 &&
1895+
!REQUIRED_CONTEXTS.some((e) => {
1896+
const pr = triggersOf(parse(sources[e.workflow]))?.pull_request;
1897+
return pr && typeof pr === 'object' && ('branches' in pr || 'branches-ignore' in pr);
1898+
}),
1899+
'the checked-in workflows carry NO base filter on pull_request — the required contexts report for any base (#16482)',
1900+
);
1901+
18431902
// ── (9) the shadowing collision, on the live specimen ────────────────────
18441903
// ci.yml's sharded `test` job is named `Test Core (${{ matrix.shard }}/6)`
18451904
// and its aggregate gate is named `Test Core`. Dropping the suffix makes two

0 commit comments

Comments
 (0)