Skip to content

Commit 5cd403e

Browse files
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

File tree

0 commit comments

Comments
 (0)