Skip to content

Commit 853dded

Browse files
committed
ci(test-ui): add the Test UI aggregate job and register it as a required context
A `test-ui-gate` job named `Test UI` over the 16 `test-ui` shards, in test-gate's shape (`name:` literal, `if: always()`, roster declaration), red on any rollup other than success or a filter-explained skip. One REQUIRED_CONTEXTS row, the REQUIRED_AGGREGATORS floor row, self-test cases for both, and the two statesTheSet instruction surfaces follow the registry (eight to nine). Claude-Session: https://claude.ai/code/session_01ADHkmNhqdySz5JeM3ARKGW Co-authored-by: Claude <noreply@anthropic.com>
1 parent f48aa45 commit 853dded

5 files changed

Lines changed: 119 additions & 14 deletions

File tree

‎.claude/skills/pm-dispatch/references/platform-readings.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,9 +387,9 @@
387387
- 本车道强制多是过宽的回忆:`dispatch-gates.mjs --tier PATH` 逐路径现推,路径线是下限非放行。
388388
- 该脚本只答自己那棵树:姊妹仓路径回 absent from this tree,姊妹仓的档位与条款②只能手推。
389389
- `os-verify-lock.sh` 只住 objectstack:objectui 无它,跑本仓副本读的是容器级锁,非该仓深度。
390-
- required checks 的名单是每仓事实,objectstack 八个:
390+
- required checks 的名单是每仓事实,objectstack 九个:
391391
`TypeScript Type Check` · `Lint & Repo Gates` · `Test Core` · `Build Core` · `Lint UI` ·
392-
`Governed Surface Queue Guard` · `Type Check UI` · `Build & E2E UI`。
392+
`Governed Surface Queue Guard` · `Type Check UI` · `Test UI` · `Build & E2E UI`。
393393
- ⭐ 本表的 `mustName` 声明 `statesTheSet` ⇒ 必须逐字等于注册表,加注册行不跟改本表即红。
394394
- ⇒ 门绿即名单逐字齐全的读数;⛔ 但数量词未钉:实测 七→六 照绿,只能手跟改。
395395
- ⛔ 聚合命令同样不作判定:`check:type-check-debt` 可在包级 typecheck 绿时红。

‎.github/workflows/ci.yml‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3722,6 +3722,74 @@ jobs:
37223722
pnpm --dir ui run test --shard=${{ matrix.shard }}/16 --reporter=default --reporter=github-actions
37233723
fi
37243724
3725+
test-ui-gate:
3726+
# Stable required-check name for the sharded Test UI matrix (#26460, ruling
3727+
# A, comment 6435318516), in test-gate's shape and for test-gate's reason
3728+
# (#3622): a matrix publishes only `Test UI (k/16)`, so the bare `Test UI`
3729+
# context the ruleset requires can come from nothing but this job, and the
3730+
# shard count stays free to move without a settings edit. Until this job,
3731+
# none of the 16 shards was required and the queue landed PRs whose Test UI
3732+
# was red (#26419 → #26421).
3733+
#
3734+
# `name:` and `if: always()` are both load-bearing, as on test-gate. The
3735+
# name IS the required context (`check:required-contexts` pins the
3736+
# literal), and a gate that skips because a dependency failed publishes no
3737+
# context at all, which leaves the PR permanently pending (#3622). ⛔ Never
3738+
# `if: !cancelled()` here.
3739+
#
3740+
# The verdict reads the matrix rollup `needs.test-ui.result`, and only
3741+
# `success` passes, with one exception: `skipped`, when `filter` concluded
3742+
# `success` AND said the diff reaches no ui path (the shards' own `if:`).
3743+
# That is #4928's skipped-only-when-filter-succeeded guard, and it makes a
3744+
# queue build or PR that touches no ui path read green here the way Lint
3745+
# UI and Build & E2E UI read `skipped` there. Everything else is red:
3746+
# `failure`; `cancelled`, which is also what a shard killed by its own
3747+
# `timeout-minutes` reads (on a superseded push this paints the OLD sha
3748+
# red, which no required check reads); a `skipped` that `filter` does not
3749+
# explain; and any lifecycle value such as the `abandoned` test-gate met
3750+
# (run 31120902911). Unlike test-gate it counts no shard credentials: the
3751+
# Test UI shards publish none, and adding them is a change to `test-ui`,
3752+
# which is the ui lane's job.
3753+
name: Test UI
3754+
needs: [test-ui, filter]
3755+
# ── ROSTER DECLARATION (#10490) ───────────────────────────────────────
3756+
# `check:aggregator-roster` holds this equal to `needs:` in both
3757+
# directions. `filter` is the #4928 input, not a lane.
3758+
env:
3759+
OS_AGGREGATOR_MEMBERS: test-ui
3760+
OS_AGGREGATOR_NON_MEMBERS: filter
3761+
if: always()
3762+
runs-on: ubuntu-latest
3763+
timeout-minutes: 10
3764+
permissions:
3765+
contents: read
3766+
steps:
3767+
- name: Verify Test UI shard results
3768+
env:
3769+
OS_TEST_UI_RESULT: ${{ needs['test-ui'].result }}
3770+
OS_FILTER_RESULT: ${{ needs.filter.result }}
3771+
OS_UI_TREE: ${{ needs.filter.outputs.ui_tree }}
3772+
OS_UI: ${{ needs.filter.outputs.ui }}
3773+
run: |
3774+
echo "Test UI (k/16) rollup: '$OS_TEST_UI_RESULT'; filter: '$OS_FILTER_RESULT' (ui_tree='$OS_UI_TREE', ui='$OS_UI')."
3775+
case "$OS_TEST_UI_RESULT" in
3776+
success)
3777+
echo "✓ Test UI: every shard concluded success."
3778+
;;
3779+
skipped)
3780+
if [ "$OS_FILTER_RESULT" = 'success' ] && { [ "$OS_UI_TREE" = 'false' ] || [ "$OS_UI" = 'false' ]; }; then
3781+
echo "✓ Test UI: the shards were skipped because filter found no ui path in this diff."
3782+
else
3783+
echo "::error title=Test UI::the Test UI shards were skipped, but filter (result '$OS_FILTER_RESULT', ui_tree='$OS_UI_TREE', ui='$OS_UI') does not explain the skip. A skip passes only when filter succeeded and said the diff reaches no ui path (#4928)."
3784+
exit 1
3785+
fi
3786+
;;
3787+
*)
3788+
echo "::error title=Test UI::the Test UI shards concluded '$OS_TEST_UI_RESULT', not success. Read the red 'Test UI (k/16)' check-runs on this sha for the failing test; a shard killed by its timeout reads 'cancelled'."
3789+
exit 1
3790+
;;
3791+
esac
3792+
37253793
# ── Lint UI and Build & E2E UI: the ceilings (#25869 row F13) ──────────
37263794
# objectui's rule (its lint.yml `Lint` ceiling): the smallest round number
37273795
# that is both >= 3x the slowest successful run and >= that run + 15 min, so

‎AGENTS.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -520,11 +520,11 @@ Even inside your own worktree, operate defensively:
520520
yet"; `in_progress` is not a pass. Arming a red PR does not queue it, it hides it:
521521
every poll then misreads "not on `main` yet" as "queued". Always read *two* things:
522522
the queue branch **and** `origin/main`. And **the queue enforces only the required
523-
set** — eight contexts block: `Lint & Repo Gates` (all `check:*` gates), `TypeScript Type Check`,
524-
`Test Core`, `Build Core`, `Governed Surface Queue Guard`, `Lint UI`, `Type Check UI` and
525-
`Build & E2E UI`. A check outside those eight is advisory and rides through, and an advisory red
523+
set** — nine contexts block: `Lint & Repo Gates` (all `check:*` gates), `TypeScript Type Check`,
524+
`Test Core`, `Build Core`, `Governed Surface Queue Guard`, `Lint UI`, `Type Check UI`, `Test UI`
525+
and `Build & E2E UI`. A check outside those nine is advisory and rides through, and an advisory red
526526
that lands rides `main`'s merge ref into every later PR until stanched. A required context is
527-
matched by check-run name, so a rename detaches its gate silently — treat those eight names as contract.
527+
matched by check-run name, so a rename detaches its gate silently — treat those nine names as contract.
528528
**Two speeds** (ruling record 6120820653): PR and queue runs carry the fast set, 10 minutes of
529529
wall per queue entry; `main`'s scheduled (4-hourly) and push runs carry the full battery — dogfood, e2e,
530530
`Temporal Conformance (live PG + MySQL)` — read by the publish lane before

‎scripts/check-aggregator-roster.mjs‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
*
1111
* ## The gap this closes
1212
*
13-
* Four of this repo's required contexts are aggregator jobs standing in for a
13+
* Five of this repo's required contexts are aggregator jobs standing in for a
1414
* set of real jobs, each deciding its verdict from what it lists in `needs:`:
1515
* `test-gate` / `Test Core` and `dogfood-gate` / `Dogfood Regression Gate` in
1616
* ci.yml, and `typecheck` / `TypeScript Type Check` and, since #22991,
17-
* `lint` / `Lint & Repo Gates` in lint.yml.
17+
* `lint` / `Lint & Repo Gates` in lint.yml, and, since #26460, ci.yml's
18+
* `test-ui-gate` / `Test UI`.
1819
*
1920
* Nothing held those rosters to anything. The failure mode is one forgotten
2021
* line:
@@ -68,7 +69,7 @@
6869
*
6970
* Discovery is by declaration, so a NEW aggregator that adds the two keys is
7071
* covered without editing this file. `REQUIRED_AGGREGATORS` is the floor
71-
* underneath that: the four jobs that carry a branch-protection-required
72+
* underneath that: the five jobs that carry a branch-protection-required
7273
* context must each be found AND declared, so deleting the declaration to make
7374
* a red go away is itself red.
7475
*
@@ -110,6 +111,8 @@ export const REQUIRED_AGGREGATORS = [
110111
{ workflow: 'lint.yml', job: 'typecheck', context: 'TypeScript Type Check' },
111112
// #22991: the single 193-step job became an aggregate over eight lanes.
112113
{ workflow: 'lint.yml', job: 'lint', context: 'Lint & Repo Gates' },
114+
// #26460: the `Test UI` aggregate over the 16 `test-ui` shards.
115+
{ workflow: 'ci.yml', job: 'test-ui-gate', context: 'Test UI' },
113116
];
114117

115118
/** A legal GitHub Actions job id. A comma or a stray quote fails here. */
@@ -507,6 +510,7 @@ async function selfTest() {
507510
from: ' needs: [dogfood, dogfood-verify, filter]\n',
508511
to: ' needs: [dogfood, filter]\n',
509512
},
513+
{ label: 'test-ui-gate loses its matrix from needs:', file: 'ci.yml', member: 'test-ui', from: ' needs: [test-ui, filter]\n', to: ' needs: [filter]\n' },
510514
];
511515
for (const scenario of dropped) {
512516
const verdict = fixture(scenario.label, scenario.file, (s) => s.replace(scenario.from, scenario.to));
@@ -536,6 +540,7 @@ async function selfTest() {
536540
to: ' needs: [dogfood, dogfood-verify, filter, dogfood-ghost]\n',
537541
ghost: 'dogfood-ghost',
538542
},
543+
{ label: 'test-ui-gate needs a job that was deleted', file: 'ci.yml', from: ' needs: [test-ui, filter]\n', to: ' needs: [test-ui, filter, test-ui-ghost]\n', ghost: 'test-ui-ghost' },
539544
];
540545
for (const scenario of phantom) {
541546
const verdict = fixture(scenario.label, scenario.file, (s) => s.replace(scenario.from, scenario.to));

‎scripts/check-required-contexts.mjs‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,15 @@ export const REQUIRED_CONTEXTS = [
471471
'after #25856 (fc2aaaec52) closed bill item 49 with the job green on the main push run 40253800516',
472472
carries: 'the type-check + vitest-setup type-check family of the `ui/` workspace',
473473
},
474+
{
475+
workflow: 'ci.yml',
476+
job: 'test-ui-gate',
477+
context: 'Test UI',
478+
authorized:
479+
'#26460 ruling letter A (staged), maintainer 「批 #408 同意」 2026-10-27T06:24Z, recorded in comment 6435318516 — ' +
480+
'after the ui flake reading 6436579765 (0/50); the ruleset entry is the maintainer\'s, in the same sitting as the merge (#9325)',
481+
carries: 'the sharded `ui/` vitest matrix, `Test UI (k/16)`, read through the aggregate job',
482+
},
474483
];
475484

476485
/**
@@ -585,10 +594,10 @@ export const INSTRUCTION_SURFACES = [
585594
// with every gate green, which is what put the exclusivity rule below in
586595
// the judge. Then 7→5 by #22998 (two-speed CI: the dogfood and temporal
587596
// contexts moved to the full battery). Then 5→7 by #25853 (the `ui/`
588-
// import's `Lint UI` and `Build & E2E UI`) and 7→8 with `Type Check UI`
589-
// once bill item 49 closed (#25856). The full set is pinned here so the
590-
// corrected sentence cannot rot back — a rename in ANY of the eight
591-
// reddens this gate instead.
597+
// import's `Lint UI` and `Build & E2E UI`), 7→8 with `Type Check UI`
598+
// once bill item 49 closed (#25856), and 8→9 with the `Test UI` aggregate
599+
// (#26460). The full set is pinned here so the corrected sentence cannot
600+
// rot back — a rename in ANY of the nine reddens this gate instead.
592601
file: 'AGENTS.md',
593602
statesTheSet: true,
594603
mustName: [
@@ -600,6 +609,7 @@ export const INSTRUCTION_SURFACES = [
600609
'Lint UI',
601610
'Build & E2E UI',
602611
'Type Check UI',
612+
'Test UI',
603613
],
604614
},
605615
{
@@ -631,6 +641,7 @@ export const INSTRUCTION_SURFACES = [
631641
'Lint UI',
632642
'Build & E2E UI',
633643
'Type Check UI',
644+
'Test UI',
634645
],
635646
},
636647
{
@@ -652,7 +663,7 @@ export const INSTRUCTION_SURFACES = [
652663
* of the six surfaces have one; `AGENTS.md` does not, because a repo-root FILE
653664
* has no separator to be found by — so an AGENTS.md card derived this gate not
654665
* at all, while that surface is the one carrying `mustName` for every
655-
* required context (widened 2→6 by #9677, 6→7 by #17798, 7→5 by #22998, 5→7 by #25853, 7→8). Editing the merge-queue
666+
* required context (widened 2→6 by #9677, 6→7 by #17798, 7→5 by #22998, 5→7 by #25853, 7→8, 8→9 by #26460). Editing the merge-queue
656667
* paragraph there is precisely how this gate goes red, and it was reachable
657668
* only by judgment.
658669
*
@@ -1936,6 +1947,13 @@ async function selfTest() {
19361947
renamedTestCore.problems.some((p) => p.includes("job 'test-gate'") && p.includes("'Test Core'")),
19371948
'growing a suffix on the bare `Test Core` name ⇒ red — the exact literal is the contract, not decoration',
19381949
);
1950+
// The same property on the second matrix aggregate (#26460): `Test UI` is the
1951+
// bare name of `test-ui-gate`, and its shards are `Test UI (k/16)`.
1952+
const renamedTestUi = fixture('rename Test UI', 'ci.yml', (s) => s.replace(' name: Test UI\n', ' name: Test UI (aggregate)\n'));
1953+
assert(
1954+
renamedTestUi.problems.some((p) => p.includes("job 'test-ui-gate'") && p.includes("'Test UI'")),
1955+
'growing a suffix on the bare `Test UI` name ⇒ red, naming the aggregate job and the required context',
1956+
);
19391957

19401958
// ── (2) the job disappearing entirely ─────────────────────────────────────
19411959
// Re-pointed from `console-pin` to `temporal-conformance` when #9533 dropped
@@ -2087,6 +2105,20 @@ async function selfTest() {
20872105
baseline.problems.length === 0 && sources['ci.yml'].includes(TEST_CORE_SHARD_NAME),
20882106
'the real suffixed shard name coexists with the bare gate name (assertion 9 compares whole names, not prefixes)',
20892107
);
2108+
// The second live specimen (#26460): the `test-ui` shards and their `Test UI`
2109+
// aggregate, the same shape with the matrix width read the same way.
2110+
const TEST_UI_SHARD_NAME = `name: Test UI (\${{ matrix.shard }}/${shardTotal(sources['ci.yml'], 'test-ui')})`;
2111+
const collidedUi = fixture('collide the Test UI shard name with its gate name', 'ci.yml', (s) =>
2112+
s.replace(TEST_UI_SHARD_NAME, 'name: Test UI'),
2113+
);
2114+
assert(
2115+
collidedUi.problems.some((p) => p.includes("job 'test-ui'") && p.includes('published by ci.yml:test-ui-gate')),
2116+
'a Test UI shard wearing the bare `Test UI` context name ⇒ red',
2117+
);
2118+
assert(
2119+
sources['ci.yml'].includes(TEST_UI_SHARD_NAME),
2120+
'the real suffixed Test UI shard name coexists with the bare `Test UI` gate name',
2121+
);
20902122

20912123
// ── (8) a registry that lists one context twice ──────────────────────────
20922124
battery('(8) a registry that lists one context twice');

0 commit comments

Comments
 (0)