diff --git a/.claude/skills/pm-dispatch/references/platform-readings.md b/.claude/skills/pm-dispatch/references/platform-readings.md index 23b7bf343e4..1e9c4faf4b1 100644 --- a/.claude/skills/pm-dispatch/references/platform-readings.md +++ b/.claude/skills/pm-dispatch/references/platform-readings.md @@ -387,9 +387,9 @@ - 本车道强制多是过宽的回忆:`dispatch-gates.mjs --tier PATH` 逐路径现推,路径线是下限非放行。 - 该脚本只答自己那棵树:姊妹仓路径回 absent from this tree,姊妹仓的档位与条款②只能手推。 - `os-verify-lock.sh` 只住 objectstack:objectui 无它,跑本仓副本读的是容器级锁,非该仓深度。 -- required checks 的名单是每仓事实,objectstack 八个: +- required checks 的名单是每仓事实,objectstack 九个: `TypeScript Type Check` · `Lint & Repo Gates` · `Test Core` · `Build Core` · `Lint UI` · - `Governed Surface Queue Guard` · `Type Check UI` · `Build & E2E UI`。 + `Governed Surface Queue Guard` · `Type Check UI` · `Test UI` · `Build & E2E UI`。 - ⭐ 本表的 `mustName` 声明 `statesTheSet` ⇒ 必须逐字等于注册表,加注册行不跟改本表即红。 - ⇒ 门绿即名单逐字齐全的读数;⛔ 但数量词未钉:实测 七→六 照绿,只能手跟改。 - ⛔ 聚合命令同样不作判定:`check:type-check-debt` 可在包级 typecheck 绿时红。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1014f87af2..31febc892d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3722,6 +3722,74 @@ jobs: pnpm --dir ui run test --shard=${{ matrix.shard }}/16 --reporter=default --reporter=github-actions fi + test-ui-gate: + # Stable required-check name for the sharded Test UI matrix (#26460, ruling + # A, comment 6435318516), in test-gate's shape and for test-gate's reason + # (#3622): a matrix publishes only `Test UI (k/16)`, so the bare `Test UI` + # context the ruleset requires can come from nothing but this job, and the + # shard count stays free to move without a settings edit. Until this job, + # none of the 16 shards was required and the queue landed PRs whose Test UI + # was red (#26419 → #26421). + # + # `name:` and `if: always()` are both load-bearing, as on test-gate. The + # name IS the required context (`check:required-contexts` pins the + # literal), and a gate that skips because a dependency failed publishes no + # context at all, which leaves the PR permanently pending (#3622). ⛔ Never + # `if: !cancelled()` here. + # + # The verdict reads the matrix rollup `needs.test-ui.result`, and only + # `success` passes, with one exception: `skipped`, when `filter` concluded + # `success` AND said the diff reaches no ui path (the shards' own `if:`). + # That is #4928's skipped-only-when-filter-succeeded guard, and it makes a + # queue build or PR that touches no ui path read green here the way Lint + # UI and Build & E2E UI read `skipped` there. Everything else is red: + # `failure`; `cancelled`, which is also what a shard killed by its own + # `timeout-minutes` reads (on a superseded push this paints the OLD sha + # red, which no required check reads); a `skipped` that `filter` does not + # explain; and any lifecycle value such as the `abandoned` test-gate met + # (run 31120902911). Unlike test-gate it counts no shard credentials: the + # Test UI shards publish none, and adding them is a change to `test-ui`, + # which is the ui lane's job. + name: Test UI + needs: [test-ui, filter] + # ── ROSTER DECLARATION (#10490) ─────────────────────────────────────── + # `check:aggregator-roster` holds this equal to `needs:` in both + # directions. `filter` is the #4928 input, not a lane. + env: + OS_AGGREGATOR_MEMBERS: test-ui + OS_AGGREGATOR_NON_MEMBERS: filter + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Verify Test UI shard results + env: + OS_TEST_UI_RESULT: ${{ needs['test-ui'].result }} + OS_FILTER_RESULT: ${{ needs.filter.result }} + OS_UI_TREE: ${{ needs.filter.outputs.ui_tree }} + OS_UI: ${{ needs.filter.outputs.ui }} + run: | + echo "Test UI (k/16) rollup: '$OS_TEST_UI_RESULT'; filter: '$OS_FILTER_RESULT' (ui_tree='$OS_UI_TREE', ui='$OS_UI')." + case "$OS_TEST_UI_RESULT" in + success) + echo "✓ Test UI: every shard concluded success." + ;; + skipped) + if [ "$OS_FILTER_RESULT" = 'success' ] && { [ "$OS_UI_TREE" = 'false' ] || [ "$OS_UI" = 'false' ]; }; then + echo "✓ Test UI: the shards were skipped because filter found no ui path in this diff." + else + 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)." + exit 1 + fi + ;; + *) + 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'." + exit 1 + ;; + esac + # ── Lint UI and Build & E2E UI: the ceilings (#25869 row F13) ────────── # objectui's rule (its lint.yml `Lint` ceiling): the smallest round number # that is both >= 3x the slowest successful run and >= that run + 15 min, so diff --git a/AGENTS.md b/AGENTS.md index 529c970a6e1..634aa02e95f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -520,11 +520,11 @@ Even inside your own worktree, operate defensively: yet"; `in_progress` is not a pass. Arming a red PR does not queue it, it hides it: every poll then misreads "not on `main` yet" as "queued". Always read *two* things: the queue branch **and** `origin/main`. And **the queue enforces only the required - set** — eight contexts block: `Lint & Repo Gates` (all `check:*` gates), `TypeScript Type Check`, - `Test Core`, `Build Core`, `Governed Surface Queue Guard`, `Lint UI`, `Type Check UI` and - `Build & E2E UI`. A check outside those eight is advisory and rides through, and an advisory red + set** — nine contexts block: `Lint & Repo Gates` (all `check:*` gates), `TypeScript Type Check`, + `Test Core`, `Build Core`, `Governed Surface Queue Guard`, `Lint UI`, `Type Check UI`, `Test UI` + and `Build & E2E UI`. A check outside those nine is advisory and rides through, and an advisory red that lands rides `main`'s merge ref into every later PR until stanched. A required context is - matched by check-run name, so a rename detaches its gate silently — treat those eight names as contract. + matched by check-run name, so a rename detaches its gate silently — treat those nine names as contract. **Two speeds** (ruling record 6120820653): PR and queue runs carry the fast set, 10 minutes of wall per queue entry; `main`'s scheduled (4-hourly) and push runs carry the full battery — dogfood, e2e, `Temporal Conformance (live PG + MySQL)` — read by the publish lane before diff --git a/scripts/check-aggregator-roster.mjs b/scripts/check-aggregator-roster.mjs index fca48f413ee..580c0ae2e8e 100644 --- a/scripts/check-aggregator-roster.mjs +++ b/scripts/check-aggregator-roster.mjs @@ -10,11 +10,12 @@ * * ## The gap this closes * - * Four of this repo's required contexts are aggregator jobs standing in for a + * Five of this repo's required contexts are aggregator jobs standing in for a * set of real jobs, each deciding its verdict from what it lists in `needs:`: * `test-gate` / `Test Core` and `dogfood-gate` / `Dogfood Regression Gate` in * ci.yml, and `typecheck` / `TypeScript Type Check` and, since #22991, - * `lint` / `Lint & Repo Gates` in lint.yml. + * `lint` / `Lint & Repo Gates` in lint.yml, and, since #26460, ci.yml's + * `test-ui-gate` / `Test UI`. * * Nothing held those rosters to anything. The failure mode is one forgotten * line: @@ -68,7 +69,7 @@ * * Discovery is by declaration, so a NEW aggregator that adds the two keys is * covered without editing this file. `REQUIRED_AGGREGATORS` is the floor - * underneath that: the four jobs that carry a branch-protection-required + * underneath that: the five jobs that carry a branch-protection-required * context must each be found AND declared, so deleting the declaration to make * a red go away is itself red. * @@ -110,6 +111,8 @@ export const REQUIRED_AGGREGATORS = [ { workflow: 'lint.yml', job: 'typecheck', context: 'TypeScript Type Check' }, // #22991: the single 193-step job became an aggregate over eight lanes. { workflow: 'lint.yml', job: 'lint', context: 'Lint & Repo Gates' }, + // #26460: the `Test UI` aggregate over the 16 `test-ui` shards. + { workflow: 'ci.yml', job: 'test-ui-gate', context: 'Test UI' }, ]; /** A legal GitHub Actions job id. A comma or a stray quote fails here. */ @@ -507,6 +510,7 @@ async function selfTest() { from: ' needs: [dogfood, dogfood-verify, filter]\n', to: ' needs: [dogfood, filter]\n', }, + { 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' }, ]; for (const scenario of dropped) { const verdict = fixture(scenario.label, scenario.file, (s) => s.replace(scenario.from, scenario.to)); @@ -536,6 +540,7 @@ async function selfTest() { to: ' needs: [dogfood, dogfood-verify, filter, dogfood-ghost]\n', ghost: 'dogfood-ghost', }, + { 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' }, ]; for (const scenario of phantom) { const verdict = fixture(scenario.label, scenario.file, (s) => s.replace(scenario.from, scenario.to)); diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 5a58d9fcc51..8f85c619e3c 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -471,6 +471,15 @@ export const REQUIRED_CONTEXTS = [ 'after #25856 (fc2aaaec52) closed bill item 49 with the job green on the main push run 40253800516', carries: 'the type-check + vitest-setup type-check family of the `ui/` workspace', }, + { + workflow: 'ci.yml', + job: 'test-ui-gate', + context: 'Test UI', + authorized: + '#26460 ruling letter A (staged), maintainer 「批 #408 同意」 2026-10-27T06:24Z, recorded in comment 6435318516 — ' + + 'after the ui flake reading 6436579765 (0/50); the ruleset entry is the maintainer\'s, in the same sitting as the merge (#9325)', + carries: 'the sharded `ui/` vitest matrix, `Test UI (k/16)`, read through the aggregate job', + }, ]; /** @@ -585,10 +594,10 @@ export const INSTRUCTION_SURFACES = [ // with every gate green, which is what put the exclusivity rule below in // the judge. Then 7→5 by #22998 (two-speed CI: the dogfood and temporal // contexts moved to the full battery). Then 5→7 by #25853 (the `ui/` - // import's `Lint UI` and `Build & E2E UI`) and 7→8 with `Type Check UI` - // once bill item 49 closed (#25856). The full set is pinned here so the - // corrected sentence cannot rot back — a rename in ANY of the eight - // reddens this gate instead. + // import's `Lint UI` and `Build & E2E UI`), 7→8 with `Type Check UI` + // once bill item 49 closed (#25856), and 8→9 with the `Test UI` aggregate + // (#26460). The full set is pinned here so the corrected sentence cannot + // rot back — a rename in ANY of the nine reddens this gate instead. file: 'AGENTS.md', statesTheSet: true, mustName: [ @@ -600,6 +609,7 @@ export const INSTRUCTION_SURFACES = [ 'Lint UI', 'Build & E2E UI', 'Type Check UI', + 'Test UI', ], }, { @@ -631,6 +641,7 @@ export const INSTRUCTION_SURFACES = [ 'Lint UI', 'Build & E2E UI', 'Type Check UI', + 'Test UI', ], }, { @@ -652,7 +663,7 @@ export const INSTRUCTION_SURFACES = [ * of the six surfaces have one; `AGENTS.md` does not, because a repo-root FILE * has no separator to be found by — so an AGENTS.md card derived this gate not * at all, while that surface is the one carrying `mustName` for every - * 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 + * 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 * paragraph there is precisely how this gate goes red, and it was reachable * only by judgment. * @@ -1936,6 +1947,13 @@ async function selfTest() { renamedTestCore.problems.some((p) => p.includes("job 'test-gate'") && p.includes("'Test Core'")), 'growing a suffix on the bare `Test Core` name ⇒ red — the exact literal is the contract, not decoration', ); + // The same property on the second matrix aggregate (#26460): `Test UI` is the + // bare name of `test-ui-gate`, and its shards are `Test UI (k/16)`. + const renamedTestUi = fixture('rename Test UI', 'ci.yml', (s) => s.replace(' name: Test UI\n', ' name: Test UI (aggregate)\n')); + assert( + renamedTestUi.problems.some((p) => p.includes("job 'test-ui-gate'") && p.includes("'Test UI'")), + 'growing a suffix on the bare `Test UI` name ⇒ red, naming the aggregate job and the required context', + ); // ── (2) the job disappearing entirely ───────────────────────────────────── // Re-pointed from `console-pin` to `temporal-conformance` when #9533 dropped @@ -2087,6 +2105,20 @@ async function selfTest() { baseline.problems.length === 0 && sources['ci.yml'].includes(TEST_CORE_SHARD_NAME), 'the real suffixed shard name coexists with the bare gate name (assertion 9 compares whole names, not prefixes)', ); + // The second live specimen (#26460): the `test-ui` shards and their `Test UI` + // aggregate, the same shape with the matrix width read the same way. + const TEST_UI_SHARD_NAME = `name: Test UI (\${{ matrix.shard }}/${shardTotal(sources['ci.yml'], 'test-ui')})`; + const collidedUi = fixture('collide the Test UI shard name with its gate name', 'ci.yml', (s) => + s.replace(TEST_UI_SHARD_NAME, 'name: Test UI'), + ); + assert( + collidedUi.problems.some((p) => p.includes("job 'test-ui'") && p.includes('published by ci.yml:test-ui-gate')), + 'a Test UI shard wearing the bare `Test UI` context name ⇒ red', + ); + assert( + sources['ci.yml'].includes(TEST_UI_SHARD_NAME), + 'the real suffixed Test UI shard name coexists with the bare `Test UI` gate name', + ); // ── (8) a registry that lists one context twice ────────────────────────── battery('(8) a registry that lists one context twice');