ci: route the no-AK integration gate to ECS for every pull request (#16132)

* ci: route the no-AK integration gate to ECS for every pull request

Same trust class as lint_and_static (2026-11-02 extension of the
2026-10-31 decision): the integration step executes PR code, but the
lane carries zero secrets by construction — every model key is emptied
at the step and the job's only token is the read-only checkout grant.
The gate's step-level runner.temp HOME becomes run-unique and is
cleared before use (the R2-1 lesson: RUNNER_TEMP survives across jobs
on this pool). test and integration_cli keep the association-gated
ubuntu_runner. Mutation-verified: runs-on revert, fixed HOME, and a
real key in the PR-code step each red the routing battery.

* fix(ci): isolate no-AK HOME from the first step; value-pinned zero-secrets

Round-2 review findings: (1) Critical — the gate-scoped HOME missed the
lane's actual fork-code start: checkout's local action and the install
(lifecycle scripts plus scripts/prepare.js, un-skipped) ran under the
runner's real shared home with a shared COREPACK_HOME — the exact
cross-job poisoning channel the lint acceptance closed. Mirror lint:
first-step GITHUB_ENV run-unique HOME + COREPACK_HOME inside it +
shared verified XDG cache + always() reclamation. (2) Critical — the
zero-secrets guard was name-based and missed odd-named secrets; enforce
by VALUE (every non-path env key empty, no secrets.* anywhere in the
gate). (3) runs-on literal pin tightened (timeout line also mentions
lint_runner). Docs: EN single-lane phrasing, stale merge-queue claim,
2026-11-02 paragraph rewritten for the first-step shape. Six mutations
red (first-step removal, shared corepack, odd-name secret, no cleanup,
runs-on revert, fixed HOME).

* docs(ci): correct the enforcement-order claim and stale trust-basis note

Round-2 review: the doc claimed the routing battery runs before
dependency install — false (HELPER_TESTS imports yaml, runs after
install, and not at all on github_ci_only PRs' own runs); and the
ci.yml trust-basis comment still described the round-1 gate-scoped
HOME shape. State the actual timing and detection latency, and the
shipped first-step shape.

* fix(ci): scrub ANTHROPIC_AUTH_TOKEN at the no-AK gate; honest cache naming

Round-3 review: the Anthropic SDK's auth resolver back-fills
ANTHROPIC_AUTH_TOKEN from env, so a runner-account residue would survive
into fork PR code on the pool — add it to the gate's scrub list (pinned
by both suites). The 'Configure persistent npm cache' step name promised
persistence the per-run HOME removes; rename it and record the accepted
cold-install cost (shared pnpm store stays a follow-up).

* fix(ci): scrub ANTHROPIC_AUTH_TOKEN at the no-AK gate; honest cache naming

Round-3 review: the Anthropic SDK's auth resolver back-fills
ANTHROPIC_AUTH_TOKEN from env, so a runner-account residue would survive
into fork PR code on the pool — add it to the gate's scrub list. The
value whitelist only iterates present keys, so the deletion mutation
escaped it; pin the key's presence-and-empty in the battery and the
28-key literal list. The 'Configure persistent npm cache' step name
promised persistence the per-run HOME removes; rename and record the
accepted cold-install cost (shared pnpm store stays a follow-up).

* test(ci): pin the no-AK grant, HOME template and reclamation path

Three assertions in the no-AK routing battery proved less than they read:

- the job's `permissions` was never inspected, so the read-only-checkout
  grant that justifies routing untrusted fork code onto the pool could be
  widened without a red test;
- `create.includes('HOME=')` is satisfied by `COREPACK_HOME=` and
  `XDG_CACHE_HOME=`, so deleting the real HOME/USERPROFILE publication kept
  the test green;
- the cleanup only had to contain the `qwen-no-ak-home-` prefix, so a path
  that dropped `-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}` would still pass
  while removing a directory nothing used.

Pin the grant by value, the publication by its printf template, and the
reclamation by the exact run-unique path the create step builds.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw48g5gafx

* test(ci): pin the realized HOME values in the shared ungated lane loop

R2-1: assert the create `home=` line, the gate QWEN_HOME and the cleanup
command by value, and require the exact set of `$GITHUB_ENV` writers. An
`echo` stand-in for the cleanup, a `-v2` create path and an appended second
HOME writer all kept the battery green under containment checks.

R1-2 / R1-1: hoist those pins into the shared `ungated` loop so
`lint_and_static` gets them too. Its create step was pinned by
`includes('HOME=')` (satisfied by `COREPACK_HOME=`) and its cleanup by
`includes('qwen-')`, so a dropped `HOME=%s` or a retargeted `rm -rf` survived.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw4h0sl6g9

* test(ci): red on later republishes of the isolated HOME keys

R3-1: the GITHUB_ENV equality guard read step 0 only, so a later step that
republished HOME reverted the run-unique home by last-write-wins while every
assertion stayed green — the isolation would silently stop applying to the
steps that run PR code. Scan the remaining steps for isolated-key
$GITHUB_ENV writers and for step-level HOME/USERPROFILE/COREPACK_HOME/
XDG_CACHE_HOME overrides, leaving the npm cache step's own key a legitimate
later write.

Mutation-verified on this head: appending printf 'HOME=%s\n' ... to
integration_no_ak's 'Configure per-run npm cache (self-hosted)' step and
adding a step-level `env: HOME` each turn the suite red (103 pass -> 102
pass / 1 fail); reverting restores 103/103.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw4ng9wvgi

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-11-06 16:41:48 +08:00
1 parent 738c4bbf7e
commit 1a20792cfa
7 files changed
+303 -80

No files matched your search

+163 -33
View File
@@ -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.<job_id>.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);
}
});
});
+1 -1
View File
@@ -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
+51 -12
View File
@@ -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'
@@ -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;
@@ -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。
+6 -4
View File
@@ -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-
+18 -9
View File
@@ -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',