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