From 5aa9fcb3c7758fe37130e8486da924c7e9d3a33f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 01:58:59 +0000 Subject: [PATCH 1/2] ci(ui-merge): queue builds decide the four ui lanes from the merged diff against merge_group.base_sha On merge_group the paths-filter step was skipped and the ui / ui_consumers outputs fell back to 'true', so every queue entry ran Test UI, Type Check UI, Build & E2E UI and Lint UI, docs-only entries included. dorny/paths-filter v4.0.3 resolves merge_group itself (payload base_sha vs head_sha), so the step now runs on that event too, continue-on-error there so a failed diff fails open to 'true'. docs / core / crosspkg ignore the step on merge_group and stay 'true', as before. schedule and workflow_dispatch are unchanged. check-expected-skips' header and ui roster reasons now say what the queue build does for the ui family. Claude-Session: https://claude.ai/code/session_01Suqt7vsGh5kE2NhDZyqeh6 Co-authored-by: Claude --- .github/workflows/ci.yml | 69 +++++++++++++++++++---------- scripts/pm/check-expected-skips.mjs | 35 +++++++++------ 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ed4568a034..f2859e32d44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,18 +137,24 @@ jobs: contents: read pull-requests: read outputs: - # On merge_group — and on the `schedule` / `workflow_dispatch` full runs - # (#16467) — everything counts as changed: dorny/paths-filter resolves a - # diff only on `pull_request` and `push`, and each of these three events - # is a place a skipped job can never be the right answer (the queue build - # is the last validation before main; the hourly run IS the full battery). - # A skipped step's output is the empty string (falsy), so `|| 'true'` - # supplies their value without touching PR/push behavior. - docs: ${{ steps.changes.outputs.docs || 'true' }} - core: ${{ steps.changes.outputs.core || 'true' }} + # On the `schedule` / `workflow_dispatch` full runs (#16467) everything + # counts as changed: dorny/paths-filter is skipped there (no diff to + # resolve), a skipped step's output is the empty string (falsy), and + # `|| 'true'` supplies the value without touching PR/push behavior. + # + # On merge_group the step RUNS (the diff is the queue base_sha against + # the group head, see the step below), but only the three ui outputs + # read it. `docs`, `core` and `crosspkg` stay 'true' there through the + # `github.event_name != 'merge_group' &&` guard: the queue build is the + # last validation before main, and those jobs run on every entry, as + # they did before the step ran on this event. On any other event the + # guard is true and the expression reads exactly as ` || + # 'true'` did. + docs: ${{ github.event_name != 'merge_group' && steps.changes.outputs.docs || 'true' }} + core: ${{ github.event_name != 'merge_group' && steps.changes.outputs.core || 'true' }} # Gates the `test` job ONLY, ORed with `core` — see the `crosspkg` # filter below and the `test` job's `if:` (#9829, #10015). - crosspkg: ${{ steps.changes.outputs.crosspkg || 'true' }} + crosspkg: ${{ github.event_name != 'merge_group' && steps.changes.outputs.crosspkg || 'true' }} # The objectui tree under `ui/` (objectstack#25677). Three outputs, read # together by the four `* UI` jobs at the bottom of this file: # ui — a path under ui/, or a root file the ui jobs build @@ -158,13 +164,21 @@ jobs: # UI) and built (Build & E2E UI) against the new shape in # the PR that moves it; # ui_tree — `ui/package.json` exists in this checkout. + # On merge_group `ui` and `ui_consumers` come from the queue diff + # (objectstack#25867): an entry whose merged diff against + # `merge_group.base_sha` touches no ui path and none of the seven + # packages skips the four ui jobs, exactly as the same diff does on its + # PR head; an entry that touches one runs them. If the step fails (the + # base cannot be fetched, the diff errors), it is `continue-on-error` + # there, its outputs are empty and `|| 'true'` runs every ui job. + # # `ui_tree` is what keeps the ui jobs SKIPPED, not failed, on a tree that # does not carry ui/ yet: `ui` and `ui_consumers` fall back to 'true' on - # merge_group / schedule / dispatch (half 1 below), and on push to main a - # spec change sets `ui_consumers`, so neither path output alone can tell a - # job there is nothing to build. Its step runs on every event, so the - # `|| 'true'` fallback is reached only when this job died before it — and - # then THE FILTER CONTRACT's "run everything" is the intended reading. + # schedule / dispatch (half 1 below), and on push to main a spec change + # sets `ui_consumers`, so neither path output alone can tell a job there + # is nothing to build. Its step runs on every event, so the `|| 'true'` + # fallback is reached only when this job died before it — and then THE + # FILTER CONTRACT's "run everything" is the intended reading. ui: ${{ steps.changes.outputs.ui || 'true' }} ui_consumers: ${{ steps.changes.outputs.ui_consumers || 'true' }} ui_tree: ${{ steps.ui-tree.outputs.present || 'true' }} @@ -216,13 +230,15 @@ jobs: echo 'ui/package.json is absent: every ui job skips on this run.' fi - # ⭐ SPELLED AS AN ALLOW-LIST, not as `!= 'merge_group'` (#16467). This - # action reads a diff, and the only two events that hand it one it can - # resolve unaided are `pull_request` (base vs head) and `push` - # (`event.before` vs `after`). `merge_group` was the first event with - # neither, and the exclusion was written as its name; `schedule` and - # `workflow_dispatch` are the second and third, and under the old - # spelling they would have RUN the action with no resolvable base. + # ⭐ SPELLED AS AN ALLOW-LIST, never as a deny-list (#16467). This + # action reads a diff, and only three events hand it one it can resolve + # unaided: `pull_request` (base vs head), `push` (`event.before` vs + # `after`) and `merge_group` (the payload's `base_sha` vs `head_sha`; at + # the pinned v4.0.3, src/main.ts reads both when `base` / `ref` are + # unset, and src/git.ts fetches the base sha at depth 1 and diffs the two + # trees, `base..head`). `schedule` and `workflow_dispatch` hand it + # neither, and under a deny-list spelling they would RUN the action with + # no resolvable base. # # That failure direction is the dangerous one: every filter output would # come back `false`, `|| 'true'` supplies nothing (the output exists and @@ -231,9 +247,16 @@ jobs: # of nothing at all. An allow-list makes each new event fail toward # THE FILTER CONTRACT's half 1 instead: the step is skipped, its outputs # are the empty string, `|| 'true'` fills them in, and everything runs. + # + # `merge_group` joined the list for the ui outputs alone (#25867; the + # three core-side outputs above ignore the step on that event). Its + # `continue-on-error` is the fail-open half there: a queue build whose + # base cannot be fetched reads as "run every ui job", never as a red + # `filter` job. On pull_request and push a failure stays red, as before. - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: changes - if: github.event_name == 'pull_request' || github.event_name == 'push' + if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'merge_group' + continue-on-error: ${{ github.event_name == 'merge_group' }} with: filters: | docs: diff --git a/scripts/pm/check-expected-skips.mjs b/scripts/pm/check-expected-skips.mjs index f69273ccf76..9344ff0af67 100644 --- a/scripts/pm/check-expected-skips.mjs +++ b/scripts/pm/check-expected-skips.mjs @@ -42,12 +42,16 @@ * * It does NOT answer the DIFF question — "should this ROSTERED job have run on * THIS diff?" — and the boundary is deliberate. For ci.yml's `filter`-gated - * family the merge-queue build answers it: on `merge_group` every filter output - * widens to `'true'` and the family runs on the merged tree before anything - * reaches `main` (the `|| 'true'` half of ci.yml's filter contract, pinned by - * `--self-test`) — except the full-battery-only jobs (ci.yml's THE TWO SPEEDS), - * which skip on every PR head and queue build and are answered by `main`'s - * push and hourly runs instead. For the event- and label-conditional advisory jobs the reason + * family the merge-queue build answers it: on `merge_group` the `docs`, `core` + * and `crosspkg` outputs widen to `'true'` and those jobs run on the merged + * tree before anything reaches `main` (the `|| 'true'` half of ci.yml's filter + * contract, pinned by `--self-test`). Two exceptions. The four ui jobs: on + * `merge_group` their outputs are the same paths filter read against the + * queue's `base_sha`, so a queue build skips them exactly when the merged diff + * touches no ui path, and main's hourly run widens them. And the + * full-battery-only jobs (ci.yml's THE TWO SPEEDS), which skip on every PR + * head and queue build and are answered by `main`'s push and hourly runs + * instead. For the event- and label-conditional advisory jobs the reason * column names the condition a reader checks by hand. Deriving expectedness * live from the workflows' `paths` filters would answer the diff question, at * 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'; * * filter-output ci.yml's `filter` job said `false` for every named output — * the job's `if:` spells `needs.filter.outputs. != 'false'` - * and the filter widens each output with `|| 'true'` off PRs. + * and the filter widens each output with `|| 'true'` where + * its paths filter did not run (the ui outputs read it on + * merge_group too). * event the job's `if:` excludes `github.event.action` values, so a * `labeled` / `unlabeled` / `edited` run of the same head skips. * 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' const UI_CONSUMERS_GATE = Object.freeze({ kind: 'filter-output', outputs: ['ui_tree', 'ui', 'ui_consumers'] }); const UI_TREE_REASON = "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"; +const UI_QUEUE_REASON = + "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'"; const FULL_BATTERY_REASON = "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"; @@ -335,28 +343,28 @@ export const EXPECTED_SKIPS = Object.freeze([ workflow: 'ci.yml', job: 'type-check-ui', gate: UI_CONSUMERS_GATE, - 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)`, + 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}`, }, { name: 'Test UI (${{ matrix.shard }}/16)', workflow: 'ci.yml', job: 'test-ui', gate: UI_GATE, - reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path; the raw matrix template is the pre-expansion name`, + 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`, }, { name: 'Lint UI', workflow: 'ci.yml', job: 'lint-ui', gate: UI_GATE, - reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path`, + reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path; ${UI_QUEUE_REASON}`, }, { name: 'Build & E2E UI', workflow: 'ci.yml', job: 'build-e2e-ui', gate: UI_CONSUMERS_GATE, - 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)`, + 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}`, }, ]); @@ -373,8 +381,9 @@ export const FAILED_CONCLUSIONS = Object.freeze(['failure', 'cancelled', 'timed_ * exists in its workflow, carries the row's name, carries an `if:`, and that * `if:` spells the gate the row declares. For `filter-output` rows the * `filter` job's output must carry the `|| 'true'` widening, because the - * reason column promises the queue build runs the job — a promise that is only - * true while that half of the filter contract stands. + * reason column promises the job runs whenever the filter could not answer — + * the queue build for the core-side outputs, the hourly run for every output — + * a promise that is only true while that half of the filter contract stands. * * @param {readonly object[]} roster * @param {(file: string) => object|null} readWorkflow parsed YAML, or null when unreadable From be2cff5eeb6afc34785d50e99a5643bc317fb14c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 02:22:57 +0000 Subject: [PATCH 2/2] test(pm): the dispatch-gates marker census follows check-expected-skips' declaration to line 137 The header paragraph on the ui jobs' queue-diff outputs moved the self-test-reads declaration down four lines. Claude-Session: https://claude.ai/code/session_01Suqt7vsGh5kE2NhDZyqeh6 Co-authored-by: Claude --- scripts/pm/dispatch-gates.self-test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index a67369fbd90..df9b7d3855b 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -7502,7 +7502,8 @@ export function selfTest({ tier = 'full' } = {}) { 'scripts/check-issue-citations.mjs:204 local-env', 'scripts/cli-build-prerequisite.mjs:111 inherited-population', // 131 -> 133 (#22998): the header gained two lines naming the full-battery-only jobs. - 'scripts/pm/check-expected-skips.mjs:133 self-test-reads', + // 133 -> 137 (#25867): the header gained four lines on the ui jobs' queue-diff outputs. + 'scripts/pm/check-expected-skips.mjs:137 self-test-reads', 'scripts/pm/dispatch-gates.mjs:736 inherited-population', ].join(' · '), censusRows.join(' · '),