Skip to content

Commit ab4e30a

Browse files
ci(governed-surface-guard): the pull_request leg checks out commits and trees only and installs the toolchain on demand (#26311)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8DRxWpNGUz2gv8QFn8EE47u)_ Fixes #26201 Clause-②: no Metadata-protocol: none ## 维护者速读(草稿) **改了什么** 受管面守卫(`Governed Surface Queue Guard`)在 PR 上的那一跑变轻了:只拉提交历史和目录树(不拉文件内容),工作区只铺开 `scripts/` 与 `.github/` 两个目录,默认不装依赖。合并队列那一跑(真正会拒绝的那条腿)一字未改;检查名不变;翻 ready 时守卫仍会重跑,这是它的设计初衷,保留。 **为什么改** 在手机上批准受管 PR 后马上点合并,会被「required status check is in progress」拒掉:批准后 PR 被翻成 ready,守卫重跑,那一跑约 95 秒里只有 1 秒在判定,其余是拉代码和装依赖。现在这一跑只做判定需要的事,窗口缩到二十秒以内:本 PR 自己那一跑实测 19 秒(此前约 94 秒)。 **风险与代价(含回滚)** - 判定不变:PR #25965(普通受管 PR)与 PR #25813(带生成型 skills 索引文件)上,新旧两种检出跑出的判定逐字节相同。 - 碰到 skills 下生成型索引文件的 PR(近 30 天约占 main 提交的 0.45%)仍会补回整棵树并装依赖,那一跑和以前一样慢,换来判定准确。 - 回滚:revert 本 PR 即恢复原样,无数据或配置残留。 **席位意见** **你要做的** 审阅后批准或直接合并。本 PR 改了 `.github/workflows/`,属 Tier H,落地需要你本人。 ## What changed `.github/workflows/governed-surface-guard.yml`, the `pull_request` leg only: - **Checkout.** The single checkout step is now two, keyed on the event. `merge_group` keeps the unchanged step (`Checkout repository`, `fetch-depth: 0`, full tree). `pull_request` gets `Checkout repository (commits and trees only)`: `fetch-depth: 0`, `filter: blob:none`, `sparse-checkout` of `scripts` and `.github`. - **Toolchain on demand.** A new step asks the script `--toolchain-needed`. It runs on the `pull_request` leg only and appends `needed=true|false` to `$GITHUB_OUTPUT`. Every install step is gated on the output being NOT `'false'`, and so is a `git sparse-checkout disable` step that restores the full tree first. A skipped question (the queue leg) or a failed one therefore leaves the output empty and installs, which is the previous behaviour. - **Unchanged:** `types: [opened, synchronize, reopened, ready_for_review]`, the `merge_group` trigger, the job id `governed-surface-guard`, the check-run name `Governed Surface Queue Guard`, permissions, concurrency, timeouts and the judgment step. - **Header.** The `ready_for_review` note gains the reason this leg is kept cheap rather than dropped. The "Both legs install" paragraph now reads "wherever the recompute can change the answer". `scripts/pm/check-governed-queue-guard.mjs`: - `toolchainNeededFor(rows)` is the one predicate. `liftGeneratedExceptions` now consults the recompute iff it answers true, and the workflow installs iff `--toolchain-needed` answers true. Membership is still the register's `generatedExceptionFor`; no path list is restated. - `readEventDiff(env)` is extracted from `main()` so the judgment and the toolchain question read the event and the diff one way. The judgment's messages and exit codes are unchanged. - `toolchainNeededAnswer({ eventName, read })` is pure. It answers `false` only for a `pull_request` diff that was read and touches no register path. - New self-test battery, 20 cases, roster floor 28 → 29. Its pure half covers the predicate, the lift spy agreeing with it, and the answer per event and read state. Its YAML half covers exactly two checkouts (queue one unchanged; PR one blobless and sparse), the question wired to `$GITHUB_OUTPUT` on the PR leg only, every install step gated on not-`false` with no `== 'true'` spelling anywhere, the restore step ahead of the install, and an unconditional judgment step. `installStepAudit` gains a `stepBodies` field with comment lines dropped. `scripts/pm/dispatch-gates.self-test.mjs`: the LIVE pin "exactly one family classifies CI-measured" became false because of this change. `--toolchain-needed` reads the event payload, so the live derivation now sees two CI-measured invocations of the one guard. The pin now names both. ## Route taken against the dispatch hypotheses - **Route 2 taken; route 1 not taken.** `ready_for_review` stays in `types:`, and the header's reason for it stands. - **The dispatch's framing of route 2 was "no checkout of the full tree, no pnpm install". Measured, both halves needed a qualifier.** - The `pull_request` leg is not API-only. Its judgment reads git history: `merge-base`, `log --first-parent`, `diff-tree --name-only`. Those need commits and trees, not blobs. So the leg keeps `fetch-depth: 0` (the #9902 reason holds on this leg too) and drops only the blobs. - The workflow header gives a stated reason for installing on both legs: the `pull_request` leg forecasts the queue verdict, and without the generator toolchain it forecasts a refusal the queue will not make. So the install is not dropped. It is acquired exactly when the lift would recompute, decided by the same predicate the lift uses. - **Not done:** no copy of the script is vendored, and `actions/github-script` is not used. ## Premises (re-measured on `be531f685`, the branch base) - **P1 holds.** `git grep -n 'ready_for_review'` on the workflow finds 2 lines: the `types:` line and one header comment. Control `synchronize`: 1 line. - **P2 holds.** The check-runs on `e7b12fdd`, filtered to `Governed Surface Queue Guard`, are two runs. Run 121428970465 ran 06:21:40Z → 06:22:56Z, before the ready flip. The ready-fired run 121656663464 ran 15:12:37Z → 15:14:11Z (94 s). - **P3 measured.** Tree reads: the event payload, `.github/CODEOWNERS`, the workflow file and its own sources under `scripts/`, plus git objects (commits and trees only). API reads: reviews, the pull object, comments and the timeline, all at the existing `pull-requests: read`. The static and lazy import graph is `node:` built-ins plus relative `scripts/**` modules only. Measured by running `--self-test` on an archive of just `scripts/` and `.github/` with no `node_modules`: 373 cases pass at base, exit 0. ## Measurements (dev container, 2026-10-26, repository `objectstack-ai/objectstack`) - **Clone cost of full history.** - blobless: 7 s, 105 MiB - treeless: 3 s, 64 MiB - with contents: 58 s, 956 MiB - **Sparse checkout** of `scripts` and `.github` on the blobless clone: 2.7 s. - **Restoring the full tree** (`git sparse-checkout disable`) for the toolchain case: 26 s. Then `pnpm install --frozen-lockfile` on a warm store: 13 s. - **Judgment, PR #25965** (governed workflow file, no register path): blobless sparse checkout against a full checkout gives byte-identical output, exit 0 on both. `--toolchain-needed` answers `needed=false` on `pull_request` and `needed=true` on `merge_group`. - **Judgment, PR #25813** (two `skills/*/references/_index.md` files): `--toolchain-needed` answers `needed=true` and names both paths. After the restore step and the install, the judgment is byte-identical to a full checkout with an install, and the same rows LIFTED. Without the toolchain on the sparse tree, every row stays governed: fail-closed, as before. - **Frequency.** 14 of the 3142 commits reachable from `main` at `be531f685` since 2026-09-26 touched a register path. Counted on a full, non-shallow clone with `git rev-list --count --since` and a path-limited `git log`. ## Acceptance - **"Approving and readying a governed PR starts no new guard run" is not met by design.** Route 1 is what would meet it, and the dispatch kept `ready_for_review` (route 2). The ready flip still starts a run, now a short one. This is the card's second acceptance arm. - **"The `pull_request` leg's wall time is under 20 s": met.** This PR's own guard run, job 121820715758 (2026-10-26T20:12:44Z → 20:13:03Z), took 19 s wall: blobless sparse checkout 10 s, Node 3 s, install steps skipped. The ready-fired run on PR #25965 took about 94 s. - **`Governed Surface Queue Guard` remains the check-run name on both legs.** The job and its `name:` are untouched. The self-test's WIRING pin and `check:required-contexts` both pass. ## Tests and gates (final head `8cd23c77`, after merging `origin/main`) - `node scripts/pm/check-governed-queue-guard.mjs --self-test`: exit 0, 393 cases pass. - `pnpm check:pm-dispatch-gates`: 1899 cases pass (fast tier), 313.4 s. Run detached; the verdict line is quoted and there is no `ELIFECYCLE`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 52 local commands. All 52 exit 0 on `8cd23c77`, each exit code captured before any pipe. That set includes `check:required-contexts`, `check-ci-filter-parity`, `check-step-collectors` (658 steps, 40 workflows), `check:pm-expected-skips`, `check:workflow-step-name-quoting` and `check:nul-bytes`. - Two runs were added outside the derivation: `node scripts/check-required-contexts.mjs --self-test` (174 assertions) and `node scripts/pm/check-expected-skips.mjs --self-test` (173 cases), both exit 0. - `--ran` reconciliation: 51 derived, 51 run, 0 NOT-MEASURED, 0 UNRUN. It is a derived zero, because every family recorded an exit code. - No ablation: a workflow and a gate script are changed, and their pins are the self-test cases above. ## Acceptance notes - The `skip-changeset` label is declared at create. Nothing under `.github/workflows/**` or `scripts/pm/**` ships in any package's `files[]`. - The rare toolchain case pays about 26 s to restore the tree, in place of about 48 s of full-content checkout. Its wall stays roughly where it was. --- _Generated by [Claude Code](https://claude.ai/code/session_01Cpku5P4EYCqaaEGHuMcxik)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 844ea10 commit ab4e30a

3 files changed

Lines changed: 259 additions & 29 deletions

File tree

‎.github/workflows/governed-surface-guard.yml‎

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ on:
3535
# the addition and it is the point: flipping a governed draft to ready is
3636
# the first move of the exact sequence this guard exists to interrupt, and
3737
# it is not in the default set — without it the warning would not re-fire
38-
# at the one moment a seat most needs to read it.
38+
# at the one moment a seat most needs to read it. That same flip is the
39+
# moment an approver taps merge, and the re-fired run holds this REQUIRED
40+
# context `in_progress` for its whole wall (a direct merge is refused
41+
# meanwhile) — which is why this leg is kept cheap rather than dropped: it
42+
# checks out commits and trees only and installs nothing unless the diff
43+
# needs the generator toolchain (the two checkout steps below).
3944
types: [opened, synchronize, reopened, ready_for_review]
4045
merge_group:
4146

@@ -87,7 +92,10 @@ jobs:
8792
# (n = 288): max 1.1 minutes; 2× is 2.2, and never below 10. 20 + 10 = 30.
8893
timeout-minutes: 30
8994
steps:
95+
# The QUEUE leg's checkout, unchanged: the refusing leg reads the queue
96+
# head with the whole tree on disk.
9097
- name: Checkout repository
98+
if: github.event_name != 'pull_request'
9199
uses: actions/checkout@v7
92100
# CHECKOUT BUDGET: lint.yml's `typecheck-source-gates` sizes it.
93101
timeout-minutes: 20
@@ -100,6 +108,27 @@ jobs:
100108
# than diffing what it happens to have.
101109
fetch-depth: 0
102110

111+
# The EARLY-WARNING leg's checkout: the same full history, without file
112+
# contents. The judgment reads commits and trees only (`merge-base`,
113+
# `log`, `diff-tree --name-only`), and the only files it and the
114+
# self-test open live under `scripts/` and `.github/` — so a blobless
115+
# fetch plus a sparse tree of those two answers byte-identically.
116+
# Measured 2026-10-26 on this repository: a blobless full-history clone
117+
# took 7 s for 105 MiB against 58 s for 956 MiB with contents, and the
118+
# judgment on PR #25965 printed the same bytes on both checkouts. ⛔ Still
119+
# `fetch-depth: 0` — the #9902 reason above holds on this leg too. A diff
120+
# that needs the generator toolchain gets the full tree back below.
121+
- name: Checkout repository (commits and trees only)
122+
if: github.event_name == 'pull_request'
123+
uses: actions/checkout@v7
124+
timeout-minutes: 20
125+
with:
126+
fetch-depth: 0
127+
filter: blob:none
128+
sparse-checkout: |
129+
scripts
130+
.github
131+
103132
# Pinned to the same major and spelling as every other setup-node in this
104133
# repo, and it keeps its literal `node-version` (rather than
105134
# `node-version-file`) for the measured reason the Part-of Closing-Keyword
@@ -167,16 +196,37 @@ jobs:
167196
# verdict stays inside the script — where the exit contract lives — instead
168197
# of becoming an opaque red step nobody can read a governance answer out of.
169198
#
170-
# Both legs install. The `pull_request` leg is a FORECAST of the queue
171-
# verdict ("the merge-queue run of this same check will REFUSE it"), so a
172-
# leg that cannot recompute forecasts a refusal that will not happen — the
173-
# false-alarm direction, which trains seats to hold PRs that need no
174-
# holding. Same diff, same answer, on both legs and at the seat.
199+
# Both legs install WHEREVER THE RECOMPUTE CAN CHANGE THE ANSWER. The
200+
# `pull_request` leg is a FORECAST of the queue verdict ("the merge-queue
201+
# run of this same check will REFUSE it"), so a leg that cannot recompute
202+
# forecasts a refusal that will not happen — the false-alarm direction,
203+
# which trains seats to hold PRs that need no holding. Same diff, same
204+
# answer, on both legs and at the seat. But a diff that touches no
205+
# register path never runs a generator at all, so on that leg the script
206+
# is asked first (`--toolchain-needed`, the same predicate the lift
207+
# consults) and the toolchain is acquired only on `true`. The queue leg
208+
# never asks, so its output stays empty and it always installs; a
209+
# question that fails leaves it empty too, which also installs.
210+
- name: Does this diff need the generator toolchain?
211+
id: toolchain
212+
if: github.event_name == 'pull_request'
213+
continue-on-error: true
214+
run: node scripts/pm/check-governed-queue-guard.mjs --toolchain-needed >> "$GITHUB_OUTPUT"
215+
216+
# The generators read the whole tree, which the sparse checkout above
217+
# left out; the blobs arrive on demand from the partial clone's remote.
218+
- name: Restore the full tree for the generator recompute
219+
if: github.event_name == 'pull_request' && steps.toolchain.outputs.needed != 'false'
220+
continue-on-error: true
221+
run: git sparse-checkout disable
222+
175223
- name: Setup pnpm
224+
if: steps.toolchain.outputs.needed != 'false'
176225
uses: ./.github/actions/setup-pnpm
177226
continue-on-error: true
178227

179228
- name: Get pnpm store directory
229+
if: steps.toolchain.outputs.needed != 'false'
180230
shell: bash
181231
continue-on-error: true
182232
run: |
@@ -185,6 +235,7 @@ jobs:
185235
# Deliberately the same key as lint.yml's: one store, one entry, and this
186236
# job is a cache READER in the steady state rather than a second writer.
187237
- name: Setup pnpm cache
238+
if: steps.toolchain.outputs.needed != 'false'
188239
uses: actions/cache@v6
189240
continue-on-error: true
190241
with:
@@ -194,6 +245,7 @@ jobs:
194245
${{ runner.os }}-pnpm-store-v3-
195246
196247
- name: Install dependencies
248+
if: steps.toolchain.outputs.needed != 'false'
197249
continue-on-error: true
198250
run: pnpm install --frozen-lockfile
199251

0 commit comments

Comments
 (0)