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

* 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:
Shaojin Wen 2026-08-04 11:48:24 +08:00 committed by GitHub
parent 407745c5f4
commit 06cc41ee3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 443 additions and 42 deletions

View 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/);
});
});

View file

@ -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/);
});