ci: route the classify_pr gate to ECS for every pull request (#16019)

The gate is the needs-prerequisite of every Linux lane, but its own
runner stayed association-gated: untrusted fork PRs queued the whole
chain on the scarce hosted pool (observed 30+ min on PR #15851).
classify executes only trusted base-SHA code with a read-only token —
a strict subset of the lint exposure accepted on 2026-10-31 — so route
it unconditionally to ECS, kill-switch excepted. The ubuntu_runner
association gate for the test/integration lanes is unchanged.

Routing battery: gate matrix across all associations, structural pin
against clause creep; association-clause mutation reds the suite.
This commit is contained in:
易良 authored and GitHub committed 2026-11-01 00:12:22 +08:00
1 parent cf7e77810e
commit eaafba5d14
4 files changed
+93 -72

No files matched your search

+41 -44
View File
@@ -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/);
});
});
+14 -12
View File
@@ -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'
@@ -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
@@ -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`