@@ -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:
@@ -3292,11 +3315,16 @@ jobs:
32923315 # vitest root, so the suite must run with cwd = ui/ (it does, below); run
32933316 # from this repo's root every key misses and every file weighs the median.
32943317 #
3295- # Every event runs the whole suite. objectui's affected-test selector
3296- # (ui/scripts/select-affected-tests.mjs) reads `git diff --name-only`, which
3297- # prints paths relative to the GIT root — `ui/packages/...` here — against a
3298- # package graph spelled relative to the objectui root, so it is not wired
3299- # until objectui's repo-root lookups are migrated (sub-issue 2 of the epic).
3318+ # ── Test UI: the two speeds (#25868) ────────────────────────────────────
3319+ # `push`, `schedule` and `workflow_dispatch` run the whole suite: the full
3320+ # battery. `pull_request` and `merge_group` run the files
3321+ # ui/scripts/select-affected-tests.mjs selects -- the ui packages the change
3322+ # sits in and their dependents, plus the tests the package graph cannot
3323+ # place, which run on every change -- or the whole suite when the change
3324+ # reaches something every ui test runs through, outside ui/ included (a
3325+ # non-documentation root file, `patches/`, one of the seven packages the
3326+ # `ui_consumers` filter names). The selector's header states the rules and
3327+ # what it does not see; the full battery on `main` is the backstop.
33003328 test-ui :
33013329 name : Test UI (${{ matrix.shard }}/16)
33023330 needs : filter
@@ -3313,6 +3341,10 @@ jobs:
33133341 steps :
33143342 - name : Checkout repository
33153343 uses : actions/checkout@v7
3344+ with :
3345+ # The select step below diffs against the base commit, which needs
3346+ # the base's history in the clone.
3347+ fetch-depth : 0
33163348
33173349 - name : Setup Node.js
33183350 uses : actions/setup-node@v7
@@ -3363,13 +3395,85 @@ jobs:
33633395 --filter=@objectstack/sdui-parser... \
33643396 --filter=@objectstack/types...
33653397
3398+ # Fails OPEN: a selector that exits non-zero, prints no verdict, or has
3399+ # no base to diff against means the whole suite, never a skip. The exit
3400+ # status is captured beside the output, not through a command
3401+ # substitution. A pull request diffs from merge-base(origin/<base ref>,
3402+ # HEAD), never the payload's `base.sha`, which is frozen when the PR is
3403+ # opened: everything `main` gained since would be charged to this PR,
3404+ # and a lockfile change among it would make every long-open PR a full
3405+ # run (Test Core's "WHERE THE AFFECTED DIFF STARTS" above is the same
3406+ # fact). A merge group diffs from its `base_sha`. Every shard computes
3407+ # the same selection from the same checkout, so `--shard` partitions
3408+ # one set.
3409+ - name : Select the tests this change affects
3410+ id : select
3411+ env :
3412+ EVENT : ${{ github.event_name }}
3413+ PR_BASE_REF : ${{ github.event.pull_request.base.ref }}
3414+ QUEUE_BASE : ${{ github.event.merge_group.base_sha }}
3415+ run : |
3416+ base=''
3417+ case "$EVENT" in
3418+ pull_request)
3419+ if [ -n "$PR_BASE_REF" ]; then
3420+ base=$(git merge-base "refs/remotes/origin/$PR_BASE_REF" HEAD) || base=''
3421+ fi
3422+ ;;
3423+ merge_group) base="$QUEUE_BASE" ;;
3424+ *)
3425+ echo 'mode=full' >> "$GITHUB_OUTPUT"
3426+ echo "A $EVENT run is the full battery: every ui test file, sixteen shards."
3427+ exit 0
3428+ ;;
3429+ esac
3430+ status=0
3431+ if [ -z "$base" ]; then
3432+ echo "::warning::no diff base on this $EVENT event; running the full suite rather than guessing."
3433+ status=2
3434+ else
3435+ echo "Diff base: $base"
3436+ node ui/scripts/select-affected-tests.mjs --base "$base" --out "$RUNNER_TEMP/affected-tests.txt" \
3437+ > "$RUNNER_TEMP/select.out" || status=$?
3438+ cat "$RUNNER_TEMP/select.out"
3439+ fi
3440+ verdict=''
3441+ if [ "$status" -eq 0 ]; then
3442+ verdict=$(tail -n 1 "$RUNNER_TEMP/select.out")
3443+ fi
3444+ case "$verdict" in
3445+ mode=affected | mode=full) ;;
3446+ *)
3447+ echo "::warning::the affected-test selector reached no verdict (exit $status); running the full suite rather than skipping silently."
3448+ verdict='mode=full'
3449+ ;;
3450+ esac
3451+ echo "$verdict" >> "$GITHUB_OUTPUT"
3452+
3453+ # `$MODE` is the VALUE of the select step's output `mode`: its
3454+ # `mode=affected` line is GitHub's `key=value` form, so what arrives here
3455+ # is `affected` or `full`, never the line itself.
3456+ # ui/scripts/__tests__/select-affected-tests-wiring.test.ts runs both
3457+ # steps as written, so neither end can be renamed alone. The filters are
3458+ # paths relative to ui/, the vitest root, passed as positionals with no
3459+ # `--`, the only spelling ui/scripts/vitest-invocation-guard.mjs admits.
3460+ #
33663461 # objectui's full-battery runs also pass its per-file timings reporter
33673462 # and upload the result for the weekly dataset refresh. Both belong to
33683463 # that refresh workflow, which is in the governance-workflow audit
33693464 # (sub-issue 4 of the epic); until it is ported the committed dataset
33703465 # is what the sequencer reads.
33713466 - name : Run the ui tests (shard ${{ matrix.shard }}/16)
3372- run : pnpm --dir ui run test --shard=${{ matrix.shard }}/16 --reporter=default --reporter=github-actions
3467+ env :
3468+ MODE : ${{ steps.select.outputs.mode }}
3469+ run : |
3470+ if [ "$MODE" = 'affected' ]; then
3471+ mapfile -t filters < "$RUNNER_TEMP/affected-tests.txt"
3472+ echo "Affected speed: ${#filters[@]} vitest filter(s), shard ${{ matrix.shard }} of 16."
3473+ pnpm --dir ui exec vitest run --shard=${{ matrix.shard }}/16 --reporter=default --reporter=github-actions "${filters[@]}"
3474+ else
3475+ pnpm --dir ui run test --shard=${{ matrix.shard }}/16 --reporter=default --reporter=github-actions
3476+ fi
33733477
33743478 - name : Save Turbo cache (main only)
33753479 if : always() && github.event_name == 'push' && matrix.shard == 1
0 commit comments