mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
Merge remote-tracking branch 'origin/main' into fix/review-prose-re-review
This commit is contained in:
commit
ef86b03a99
14 files changed
+846
-60
No files matched your search
@@ -9,6 +9,12 @@
|
||||
// evaluate BOTH against the same event matrix — including the negative
|
||||
// associations that must stay hosted — and assert they agree.
|
||||
//
|
||||
// pick_runner also publishes `lint_runner`, the one deliberate divergence:
|
||||
// Lint & Static routes EVERY pull request to the fleet, untrusted forks
|
||||
// included (maintainer decision 2026-10-31, shared-pool code-execution risk
|
||||
// accepted). The matrix below pins that divergence AND that ubuntu_runner
|
||||
// keeps the association gate for every other lane.
|
||||
//
|
||||
// test_windows carries a deliberately different policy. A pull_request run
|
||||
// executes the workflow YAML from the PR's own merge commit, so any trust
|
||||
// clause a PR can read it can also rewrite. The matrix evaluates the real
|
||||
@@ -17,7 +23,7 @@
|
||||
// persistent pool.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
@@ -117,8 +123,8 @@ function evalRunsOn(expression, { ecsDisabled, eventName, sameRepo, assoc }) {
|
||||
}
|
||||
|
||||
// Executes the real pick_runner shell with the same inputs and returns the
|
||||
// selected runner exactly as CI would publish it.
|
||||
function runPickRunner({
|
||||
// selected runners exactly as CI would publish them.
|
||||
function runPickRunnerOutputs({
|
||||
ecsDisabled,
|
||||
sameRepo,
|
||||
assoc,
|
||||
@@ -140,13 +146,34 @@ function runPickRunner({
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const outputs = existsSync(outputFile)
|
||||
? readFileSync(outputFile, 'utf8')
|
||||
: '';
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
assert.equal(result.status, 0, `pick_runner failed: ${result.stderr}`);
|
||||
const line = result.stdout
|
||||
.split('\n')
|
||||
.find((l) => l.startsWith('Selected Linux runner: '));
|
||||
assert.ok(line, `no selection in pick_runner output: ${result.stdout}`);
|
||||
return line.slice('Selected Linux runner: '.length);
|
||||
const select = (prefix) => {
|
||||
const line = result.stdout.split('\n').find((l) => l.startsWith(prefix));
|
||||
assert.ok(line, `no selection in pick_runner output: ${result.stdout}`);
|
||||
return line.slice(prefix.length);
|
||||
};
|
||||
const ubuntuRunner = select('Selected Linux runner: ');
|
||||
const lintRunner = select('Selected lint runner: ');
|
||||
// The stdout echo and the published output must agree — a rename of the
|
||||
// GITHUB_OUTPUT key would otherwise pass the echo parse and break every
|
||||
// downstream runs-on at runtime.
|
||||
assert.ok(
|
||||
outputs.split('\n').includes(`ubuntu_runner=${ubuntuRunner}`),
|
||||
`ubuntu_runner not published: ${outputs}`,
|
||||
);
|
||||
assert.ok(
|
||||
outputs.split('\n').includes(`lint_runner=${lintRunner}`),
|
||||
`lint_runner not published: ${outputs}`,
|
||||
);
|
||||
return { ubuntuRunner, lintRunner };
|
||||
}
|
||||
|
||||
function runPickRunner(inputs) {
|
||||
return runPickRunnerOutputs(inputs).ubuntuRunner;
|
||||
}
|
||||
|
||||
const ASSOCIATIONS = [
|
||||
@@ -274,6 +301,237 @@ describe('ci.yml classify_pr runner routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ci.yml lint_runner routing', () => {
|
||||
const ARM = '["self-hosted", "linux", "arm64"]';
|
||||
const lintRunner = (inputs) => runPickRunnerOutputs(inputs).lintRunner;
|
||||
|
||||
it('routes every pull request to the fleet, untrusted forks included', () => {
|
||||
// The maintainer decision this pins: Lint & Static executes PR code on
|
||||
// the shared pool for ALL authors. If this matrix ever flips back to
|
||||
// hosted for the negative associations without a deliberate decision,
|
||||
// the lane silently regrows its hosted quota spend.
|
||||
for (const sameRepo of [true, false]) {
|
||||
for (const assoc of ASSOCIATIONS) {
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo,
|
||||
assoc,
|
||||
eventName: 'pull_request',
|
||||
}),
|
||||
ECS,
|
||||
`lint sameRepo=${sameRepo} assoc='${assoc}' must route to ECS`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the kill-switch and hosted-fleet overrides', () => {
|
||||
for (const assoc of ASSOCIATIONS) {
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: true,
|
||||
sameRepo: true,
|
||||
assoc,
|
||||
eventName: 'pull_request',
|
||||
}),
|
||||
HOSTED,
|
||||
'kill-switch must revert even lint to hosted',
|
||||
);
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc,
|
||||
eventName: 'pull_request',
|
||||
fleet: 'hosted',
|
||||
}),
|
||||
HOSTED,
|
||||
'LINUX_RUNNER_FLEET=hosted must keep lint hosted',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('follows the arm64 fleet selector', () => {
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc: 'NONE',
|
||||
eventName: 'pull_request',
|
||||
fleet: 'arm64',
|
||||
}),
|
||||
ARM,
|
||||
);
|
||||
});
|
||||
|
||||
it('inherits ubuntu_runner on non-PR events and dispatch inputs', () => {
|
||||
for (const eventName of ['merge_group', 'push']) {
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
eventName,
|
||||
}),
|
||||
ECS,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
eventName: 'workflow_dispatch',
|
||||
dispatch: 'self-hosted',
|
||||
}),
|
||||
ECS,
|
||||
);
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
eventName: 'workflow_dispatch',
|
||||
dispatch: 'hosted',
|
||||
}),
|
||||
HOSTED,
|
||||
);
|
||||
assert.equal(
|
||||
lintRunner({
|
||||
ecsDisabled: true,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
eventName: 'workflow_dispatch',
|
||||
dispatch: 'self-hosted',
|
||||
}),
|
||||
HOSTED,
|
||||
);
|
||||
});
|
||||
|
||||
it('classify_pr publishes lint_runner from pick_runner', () => {
|
||||
assert.equal(
|
||||
ciDoc.jobs.classify_pr.outputs.lint_runner,
|
||||
'${{ steps.pick_runner.outputs.lint_runner }}',
|
||||
);
|
||||
});
|
||||
|
||||
it('lint_and_static consumes lint_runner for runs-on and timeout', () => {
|
||||
assert.equal(
|
||||
String(ciDoc.jobs.lint_and_static['runs-on']),
|
||||
'${{ fromJSON(needs.classify_pr.outputs.lint_runner || \'["ubuntu-latest"]\') }}',
|
||||
);
|
||||
assert.match(
|
||||
String(ciDoc.jobs.lint_and_static['timeout-minutes']),
|
||||
/needs\.classify_pr\.outputs\.lint_runner/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps both outputs on one fleet ladder', () => {
|
||||
// The lint arm assigns from the same resolved fleet labels as
|
||||
// ubuntu_runner; a fleet value neither models must not split them when
|
||||
// the trust clause is satisfied (a second literal ladder drifts silently
|
||||
// because the timeout reads lint_runner and still reports a pool ceiling).
|
||||
for (const fleet of ['', 'x64', 'arm64', 'future-fleet']) {
|
||||
const out = runPickRunnerOutputs({
|
||||
ecsDisabled: false,
|
||||
sameRepo: true,
|
||||
assoc: 'NONE',
|
||||
eventName: 'pull_request',
|
||||
fleet,
|
||||
});
|
||||
assert.equal(out.lintRunner, out.ubuntuRunner, `fleet='${fleet}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ci.yml lint HOME isolation', () => {
|
||||
// This guard lives in the routing battery (HELPER_TESTS) because it gates
|
||||
// every full-profile PR; the scripts/tests vitest suites only run after the
|
||||
// merge queue. lint_runner admits untrusted forks to a pool where
|
||||
// RUNNER_TEMP survives across jobs, so the isolated HOME must be run-unique
|
||||
// and cleared before use — a fixed path hands a fork's ~/.npmrc to the next
|
||||
// job on the registration. The ubuntu_runner lanes' association clause is
|
||||
// throughput policy, not an enforceable boundary (a pull_request run
|
||||
// executes the PR's own YAML); their pre-existing HOME properties are
|
||||
// separately owned.
|
||||
const ungated = Object.entries(ciDoc.jobs).filter(([, job]) =>
|
||||
/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.
|
||||
assert.deepEqual(
|
||||
ungated.map(([id]) => id),
|
||||
['lint_and_static'],
|
||||
);
|
||||
});
|
||||
|
||||
it('publishes a run-unique HOME from the first step, not job env', () => {
|
||||
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).
|
||||
assert.equal(job.env?.HOME, undefined, `${id} job-level HOME`);
|
||||
assert.equal(
|
||||
job.env?.USERPROFILE,
|
||||
undefined,
|
||||
`${id} job-level USERPROFILE`,
|
||||
);
|
||||
const steps = job.steps ?? [];
|
||||
assert.equal(steps[0]?.name, 'Create isolated HOME', id);
|
||||
const run = String(steps[0]?.run ?? '');
|
||||
assert.ok(
|
||||
run.includes('GITHUB_RUN_ID') && run.includes('GITHUB_RUN_ATTEMPT'),
|
||||
`${id} HOME path must be run-unique`,
|
||||
);
|
||||
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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('reclaims the isolated HOME before the failure collector', () => {
|
||||
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`,
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ci.yml web_shell_e2e_smoke runner routing', () => {
|
||||
const expression = String(ciDoc.jobs.web_shell_e2e_smoke['runs-on'])
|
||||
.replace(/^\$\{\{\s*/, '')
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
9551 build-and-publish-image.yml
|
||||
55276 cd-cua-driver.yml
|
||||
2222 cd-mobile-mcp.yml
|
||||
156377 ci.yml
|
||||
161281 ci.yml
|
||||
4193 codeql.yml
|
||||
9389 comment-attachment-guard.yml
|
||||
2090 desktop-packaging-check.yml
|
||||
|
||||
+66
-15
@@ -133,6 +133,12 @@ jobs:
|
||||
outputs:
|
||||
skip_ci: '${{ steps.release_sync.outputs.skip_ci }}'
|
||||
ubuntu_runner: '${{ steps.pick_runner.outputs.ubuntu_runner }}'
|
||||
# Lint & Static's own routing: every pull request reaches the fleet,
|
||||
# including untrusted forks (maintainer decision 2026-10-31 — the lane
|
||||
# executes PR code on the shared pool and that risk is accepted). The
|
||||
# association gate in ubuntu_runner still governs every other Linux
|
||||
# lane; do not "simplify" the two outputs into one.
|
||||
lint_runner: '${{ steps.pick_runner.outputs.lint_runner }}'
|
||||
ci_profile: '${{ steps.ci_profile.outputs.ci_profile }}'
|
||||
steps:
|
||||
- name: 'Detect release version-sync PR'
|
||||
@@ -179,6 +185,12 @@ jobs:
|
||||
#
|
||||
# Keep push routing aligned with the classifier above so a hosted queue
|
||||
# does not delay the downstream ECS jobs.
|
||||
#
|
||||
# lint_runner is the one deliberate divergence: Lint & Static routes
|
||||
# EVERY pull request to the fleet, untrusted forks included (maintainer
|
||||
# decision 2026-10-31, risk accepted — see the classify_pr outputs
|
||||
# comment). The kill-switch and LINUX_RUNNER_FLEET=hosted still force
|
||||
# hosted, and non-PR events inherit ubuntu_runner unchanged.
|
||||
- name: 'Select Linux runner'
|
||||
id: 'pick_runner'
|
||||
env:
|
||||
@@ -197,6 +209,11 @@ jobs:
|
||||
case "${AUTHOR_ASSOCIATION}" in
|
||||
OWNER|MEMBER|COLLABORATOR) trusted_author=true ;;
|
||||
esac
|
||||
# Resolve fleet labels once; both outputs assign from this.
|
||||
fleet_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
|
||||
if [[ "${LINUX_RUNNER_FLEET}" == "arm64" ]]; then
|
||||
fleet_runner='["self-hosted", "linux", "arm64"]'
|
||||
fi
|
||||
# Dispatch input wins over the fleet variable; MAINTAINER_ECS_RUNNER_
|
||||
# DISABLED=true still hard-forces hosted runners everywhere.
|
||||
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||
@@ -211,14 +228,18 @@ jobs:
|
||||
elif [[ "${ECS_DISABLED}" == "true" || "${LINUX_RUNNER_FLEET}" == "hosted" ]]; then
|
||||
ubuntu_runner='["ubuntu-latest"]'
|
||||
elif [[ "${SAME_REPO}" == "true" || "${trusted_author}" == "true" || "${EVENT_NAME}" == "merge_group" || "${EVENT_NAME}" == "push" ]]; then
|
||||
if [[ "${LINUX_RUNNER_FLEET}" == "arm64" ]]; then
|
||||
ubuntu_runner='["self-hosted", "linux", "arm64"]'
|
||||
else
|
||||
ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]'
|
||||
fi
|
||||
ubuntu_runner="${fleet_runner}"
|
||||
fi
|
||||
echo "ubuntu_runner=${ubuntu_runner}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Selected Linux runner: ${ubuntu_runner}"
|
||||
# Lint & Static routes every pull request to the fleet (maintainer
|
||||
# decision 2026-10-31); non-PR events inherit ubuntu_runner unchanged.
|
||||
lint_runner="${ubuntu_runner}"
|
||||
if [[ "${EVENT_NAME}" == "pull_request" && "${ECS_DISABLED}" != "true" && "${LINUX_RUNNER_FLEET}" != "hosted" ]]; then
|
||||
lint_runner="${fleet_runner}"
|
||||
fi
|
||||
echo "lint_runner=${lint_runner}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Selected lint runner: ${lint_runner}"
|
||||
|
||||
# The product jobs later check out the PR head. Classify before that
|
||||
# from an explicit base-SHA checkout so no fork can replace the
|
||||
@@ -634,7 +655,7 @@ jobs:
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
# The test step's side lane (.github/scripts/ci/run-unit-test-lanes.sh)
|
||||
@@ -944,8 +965,14 @@ jobs:
|
||||
# same release-sync convention: stay running on skip_ci PRs so a required
|
||||
# context still reports, with the per-step guards making it a no-op pass.
|
||||
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
|
||||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
||||
# Routed like test's, not a bare constant: this lane was beheaded twice
|
||||
# Reads lint_runner, NOT ubuntu_runner: this lane routes every pull
|
||||
# request to the fleet, untrusted forks included (maintainer decision
|
||||
# 2026-10-31 — the lane executes PR code and the shared-pool risk is
|
||||
# accepted). test and the integration lanes keep the association-gated
|
||||
# ubuntu_runner. The kill-switch and hosted-fleet overrides still apply,
|
||||
# inside pick_runner.
|
||||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'') }}'
|
||||
# Pool-scoped like test's, not a bare constant: this lane was beheaded twice
|
||||
# running honestly on cold-cache hk3-pool runners — at 30 minutes flat
|
||||
# (install 892s vs the sibling Test job's 347s) and again at 45 (install
|
||||
# 940s, bundle closure 681s vs a warm 171s, every step 2–4x). The pool's
|
||||
@@ -955,13 +982,30 @@ jobs:
|
||||
# Keyed on `self-hosted`, not on the x64 fleet's `ecs-qwen` label: the
|
||||
# ARM64 fleet renders ["self-hosted", "linux", "arm64"], so a
|
||||
# fleet-specific substring priced those reserved machines as hosted.
|
||||
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''90'' || ''45'') }}'
|
||||
# Reads the same lint_runner as runs-on so the ceiling always prices the
|
||||
# pool the job actually lands on.
|
||||
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'', ''self-hosted'') && ''90'' || ''45'') }}'
|
||||
env:
|
||||
LINT_BASE_SHA: "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}"
|
||||
permissions:
|
||||
contents: 'read'
|
||||
pull-requests: 'read'
|
||||
steps:
|
||||
# Untrusted fork code runs here; job env cannot read the runner context
|
||||
# and RUNNER_TEMP survives across jobs, so publish a run-unique HOME via
|
||||
# GITHUB_ENV. Rationale: docs/design/2026-10-31-lint-static-ecs-routing.md.
|
||||
- name: 'Create isolated HOME'
|
||||
run: |-
|
||||
home="${RUNNER_TEMP}/qwen-lint-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}"
|
||||
# Linter archives are SHA-256-verified on every cache hit, so sharing
|
||||
# them is safe; corepack does NOT re-verify extracted entries on hit,
|
||||
# so its home stays per-run (a shared one is a poisoning channel).
|
||||
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}"
|
||||
|
||||
# Pre-checkout cleanup: a previous containerised job (e.g. qwen-triage
|
||||
# verify) may leave root-owned, read-only files anywhere in the workspace.
|
||||
# Restore ownership and write permission unconditionally so the checkout
|
||||
@@ -1300,7 +1344,7 @@ jobs:
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
- name: 'Configure npm for rate limiting'
|
||||
@@ -1495,6 +1539,12 @@ jobs:
|
||||
echo "::warning::helper tests failed once and the retry absorbed it; attempt-1 failures: $(grep -E '^not ok' "${RUNNER_TEMP}/helper-attempt1.log" | head -5 | paste -sd ';' -) — count this toward the #12772 helper-battery recurrence threshold"
|
||||
}
|
||||
|
||||
# Reclaim the run-unique HOME (constructed path, never $HOME) before the
|
||||
# failure collector, which must stay last.
|
||||
- name: 'Clean isolated HOME'
|
||||
if: '${{ always() }}'
|
||||
run: 'rm -rf -- "${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"'
|
||||
|
||||
# State-at-failure dump for the 19 substantive steps after install: the
|
||||
# install sampler is reaped by that step's `trap … EXIT`, so a death in
|
||||
# ESLint or the bundle-closure build would otherwise upload an artifact
|
||||
@@ -1715,7 +1765,7 @@ jobs:
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
- name: 'Configure npm for rate limiting'
|
||||
@@ -2117,7 +2167,7 @@ jobs:
|
||||
# so create the directory through the Node the preflight verified.
|
||||
node -e "require('node:fs').mkdirSync(process.argv[1], { recursive: true })" "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
- name: 'Configure npm for rate limiting'
|
||||
@@ -2231,7 +2281,8 @@ jobs:
|
||||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_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 `lint_and_static`: hosted fallback routing
|
||||
# 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'') }}'
|
||||
@@ -2393,7 +2444,7 @@ jobs:
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
- name: 'Disk floor gate (self-hosted)'
|
||||
@@ -2620,7 +2671,7 @@ jobs:
|
||||
cache_dir="${HOME}/.cache/qwen-code/npm"
|
||||
mkdir -p "${cache_dir}"
|
||||
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
|
||||
echo "Using persistent npm cache at ${cache_dir}"
|
||||
echo "Using npm cache at ${cache_dir}"
|
||||
du -sh "${cache_dir}" 2>/dev/null || true
|
||||
|
||||
# Same pre-install admission check as the test job (#10035): this job
|
||||
|
||||
@@ -1368,9 +1368,12 @@ author-permission gate by design, not a head-repository gate: takeover
|
||||
engages maintainer fork PRs, and the pattern matches qwen-code-pr-review,
|
||||
whose ECS-routed review job also rides its upstream write+ check. The
|
||||
job therefore runs host-side (no `container:`): the branch code it
|
||||
executes is collaborator-authored — the same trust class ci.yml's
|
||||
pick_runner routes onto this pool — and persistent-workspace residue is
|
||||
scrubbed by the hygiene steps below. On pull_request /
|
||||
executes is collaborator-authored — a narrower trust class than ci.yml's
|
||||
pick_runner now routes onto this pool, since the 2026-10-31 decision
|
||||
sends every pull request, untrusted forks included, to lint_and_static
|
||||
(that lane pins a per-job HOME; this job does not) — and
|
||||
persistent-workspace residue is scrubbed by the hygiene steps below. On
|
||||
pull_request /
|
||||
pull_request_review events the ECS route additionally needs a same-repo
|
||||
head or a write+ author (ci.yml's pick_runner form); issue_comment and
|
||||
the other triggers skip that clause and rely on the live write+ gates.
|
||||
|
||||
@@ -71,12 +71,19 @@ Following the established precedent (`hosted-harness-process.ts`,
|
||||
- `integration-tests/helpers/hosted-shell-quote.ts` — `quotePosix(value)`.
|
||||
Four call sites swap their local const for the import.
|
||||
|
||||
### Java: one package-private test-support class
|
||||
### Java: one shared test-support class
|
||||
|
||||
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport` (final class,
|
||||
static methods, test scope), joining the existing test-support precedent
|
||||
(`CoordinatorTestSupport`):
|
||||
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport` (public final
|
||||
class, static methods, test scope), joining the existing test-support
|
||||
precedent (`CoordinatorTestSupport`). It was package-private until the
|
||||
connector-owner resolver below had to be shared with a gate in the `harness`
|
||||
subpackage; only that resolver is public, the rest stay package-private:
|
||||
|
||||
- `connectorOwning(HarnessConnector)` — walks the delegate chain to the
|
||||
connector that owns the private state the gates read, because the wired
|
||||
bean is a decorator rather than that connector. `attachmentsOf` and the
|
||||
pool gate's concrete-type lookup both go through it, so there is one walk
|
||||
rather than a copy per read site.
|
||||
- `authentication(String tenant, String fixtureName)` — the
|
||||
`FilterRegistrationBean` factory; both probes delegate, the fixture name is
|
||||
the only parameter.
|
||||
|
||||
@@ -43,10 +43,11 @@
|
||||
- `integration-tests/helpers/hosted-sse-observer.ts` —— `Observer` 类(SSE fetch、块解析、`frames`、`through`、`terminalOfOtherTurn`、`turnIdOf`、`close`),`surface`、`sessionId`、`storeUrl`、`tenantId` 与超时全部经构造器注入;外加 `projectEvents(rows, surface)`,逐字承载 FG6e/FG8 的字段映射。FG6e 的 driver 不再为 observer 读模块级 config;两个 driver 都改为 import 该类。
|
||||
- `integration-tests/helpers/hosted-shell-quote.ts` —— `quotePosix(value)`。四个调用点把本地 const 换成 import。
|
||||
|
||||
### Java:一个包级私有的测试支撑类
|
||||
### Java:一个共享的测试支撑类
|
||||
|
||||
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport`(final class、静态方法、test 作用域),加入既有测试支撑先例(`CoordinatorTestSupport`)之列:
|
||||
`com.alibaba.qwen.code.managedagent.HostedGateTestSupport`(public final class、静态方法、test 作用域),加入既有测试支撑先例(`CoordinatorTestSupport`)之列。它原本是包级私有,直到下面这个 connector owner 解析器需要与 `harness` 子包里的一条门禁共享;只有该解析器是 public,其余仍为包级私有:
|
||||
|
||||
- `connectorOwning(HarnessConnector)` —— 沿 delegate 链走到持有「门禁要读的私有状态」的那个 connector,因为 wiring 交出的 bean 是装饰器而不是该 connector。`attachmentsOf` 与 pool 门禁的具体类型查找都走它,因此只有一份走链,而不是每个读取点一份拷贝。
|
||||
- `authentication(String tenant, String fixtureName)` —— `FilterRegistrationBean` 工厂;两个 probe 都改为委托,fixture 名是唯一参数。
|
||||
- relay 循环的共享机制:用钉死的 fixture token 与 capability digest 构建 client、boot-id 能力校验、`parseSession` 反射桥,以及「流 → 投影 → 落库」的行走,源键为 `bootId:epoch:eventId`。各 letter 的循环控制留在一个小策略缝后面,恰好三个钩子——事件属于哪个 turn(FG6e 的固定 `promptId` 对 FG8 的每事件 prompt id)、一个允许阻塞的落库前钩子(FG6e 的 terminal 闩)、以及 terminal 是否停下的决定(FG6e 遇 terminal 跳出;FG8 从不,靠流关闭停下)。FG8 关闭流引发的 `IllegalStateException` 吞留处理留在 FG8 的 probe 里。
|
||||
- `toolPublicationArgs(int servicePort, String bucket, long executionBytes)` —— 那 16 行属性。
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Lint & Static on the ECS pool for every pull request
|
||||
|
||||
[English](2026-10-31-lint-static-ecs-routing.md) | [简体中文](2026-10-31-lint-static-ecs-routing.zh-CN.md)
|
||||
|
||||
Decision taken 2026-10-31 by jinjing (maintainer). Scope: `.github/workflows/ci.yml` runner routing.
|
||||
Retracts the "collaborator-authored — the same trust class ci.yml's pick_runner
|
||||
routes onto this pool" premise formerly stated in
|
||||
[`qwen-autofix.md` §38](../../.github/workflows/qwen-autofix.md) (rewritten in
|
||||
the same PR); [`autofix-gate-runner-isolation.md`](./autofix-gate-runner-isolation.md)
|
||||
remains the reference for the pool's shared-HOME property.
|
||||
|
||||
## What changed
|
||||
|
||||
`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` |
|
||||
|
||||
Fallback priority: `MAINTAINER_ECS_RUNNER_DISABLED=true` overrides everything,
|
||||
including manual dispatch. `LINUX_RUNNER_FLEET=hosted` forces pull requests,
|
||||
merge groups and pushes to hosted, but a `workflow_dispatch` run whose
|
||||
`linux_runner` input explicitly selects `self-hosted` / `self-hosted-arm64`
|
||||
still reaches the pool — the dispatch branch is evaluated first. During a pool
|
||||
outage, use the kill switch, not the fleet variable, to guarantee hosted.
|
||||
|
||||
`test_windows` and the macOS lanes are out of scope: they read no classifier
|
||||
output.
|
||||
|
||||
## Why the untrusted-fork lane is acceptable
|
||||
|
||||
The lane's job is "the checks whose verdict depends only on the tree". Routing
|
||||
it to the fleet at all is a throughput decision: a hosted round trip per PR
|
||||
delays the checks that gate merge. The trust cost is that PR code —
|
||||
`pnpm install` lifecycle scripts and lint/static tooling — now runs on the
|
||||
shared pool for a fork author nobody has vetted.
|
||||
|
||||
## Isolation boundary (and its holes)
|
||||
|
||||
`lint_and_static`'s first step publishes `HOME` / `USERPROFILE` via
|
||||
`GITHUB_ENV`, pointing at
|
||||
`${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}` —
|
||||
run-unique, cleared with `rm -rf` before creation, and reclaimed by an
|
||||
`always()` cleanup step before the failure collector. Job-level `env` cannot
|
||||
read the `runner` context (GitHub rejects the whole workflow file), and
|
||||
`RUNNER_TEMP` survives across jobs on this pool, so a fixed directory name
|
||||
would hand a fork's `~/.npmrc` / `~/.gitconfig` to the next job on the
|
||||
registration — including the `test` lane, which pins its own constant
|
||||
`qwen-ci-home` (a pre-existing property, separately owned).
|
||||
|
||||
The pin closes the **HOME-relative (automatic) write class**: config that git,
|
||||
npm and pnpm resolve through `~`/`$HOME`. It does not stop a job that writes
|
||||
the account's real home by absolute path; that remains in the accepted
|
||||
residual surface below. Limits that stay accepted:
|
||||
|
||||
- **Package caches are cold.** npm and pnpm stores live under the per-run
|
||||
HOME, so each run installs from the registry; yamllint is re-pipped into
|
||||
`$HOME/.local` and the corepack shim is re-downloaded per run. The measured
|
||||
cold install (~900 s) is what the 90/45 ceiling already prices. Only the
|
||||
linter-archive cache stays shared and warm: `XDG_CACHE_HOME` points at
|
||||
`${RUNNER_TOOL_CACHE}/qwen-ci-cache`, and lint.js SHA-256-verifies every
|
||||
archive on each cache hit before extraction, rejecting and re-fetching
|
||||
tampered entries. `COREPACK_HOME` stays inside the run-unique HOME:
|
||||
corepack does not re-verify extracted entries on cache hit (reproduced on
|
||||
0.34.6 and 0.36.0 — a tampered pnpm entry executes with the recorded hash
|
||||
unchanged), so a shared corepack home is a cross-job poisoning channel.
|
||||
- **`$GITHUB_WORKSPACE` is still shared.** The pre-checkout "Restore workspace
|
||||
ownership" step and the checkout itself reduce, but cannot eliminate,
|
||||
residue a co-resident job leaves behind. The lane's only credential is the
|
||||
read-only `GITHUB_TOKEN` (`contents: read`, `pull-requests: read`), bound to
|
||||
the freshness, contract and test-growth steps — harvesting the residue
|
||||
yields no write credential.
|
||||
|
||||
Steps that need the real home (none today) would have to be re-gated.
|
||||
|
||||
### Why not a container
|
||||
|
||||
`qwen-triage`'s `tmux-testing`/`verify` jobs wrap PR code in
|
||||
`container: node:22-bookworm`. That path was considered for this lane and
|
||||
rejected: the lane's own "Restore workspace ownership" step exists because
|
||||
containerised jobs leave root-owned residue on these hosts, a fourth
|
||||
containerised lane worsens exactly that problem, and the pnpm-store/bundle-cache
|
||||
behavior would need re-proving on the pool. Per-run HOME closes the automatic
|
||||
config-inheritance class at a few lines of cost; it is not a sandbox, and the
|
||||
residual surface below stays accepted.
|
||||
|
||||
## Residual risks (accepted)
|
||||
|
||||
- **Co-resident exposure is concretely reachable, not theoretical.** For
|
||||
example, `publish-tmux` runs on this pool holding `CI_BOT_PAT`,
|
||||
uncontainerised and without a trust gate on its `runs-on`; same-UID
|
||||
processes can read `/proc/<pid>/environ`. It is one example, not a census:
|
||||
`serve-ab-publish`, `web-shell-visuals-cleanup` and `qwen-autofix`'s
|
||||
`issue-autofix` also carry bot PATs on ECS pools, and `web_shell_e2e_smoke`
|
||||
reaches the self-hosted ARM64 fleet off `ubuntu_runner` (write-access fork
|
||||
authors included) with no per-job HOME — pre-existing surfaces this PR does
|
||||
not change. A lint step spinning under its 90-minute ceiling can scan for
|
||||
such windows and retry across fork PRs. The structural fix is an isolation
|
||||
fleet — out of scope here.
|
||||
- **The association gate is throughput policy, not an enforceable trust
|
||||
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.
|
||||
- **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.
|
||||
- **`/tmp` inode exhaustion.** The pool's tmpfs `/tmp` has a ~1M inode ceiling
|
||||
the disk-floor gate does not cover (it checks the workspace and
|
||||
`RUNNER_TEMP` mounts). A 2026-10-31 lint failure on `ecs-qwen-hz1-9` was this
|
||||
class. Follow-up: extend the floor gate to `os.tmpdir()` and clean host
|
||||
`/tmp` accumulation.
|
||||
|
||||
## Kill switch
|
||||
|
||||
`MAINTAINER_ECS_RUNNER_DISABLED=true` — all Linux lanes, including this one,
|
||||
fall back to `ubuntu-latest`, each with its own hosted ceiling: lint 45
|
||||
minutes, test 120, web-shell smoke 60, no-AK integration 30.
|
||||
|
||||
## Tests
|
||||
|
||||
`.github/scripts/ci-runner-routing.test.mjs` (in `HELPER_TESTS`, so it gates
|
||||
every full-profile PR) pins: the producer binding
|
||||
(`classify_pr.outputs.lint_runner` ← `pick_runner`), the full association /
|
||||
event matrix against the real selector shell, the single-lane scope of
|
||||
`lint_runner` (a superset must update this record), the run-unique HOME
|
||||
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.
|
||||
|
||||
## Decision record and open questions
|
||||
|
||||
- Acceptance: jinjing reviewed and accepted the shared-pool code-execution
|
||||
risk on 2026-10-31, with Lint & Static as the sole default-routing change —
|
||||
noting that the association gate is not enforceable against a fork that
|
||||
rewrites the workflow YAML, so the acceptance covers the residual surface
|
||||
above, not a one-lane fence. A second maintainer's confirmation is welcome
|
||||
(triage stage-3 deferred on exactly this); it is a human decision, not a
|
||||
code change.
|
||||
- The PR's own CI exercises the routing plumbing on the pool (the author is a
|
||||
collaborator, so both outputs agree on that run); the fork divergence is
|
||||
proven by the routing battery, and its first live end-to-end witness is the
|
||||
next untrusted fork PR.
|
||||
- Follow-ups owned separately: warm shared pnpm store 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;
|
||||
an isolation fleet if the accepted residual surface is ever revisited.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Lint & Static 对所有 pull request 走 ECS 池
|
||||
|
||||
[English](2026-10-31-lint-static-ecs-routing.md) | [简体中文](2026-10-31-lint-static-ecs-routing.zh-CN.md)
|
||||
|
||||
2026-10-31 由 jinjing(维护者)决定。范围:`.github/workflows/ci.yml` 的 runner 路由。
|
||||
它撤回的是 [`qwen-autofix.md` 第 38 节](../../.github/workflows/qwen-autofix.md)
|
||||
原先"执行的代码是 collaborator 编写——与 ci.yml 的 pick_runner 路由到该池的信任等级相同"
|
||||
的前提(该节已在同一 PR 改写);[`autofix-gate-runner-isolation.md`](./autofix-gate-runner-isolation.md)
|
||||
仍是池上共享 HOME 属性的参考文档。
|
||||
|
||||
## 改了什么
|
||||
|
||||
`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` |
|
||||
|
||||
回退优先级:`MAINTAINER_ECS_RUNNER_DISABLED=true` 覆盖一切,包括手动调度。
|
||||
`LINUX_RUNNER_FLEET=hosted` 把 pull request、merge group 和 push 强制到 hosted,
|
||||
但 `workflow_dispatch` 的 `linux_runner` 输入若显式选择 `self-hosted` /
|
||||
`self-hosted-arm64` 仍会进池——dispatch 分支最先求值。池故障时要保证回 hosted,
|
||||
请用 kill switch,而不是 fleet 变量。
|
||||
|
||||
`test_windows` 与 macOS 车道不在范围内:它们不读任何 classifier 输出。
|
||||
|
||||
## 为什么可以接纳未受信 fork
|
||||
|
||||
这条车道的定位是"判决只取决于代码树的那批检查"。把它放到自建池纯粹是吞吐决定:
|
||||
每个 PR 往返一次 hosted 会拖慢真正卡合并的检查。代价是 PR 代码——`pnpm install`
|
||||
的生命周期脚本与 lint/static 工具链——现在会在共享池上执行,作者并未经过任何审核。
|
||||
|
||||
## 隔离边界(以及它的洞)
|
||||
|
||||
`lint_and_static` 的首步通过 `GITHUB_ENV` 发布 `HOME` / `USERPROFILE`,指向
|
||||
`${RUNNER_TEMP}/qwen-lint-home-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}`——
|
||||
按 run 唯一,创建前先 `rm -rf` 清空,并由失败收集器之前的 `always()` 清理步骤回收。
|
||||
job 级 `env` 读不到 `runner` 上下文(GitHub 会拒绝整个 workflow 文件),而
|
||||
`RUNNER_TEMP` 在本池跨 job 持久,所以固定目录名会把 fork 写下的 `~/.npmrc` /
|
||||
`~/.gitconfig` 交给同一注册上的下一个 job——包括钉着自己那份固定 `qwen-ci-home`
|
||||
的 `test` 车道(那是 pre-existing 属性,另行拥有)。
|
||||
|
||||
这个钉住关闭的是 **HOME 相对(自动)写入这一类**:git、npm、pnpm 通过
|
||||
`~`/`$HOME` 解析的配置。它拦不住用绝对路径写账号真实 home 的 job;那仍在下文
|
||||
已接受的残余暴露面里。仍然接受的限制:
|
||||
|
||||
- **包缓存是冷的。** npm 与 pnpm store 落在 per-run HOME 下,每次运行都要从
|
||||
registry 安装;yamllint 每次重新 pip 到 `$HOME/.local`,corepack shim 每次
|
||||
重新下载。实测冷装约 900 s,90/45 的时限本来就是按这个口径定的。只有
|
||||
linter 归档缓存保持共享温热:`XDG_CACHE_HOME` 指向
|
||||
`${RUNNER_TOOL_CACHE}/qwen-ci-cache`,lint.js 在每次缓存命中时、解压之前
|
||||
对归档做 SHA-256 校验,被篡改的条目会被拒绝并重新拉取。`COREPACK_HOME`
|
||||
留在 run 唯一的 HOME 内:corepack 缓存命中时不会重新校验已解压的条目
|
||||
(在 0.34.6 与 0.36.0 上复现——记录的 hash 不变,被篡改的 pnpm 入口照样
|
||||
执行),共享 corepack home 是一条跨 job 投毒通道。
|
||||
- **`$GITHUB_WORKSPACE` 仍然共享。** 检出前的 "Restore workspace ownership" 与
|
||||
检出本身只能减轻、不能消除同机并行 job 留下的残留。该车道唯一的凭证是只读
|
||||
`GITHUB_TOKEN`(`contents: read`、`pull-requests: read`),绑定在 freshness、
|
||||
contract、test-growth 三个步骤上——收割残留拿不到任何写凭证。
|
||||
|
||||
将来若出现必须用真实 home 的步骤,需要重新给它单独设门。
|
||||
|
||||
### 为什么不用容器
|
||||
|
||||
`qwen-triage` 的 `tmux-testing`/`verify` 用 `container: node:22-bookworm`
|
||||
包裹 PR 代码。本车道考虑过该路径并否决:车道自己的 "Restore workspace
|
||||
ownership" 步骤正是因为容器化 job 在这些主机上留下 root 属主残留物而存在,
|
||||
再加一条容器化车道会加重该问题;pnpm store/bundle 缓存行为也需要在池上重新
|
||||
验证。per-run HOME 以几行成本关闭了自动配置继承这一类;它不是沙箱,下文的
|
||||
残余暴露面仍属接受范围。
|
||||
|
||||
## 残余风险(已接受)
|
||||
|
||||
- **同机暴露是具体可达的,不是理论上的。** 例如 `publish-tmux` 在该池上持有
|
||||
`CI_BOT_PAT`、未容器化、`runs-on` 无信任门禁;同 UID 进程可读
|
||||
`/proc/<pid>/environ`。这只是举例,不是清单:`serve-ab-publish`、
|
||||
`web-shell-visuals-cleanup`、`qwen-autofix` 的 `issue-autofix` 也带 bot PAT
|
||||
跑在 ECS 池上;`web_shell_e2e_smoke` 经由 `ubuntu_runner` 落到自托管 ARM64
|
||||
fleet(含 write 权限作者的 fork),且没有 per-job HOME——都是本 PR 不改的
|
||||
pre-existing 暴露面。一个在 90 分钟上限内空转的 lint 步骤可以扫描这类窗口并
|
||||
在连续 fork PR 中重试。结构性解法是隔离 fleet——不在本变更范围内。
|
||||
- **association 门禁是吞吐策略,不是可强制的信任边界。** `pull_request` 运行
|
||||
执行的是 PR 自己的 workflow 文件,任何 fork 都能在开 PR 的同一个 diff 里把
|
||||
任何车道(包括 `test`)放到池上(ci.yml 的 `test_windows` 注释记录了同一
|
||||
事实)。GitHub 的首次贡献者 workflow 审批是外层门。本决定的单车道范围描述
|
||||
的是默认路由,不是一道围栏。
|
||||
- **云 metadata。** 若池实例绑定了 RAM 角色,任何 job 进程都可访问 metadata
|
||||
端点。建议 fleet 侧开启加固模式,与本 workflow 变更独立。
|
||||
- **`/tmp` inode 耗尽。** 池的 tmpfs `/tmp` 有约 100 万 inode 上限,磁盘下限门
|
||||
未覆盖(它查工作区与 `RUNNER_TEMP` 挂载)。2026-10-31 `ecs-qwen-hz1-9` 上的
|
||||
一次 lint 失败即此类。后续:把下限门扩到 `os.tmpdir()` 并清理主机 `/tmp`
|
||||
积压。
|
||||
|
||||
## Kill switch
|
||||
|
||||
`MAINTAINER_ECS_RUNNER_DISABLED=true` —— 所有 Linux 车道(含本车道)回落到
|
||||
`ubuntu-latest`,各按各的 hosted 上限:lint 45 分钟、test 120、web-shell
|
||||
smoke 60、no-AK 集成 30。
|
||||
|
||||
## 测试
|
||||
|
||||
`.github/scripts/ci-runner-routing.test.mjs`(在 `HELPER_TESTS` 里,因此门禁
|
||||
每个 full-profile PR)钉住:生产者绑定(`classify_pr.outputs.lint_runner` ←
|
||||
`pick_runner`)、真实选择器 shell 在全 association/事件矩阵下的行为、
|
||||
`lint_runner` 的单车道范围(超集必须更新本记录)、首步发布的 run 唯一 HOME
|
||||
(先清后建、共享可校验缓存)、清理步骤先于收集器、以及两个输出在未建模的
|
||||
fleet 值下仍走同一份标签。`scripts/tests/ci-platform-lanes.test.js` 钉住
|
||||
`runs-on` 字面量(lint 读 `lint_runner`、test 读 `ubuntu_runner`),并按各车道
|
||||
自己的路由输出求值 timeout;注意该套件在 merge queue 之后才跑,所以承重的
|
||||
HOME 守卫放在路由电池里。
|
||||
|
||||
## 决策记录与开放问题
|
||||
|
||||
- 接受记录:jinjing 于 2026-10-31 评审并接受共享池代码执行风险,Lint & Static
|
||||
是唯一的默认路由变更——并注明 association 门禁对改写 workflow YAML 的 fork
|
||||
不可强制,因此接受范围覆盖上述残余暴露面,而不是一道单车道围栏。欢迎第二位
|
||||
维护者确认(triage stage-3 正是因此暂缓);这是人的决定,不是代码变更。
|
||||
- PR 自身 CI 在池上验证路由管道(作者是 collaborator,该运行中两个输出一致);
|
||||
fork 分歧由路由回归矩阵证明,其首个线上端到端见证是下一个不可信 fork PR。
|
||||
- 另行拥有的后续:per-run HOME 下恢复热共享 pnpm store(如通过 `XDG_DATA_HOME`
|
||||
指向内容可校验的共享路径),找回约 900 s 的冷装成本;磁盘下限门覆盖 `/tmp`
|
||||
inode;fleet metadata 加固;`test` 车道固定 `qwen-ci-home` 的持久化问题;
|
||||
若重新审视已接受的残余暴露面,则建隔离 fleet。
|
||||
+40
-3
@@ -27,7 +27,9 @@ import org.springframework.core.Ordered;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.URI;
|
||||
import java.security.Principal;
|
||||
import java.time.Duration;
|
||||
@@ -43,7 +45,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
// mechanics — the relay's loop control, the fault arms, the case
|
||||
// choreography — stay in the letters; only the byte-identical machinery
|
||||
// lives here.
|
||||
final class HostedGateTestSupport {
|
||||
public final class HostedGateTestSupport {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private HostedGateTestSupport() {
|
||||
@@ -375,8 +377,7 @@ final class HostedGateTestSupport {
|
||||
// Spring holds no attachment to it.
|
||||
static HarnessSessionRef attachment(HarnessConnector connector,
|
||||
String sessionId) {
|
||||
for (Object value : ((Map<?, ?>) ReflectionTestUtils.getField(
|
||||
connector, "attachments")).values()) {
|
||||
for (Object value : attachmentsOf(connector).values()) {
|
||||
if (value instanceof HarnessSessionRef ref
|
||||
&& sessionId.equals(ref.getHarnessSessionId())) {
|
||||
return ref;
|
||||
@@ -384,4 +385,40 @@ final class HostedGateTestSupport {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The bean Spring hands out can be a decorator around the connector that
|
||||
// owns the private state these gates read, so resolve the owner once and
|
||||
// let every read share it. A chain that owns no attachment map stops at
|
||||
// its last connector, so the caller's getField still names the missing
|
||||
// field instead of failing somewhere less obvious. A delegate that is
|
||||
// itself null falls back to the connector we started from, for the same
|
||||
// reason: getField(null, …) complains about the missing target instead of
|
||||
// naming the field the caller asked for.
|
||||
public static HarnessConnector connectorOwning(HarnessConnector connector) {
|
||||
HarnessConnector owner = connector;
|
||||
while (owner != null && declaredField(owner, "attachments", Map.class) == null) {
|
||||
Field delegate = declaredField(owner, "delegate", HarnessConnector.class);
|
||||
if (delegate == null) {
|
||||
break;
|
||||
}
|
||||
ReflectionUtils.makeAccessible(delegate);
|
||||
owner = (HarnessConnector) ReflectionUtils.getField(delegate, owner);
|
||||
}
|
||||
return owner != null ? owner : connector;
|
||||
}
|
||||
|
||||
static Map<?, ?> attachmentsOf(HarnessConnector connector) {
|
||||
return (Map<?, ?>) ReflectionTestUtils.getField(
|
||||
connectorOwning(connector), "attachments");
|
||||
}
|
||||
|
||||
// findField's type argument matches the declared type exactly, not by
|
||||
// assignability, so a delegate declared as a concrete connector would be
|
||||
// missed. Match on the name and check assignability instead. A field that
|
||||
// exists and holds null is still a field: the caller reads its value.
|
||||
private static Field declaredField(HarnessConnector owner, String name,
|
||||
Class<?> type) {
|
||||
Field field = ReflectionUtils.findField(owner.getClass(), name);
|
||||
return field != null && type.isAssignableFrom(field.getType()) ? field : null;
|
||||
}
|
||||
}
|
||||
+94
@@ -1,7 +1,10 @@
|
||||
package com.alibaba.qwen.code.managedagent;
|
||||
|
||||
import com.alibaba.qwen.code.daemon.HarnessSessionRef;
|
||||
import com.alibaba.qwen.code.daemon.HostedHarnessClient;
|
||||
import com.alibaba.qwen.code.managedagent.harness.QwenHostedHarnessConnector;
|
||||
import com.alibaba.qwen.code.managedagent.harness.SdkHarnessBoundary;
|
||||
import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -15,8 +18,10 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.attachment;
|
||||
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.attachmentsOf;
|
||||
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.bootWithFreshPorts;
|
||||
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.camel;
|
||||
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.connectorOwning;
|
||||
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.quote;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -61,6 +66,95 @@ class HostedGateTestSupportTest {
|
||||
assertThat(attachment(connector, "session-c")).isNull();
|
||||
}
|
||||
|
||||
// The Spring bean is a decorator, not the connector that owns the map, so
|
||||
// the lookup has to walk the delegate chain. Without this the Hosted gates
|
||||
// fail in the test-support layer before the row under test runs (#15972).
|
||||
@Test
|
||||
void attachmentUnwrapsTheBoundaryToTheConnectorThatOwnsTheMap() {
|
||||
var connector = mock(QwenHostedHarnessConnector.class);
|
||||
var attachments = new LinkedHashMap<Object, HarnessSessionRef>();
|
||||
HarnessSessionRef mine = ref("session-a");
|
||||
attachments.put("mine", mine);
|
||||
ReflectionTestUtils.setField(connector, "attachments", attachments);
|
||||
var decorated = new SdkHarnessBoundary(new SdkHarnessBoundary(connector));
|
||||
|
||||
assertThat(attachment(decorated, "session-a")).isSameAs(mine);
|
||||
assertThat(attachment(decorated, "session-c")).isNull();
|
||||
assertThat(attachmentsOf(decorated)).isSameAs(attachments);
|
||||
}
|
||||
|
||||
// The same bean is read for its client one line after its attachment, so
|
||||
// resolving the owner has to serve every private read on it, not just the
|
||||
// map (#15972 R1-1).
|
||||
@Test
|
||||
void connectorOwningServesEveryPrivateReadOnTheDecoratedBean() {
|
||||
var connector = mock(QwenHostedHarnessConnector.class);
|
||||
var attachments = new LinkedHashMap<Object, HarnessSessionRef>();
|
||||
attachments.put("mine", ref("session-a"));
|
||||
ReflectionTestUtils.setField(connector, "attachments", attachments);
|
||||
HostedHarnessClient client = mock(HostedHarnessClient.class);
|
||||
ReflectionTestUtils.setField(connector, "client", client);
|
||||
var decorated = new SdkHarnessBoundary(new SdkHarnessBoundary(connector));
|
||||
|
||||
assertThat(ReflectionTestUtils.getField(connectorOwning(decorated), "client"))
|
||||
.isSameAs(client);
|
||||
assertThat(connectorOwning(decorated)).isSameAs(connector);
|
||||
assertThat(attachmentsOf(decorated)).isSameAs(attachments);
|
||||
}
|
||||
|
||||
// A delegate that is itself null must not become a complaint about a
|
||||
// missing target: fall back to the connector we started from, so the error
|
||||
// still names the field the caller asked for.
|
||||
@Test
|
||||
void aNullDelegateStillFailsNamingTheRequestedField() {
|
||||
var decorated = new SdkHarnessBoundary(null);
|
||||
|
||||
assertThatThrownBy(() -> attachmentsOf(decorated))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("attachments");
|
||||
}
|
||||
|
||||
// findField's type argument matches the declared type exactly, so a
|
||||
// decorator that declares its delegate as a concrete connector is only
|
||||
// walked past by a name-plus-assignability match. Without this the next
|
||||
// such wrapper silently reintroduces #15972 on a green build.
|
||||
@Test
|
||||
void theWalkFollowsADelegateDeclaredAsAConcreteConnector() {
|
||||
var connector = mock(QwenHostedHarnessConnector.class);
|
||||
var attachments = new LinkedHashMap<Object, HarnessSessionRef>();
|
||||
attachments.put("mine", ref("session-a"));
|
||||
ReflectionTestUtils.setField(connector, "attachments", attachments);
|
||||
var decorated = new ConcreteTypedBoundary(connector);
|
||||
|
||||
assertThat(connectorOwning(decorated)).isSameAs(connector);
|
||||
assertThat(attachmentsOf(decorated)).isSameAs(attachments);
|
||||
}
|
||||
|
||||
// A chain whose leaf owns neither field — the pooled connector is shaped
|
||||
// like this — must stop at the leaf, so the caller still gets the error
|
||||
// naming the missing field rather than a NullPointerException from
|
||||
// dereferencing past the end.
|
||||
@Test
|
||||
void aChainThatOwnsNoMapStopsAndStillNamesTheMissingField() {
|
||||
var leaf = new UnavailableHarnessConnector();
|
||||
var decorated = new SdkHarnessBoundary(leaf);
|
||||
|
||||
assertThat(connectorOwning(decorated)).isSameAs(leaf);
|
||||
assertThatThrownBy(() -> attachmentsOf(decorated))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("attachments");
|
||||
}
|
||||
|
||||
// Declares its delegate as the concrete connector, not the interface.
|
||||
private static final class ConcreteTypedBoundary
|
||||
extends UnavailableHarnessConnector {
|
||||
private final QwenHostedHarnessConnector delegate;
|
||||
|
||||
ConcreteTypedBoundary(QwenHostedHarnessConnector delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
}
|
||||
|
||||
// The boot retry's only consumer is the deep MySQL lane; pin its contract
|
||||
// here so a wrong edit goes red in the default surefire lane instead.
|
||||
@Test
|
||||
|
||||
+5
-2
@@ -57,6 +57,8 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.attachment;
|
||||
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.attachmentsOf;
|
||||
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.connectorOwning;
|
||||
import static com.alibaba.qwen.code.managedagent.HostedGateTestSupport.quote;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
@@ -644,7 +646,8 @@ class HostedPublicWorkspaceIT {
|
||||
var connector = spring.getBean(HarnessConnector.class);
|
||||
var attached = attachment(connector, session);
|
||||
assertThat(attached).as("Spring's attachment to %s", session).isNotNull();
|
||||
var client = (HostedHarnessClient) ReflectionTestUtils.getField(connector, "client");
|
||||
var client = (HostedHarnessClient) ReflectionTestUtils.getField(
|
||||
connectorOwning(connector), "client");
|
||||
// The third request is counted before its handler finishes; wait for the
|
||||
// producer's prompt and all receipt handlers before taking final evidence.
|
||||
await().atMost(Duration.ofSeconds(15)).failFast(() -> {
|
||||
@@ -1620,7 +1623,7 @@ class HostedPublicWorkspaceIT {
|
||||
"requestId", "d6b-action", "response", response) : response;
|
||||
assertThat(request("POST", route, body, id, "reader", 403).at("/error/code").asText()).isEqualTo("action_forbidden");
|
||||
// The live Harness retains this approval while the dispatcher's attachment cache is cold.
|
||||
((Map<?, ?>) ReflectionTestUtils.getField(spring.getBean(HarnessConnector.class), "attachments")).clear();
|
||||
attachmentsOf(spring.getBean(HarnessConnector.class)).clear();
|
||||
// Files sessions answer the first approval as a second OPERATOR
|
||||
// (R1's handoff) and the rest as the owner. A Shell answer runs
|
||||
// a command, so only the recorded owner may answer it.
|
||||
|
||||
+6
-1
@@ -1,8 +1,10 @@
|
||||
package com.alibaba.qwen.code.managedagent.harness;
|
||||
|
||||
import com.alibaba.qwen.code.daemon.HostedHarnessClient;
|
||||
import com.alibaba.qwen.code.managedagent.HostedGateTestSupport;
|
||||
import com.alibaba.qwen.code.managedagent.ManagedAgentServerApplication;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.contracts.harness.HarnessConnector;
|
||||
import com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy;
|
||||
import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException;
|
||||
import com.alibaba.qwen.code.managedagent.persistence.HarnessPlacementStore;
|
||||
@@ -86,7 +88,10 @@ class HostedHarnessPoolMySqlIT {
|
||||
var settings = spring.getBean(ManagedAgentProperties.class);
|
||||
settings.getSessionStore().setBaseUrl("http://127.0.0.1:" + spring.getWebServer().getPort());
|
||||
var placements = spring.getBean(HarnessPlacementStore.class);
|
||||
pool = spring.getBean(HarnessPoolConnector.class);
|
||||
// The wired bean is the SdkHarnessBoundary decorator, so asking Spring
|
||||
// for the concrete pool type finds nothing; resolve the owner instead.
|
||||
pool = (HarnessPoolConnector) HostedGateTestSupport.connectorOwning(
|
||||
spring.getBean(HarnessConnector.class));
|
||||
pool.refresh();
|
||||
assertThat(pool.supportsFencedTitles()).isTrue();
|
||||
String tenantA = "pool-a-" + UUID.randomUUID();
|
||||
|
||||
@@ -98,9 +98,11 @@ describe('complete macOS test groups', () => {
|
||||
});
|
||||
|
||||
// The extended ceilings below, except the test lane's, are scoped to the
|
||||
// self-hosted pool: `runs-on` falls back to ubuntu-latest for fork PRs from
|
||||
// untrusted authors and when MAINTAINER_ECS_RUNNER_DISABLED=true, so those
|
||||
// hosted paths keep the pre-contention ceiling. The ARM64 fleet renders
|
||||
// self-hosted pool: `runs-on` falls back to ubuntu-latest when
|
||||
// MAINTAINER_ECS_RUNNER_DISABLED=true (and, for the association-gated lanes,
|
||||
// for fork PRs from untrusted authors — lint_and_static reads lint_runner,
|
||||
// which routes every PR to the pool), so those hosted paths keep the
|
||||
// pre-contention ceiling. The ARM64 fleet renders
|
||||
// `["self-hosted", "linux", "arm64"]` — no `ecs-qwen` label — so the scope
|
||||
// cannot key on the x64 fleet's name. Evaluate the real timeout expression for
|
||||
// each routing instead of pinning a bare number -- the same
|
||||
@@ -111,15 +113,42 @@ describe('complete macOS test groups', () => {
|
||||
const ECS_RUNNER = '["self-hosted", "linux", "x64", "ecs-qwen"]';
|
||||
const ARM_RUNNER = '["self-hosted", "linux", "arm64"]';
|
||||
const HOSTED_RUNNER = '["ubuntu-latest"]';
|
||||
const timeoutMinutesOn = (job, ubuntuRunner) => {
|
||||
// The classifier output a job's own `runs-on` reads. Deriving it is what makes
|
||||
// a ceiling pointed at the OTHER output red: only this name is substituted, so
|
||||
// `lint_runner` left inside a gate that runs on `ubuntu_runner` survives the
|
||||
// substitution and trips the unmodelled-term throw below.
|
||||
const runnerOutputOf = (job) => {
|
||||
const names = [
|
||||
...new Set(
|
||||
[
|
||||
...String(ci.jobs[job]['runs-on']).matchAll(
|
||||
/needs\.classify_pr\.outputs\.(\w+)/g,
|
||||
),
|
||||
].map((match) => match[1]),
|
||||
),
|
||||
];
|
||||
if (names.length !== 1) {
|
||||
throw new Error(
|
||||
`${job} runs-on names ${names.length} classifier outputs: ${names}`,
|
||||
);
|
||||
}
|
||||
return names[0];
|
||||
};
|
||||
const timeoutMinutesOn = (job, runner) => {
|
||||
// The same `|| '["ubuntu-latest"]'` default the runs-on expression carries:
|
||||
// a missing output (classify_pr skipped) is the hosted routing.
|
||||
const effective = ubuntuRunner || HOSTED_RUNNER;
|
||||
// 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.
|
||||
const effective = runner || HOSTED_RUNNER;
|
||||
const output = runnerOutputOf(job);
|
||||
let expr = String(ci.jobs[job]['timeout-minutes'])
|
||||
.replace(/^\$\{\{\s*/, '')
|
||||
.replace(/\s*\}\}$/, '')
|
||||
.replace(
|
||||
/contains\(needs\.classify_pr\.outputs\.ubuntu_runner \|\| '\["ubuntu-latest"\]', 'self-hosted'\)/g,
|
||||
new RegExp(
|
||||
`contains\\(needs\\.classify_pr\\.outputs\\.${output} \\|\\| '\\["ubuntu-latest"\\]', 'self-hosted'\\)`,
|
||||
'g',
|
||||
),
|
||||
String(effective.includes('self-hosted')),
|
||||
)
|
||||
.replace(/fromJSON\(/g, '(');
|
||||
@@ -241,7 +270,8 @@ describe('raw JUnit report retention', () => {
|
||||
// but picks its own architecture, so its `runs-on` cannot be compared with
|
||||
// another lane's. Evaluate the real expression the way
|
||||
// `.github/scripts/ci-runner-routing.test.mjs` does.
|
||||
const runnerOn = (job, ubuntuRunner) => {
|
||||
const runnerOn = (job, runner) => {
|
||||
const output = runnerOutputOf(job);
|
||||
const expression = String(ci.jobs[job]['runs-on'])
|
||||
.replace(/^\$\{\{\s*/, '')
|
||||
.replace(/\s*\}\}$/, '');
|
||||
@@ -251,7 +281,7 @@ const runnerOn = (job, ubuntuRunner) => {
|
||||
'contains',
|
||||
`return (${expression});`,
|
||||
)(
|
||||
{ classify_pr: { outputs: { ubuntu_runner: ubuntuRunner } } },
|
||||
{ classify_pr: { outputs: { [output]: runner } } },
|
||||
JSON.parse,
|
||||
(labels, label) => labels.includes(label),
|
||||
);
|
||||
@@ -558,18 +588,19 @@ describe('post-merge push lane', () => {
|
||||
expect(condOf('lint_and_static')).toBe(condOf('test'));
|
||||
});
|
||||
|
||||
it('routes lint_and_static through the classify_pr fork-trust output', () => {
|
||||
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 is the fork-trust
|
||||
// boundary — untrusted fork PRs stay on hosted runners — and a literal
|
||||
// pool label array here would bypass that indirection whole (and desync
|
||||
// from timeout-minutes, which reads the same output). Whole-expression
|
||||
// match, as no-ak-integration-ci does for test_windows.
|
||||
// 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 —
|
||||
// untrusted forks included — to the fleet (maintainer decision
|
||||
// 2026-10-31, shared-pool code-execution risk accepted). 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-
|
||||
// expression match, as no-ak-integration-ci does for test_windows.
|
||||
expect(String(ci.jobs.lint_and_static['runs-on'])).toBe(
|
||||
String(ci.jobs.test['runs-on']),
|
||||
);
|
||||
expect(String(ci.jobs.lint_and_static['runs-on'])).toBe(
|
||||
'${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || \'["ubuntu-latest"]\') }}',
|
||||
'${{ fromJSON(needs.classify_pr.outputs.lint_runner || \'["ubuntu-latest"]\') }}',
|
||||
);
|
||||
// The needs edge is what makes every needs.classify_pr.outputs.* this
|
||||
// describe relies on non-empty at runtime. Drop it and the lane silently
|
||||
@@ -583,6 +614,18 @@ describe('post-merge push lane', () => {
|
||||
).toContain('classify_pr');
|
||||
});
|
||||
|
||||
it('keeps test on the association-gated output', () => {
|
||||
// Nothing else reads test's runs-on: switching it to lint_runner would
|
||||
// hand every untrusted fork the pool while the timeout pins above stayed
|
||||
// green — timeoutMinutesOn derives each lane's output from its own
|
||||
// runs-on, so it cannot catch a lane pointed at the wrong output. Pin the
|
||||
// expression, not an equality with lint_and_static — the two are meant
|
||||
// to differ.
|
||||
expect(String(ci.jobs.test['runs-on'])).toBe(
|
||||
'${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || \'["ubuntu-latest"]\') }}',
|
||||
);
|
||||
});
|
||||
|
||||
it('lint_and_static keeps the minimal token its required-check role needs', () => {
|
||||
// This block overrides the workflow-level `checks: write` /
|
||||
// `statuses: write` grant. The lane runs contributor code (`npm ci`
|
||||
|
||||
@@ -53,14 +53,18 @@ const branchFamily = toPosix(reviewBranch(probePr)).slice(
|
||||
const ciYaml = parse(readFileSync('.github/workflows/ci.yml', 'utf8'));
|
||||
// Every ci.yml job that checks out on the shared self-hosted pool inherits a
|
||||
// possibly dirty workspace. Match the pool itself, not just the output
|
||||
// reference that usually names it: jobs can also hard-code the shared label
|
||||
// references that usually name it: jobs can also hard-code the shared label
|
||||
// array, and a checkout on either form inherits the same leftovers.
|
||||
// lint_and_static reads lint_runner (every PR reaches the pool), the other
|
||||
// lanes ubuntu_runner — both name the same shared workspace.
|
||||
// Include retained recovery steps on hosted jobs too, so rerouting cannot
|
||||
// silently restore an untested sweep. Select by properties, not job name.
|
||||
const ciCleanSteps = Object.entries(ciYaml.jobs)
|
||||
.filter(
|
||||
([, job]) =>
|
||||
(/ubuntu_runner|ecs-qwen/.test(JSON.stringify(job['runs-on'] ?? '')) ||
|
||||
(/ubuntu_runner|lint_runner|ecs-qwen/.test(
|
||||
JSON.stringify(job['runs-on'] ?? ''),
|
||||
) ||
|
||||
(job.steps ?? []).some(
|
||||
(s) => s.name === 'Clean stale .qwen before checkout',
|
||||
)) &&
|
||||
|
||||
Reference in new issue
Block a user