mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
738c4bbf7e
commit
1a20792cfa
7 files changed
+303
-80
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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。
|
||||
@@ -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-
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user