diff --git a/.github/scripts/ci-runner-routing.test.mjs b/.github/scripts/ci-runner-routing.test.mjs index 1bc6a82fa09..875a82926c9 100644 --- a/.github/scripts/ci-runner-routing.test.mjs +++ b/.github/scripts/ci-runner-routing.test.mjs @@ -2,18 +2,15 @@ // qwen-autofix.yml scan lane, the trusted-PR lanes of #13245, and the // control-plane jobs that run no PR code. // -// classify_pr carries the routing logic TWICE — the `runs-on` expression -// (which selects the classify job's own runner) and the `pick_runner` shell -// step (which publishes `ubuntu_runner` for downstream Linux jobs). If -// they drift, classify and the Test job land on different pools. These tests -// evaluate BOTH against the same event matrix — including the negative -// associations that must stay hosted — and assert they agree. -// -// pick_runner also publishes `lint_runner`, the one deliberate divergence: -// Lint & Static routes EVERY pull request to the fleet, untrusted forks -// included (maintainer decision 2026-10-31, shared-pool code-execution risk -// accepted). The matrix below pins that divergence AND that ubuntu_runner -// keeps the association gate for every other lane. +// classify_pr's own runner is unconditional ECS (kill-switch excepted): +// the gate executes only trusted base-SHA code with a read-only token, and +// keeping it on hosted for forks queued the whole fork CI chain behind a +// scarce pool (2026-10-31 decision, same accepted risk class as lint). +// The association routing for DOWNSTREAM lanes lives in the `pick_runner` +// shell step, which publishes `ubuntu_runner` (association-gated) and +// `lint_runner` (every pull request reaches the fleet). The matrix below +// pins the gate's unconditional routing AND that ubuntu_runner keeps the +// association gate for the test/integration lanes. // // test_windows carries a deliberately different policy. A pull_request run // executes the workflow YAML from the PR's own merge commit, so any trust @@ -52,15 +49,10 @@ const pickRunner = ciDoc.jobs.classify_pr.steps.find( (s) => s.id === 'pick_runner', ); -// GitHub expression semantics for the classify runs-on, restricted to the -// routing-relevant inputs: contains(list, '') is false, a missing +// GitHub expression semantics for the runs-on expressions, restricted to +// the routing-relevant inputs: contains(list, '') is false, a missing // pull_request (merge_group / dispatch) yields '' for both head.repo and // author_association. -function simulateRunsOn({ ecsDisabled, sameRepo, assoc, mergeGroup }) { - const trusted = TRUSTED.includes(assoc); - const ecs = !ecsDisabled && (sameRepo || trusted || mergeGroup); - return ecs ? ECS : HOSTED; -} // Evaluates a real `runs-on` expression text with the routing inputs // substituted, leaving only the &&/||/parenthesis skeleton — which matches @@ -186,25 +178,31 @@ const ASSOCIATIONS = [ ]; describe('ci.yml classify_pr runner routing', () => { - it('the expression and the shell step agree on every association', () => { + it('the gate routes every association to ECS; the kill-switch wins', () => { + // classify executes trusted base-SHA code only, so its own runner needs + // no association gate — a hosted gate queued the entire fork CI chain. + // The downstream ubuntu_runner keeps the gate (next test). for (const sameRepo of [true, false]) { for (const assoc of ASSOCIATIONS) { - const expected = simulateRunsOn({ - ecsDisabled: false, - sameRepo, - assoc, - mergeGroup: false, - }); - const actual = runPickRunner({ - ecsDisabled: false, - sameRepo, - assoc, - eventName: 'pull_request', - }); - assert.equal( - actual, - expected, - `drift for sameRepo=${sameRepo} assoc='${assoc}'`, + assert.deepEqual( + evalRunsOn(classifyRunsOn, { + ecsDisabled: false, + eventName: 'pull_request', + sameRepo, + assoc, + }), + JSON.parse(ECS), + `gate sameRepo=${sameRepo} assoc='${assoc}' must reach ECS`, + ); + assert.deepEqual( + evalRunsOn(classifyRunsOn, { + ecsDisabled: true, + eventName: 'pull_request', + sameRepo, + assoc, + }), + JSON.parse(HOSTED), + `kill-switch must revert the gate (assoc='${assoc}')`, ); } } @@ -286,18 +284,17 @@ describe('ci.yml classify_pr runner routing', () => { } }); - it('the runs-on expression keeps the trusted clause and kill-switch', () => { - // Structural pins for the expression half of the drift guard — the - // simulation above re-implements it, so pin the real text too. - assert.match( - classifyRunsOn, - /contains\(fromJSON\('\["OWNER","MEMBER","COLLABORATOR"\]'\), github\.event\.pull_request\.author_association\)/, - ); + it('the gate expression is unconditional except the kill-switch', () => { + // Structural pins: no association/head-repo clause may creep back into + // the gate (it would re-queue fork CI on hosted), and the kill-switch + // must stay the single escape hatch. + assert.doesNotMatch(classifyRunsOn, /author_association/); + assert.doesNotMatch(classifyRunsOn, /head\.repo\.full_name/); assert.match( classifyRunsOn, /vars\.MAINTAINER_ECS_RUNNER_DISABLED != 'true'/, ); - assert.match(classifyRunsOn, /github\.event_name == 'merge_group'/); + assert.match(classifyRunsOn, /ecs-qwen/); }); }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8f7354dd2f..58e2d1a2d42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,18 +114,20 @@ jobs: classify_pr: name: 'Classify PR' if: "${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' }}" - # Gate runs on ECS for in-repo PRs, fork PRs whose author has write access - # (OWNER/MEMBER/COLLABORATOR association — a write-access author is as - # trusted as an in-repo branch), the merge queue, and pushes to main, - # else a busy hosted pool delays it and blocks the ECS-bound jobs. The - # kill-switch is read here, so flipping it reverts everything to hosted. - # This runs-on and the pick_runner step below are the canonical home of - # the association routing; sdk-java.yml and serve-ab.yml mirror it (the - # routing tests hold the mirrors to it). test_windows deliberately does - # not: a pull_request run executes the PR's own YAML, so no runs-on trust - # clause is enforceable there and its lane never admits pull requests to - # the pool. - runs-on: '${{ (vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association) || github.event_name == ''merge_group'' || github.event_name == ''push'')) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' + # Gate runs on ECS for EVERY event it accepts — pull requests (untrusted + # forks included), merge queue, pushes to main, and dispatch — unless the + # kill-switch forces hosted. classify executes only trusted base-SHA code + # with a read-only token; the residual PR-YAML risk is the same class + # accepted for lint_and_static (2026-10-31 decision, see + # docs/design/2026-10-31-lint-static-ecs-routing.md). Keeping the gate on + # hosted for forks queued the entire fork CI chain behind a scarce pool. + # The association routing for the downstream lanes lives in pick_runner + # (ubuntu_runner) below; sdk-java.yml and serve-ab.yml mirror that form + # (the routing tests hold the mirrors to it). test_windows deliberately + # does not: a pull_request run executes the PR's own YAML, so no runs-on + # trust clause is enforceable there and its lane never admits pull + # requests to the pool. + runs-on: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' continue-on-error: true permissions: contents: 'read' diff --git a/docs/design/2026-10-31-lint-static-ecs-routing.md b/docs/design/2026-10-31-lint-static-ecs-routing.md index b30ff72dd05..959a09689d1 100644 --- a/docs/design/2026-10-31-lint-static-ecs-routing.md +++ b/docs/design/2026-10-31-lint-static-ecs-routing.md @@ -19,6 +19,14 @@ assigned from a single resolved fleet ladder: | `ubuntu_runner` | same-repo heads, `OWNER` / `MEMBER` / `COLLABORATOR` authors, `merge_group`, `push` — else hosted | `test`, `integration_no_ak`, `integration_cli`, `web_shell_e2e_smoke` | | `lint_runner` | every `pull_request`, untrusted forks included; otherwise identical to `ubuntu_runner` | `lint_and_static` | +`classify_pr`'s OWN runner is unconditional ECS (kill-switch excepted) for +every event it accepts. The gate executes only trusted base-SHA code with a +read-only token — strictly less exposure than the lint lane — and keeping it +on hosted for untrusted forks queued the entire fork CI chain (classify is +the `needs` prerequisite of every Linux lane) behind the scarce hosted pool. +The association gate survives where it bounds heavy PR-code execution: +`ubuntu_runner`, read by the test and integration lanes. + Fallback priority: `MAINTAINER_ECS_RUNNER_DISABLED=true` overrides everything, including manual dispatch. `LINUX_RUNNER_FLEET=hosted` forces pull requests, merge groups and pushes to hosted, but a `workflow_dispatch` run whose @@ -123,7 +131,9 @@ minutes, test 120, web-shell smoke 60, no-AK integration 30. ## Tests `.github/scripts/ci-runner-routing.test.mjs` (in `HELPER_TESTS`, so it gates -every full-profile PR) pins: the producer binding +every full-profile PR) pins: the gate's unconditional ECS routing across the +full association matrix (kill-switch reverting it, no association clause +creeping back), the producer binding (`classify_pr.outputs.lint_runner` ← `pick_runner`), the full association / event matrix against the real selector shell, the single-lane scope of `lint_runner` (a superset must update this record), the run-unique HOME @@ -138,12 +148,14 @@ why the load-bearing HOME guard lives in the routing battery. ## Decision record and open questions - Acceptance: jinjing reviewed and accepted the shared-pool code-execution - risk on 2026-10-31, with Lint & Static as the sole default-routing change — - noting that the association gate is not enforceable against a fork that - rewrites the workflow YAML, so the acceptance covers the residual surface - above, not a one-lane fence. A second maintainer's confirmation is welcome - (triage stage-3 deferred on exactly this); it is a human decision, not a - code change. + risk on 2026-10-31 for Lint & Static, and later the same day extended the + default ECS routing to the classify_pr gate (a strict subset of the lint + exposure: trusted base-SHA code, read-only token) so fork PRs no longer + queue behind hosted at the prerequisite job — noting that the association + gate is not enforceable against a fork that rewrites the workflow YAML, so + the acceptance covers the residual surface above, not a one-lane fence. A + second maintainer's confirmation is welcome (triage stage-3 deferred on + exactly this); it is a human decision, not a code change. - The PR's own CI exercises the routing plumbing on the pool (the author is a collaborator, so both outputs agree on that run); the fork divergence is proven by the routing battery, and its first live end-to-end witness is the diff --git a/docs/design/2026-10-31-lint-static-ecs-routing.zh-CN.md b/docs/design/2026-10-31-lint-static-ecs-routing.zh-CN.md index 2cbd2b5fc49..adac55c3855 100644 --- a/docs/design/2026-10-31-lint-static-ecs-routing.zh-CN.md +++ b/docs/design/2026-10-31-lint-static-ecs-routing.zh-CN.md @@ -18,6 +18,12 @@ | `ubuntu_runner` | 同仓库 head、`OWNER` / `MEMBER` / `COLLABORATOR` 作者、`merge_group`、`push`,其余走 hosted | `test`、`integration_no_ak`、`integration_cli`、`web_shell_e2e_smoke` | | `lint_runner` | 每一个 `pull_request`,未受信 fork 也算;其余情况与 `ubuntu_runner` 相同 | `lint_and_static` | +`classify_pr` 自身的 runner 对其接纳的所有事件一律走 ECS(kill-switch 除外)。 +门禁 job 只执行可信 base-SHA 的代码、拿只读 token——暴露面严格小于 lint +车道;而把它对不可信 fork 留在 hosted,会让整条 fork CI 链(classify 是所有 +Linux 车道的 `needs` 前置)排在稀缺的 hosted 池后面。association 门禁保留在 +它真正约束重 PR 代码执行的地方:`ubuntu_runner`(test 与集成车道读取)。 + 回退优先级:`MAINTAINER_ECS_RUNNER_DISABLED=true` 覆盖一切,包括手动调度。 `LINUX_RUNNER_FLEET=hosted` 把 pull request、merge group 和 push 强制到 hosted, 但 `workflow_dispatch` 的 `linux_runner` 输入若显式选择 `self-hosted` / @@ -102,21 +108,25 @@ smoke 60、no-AK 集成 30。 ## 测试 `.github/scripts/ci-runner-routing.test.mjs`(在 `HELPER_TESTS` 里,因此门禁 -每个 full-profile PR)钉住:生产者绑定(`classify_pr.outputs.lint_runner` ← -`pick_runner`)、真实选择器 shell 在全 association/事件矩阵下的行为、 -`lint_runner` 的单车道范围(超集必须更新本记录)、首步发布的 run 唯一 HOME -(先清后建、共享可校验缓存)、清理步骤先于收集器、以及两个输出在未建模的 -fleet 值下仍走同一份标签。`scripts/tests/ci-platform-lanes.test.js` 钉住 +每个 full-profile PR)钉住:门禁 job 在全 association 矩阵下无条件走 ECS +(kill-switch 可退回、association 子句不得回潮)、生产者绑定 +(`classify_pr.outputs.lint_runner` ← `pick_runner`)、真实选择器 shell 在全 +association/事件矩阵下的行为、`lint_runner` 的单车道范围(超集必须更新本记录)、 +首步发布的 run 唯一 HOME(先清后建、共享可校验缓存)、清理步骤先于收集器、 +以及两个输出在未建模的 fleet 值下仍走同一份标签。 +`scripts/tests/ci-platform-lanes.test.js` 钉住 `runs-on` 字面量(lint 读 `lint_runner`、test 读 `ubuntu_runner`),并按各车道 自己的路由输出求值 timeout;注意该套件在 merge queue 之后才跑,所以承重的 HOME 守卫放在路由电池里。 ## 决策记录与开放问题 -- 接受记录:jinjing 于 2026-10-31 评审并接受共享池代码执行风险,Lint & Static - 是唯一的默认路由变更——并注明 association 门禁对改写 workflow YAML 的 fork - 不可强制,因此接受范围覆盖上述残余暴露面,而不是一道单车道围栏。欢迎第二位 - 维护者确认(triage stage-3 正是因此暂缓);这是人的决定,不是代码变更。 +- 接受记录:jinjing 于 2026-10-31 评审并接受共享池代码执行风险(Lint & Static), + 同日稍晚将默认 ECS 路由扩展到 classify_pr 门禁 job(lint 暴露面的严格子集: + 可信 base-SHA 代码、只读 token),使 fork PR 不再在前置 job 上排 hosted 队—— + 并注明 association 门禁对改写 workflow YAML 的 fork 不可强制,因此接受范围 + 覆盖上述残余暴露面,而不是一道单车道围栏。欢迎第二位维护者确认(triage + stage-3 正是因此暂缓);这是人的决定,不是代码变更。 - PR 自身 CI 在池上验证路由管道(作者是 collaborator,该运行中两个输出一致); fork 分歧由路由回归矩阵证明,其首个线上端到端见证是下一个不可信 fork PR。 - 另行拥有的后续:per-run HOME 下恢复热共享 pnpm store(如通过 `XDG_DATA_HOME`