mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-16 20:24:50 +00:00
ci: route trusted-author fork PRs and no-checkout jobs to the ECS pool (#8502)
Some checks failed
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
npm cache producer / Save npm cache (push) Has been cancelled
Some checks failed
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
npm cache producer / Save npm cache (push) Has been cancelled
* ci: route trusted-author fork PRs and no-checkout jobs to the ECS pool Fork PRs whose author has write access (OWNER/MEMBER/COLLABORATOR association) now run Linux CI on the self-hosted ECS pool instead of the saturated GitHub-hosted quota, and bot workflows that check out no code move to ECS unconditionally. Everything stays gated on the MAINTAINER_ECS_RUNNER_DISABLED kill-switch. * ci: address review — real write-permission routing, watchdog independence, timeouts Route the triage agent on the collaborator-permission API result computed by authorize instead of the coarse author_association, which admits org members and read-only collaborators; the two permission-gate jobs revert to the same-repo guard. Keep the fleet watchdog and the CI-failure reporter hosted so they stay independent of the pool they watch. Add missing timeouts, wipe serve-ab's reused workspace, and pin the routing logic with drift and negative-case tests. --------- Co-authored-by: 易良 <1204183885@qq.com>
This commit is contained in:
parent
407745c5f4
commit
06cc41ee3f
21 changed files with 443 additions and 42 deletions
199
.github/scripts/ci-runner-routing.test.mjs
vendored
Normal file
199
.github/scripts/ci-runner-routing.test.mjs
vendored
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// Runner-routing regression guards for ci.yml and serve-ab.yml.
|
||||
//
|
||||
// classify_pr carries the routing logic TWICE — the `runs-on` expression
|
||||
// (which selects the classify job's own runner) and the `pick_runner` shell
|
||||
// step (which publishes `ubuntu_runner` for every downstream Linux job). If
|
||||
// they drift, classify and the Test job land on different pools. These tests
|
||||
// evaluate BOTH against the same event matrix — including the negative
|
||||
// associations that must stay hosted — and assert they agree.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
const workflowsDir = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'workflows',
|
||||
);
|
||||
const ciDoc = parse(readFileSync(join(workflowsDir, 'ci.yml'), 'utf8'));
|
||||
const serveAbDoc = parse(
|
||||
readFileSync(join(workflowsDir, 'serve-ab.yml'), 'utf8'),
|
||||
);
|
||||
|
||||
const TRUSTED = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
||||
const ECS = '["self-hosted", "linux", "x64", "ecs-qwen"]';
|
||||
const HOSTED = '["ubuntu-latest"]';
|
||||
|
||||
const classifyRunsOn = String(ciDoc.jobs.classify_pr['runs-on']);
|
||||
const pickRunner = ciDoc.jobs.classify_pr.steps.find(
|
||||
(s) => s.id === 'pick_runner',
|
||||
);
|
||||
|
||||
// GitHub expression semantics for the classify runs-on, restricted to the
|
||||
// routing-relevant inputs: contains(list, '') is false, a missing
|
||||
// pull_request (merge_group / dispatch) yields '' for both head.repo and
|
||||
// author_association.
|
||||
function simulateRunsOn({ ecsDisabled, sameRepo, assoc, mergeGroup }) {
|
||||
const trusted = TRUSTED.includes(assoc);
|
||||
const ecs =
|
||||
!ecsDisabled && (sameRepo || trusted || mergeGroup);
|
||||
return ecs ? ECS : HOSTED;
|
||||
}
|
||||
|
||||
// Executes the real pick_runner shell with the same inputs and returns the
|
||||
// selected runner exactly as CI would publish it.
|
||||
function runPickRunner({ ecsDisabled, sameRepo, assoc, eventName, dispatch }) {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'pick-runner-'));
|
||||
const outputFile = join(tmp, 'github_output');
|
||||
const result = spawnSync('bash', ['-c', pickRunner.run], {
|
||||
env: {
|
||||
SAME_REPO: sameRepo ? 'true' : 'false',
|
||||
AUTHOR_ASSOCIATION: assoc,
|
||||
ECS_DISABLED: ecsDisabled ? 'true' : '',
|
||||
EVENT_NAME: eventName,
|
||||
DISPATCH_LINUX_RUNNER: dispatch ?? '',
|
||||
GITHUB_OUTPUT: outputFile,
|
||||
},
|
||||
encoding: '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);
|
||||
}
|
||||
|
||||
describe('ci.yml classify_pr runner routing', () => {
|
||||
it('the expression and the shell step agree on every association', () => {
|
||||
const associations = [
|
||||
...TRUSTED,
|
||||
'CONTRIBUTOR',
|
||||
'FIRST_TIME_CONTRIBUTOR',
|
||||
'FIRST_TIMER',
|
||||
'NONE',
|
||||
'',
|
||||
];
|
||||
for (const sameRepo of [true, false]) {
|
||||
for (const assoc of associations) {
|
||||
const expected = simulateRunsOn({
|
||||
ecsDisabled: false,
|
||||
sameRepo,
|
||||
assoc,
|
||||
mergeGroup: false,
|
||||
});
|
||||
const actual = runPickRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo,
|
||||
assoc,
|
||||
eventName: 'pull_request',
|
||||
});
|
||||
assert.equal(
|
||||
actual,
|
||||
expected,
|
||||
`drift for sameRepo=${sameRepo} assoc='${assoc}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('only write-access associations leave the hosted pool', () => {
|
||||
for (const assoc of ['CONTRIBUTOR', 'FIRST_TIME_CONTRIBUTOR', 'NONE', '']) {
|
||||
assert.equal(
|
||||
runPickRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc,
|
||||
eventName: 'pull_request',
|
||||
}),
|
||||
HOSTED,
|
||||
`assoc '${assoc}' must stay hosted`,
|
||||
);
|
||||
}
|
||||
for (const assoc of TRUSTED) {
|
||||
assert.equal(
|
||||
runPickRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc,
|
||||
eventName: 'pull_request',
|
||||
}),
|
||||
ECS,
|
||||
`assoc '${assoc}' must route to ECS`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('merge queue and explicit dispatch still reach ECS; the kill-switch wins', () => {
|
||||
assert.equal(
|
||||
runPickRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
eventName: 'merge_group',
|
||||
}),
|
||||
ECS,
|
||||
);
|
||||
assert.equal(
|
||||
runPickRunner({
|
||||
ecsDisabled: false,
|
||||
sameRepo: false,
|
||||
assoc: '',
|
||||
eventName: 'workflow_dispatch',
|
||||
dispatch: 'self-hosted',
|
||||
}),
|
||||
ECS,
|
||||
);
|
||||
assert.equal(
|
||||
runPickRunner({
|
||||
ecsDisabled: true,
|
||||
sameRepo: true,
|
||||
assoc: 'OWNER',
|
||||
eventName: 'pull_request',
|
||||
}),
|
||||
HOSTED,
|
||||
'kill-switch must revert even trusted runs to hosted',
|
||||
);
|
||||
});
|
||||
|
||||
it('the runs-on expression keeps the trusted clause and kill-switch', () => {
|
||||
// Structural pins for the expression half of the drift guard — the
|
||||
// simulation above re-implements it, so pin the real text too.
|
||||
assert.match(
|
||||
classifyRunsOn,
|
||||
/contains\(fromJSON\('\["OWNER","MEMBER","COLLABORATOR"\]'\), github\.event\.pull_request\.author_association\)/,
|
||||
);
|
||||
assert.match(classifyRunsOn, /vars\.MAINTAINER_ECS_RUNNER_DISABLED != 'true'/);
|
||||
assert.match(classifyRunsOn, /github\.event_name == 'merge_group'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serve-ab.yml runner routing', () => {
|
||||
const runsOn = String(serveAbDoc.jobs.ab['runs-on']);
|
||||
|
||||
it('admits same-repo and write-access fork PRs, guarded by the kill-switch', () => {
|
||||
assert.match(runsOn, /head\.repo\.full_name == github\.repository/);
|
||||
assert.match(
|
||||
runsOn,
|
||||
/contains\(fromJSON\('\["OWNER","MEMBER","COLLABORATOR"\]'\), github\.event\.pull_request\.author_association\)/,
|
||||
);
|
||||
assert.match(runsOn, /vars\.MAINTAINER_ECS_RUNNER_DISABLED != 'true'/);
|
||||
assert.match(runsOn, /ecs-qwen/);
|
||||
assert.match(runsOn, /ubuntu-latest/);
|
||||
});
|
||||
|
||||
it('wipes the reused workspace before checking out PR code', () => {
|
||||
const wipe = serveAbDoc.jobs.ab.steps.find(
|
||||
(s) => s.name === 'Wipe stale workspace before checkout',
|
||||
);
|
||||
assert.ok(wipe, 'self-hosted reuse must not bleed one PR into the next');
|
||||
assert.equal(wipe.if, "${{ runner.environment == 'self-hosted' }}");
|
||||
assert.match(wipe.run, /find "\$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf/);
|
||||
});
|
||||
});
|
||||
39
.github/scripts/qwen-triage-workflow.test.mjs
vendored
39
.github/scripts/qwen-triage-workflow.test.mjs
vendored
|
|
@ -199,12 +199,49 @@ describe('qwen-triage: agent tool/permission settings', () => {
|
|||
|
||||
describe('qwen-triage: fork-PR runner routing', () => {
|
||||
const runsOn = String(triageJob['runs-on']);
|
||||
const authorizeJob = doc.jobs.authorize;
|
||||
const authorizeRunsOn = String(authorizeJob['runs-on']);
|
||||
|
||||
it('gates the persistent ECS pool on same-repo (fork code never persists)', () => {
|
||||
it('routes the ECS pool on same-repo or a REAL write-permission check', () => {
|
||||
assert.match(runsOn, /head\.repo\.full_name == github\.repository/);
|
||||
// The boundary is the collaborator-permission lookup authorize computes,
|
||||
// NOT the coarse author_association: MEMBER admits any org member and
|
||||
// COLLABORATOR admits read-only invitees — neither implies write access
|
||||
// on this repo, and this job's agent loads bot PATs and a model key.
|
||||
assert.match(
|
||||
runsOn,
|
||||
/needs\.authorize\.outputs\.author_can_write == 'true'/,
|
||||
);
|
||||
assert.doesNotMatch(runsOn, /author_association/);
|
||||
assert.match(runsOn, /ecs-qwen/);
|
||||
});
|
||||
|
||||
it('keeps the authorize gate itself on the same-repo guard', () => {
|
||||
// authorize IS the permission check (and loads CI_BOT_PAT); it cannot
|
||||
// route on its own output and must not widen to association-based trust.
|
||||
assert.match(authorizeRunsOn, /head\.repo\.full_name == github\.repository/);
|
||||
assert.doesNotMatch(authorizeRunsOn, /author_association/);
|
||||
assert.doesNotMatch(authorizeRunsOn, /needs\./);
|
||||
});
|
||||
|
||||
it('computes author_can_write from the collaborator-permission API', () => {
|
||||
assert.equal(
|
||||
authorizeJob.outputs.author_can_write,
|
||||
'${{ steps.perm.outputs.author_can_write }}',
|
||||
);
|
||||
const perm = authorizeJob.steps.find((s) => s.id === 'perm');
|
||||
assert.ok(
|
||||
String(perm.env.PR_AUTHOR).includes(
|
||||
'github.event.pull_request.user.login',
|
||||
),
|
||||
);
|
||||
assert.match(perm.run, /collaborators\/\$\{PR_AUTHOR\}\/permission/);
|
||||
assert.match(
|
||||
perm.run,
|
||||
/admin\|maintain\|write\) echo "author_can_write=true"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to an ephemeral hosted runner', () => {
|
||||
assert.match(runsOn, /ubuntu-latest/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue