diff --git a/.github/scripts/ci-runner-routing.test.mjs b/.github/scripts/ci-runner-routing.test.mjs index 875a82926c9..9631b466084 100644 --- a/.github/scripts/ci-runner-routing.test.mjs +++ b/.github/scripts/ci-runner-routing.test.mjs @@ -456,16 +456,116 @@ describe('ci.yml lint HOME isolation', () => { /outputs\.lint_runner/.test(String(job['runs-on'] ?? '')), ); - it('scopes the ungated routing to the single recorded lane', () => { - // The decision record scopes the boundary change to Lint & Static alone; - // a superset must force a deliberate record update, not pass silently. + // The one run-unique path per lane. create, the gate's QWEN_HOME and the + // cleanup must all name it: a `home=` that drifts at one site leaves the + // others pointing at a directory this run never cleared. + const HOME_TEMPLATE = { + lint_and_static: 'qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}', + integration_no_ak: 'qwen-no-ak-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}', + }; + + // The `$GITHUB_ENV` writers, by value. It is last-write-wins, so an added + // HOME/USERPROFILE writer (or a dropped `HOME=%s`) must break equality; + // COREPACK_HOME stays inside the run-unique home (corepack does not + // re-verify entries on cache hit — a shared home is a poisoning channel) + // and XDG_CACHE_HOME rides the SHA-256-verified linter-archive cache. + const ENV_WRITES = [ + 'printf \'HOME=%s\\nUSERPROFILE=%s\\n\' "$home" "$home" >> "${GITHUB_ENV}"', + 'printf \'XDG_CACHE_HOME=%s\\n\' "${RUNNER_TOOL_CACHE}/qwen-ci-cache" >> "${GITHUB_ENV}"', + 'printf \'COREPACK_HOME=%s\\n\' "${home}/.cache/node/corepack" >> "${GITHUB_ENV}"', + ]; + + it('scopes the ungated routing to the recorded lanes', () => { + // The decision record scopes the boundary change to Lint & Static and + // (2026-11-02, same trust class — PR code on the pool, zero secrets by + // construction) the no-AK integration lane; a superset must force a + // deliberate record update, not pass silently. assert.deepEqual( ungated.map(([id]) => id), - ['lint_and_static'], + ['lint_and_static', 'integration_no_ak'], + ); + }); + + it('integration_no_ak isolates from its first step, zero-secrets at the gate', () => { + // no_ak's fork code starts at the checkout action and the install + // (lifecycle scripts AND scripts/prepare.js — build+bundle), not at the + // gate step, so the whole job needs lint's first-step isolation: + // GITHUB_ENV-published run-unique HOME, COREPACK_HOME inside it (corepack + // does not re-verify entries on cache hit — a shared home is a + // cross-job poisoning channel), XDG_CACHE_HOME on the shared verified + // tool cache. The gate keeps the model-key scrub. + // The routing decision's premise is that this job carries no secrets + // because its only grant is the read-only checkout. Nothing else pins it: + // adding any other scope would leave every assertion below green while + // the accepted-risk argument went stale. + assert.deepEqual( + ciDoc.jobs.integration_no_ak.permissions, + { contents: 'read' }, + 'no_ak must keep only the read-only checkout grant', + ); + + const steps = ciDoc.jobs.integration_no_ak.steps ?? []; + assert.equal( + steps[0]?.name, + 'Create isolated HOME', + 'no_ak must isolate HOME from its first step', + ); + // The create/cleanup step text is pinned by VALUE in the two shared tests + // below, which iterate every ungated lane; looped substring checks used to + // survive a `-v2` path drift, a dropped `HOME=%s` and an added HOME writer. + // Job env must stay empty of HOME (the runner context is illegal there). + assert.equal(ciDoc.jobs.integration_no_ak.env?.HOME, undefined); + + const gate = steps.find( + (s) => s.name === 'Run required no-AK integration gate', + ); + assert.ok(gate, 'integration gate step missing'); + // By value, not by prefix: a drifted create path or a `-v2` suffix would + // satisfy `startsWith` while QWEN_HOME pointed at a directory this run + // never cleared. + assert.equal( + gate.env?.QWEN_HOME, + '${{ runner.temp }}/qwen-no-ak-home-${{ github.run_id }}-${{ github.run_attempt }}/.qwen', + 'QWEN_HOME must be the run-unique home the create step built', + ); + // Zero-secrets contract, enforced by VALUE not by name: every env key + // outside QWEN_HOME must be the empty string, and the step must + // reference no secrets.* anywhere. A name-based regex inherits the + // implementation's own premise about which names are credentials, so a + // secret under any other name (CI_BOT_PAT, *_DSN, *_BEARER) shipped + // green — falsifying the contract that justifies routing untrusted-fork + // PR code onto the shared pool. + const PATH_KEYS = new Set(['QWEN_HOME']); + for (const [key, value] of Object.entries(gate.env ?? {})) { + if (!PATH_KEYS.has(key)) { + assert.equal( + value, + '', + `${key} must be empty in the no-AK PR-code step`, + ); + } + } + assert.ok( + !JSON.stringify(gate).includes('secrets.'), + 'the no-AK PR-code step must not reference any secrets.* context', + ); + // The value whitelist above only iterates PRESENT keys, so a deleted + // scrub key escapes it (verified by mutation: dropping + // ANTHROPIC_AUTH_TOKEN left both suites green). Pin a floor on the + // known-dangerous set — the SDK back-fills this one from env. + assert.equal( + gate.env?.ANTHROPIC_AUTH_TOKEN, + '', + 'ANTHROPIC_AUTH_TOKEN must be present and empty (SDK auth-resolver ' + + 'back-fill; deleting the scrub key escapes the value whitelist)', ); }); it('publishes a run-unique HOME from the first step, not job env', () => { + // Both ungated lanes now share lint's shape: fork code runs from early + // steps, so the first step publishes HOME/USERPROFILE via GITHUB_ENV for + // every later step. no_ak's specifics (gate key scrub, reclamation) are + // covered by its own test above. for (const [id, job] of ungated) { // jobs..env cannot read the runner context — GitHub rejects the // whole workflow file (actionlint 1.7.12 witnesses it). @@ -478,53 +578,83 @@ describe('ci.yml lint HOME isolation', () => { const steps = job.steps ?? []; assert.equal(steps[0]?.name, 'Create isolated HOME', id); const run = String(steps[0]?.run ?? ''); + const lines = run.split('\n').map((line) => line.trim()); + // The declared `home=` by value: a `-v2` suffix here leaves the gate's + // QWEN_HOME and the cleanup aimed at a directory this run never built. assert.ok( - run.includes('GITHUB_RUN_ID') && run.includes('GITHUB_RUN_ATTEMPT'), - `${id} HOME path must be run-unique`, + lines.includes(`home="\${RUNNER_TEMP}/${HOME_TEMPLATE[id]}"`), + `${id} must build its run-unique HOME from the lane template`, ); assert.ok( run.includes('rm -rf') && run.indexOf('rm -rf') < run.indexOf('mkdir'), `${id} must clear the path before creating it`, ); - assert.ok( - run.includes('HOME=') && - run.includes('USERPROFILE=') && - run.includes('GITHUB_ENV'), - `${id} must publish HOME/USERPROFILE via GITHUB_ENV`, - ); - // Linter archives are SHA-256-verified on every cache hit (lint.js), so - // the shared tool cache is safe for them. Corepack does NOT re-verify - // extracted entries on hit (reproduced on 0.34.6 and 0.36.0), so - // COREPACK_HOME must stay inside the run-unique HOME — a shared one is - // a cross-job poisoning channel. Deleting either line reds this. - assert.ok( - run.includes('XDG_CACHE_HOME=') && run.includes('RUNNER_TOOL_CACHE'), - `${id} must share the verified linter-archive cache`, - ); - assert.ok( - run.includes('COREPACK_HOME=') && - run.includes('"${home}/.cache/node/corepack"'), - `${id} COREPACK_HOME must stay inside the run-unique HOME`, + assert.deepEqual( + lines.filter((line) => line.includes('${GITHUB_ENV}')), + ENV_WRITES, + `${id} must publish exactly HOME/USERPROFILE, XDG_CACHE_HOME and COREPACK_HOME via GITHUB_ENV`, ); + // GITHUB_ENV is last-write-wins, so the guard cannot stop at step 0: a + // later step that republishes an isolated key silently reverts the home + // for the steps that run PR code while step 0 still matches. The npm + // cache step is a legitimate later writer, so only the isolated keys + // are banned — not every later `$GITHUB_ENV` append. + // `[^\w]`, not `\s`: the realistic form is `printf 'HOME=%s\n'` inside + // quotes, which a whitespace-only prefix let through. + const ISOLATED_KEY = + /(^|[^\w])(HOME|USERPROFILE|COREPACK_HOME|XDG_CACHE_HOME)=/; + for (const [index, step] of steps.entries()) { + if (index === 0) continue; + for (const line of String(step.run ?? '') + .split('\n') + .map((l) => l.trim())) { + assert.ok( + !(line.includes('${GITHUB_ENV}') && ISOLATED_KEY.test(line)), + `${id} step ${index} (${step.name}) must not republish an isolated HOME key via GITHUB_ENV`, + ); + } + for (const key of Object.keys(step.env ?? {})) { + assert.ok( + !/^(HOME|USERPROFILE|COREPACK_HOME|XDG_CACHE_HOME)$/.test(key), + `${id} step ${index} (${step.name}) must not override ${key} for a single step`, + ); + } + } } }); it('reclaims the isolated HOME before the failure collector', () => { + // Both ungated lanes reclaim: no_ak's cleanup is its last step (it has + // no collector); lint's sits before the failure collector, which must + // stay last. for (const [id, job] of ungated) { const steps = job.steps ?? []; const names = steps.map((s) => s.name); const clean = steps.find((s) => s.name === 'Clean isolated HOME'); assert.ok(clean, `${id} cleanup step missing`); assert.equal(clean.if, '${{ always() }}', id); - assert.ok( - names.indexOf('Clean isolated HOME') < - names.indexOf('Upload disk-pressure samples'), - `${id} collector must stay last`, + const collectorIndex = names.indexOf('Upload disk-pressure samples'); + if (collectorIndex !== -1) { + assert.ok( + names.indexOf('Clean isolated HOME') < collectorIndex, + `${id} collector must stay last`, + ); + } else { + // No collector (no_ak): the cleanup itself is the last step. + assert.equal( + names[names.length - 1], + 'Clean isolated HOME', + `${id} cleanup must be the final step`, + ); + } + // The realized removal by value, never $HOME: any `qwen-*` path passes a + // substring check, so a retargeted `rm -rf` (or an `echo` stand-in) + // would leave the run-unique home to accumulate on this persistent pool. + assert.equal( + String(clean.run), + `rm -rf -- "\${RUNNER_TEMP}/${HOME_TEMPLATE[id]}"`, + `${id} must reclaim the exact run-unique HOME the create step built`, ); - // The constructed path, never $HOME: a failed init must not aim the - // removal at the account's real home. - assert.ok(String(clean.run).includes('qwen-lint-home-'), id); - assert.ok(!String(clean.run).includes('"$HOME"'), id); } }); }); diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 90d8bc82e63..462b40a6268 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -19,7 +19,7 @@ 9551 build-and-publish-image.yml 55276 cd-cua-driver.yml 2222 cd-mobile-mcp.yml -195906 ci.yml +198487 ci.yml 4193 codeql.yml 9389 comment-attachment-guard.yml 2090 desktop-packaging-check.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cc8ca4315a..f312e13d43c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2878,17 +2878,42 @@ jobs: # gate, classified here because depending on `test` for its output would # serialize this job behind the hour-long unit run. if: "${{ !cancelled() && needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}" - runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}' + # Reads lint_runner, NOT ubuntu_runner: like lint_and_static, this lane + # executes PR code on the pool for every pull request — accepted under the + # same 2026-10-31 decision, and it carries NO secrets by construction + # (every model key is emptied at the integration step; the job's only + # token is the read-only checkout grant). The lane publishes a run-unique + # HOME from its first step (lint's shape) and empties every model key at + # the gate, so the pool exposure is the class already accepted for lint. + # test and integration_cli keep the association-gated ubuntu_runner. + runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'') }}' # Dependency installation alone can exceed 30 minutes on a contended shared # ECS host, before the separately bounded 20-minute integration step starts. - # Same self-hosted scoping as `test` (this lane keeps the association-gated - # ubuntu_runner, unlike lint_and_static): hosted fallback routing - # keeps the pre-contention ceiling, and the ARM64 fleet — which carries - # no `ecs-qwen` label — gets the contended-pool allowance it runs under. - timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''60'' || ''30'') }}' + # Same self-hosted scoping as lint_and_static (60/30 like its 90/45): + # hosted fallback routing keeps the pre-contention ceiling, and the ARM64 + # fleet — which carries no `ecs-qwen` label — gets the contended-pool + # allowance it runs under. + timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''60'' || ''30'') }}' permissions: contents: 'read' steps: + # This lane's fork code starts at the local checkout action and the + # install (lifecycle scripts AND scripts/prepare.js — build+bundle), + # not at the gate step, so isolate HOME from the FIRST step like + # lint_and_static: publish a run-unique HOME via GITHUB_ENV (job env + # cannot read the runner context), keep the content-verified linter + # cache shared, and keep COREPACK_HOME inside the per-run home + # (corepack does not re-verify entries on cache hit — a shared home is + # a cross-job poisoning channel). The gate step's key scrub stays. + - name: 'Create isolated HOME' + run: |- + home="${RUNNER_TEMP}/qwen-no-ak-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + rm -rf -- "$home" + mkdir -p "$home" "${RUNNER_TOOL_CACHE}/qwen-ci-cache" + printf 'HOME=%s\nUSERPROFILE=%s\n' "$home" "$home" >> "${GITHUB_ENV}" + printf 'XDG_CACHE_HOME=%s\n' "${RUNNER_TOOL_CACHE}/qwen-ci-cache" >> "${GITHUB_ENV}" + printf 'COREPACK_HOME=%s\n' "${home}/.cache/node/corepack" >> "${GITHUB_ENV}" + - name: 'Restore workspace ownership' run: |- set -uo pipefail @@ -3038,13 +3063,16 @@ jobs: if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}" uses: './.github/actions/self-hosted-node' - - name: 'Configure persistent npm cache (self-hosted)' + # HOME is per-run (isolated above), so this cache dies with the job — + # the name must not promise persistence. The cold install is the + # accepted cost (review round-3); a shared pnpm store is a follow-up. + - name: 'Configure per-run npm cache (self-hosted)' if: "${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}" run: |- cache_dir="${HOME}/.cache/qwen-code/npm" mkdir -p "${cache_dir}" echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}" - echo "Using npm cache at ${cache_dir}" + echo "Using per-run npm cache at ${cache_dir}" du -sh "${cache_dir}" 2>/dev/null || true - name: 'Disk floor gate (self-hosted)' @@ -3068,12 +3096,17 @@ jobs: - name: 'Run required no-AK integration gate' if: "${{ steps.ci_profile.outputs.ci_profile == 'full' }}" timeout-minutes: 20 + # HOME/USERPROFILE come from the first step's GITHUB_ENV publication + # (run-unique, cleared before use). QWEN_HOME rides the same home. + # The key scrub below is the zero-secrets contract: pinned by the + # routing battery (every non-path env value must be ''). env: - HOME: '${{ runner.temp }}/qwen-no-ak-home' - USERPROFILE: '${{ runner.temp }}/qwen-no-ak-home' - QWEN_HOME: '${{ runner.temp }}/qwen-no-ak-home/.qwen' + QWEN_HOME: '${{ runner.temp }}/qwen-no-ak-home-${{ github.run_id }}-${{ github.run_attempt }}/.qwen' API_KEY: '' ANTHROPIC_API_KEY: '' + # The SDK auth resolver back-fills this from env; a runner-account + # residue would survive into fork PR code (review round-3). + ANTHROPIC_AUTH_TOKEN: '' ANTHROPIC_BASE_URL: '' ANTHROPIC_MODEL: '' BAILIAN_CODING_PLAN_API_KEY: '' @@ -3101,10 +3134,16 @@ jobs: QWENCLOUD_TOKEN_PLAN_API_KEY: '' QWEN_DEFAULT_AUTH_TYPE: '' run: |- - mkdir -p "${HOME}" "${QWEN_HOME}" + mkdir -p "${QWEN_HOME}" npm run typecheck:integration npm run test:integration:no-ak:sandbox:none + # Reclaim the run-unique HOME (constructed path, never $HOME) so it + # does not accumulate in RUNNER_TEMP on this persistent pool. + - name: 'Clean isolated HOME' + if: '${{ always() }}' + run: 'rm -rf -- "${RUNNER_TEMP}/qwen-no-ak-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"' + integration_cli: name: 'Integration Tests (CLI, No Sandbox)' needs: 'classify_pr' 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 959a09689d1..affd7728d7e 100644 --- a/docs/design/2026-10-31-lint-static-ecs-routing.md +++ b/docs/design/2026-10-31-lint-static-ecs-routing.md @@ -14,10 +14,10 @@ remains the reference for the pool's shared-HOME property. `classify_pr`'s `pick_runner` publishes two routings instead of one, both assigned from a single resolved fleet ladder: -| Output | Admits | Read by | -| --------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| `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` | +| Output | Admits | Read by | +| --------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| `ubuntu_runner` | same-repo heads, `OWNER` / `MEMBER` / `COLLABORATOR` authors, `merge_group`, `push` — else hosted | `test`, `integration_cli`, `web_shell_e2e_smoke` | +| `lint_runner` | every `pull_request`, untrusted forks included; otherwise identical to `ubuntu_runner` | `lint_and_static`, `integration_no_ak` (2026-11-02) | `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 @@ -25,7 +25,24 @@ 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. +`ubuntu_runner`, read by the test lane and the merge-queue-only CLI +integration lane. + +2026-11-02: `integration_no_ak` joined `lint_runner`. Same trust class as +lint — the lane executes fork code from its checkout and install steps +(lifecycle scripts plus scripts/prepare.js) — but zero secrets by +construction: every model key is emptied at the integration gate and the +job's only token is the read-only checkout grant. Round 2 of review caught +the gate-scoped HOME being the wrong shape (fork code starts ~14 steps +earlier), so the lane now mirrors lint exactly: the FIRST step publishes a +run-unique HOME plus COREPACK_HOME inside it via GITHUB_ENV (corepack does +not re-verify entries on cache hit — a shared home is a cross-job +poisoning channel), XDG_CACHE_HOME stays on the shared verified tool +cache, and an always() cleanup step reclaims the home at the end. The +routing battery pins the lane set, the first-step isolation, the cleanup, +and a zero-secrets env contract enforced by VALUE (every non-path key must +be empty, including `ANTHROPIC_AUTH_TOKEN`, which the Anthropic SDK's auth +resolver back-fills from env; no secrets.\* anywhere in the gate). Fallback priority: `MAINTAINER_ECS_RUNNER_DISABLED=true` overrides everything, including manual dispatch. `LINUX_RUNNER_FLEET=hosted` forces pull requests, @@ -111,8 +128,9 @@ residual surface below stays accepted. boundary.** A `pull_request` run executes the PR's own workflow file, so any fork can put any lane — `test` included — on the pool in the same diff it opens (ci.yml's `test_windows` comment records the same fact). GitHub's - first-timer workflow approval is the outer gate. The single-lane scope of - this decision describes the default routing, not a fence. + first-timer workflow approval is the outer gate. The two-lane scope of this + decision (Lint & Static and the no-AK integration lane) describes the + default routing, not a fence. - **Cloud metadata.** If a pool instance binds a RAM role, any job process can reach the metadata endpoint. Fleet-side hardened mode is recommended, independent of this workflow change. @@ -135,15 +153,21 @@ 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 +event matrix against the real selector shell, the recorded two-lane scope of `lint_runner` (a superset must update this record), the run-unique HOME published from the first step with clear-before-create and shared verified caches, the cleanup step preceding the collector, and both outputs agreeing on one fleet ladder for unmodeled fleet values. `scripts/tests/ci-platform-lanes.test.js` pins the `runs-on` literals (lint on `lint_runner`, test on `ubuntu_runner`) and evaluates each lane's timeout from -its own routing output; note that suite runs after the merge queue, which is -why the load-bearing HOME guard lives in the routing battery. +its own routing output; note that suite runs in the test job's side lane on +every full-profile PR. The routing battery runs in lint_and_static after +dependency install (it imports the `yaml` package, so it cannot join the +dep-free fast lane), and on `github_ci_only` PRs it does not run in the PR's +own run at all — a stripped isolation is caught by the next full-profile PR +or main push, one run later. The HOME guard still lives there because the +battery drives the real `pick_runner` shell and gates every full-profile PR, +a strictly larger surface than the vitest side lane. ## Decision record and open questions @@ -160,7 +184,8 @@ why the load-bearing HOME guard lives in the routing battery. 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 next untrusted fork PR. -- Follow-ups owned separately: warm shared pnpm store under the per-run HOME +- Follow-ups owned separately: warm shared pnpm store (the no-AK lane now + pays the same accepted cold-install cost as lint) under the per-run HOME (e.g. via `XDG_DATA_HOME` at a content-verified shared path) to recover the ~900 s cold-install cost; disk-floor gate coverage of `/tmp` inodes; fleet metadata hardening; the `test` lane's constant `qwen-ci-home` persistence; 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 adac55c3855..3f2d57d4283 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 @@ -13,16 +13,28 @@ `classify_pr` 的 `pick_runner` 现在发布两套路由,而不是一套,且两者都从同一份 解析好的 fleet 标签赋值: -| 输出 | 接纳对象 | 读取方 | -| --------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| `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` | +| 输出 | 接纳对象 | 读取方 | +| --------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `ubuntu_runner` | 同仓库 head、`OWNER` / `MEMBER` / `COLLABORATOR` 作者、`merge_group`、`push`,其余走 hosted | `test`、`integration_cli`、`web_shell_e2e_smoke` | +| `lint_runner` | 每一个 `pull_request`,未受信 fork 也算;其余情况与 `ubuntu_runner` 相同 | `lint_and_static`、`integration_no_ak`(2026-11-02 加入) | `classify_pr` 自身的 runner 对其接纳的所有事件一律走 ECS(kill-switch 除外)。 门禁 job 只执行可信 base-SHA 的代码、拿只读 token——暴露面严格小于 lint 车道;而把它对不可信 fork 留在 hosted,会让整条 fork CI 链(classify 是所有 Linux 车道的 `needs` 前置)排在稀缺的 hosted 池后面。association 门禁保留在 -它真正约束重 PR 代码执行的地方:`ubuntu_runner`(test 与集成车道读取)。 +它真正约束重 PR 代码执行的地方:`ubuntu_runner`(test 车道与仅 merge-queue +的 CLI 集成车道读取)。 + +2026-11-02:`integration_no_ak` 加入 `lint_runner`。与 lint 同信任类——该车 +道从 checkout 与 install 步骤起就执行 fork 代码(生命周期脚本加 +scripts/prepare.js)——但按构造零密钥:集成门禁清空所有模型 key,job 唯一 +的 token 是只读检出授权。评审第 2 轮抓到"HOME 只钉在 gate 步骤"是错误形状 +(fork 代码在约 14 个步骤之前就开始执行),因此该车道现在完全镜像 lint: +首步通过 GITHUB_ENV 发布 run 唯一的 HOME 并把 COREPACK_HOME 放在其内 +(corepack 缓存命中不重验条目——共享 home 是跨 job 投毒通道),XDG_CACHE_HOME +留在共享的可校验 tool cache 上,末尾 always() 清理步骤回收该 HOME。路由电池 +钉住车道集合、首步隔离、清理步骤,以及按值强制(非按名)的零密钥 env 契约 +(非路径 key 必须为空——包括 `ANTHROPIC_AUTH_TOKEN`(Anthropic SDK 的鉴权解析器会从 env 回填它)、gate 不得引用任何 secrets.\*)。 回退优先级:`MAINTAINER_ECS_RUNNER_DISABLED=true` 覆盖一切,包括手动调度。 `LINUX_RUNNER_FLEET=hosted` 把 pull request、merge group 和 push 强制到 hosted, @@ -90,7 +102,8 @@ ownership" 步骤正是因为容器化 job 在这些主机上留下 root 属主 - **association 门禁是吞吐策略,不是可强制的信任边界。** `pull_request` 运行 执行的是 PR 自己的 workflow 文件,任何 fork 都能在开 PR 的同一个 diff 里把 任何车道(包括 `test`)放到池上(ci.yml 的 `test_windows` 注释记录了同一 - 事实)。GitHub 的首次贡献者 workflow 审批是外层门。本决定的单车道范围描述 + 事实)。GitHub 的首次贡献者 workflow 审批是外层门。本决定的车道范围(Lint & + Static 与 no-AK 集成)描述 的是默认路由,不是一道围栏。 - **云 metadata。** 若池实例绑定了 RAM 角色,任何 job 进程都可访问 metadata 端点。建议 fleet 侧开启加固模式,与本 workflow 变更独立。 @@ -111,13 +124,18 @@ smoke 60、no-AK 集成 30。 每个 full-profile PR)钉住:门禁 job 在全 association 矩阵下无条件走 ECS (kill-switch 可退回、association 子句不得回潮)、生产者绑定 (`classify_pr.outputs.lint_runner` ← `pick_runner`)、真实选择器 shell 在全 -association/事件矩阵下的行为、`lint_runner` 的单车道范围(超集必须更新本记录)、 +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 守卫放在路由电池里。 +自己的路由输出求值 timeout;注意该套件在 test job 的旁路 lane 上对每个 +full-profile PR 运行。路由电池在 lint_and_static 的依赖安装之后运行(它 +import `yaml` 包,进不了免依赖快车道),且对 `github_ci_only` PR 在其自身 +的 run 里根本不跑——被剥掉的隔离要等下一个 full-profile PR 或 main push +才会被抓到,晚一个 run。HOME 守卫仍放在那里,是因为电池驱动真实的 +`pick_runner` shell 并门禁每个 full-profile PR,覆盖面严格大于 vitest 旁路 +lane。 ## 决策记录与开放问题 @@ -129,7 +147,7 @@ HOME 守卫放在路由电池里。 stage-3 正是因此暂缓);这是人的决定,不是代码变更。 - PR 自身 CI 在池上验证路由管道(作者是 collaborator,该运行中两个输出一致); fork 分歧由路由回归矩阵证明,其首个线上端到端见证是下一个不可信 fork PR。 -- 另行拥有的后续:per-run HOME 下恢复热共享 pnpm store(如通过 `XDG_DATA_HOME` +- 另行拥有的后续:per-run HOME 下恢复热共享 pnpm store(no-AK 车道现在与 lint 同付已接受的冷装成本)(如通过 `XDG_DATA_HOME` 指向内容可校验的共享路径),找回约 900 s 的冷装成本;磁盘下限门覆盖 `/tmp` inode;fleet metadata 加固;`test` 车道固定 `qwen-ci-home` 的持久化问题; 若重新审视已接受的残余暴露面,则建隔离 fleet。 diff --git a/scripts/tests/ci-platform-lanes.test.js b/scripts/tests/ci-platform-lanes.test.js index 280a75ec261..4865eedb312 100644 --- a/scripts/tests/ci-platform-lanes.test.js +++ b/scripts/tests/ci-platform-lanes.test.js @@ -151,7 +151,8 @@ const timeoutMinutesOn = (job, runner) => { // The same `|| '["ubuntu-latest"]'` default the runs-on expression carries: // a missing output (classify_pr skipped) is the hosted routing. The caller // passes the value of the output the job itself reads — ubuntu_runner for - // the association-gated lanes, lint_runner for lint_and_static. + // the association-gated lanes, lint_runner for lint_and_static and + // integration_no_ak. const effective = runner || HOSTED_RUNNER; const output = runnerOutputOf(job); let expr = String(ci.jobs[job]['timeout-minutes']) @@ -752,10 +753,11 @@ describe('post-merge push lane', () => { it('routes lint_and_static through the classify_pr lint output', () => { // runs-on is where the contributor code this describe's other pins talk // about actually executes. classify_pr's pick_runner publishes TWO - // routings: ubuntu_runner keeps the fork-trust boundary for test and the - // integration lanes, while lint_runner routes every pull request — + // routings: ubuntu_runner keeps the fork-trust boundary for test and + // integration_cli, while lint_runner routes every pull request — // untrusted forks included — to the fleet (maintainer decision - // 2026-10-31, shared-pool code-execution risk accepted). A literal pool + // 2026-10-31, shared-pool code-execution risk accepted, extended to + // integration_no_ak 2026-11-02). A literal pool // label array here would bypass the classifier indirection whole (and // desync from timeout-minutes, which reads the same output), and reading // ubuntu_runner would silently restore the hosted fork lane. Whole- diff --git a/scripts/tests/no-ak-integration-ci.test.js b/scripts/tests/no-ak-integration-ci.test.js index 8787e084fac..609201e4dae 100644 --- a/scripts/tests/no-ak-integration-ci.test.js +++ b/scripts/tests/no-ak-integration-ci.test.js @@ -444,8 +444,14 @@ describe('no-AK integration CI wiring', () => { " name: 'Integration Tests (no-AK, No Sandbox)'", ); expect(gateJob).toContain(" needs: 'classify_pr'"); - // Same runner routing as the Ubuntu gate, never a hard-coded pool. - expect(gateJob).toContain('needs.classify_pr.outputs.ubuntu_runner'); + // Same trust class as lint (PR code on the pool, zero secrets by + // construction — 2026-11-02 extension of the 2026-10-31 decision), + // never a hard-coded pool. Pin the runs-on line itself: the timeout line + // also mentions lint_runner, so a bare substring stays green under a + // runs-on-only revert. + expect(gateJob).toContain( + "runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner", + ); const jobIf = gateJob .split('\n') .find((line) => line.startsWith(' if:')); @@ -597,19 +603,22 @@ describe('no-AK integration CI wiring', () => { expect(gateStep.indexOf(integrationTypecheckCommand)).toBeLessThan( gateStep.indexOf(`npm run ${NO_AK_SCRIPT}`), ); + // The job's first step publishes the run-unique HOME via GITHUB_ENV + // (2026-11-02 round-2: fork code starts at checkout/install, not at the + // gate — HOME/USERPROFILE are job-wide, not gate-scoped); the gate keeps + // QWEN_HOME + the key scrub. expect(gateStep).toContain( - "QWEN_HOME: '${{ runner.temp }}/qwen-no-ak-home/.qwen'", + "QWEN_HOME: '${{ runner.temp }}/qwen-no-ak-home-${{ github.run_id }}-${{ github.run_attempt }}/.qwen'", ); expect(gateStep).toContain('timeout-minutes: 20'); - expect(gateStep).toContain( - "\n HOME: '${{ runner.temp }}/qwen-no-ak-home'", - ); - expect(gateStep).toContain( - "\n USERPROFILE: '${{ runner.temp }}/qwen-no-ak-home'", - ); + // HOME/USERPROFILE are job-wide now (first-step GITHUB_ENV), so the + // gate must not re-pin them step-locally. + expect(gateStep).not.toContain('\n HOME: '); + expect(gateStep).not.toContain('\n USERPROFILE: '); for (const key of [ 'API_KEY', 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'BAILIAN_CODING_PLAN_API_KEY',