mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
4 files changed
+93
-72
No files matched your search
@@ -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
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user