Commit 5cd403e
fix(pm): check-expected-skips derives schedule/dispatch-only jobs as expected skips under their raw name (#20835)
Fixes #20756
Clause-②: no
## What changed
`scripts/pm/check-expected-skips.mjs` now **derives** a second half of
its expected-skip roster from the workflows, next to the eleven listed
rows. The listed rows are unchanged. The derived half admits a job if
its job-level `if:` is a disjunction of `github.event_name ==
'schedule'` / `github.event_name == 'workflow_dispatch'` terms **only**,
optionally wrapped whole in one `${{ }}`. Such a job skips on every
`pull_request`, `push` and `merge_group` run by construction. The row
takes the check-run name GitHub reports for that skip: `name:` verbatim,
else the job key. The judge (`--pr` / `--head` / `--check-runs-json`)
and `--roster` both read the joined roster. There is no literal
`Registry canary` entry.
- **Recogniser, not evaluator.** `nonPrEventGate` is an allow-list of
one comparison shape, `github.event_name == 'EVENT'`, joined by `||`. It
returns null on anything else: `&&`, `!=`, parentheses, `inputs.*`,
`needs.*`, `success()`, `matrix.*`, a label read, a case-folded or
double-quoted literal, two `${{ }}` blocks, or an event outside the
closed `NON_PR_EVENTS = ['schedule', 'workflow_dispatch']`. `push` and
`merge_group` are refused on purpose. The header's boundary still holds:
nothing here evaluates GitHub's expression language or `paths:` filters.
- **The name rule (`skippedCheckRunName`).** GitHub evaluates a
job-level `if:` before it expands the matrix, so a gated matrix job's
skipped check-run keeps each `${{ matrix.* }}` literally. Only a bare
matrix reference is admitted. A name holding any other expression, a
matrix reference on a job with no `strategy.matrix`, or a non-string
`name:` is refused, because its skipped spelling is unmeasured. Expanded
names are deliberately **not** rostered: an expanded job ran, so its
check-runs are never a gate skip.
- **Refusals are the safe direction.** A candidate stays out of the
roster, and so still answers exit 4, when any of these holds:
- another job anywhere in the tree carries the same check-run name, so a
skip of that other job would read expected;
- the listed roster already carries the name (derive, don't list);
- the name cannot be told;
- its workflow cannot be read.
The live self-test holds the refusal list at empty, so a job's author
hears about a refusal before a landing seat does.
## Measured
**Premise, confirmed.** The shape is on the real check-run: in the full
`GET /commits/a84b73af13/check-runs` listing for PR #20748 (42 runs),
`Registry canary: ${{ matrix.template }}` is `skipped` in check suite
99276458880 (run 36658032070). It sits beside `Scaffold with repo dist`
= success in the same suite. `.github/workflows/scaffold-e2e.yml` at
`origin/main` `73155fed` declares the job as `name: 'Registry canary:
${{ matrix.template }}'` / `if: github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch'`, with no `needs`.
**What the predicate admits on this tree (39 workflow files,
`73155fed`): 2 jobs, 0 refused.**
| workflow › job | check-run name | `on:` | runs on a PR? |
|---|---|---|---|
| `scaffold-e2e.yml` › `registry-canary` | `Registry canary: ${{
matrix.template }}` | pull_request (paths), schedule, workflow_dispatch
| never: skipped on every PR run |
| `publish-smoke.yml` › `registry-canary` | `Registry canary (published
latest)` | workflow_run (Release), schedule, workflow_dispatch | never:
the workflow has no PR trigger |
These job-level `if:`s read `github.event_name` and are **not**
admitted, pinned live:
- `release.yml` › `version-pr`: a `workflow_dispatch` term conjoined
with `inputs.refresh_version_pr`.
- `release.yml` › `release-integrity` / `stale-prompts` / `publish`:
`push`, `inputs`, `needs` outputs.
- `merged-branch-reaper.yml` › `reap`: `success()`, `!= 'pull_request'`,
`inputs.dry_run`.
- `publish-smoke.yml` › `resolve` and `scaffold-e2e.yml` ›
`scaffold-local`: `!= 'schedule'`. `scaffold-local` runs on every PR.
**Live control** (`node scripts/pm/check-expected-skips.mjs --pr 20748`,
read-only):
- before (`73155fed`): `VERDICT check-expected-skips: ⛔ 1 skipped
check-run(s) outside the roster (exit 4)`, naming `Registry canary: ${{
matrix.template }}`;
- after (`3993387d`): `VERDICT check-expected-skips: OK — 8 skipped
check-run(s), every one in the roster (exit 0)`. The canary is listed as
`[scaffold-e2e.yml › registry-canary; derived]`.
- A second, unrelated control (`--pr 20816`, after) still answers `OK —
3 skipped … (exit 0)`.
## Pins (`pnpm check:pm-expected-skips`, 99 → 154 cases)
- **The card's pin.** The measured #20748 listing is carried verbatim as
`MEASURED_20748`. On disk through `--check-runs-json` it answers **exit
0**, with the canary tagged derived. In-process against the joined
roster it is 0 as well. Against the listed half alone it still names the
canary, which shows the derivation is what moved it.
- **The control.** The same head plus a genuinely unexpected skip
answers **exit 4**, naming only that skip: `Lint & Repo Gates` through
the CLI, `TypeScript Type Check` in-process. The canary's PR-running
sibling `Scaffold with repo dist`, when skipped, also answers 4.
- **Narrowness.** `github.event_name == 'schedule' || github.event_name
== 'pull_request'` is not admitted. The synthetic tree's job carrying
that `if:` answers exit 4 when skipped. `push`, `merge_group`, negation,
`inputs`, `needs`, `success()`, `matrix`, label, parenthesised,
case-folded and double-quoted forms each have their own null case. So do
both live mixed shapes (`version-pr`, `reap`).
- **The derivation's refusals** are each driven on a synthetic tree: a
shared name, a listed name, an untellable name, an unreadable file.
**Ablation (committed first, restore proven).** At `3993387d`, `node
scripts/ablation-replace.mjs` replaced the recogniser's first line with
`return null;`. On disk: anchor x1 → x0, marker x0 → x1, blob `a999480b`
→ `0dc4f77f`. The self-test then went **red: 22 of 154 failed**. Among
the red cases:
- `measured #20748: the raw-named skipped canary judges exit 0 … (got 4,
want 0)`;
- `cli: the measured #20748 head on disk (raw-named skipped canary) →
exit 0 (got 4, want 0)`.
The control and narrowness cases stayed green, which is the expected
direction. The restore was proven: blob after restore == blob at HEAD
(`a999480b`), `git diff HEAD` empty, marker count 0, `git status
--porcelain` empty.
## Gates
`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` was run with no paths at `3993387d`. It derived the same 30
commands the dispatch named. **All 30 ran and all 30 answered exit 0**,
including `pnpm check:pm-dispatch-gates` (`✓ dispatch-gates self-test:
1976 cases pass.`) and `pnpm check:pm-expected-skips` (154 cases).
`--ran` reconciles: `30 derived famil(ies) accounted for — 30 run, 0
NOT-MEASURED`.
Lint was narrowed to the one touched file and still counts as a
measurement:
- eslint's own config resolves for
`scripts/pm/check-expected-skips.mjs`: `--print-config`, 2 rules, the
file is not ignored;
- `--no-inline-config --format json` reports 1 file, 0 errors, 0
warnings;
- the config enables no type-aware linting (no `parserOptions.project`,
no `projectService`), so this diff cannot move the verdict on any
untouched file.
`dispatch-gates.mjs` pins this file's `self-test-reads` declaration at
line 131. Every edit here keeps that line in place: the one header
sentence changed is rewritten in the same line count.
`skip-changeset` basis: the diff is
`scripts/pm/check-expected-skips.mjs` alone. It sits under the repo
root, whose `package.json` is `private: true`. None of the 69
non-private `package.json` files in the tree has a directory that
contains `scripts/pm/`, so the diff publishes nothing. The seat applies
the label.
## Acceptance notes
- **A residual exit 4 remains on PRs that edit
`merged-branch-reaper.yml`.** That workflow's `pull_request` trigger is
path-filtered to itself, and its `reap` job (`Delete the reapable
branches`) skips on those runs by design. The narrow recogniser refuses
its `if:` by construction (`success()` plus `inputs.dry_run`), as the
dispatch required. It is noted here and not filed (carrier: none).
- **Expanded matrix names are not rostered, although the dispatch's
mechanism note mentioned them.** A job reports expanded names only once
it has run, and a run is never a job-level skip. Adding them would widen
the roster with names that cannot skip by design.
- **This file's `--self-test` pins no battery floor.** It keeps its
handshake (`selfTestReachedVerdict`-equivalent `SELF_TEST_VERDICT`
record) but has no `SELF_TEST_BATTERIES` floor as AGENTS.md describes.
That was true before this PR; the new cases join its existing sections.
Noted, not changed (carrier: none).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent bbe03f4 commit 5cd403e
1 file changed
Lines changed: 386 additions & 11 deletions
0 commit comments