Skip to content

Commit 487056b

Browse files
ci(ui-merge): queue builds decide the four ui lanes from the merged diff against merge_group.base_sha (#25881)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoMenwthdt1JYWvY79ZDjVL8)_ Fixes #25867 Part of #25677 (post-cutover CI speed, item 1). Clause-②: no Metadata-protocol: none ## What changed `ci.yml` `filter` job, nothing else in the workflow: - The `dorny/paths-filter` step's allow-list gains `merge_group`. At the pinned v4.0.3 (`ceb8a2b8`) the action resolves that event itself: `src/main.ts` takes `merge_group.base_sha` / `merge_group.head_sha` from the payload when `base` / `ref` are unset, and `src/git.ts` fetches the base sha at depth 1 and diffs the two trees (`base..head`). So the queue build reads the same filter block, with the same globs, as the `pull_request` run. There is no second copy of the globs to keep in step. - On `merge_group` the step is `continue-on-error`, so a base that cannot be fetched leaves the outputs empty, `|| 'true'` fills them in and every ui lane runs (fail-open). On `pull_request` / `push` a failure stays red, as before. - `docs`, `core` and `crosspkg` ignore the step on `merge_group`: they are spelled `github.event_name != 'merge_group' && steps.changes.outputs.X || 'true'`, so Build Core, Build Docs and Test Core keep running on every queue entry exactly as today. Only `ui` and `ui_consumers` read the queue diff. - `schedule` / `workflow_dispatch` still skip the step and widen everything to `'true'` (full battery, ruling Q6-b). `push` is unchanged. The four ui lanes' `if:` lines are untouched. `scripts/pm/check-expected-skips.mjs`: the header and the four ui roster reasons now say that a queue build skips the ui lanes when the merged diff touches no ui path, rather than "every output widens to 'true' on merge_group". `scripts/pm/dispatch-gates.self-test.mjs`: the marker-census row for that file moves 133 to 137 (the header grew four lines). Resulting outputs per event (step value, then output): | event | step `ui` | `ui` | step `core` | `core` | |---|---|---|---|---| | pull_request / push | from the diff | as the step said | from the diff | as the step said | | merge_group | from base_sha..head_sha | as the step said | ignored | `'true'` | | merge_group, step failed | empty | `'true'` | ignored | `'true'` | | schedule / workflow_dispatch | step skipped | `'true'` | step skipped | `'true'` | ## Verification Replayed the pinned action's `dist/index.js` locally with a synthetic `merge_group` event payload (`GITHUB_EVENT_NAME=merge_group`, `base_sha` / `head_sha` set, `INPUT_FILTERS` = the filter block read out of this branch's `ci.yml`): | entry | base..head | `ui` | `ui_consumers` | ui lanes | |---|---|---|---|---| | `.claude/` + `scripts/` only (abebc4e) | 17f8217..abebc4e | false | false | all four skip | | spec + lint (b00fc55) | abebc4e..b00fc55 | false | true | Type Check UI and Build & E2E UI run; Test UI and Lint UI skip | | `ui/` scripts (1210544) | 3fca599..1210544 | true | false | all four run | | three-commit group, ui + scripts | 3fca599..c475b45 | true | false | all four run | | depth-1 checkout, base absent locally | 1210544..5aa9fcb | true | false | the action fetched the base by sha at depth 1, then diffed | | unfetchable base | 000…001..c475b45 | (none) | (none) | action exits 1 with no outputs; with continue-on-error the `'true'` fallback runs all four | This PR's own queue entry changes `ci.yml`, which is in the `ui` filter, so it runs all four ui lanes. The first non-ui entry after it lands is the live evidence the card asks for (four ui lanes `skipped`, `Lint UI` and `Build & E2E UI` reported as skipped, which a required check accepts). It will be posted on #25867. Local gates at head `be2cff5e`: `node scripts/pm/dispatch-gates.mjs --commands` derives 67 commands; all 67 exit 0, and `--ran` reconciles 67 derived, 67 run, 0 NOT-MEASURED (a derived zero: every row carries its exit code). That includes `check:pm-dispatch-gates` (fast tier, 1917 cases) and the four `dist/`-reading Build Core guards, which ran after that battery's build. The gates the card names: `check-ci-filter-parity` (scan and `--self-test`), `check:pm-expected-skips`, `check-step-collectors` (scan and `--self-test`) all exit 0. The only failure on the way was the `dispatch-gates` marker census pinning `check-expected-skips.mjs:133`, fixed in the second commit. ## Acceptance notes - Route differs from the card's wording: the card says `git diff --name-only` with the same globs. Running the action itself on the event gives the same globs by construction and keeps `check-ci-filter-parity`'s one-step rule intact. - `docs` / `core` / `crosspkg` could read the queue diff the same way (Build Core and Build Docs would then skip on queue entries outside their paths). Not done here: the card scopes the ui outputs only, and the filter comment's "the queue build is the last validation before main" reasoning still governs those jobs. Carrier: none. ## 维护者速读(草稿) **改了什么:** 合并队列里,ui 的四个作业(Test UI、Type Check UI、Build & E2E UI、Lint UI)改成按"这批改动相对队列基点到底动没动 ui"来决定跑不跑。判定用的是 PR 上同一套路径规则。core、docs 那几个作业在队列里照旧全跑。 **为什么改:** 合并 ui 之后,每个排队条目都会把四个 ui 作业跑一遍,哪怕只改了文档或脚本。小条目从 4–10 分钟变成 16–18 分钟,每条多花约 220 runner-min。 **风险与代价(含回滚):** 判定失败(比如取不到基点)时按"全跑"处理,不会因此漏跑。动了 ui 或那七个被 ui 使用的包,队列里照样会跑 ui 作业。回滚只需 revert 这一个 PR。 **席位意见:** **你要做的:** 这个 PR 改了 `.github/workflows/ci.yml`,属于 Tier H,需要你批准后由席位落地。落地后第一个非 ui 条目进队时,席位会在 #25867 贴出前后耗时对比。 --- _Generated by [Claude Code](https://claude.ai/code/session_01Suqt7vsGh5kE2NhDZyqeh6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 744900d commit 487056b

3 files changed

Lines changed: 70 additions & 37 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -137,18 +137,24 @@ jobs:
137137
contents: read
138138
pull-requests: read
139139
outputs:
140-
# On merge_group — and on the `schedule` / `workflow_dispatch` full runs
141-
# (#16467) — everything counts as changed: dorny/paths-filter resolves a
142-
# diff only on `pull_request` and `push`, and each of these three events
143-
# is a place a skipped job can never be the right answer (the queue build
144-
# is the last validation before main; the hourly run IS the full battery).
145-
# A skipped step's output is the empty string (falsy), so `|| 'true'`
146-
# supplies their value without touching PR/push behavior.
147-
docs: ${{ steps.changes.outputs.docs || 'true' }}
148-
core: ${{ steps.changes.outputs.core || 'true' }}
140+
# On the `schedule` / `workflow_dispatch` full runs (#16467) everything
141+
# counts as changed: dorny/paths-filter is skipped there (no diff to
142+
# resolve), a skipped step's output is the empty string (falsy), and
143+
# `|| 'true'` supplies the value without touching PR/push behavior.
144+
#
145+
# On merge_group the step RUNS (the diff is the queue base_sha against
146+
# the group head, see the step below), but only the three ui outputs
147+
# read it. `docs`, `core` and `crosspkg` stay 'true' there through the
148+
# `github.event_name != 'merge_group' &&` guard: the queue build is the
149+
# last validation before main, and those jobs run on every entry, as
150+
# they did before the step ran on this event. On any other event the
151+
# guard is true and the expression reads exactly as `<output> ||
152+
# 'true'` did.
153+
docs: ${{ github.event_name != 'merge_group' && steps.changes.outputs.docs || 'true' }}
154+
core: ${{ github.event_name != 'merge_group' && steps.changes.outputs.core || 'true' }}
149155
# Gates the `test` job ONLY, ORed with `core` — see the `crosspkg`
150156
# filter below and the `test` job's `if:` (#9829, #10015).
151-
crosspkg: ${{ steps.changes.outputs.crosspkg || 'true' }}
157+
crosspkg: ${{ github.event_name != 'merge_group' && steps.changes.outputs.crosspkg || 'true' }}
152158
# The objectui tree under `ui/` (objectstack#25677). Three outputs, read
153159
# together by the four `* UI` jobs at the bottom of this file:
154160
# ui — a path under ui/, or a root file the ui jobs build
@@ -158,13 +164,21 @@ jobs:
158164
# UI) and built (Build & E2E UI) against the new shape in
159165
# the PR that moves it;
160166
# ui_tree — `ui/package.json` exists in this checkout.
167+
# On merge_group `ui` and `ui_consumers` come from the queue diff
168+
# (objectstack#25867): an entry whose merged diff against
169+
# `merge_group.base_sha` touches no ui path and none of the seven
170+
# packages skips the four ui jobs, exactly as the same diff does on its
171+
# PR head; an entry that touches one runs them. If the step fails (the
172+
# base cannot be fetched, the diff errors), it is `continue-on-error`
173+
# there, its outputs are empty and `|| 'true'` runs every ui job.
174+
#
161175
# `ui_tree` is what keeps the ui jobs SKIPPED, not failed, on a tree that
162176
# does not carry ui/ yet: `ui` and `ui_consumers` fall back to 'true' on
163-
# merge_group / schedule / dispatch (half 1 below), and on push to main a
164-
# spec change sets `ui_consumers`, so neither path output alone can tell a
165-
# job there is nothing to build. Its step runs on every event, so the
166-
# `|| 'true'` fallback is reached only when this job died before it — and
167-
# then THE FILTER CONTRACT's "run everything" is the intended reading.
177+
# schedule / dispatch (half 1 below), and on push to main a spec change
178+
# sets `ui_consumers`, so neither path output alone can tell a job there
179+
# is nothing to build. Its step runs on every event, so the `|| 'true'`
180+
# fallback is reached only when this job died before it — and then THE
181+
# FILTER CONTRACT's "run everything" is the intended reading.
168182
ui: ${{ steps.changes.outputs.ui || 'true' }}
169183
ui_consumers: ${{ steps.changes.outputs.ui_consumers || 'true' }}
170184
ui_tree: ${{ steps.ui-tree.outputs.present || 'true' }}
@@ -216,13 +230,15 @@ jobs:
216230
echo 'ui/package.json is absent: every ui job skips on this run.'
217231
fi
218232
219-
# ⭐ SPELLED AS AN ALLOW-LIST, not as `!= 'merge_group'` (#16467). This
220-
# action reads a diff, and the only two events that hand it one it can
221-
# resolve unaided are `pull_request` (base vs head) and `push`
222-
# (`event.before` vs `after`). `merge_group` was the first event with
223-
# neither, and the exclusion was written as its name; `schedule` and
224-
# `workflow_dispatch` are the second and third, and under the old
225-
# spelling they would have RUN the action with no resolvable base.
233+
# ⭐ SPELLED AS AN ALLOW-LIST, never as a deny-list (#16467). This
234+
# action reads a diff, and only three events hand it one it can resolve
235+
# unaided: `pull_request` (base vs head), `push` (`event.before` vs
236+
# `after`) and `merge_group` (the payload's `base_sha` vs `head_sha`; at
237+
# the pinned v4.0.3, src/main.ts reads both when `base` / `ref` are
238+
# unset, and src/git.ts fetches the base sha at depth 1 and diffs the two
239+
# trees, `base..head`). `schedule` and `workflow_dispatch` hand it
240+
# neither, and under a deny-list spelling they would RUN the action with
241+
# no resolvable base.
226242
#
227243
# That failure direction is the dangerous one: every filter output would
228244
# come back `false`, `|| 'true'` supplies nothing (the output exists and
@@ -231,9 +247,16 @@ jobs:
231247
# of nothing at all. An allow-list makes each new event fail toward
232248
# THE FILTER CONTRACT's half 1 instead: the step is skipped, its outputs
233249
# are the empty string, `|| 'true'` fills them in, and everything runs.
250+
#
251+
# `merge_group` joined the list for the ui outputs alone (#25867; the
252+
# three core-side outputs above ignore the step on that event). Its
253+
# `continue-on-error` is the fail-open half there: a queue build whose
254+
# base cannot be fetched reads as "run every ui job", never as a red
255+
# `filter` job. On pull_request and push a failure stays red, as before.
234256
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
235257
id: changes
236-
if: github.event_name == 'pull_request' || github.event_name == 'push'
258+
if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'merge_group'
259+
continue-on-error: ${{ github.event_name == 'merge_group' }}
237260
with:
238261
filters: |
239262
docs:

‎scripts/pm/check-expected-skips.mjs‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,16 @@
4242
*
4343
* It does NOT answer the DIFF question — "should this ROSTERED job have run on
4444
* THIS diff?" — and the boundary is deliberate. For ci.yml's `filter`-gated
45-
* family the merge-queue build answers it: on `merge_group` every filter output
46-
* widens to `'true'` and the family runs on the merged tree before anything
47-
* reaches `main` (the `|| 'true'` half of ci.yml's filter contract, pinned by
48-
* `--self-test`) — except the full-battery-only jobs (ci.yml's THE TWO SPEEDS),
49-
* which skip on every PR head and queue build and are answered by `main`'s
50-
* push and hourly runs instead. For the event- and label-conditional advisory jobs the reason
45+
* family the merge-queue build answers it: on `merge_group` the `docs`, `core`
46+
* and `crosspkg` outputs widen to `'true'` and those jobs run on the merged
47+
* tree before anything reaches `main` (the `|| 'true'` half of ci.yml's filter
48+
* contract, pinned by `--self-test`). Two exceptions. The four ui jobs: on
49+
* `merge_group` their outputs are the same paths filter read against the
50+
* queue's `base_sha`, so a queue build skips them exactly when the merged diff
51+
* touches no ui path, and main's hourly run widens them. And the
52+
* full-battery-only jobs (ci.yml's THE TWO SPEEDS), which skip on every PR
53+
* head and queue build and are answered by `main`'s push and hourly runs
54+
* instead. For the event- and label-conditional advisory jobs the reason
5155
* column names the condition a reader checks by hand. Deriving expectedness
5256
* live from the workflows' `paths` filters would answer the diff question, at
5357
* the cost of a SECOND evaluator of the platform's own semantics — the
@@ -184,7 +188,9 @@ export const OWN_PROXY_REARM_GUARD = 'OS_EXPECTED_SKIPS_PROXY_REARMED';
184188
*
185189
* filter-output ci.yml's `filter` job said `false` for every named output —
186190
* the job's `if:` spells `needs.filter.outputs.<o> != 'false'`
187-
* and the filter widens each output with `|| 'true'` off PRs.
191+
* and the filter widens each output with `|| 'true'` where
192+
* its paths filter did not run (the ui outputs read it on
193+
* merge_group too).
188194
* event the job's `if:` excludes `github.event.action` values, so a
189195
* `labeled` / `unlabeled` / `edited` run of the same head skips.
190196
* label the job's `if:` reads a label literal — an opt-out (skip when
@@ -216,6 +222,8 @@ const UI_GATE = Object.freeze({ kind: 'filter-output', outputs: ['ui_tree', 'ui'
216222
const UI_CONSUMERS_GATE = Object.freeze({ kind: 'filter-output', outputs: ['ui_tree', 'ui', 'ui_consumers'] });
217223
const UI_TREE_REASON =
218224
"gated on ci.yml's `filter` job `ui_tree` output: it skips on every event while `ui/package.json` is absent (before the objectui import), and is not a required context";
225+
const UI_QUEUE_REASON =
226+
"the merge-queue build reads the same paths filter against the queue's `base_sha`, so a queue entry whose merged diff touches none of those paths skips it too; main's hourly run widens it to 'true'";
219227
const FULL_BATTERY_REASON =
220228
"full battery only (ci.yml's THE TWO SPEEDS): its `if:` excludes `pull_request` and `merge_group`, so it skips on every PR head and every queue build and runs on main's push, hourly schedule and dispatch runs";
221229

@@ -335,28 +343,28 @@ export const EXPECTED_SKIPS = Object.freeze([
335343
workflow: 'ci.yml',
336344
job: 'type-check-ui',
337345
gate: UI_CONSUMERS_GATE,
338-
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path or one of the seven packages objectui consumes (the \`ui_consumers\` output)`,
346+
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path or one of the seven packages objectui consumes (the \`ui_consumers\` output); ${UI_QUEUE_REASON}`,
339347
},
340348
{
341349
name: 'Test UI (${{ matrix.shard }}/16)',
342350
workflow: 'ci.yml',
343351
job: 'test-ui',
344352
gate: UI_GATE,
345-
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path; the raw matrix template is the pre-expansion name`,
353+
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path; ${UI_QUEUE_REASON}; the raw matrix template is the pre-expansion name`,
346354
},
347355
{
348356
name: 'Lint UI',
349357
workflow: 'ci.yml',
350358
job: 'lint-ui',
351359
gate: UI_GATE,
352-
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path`,
360+
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path; ${UI_QUEUE_REASON}`,
353361
},
354362
{
355363
name: 'Build & E2E UI',
356364
workflow: 'ci.yml',
357365
job: 'build-e2e-ui',
358366
gate: UI_CONSUMERS_GATE,
359-
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path or one of the seven packages objectui consumes (the \`ui_consumers\` output)`,
367+
reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path or one of the seven packages objectui consumes (the \`ui_consumers\` output); ${UI_QUEUE_REASON}`,
360368
},
361369
]);
362370

@@ -373,8 +381,9 @@ export const FAILED_CONCLUSIONS = Object.freeze(['failure', 'cancelled', 'timed_
373381
* exists in its workflow, carries the row's name, carries an `if:`, and that
374382
* `if:` spells the gate the row declares. For `filter-output` rows the
375383
* `filter` job's output must carry the `|| 'true'` widening, because the
376-
* reason column promises the queue build runs the job — a promise that is only
377-
* true while that half of the filter contract stands.
384+
* reason column promises the job runs whenever the filter could not answer —
385+
* the queue build for the core-side outputs, the hourly run for every output —
386+
* a promise that is only true while that half of the filter contract stands.
378387
*
379388
* @param {readonly object[]} roster
380389
* @param {(file: string) => object|null} readWorkflow parsed YAML, or null when unreadable

‎scripts/pm/dispatch-gates.self-test.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7502,7 +7502,8 @@ export function selfTest({ tier = 'full' } = {}) {
75027502
'scripts/check-issue-citations.mjs:204 local-env',
75037503
'scripts/cli-build-prerequisite.mjs:111 inherited-population',
75047504
// 131 -> 133 (#22998): the header gained two lines naming the full-battery-only jobs.
7505-
'scripts/pm/check-expected-skips.mjs:133 self-test-reads',
7505+
// 133 -> 137 (#25867): the header gained four lines on the ui jobs' queue-diff outputs.
7506+
'scripts/pm/check-expected-skips.mjs:137 self-test-reads',
75067507
'scripts/pm/dispatch-gates.mjs:736 inherited-population',
75077508
].join(' · '),
75087509
censusRows.join(' · '),

0 commit comments

Comments
 (0)