Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7c9c073
fix(ci): give the macOS and Windows lanes a trigger again
wenshao Aug 18, 2026
49ae2b2
fix(ci): gate the Windows lane's checkout verification per trigger
wenshao Aug 18, 2026
a119a73
Merge branch 'main' into fix/revive-platform-lanes
qwen-code-dev-bot Aug 18, 2026
8147795
Merge branch 'main' into fix/revive-platform-lanes
qwen-code-dev-bot Aug 18, 2026
ffcb290
Merge branch 'main' into fix/revive-platform-lanes
qwen-code-dev-bot Aug 18, 2026
c1a7a68
fix(ci): close review round on the revived platform lanes (#9370)
qwen-code-dev-bot Aug 18, 2026
66ec023
Merge branch 'main' into fix/revive-platform-lanes
wenshao Aug 18, 2026
951521c
fix(ci): pin the Windows lane routing to the canonical trust policy (…
qwen-code-dev-bot Aug 18, 2026
6103257
Merge branch 'fix/revive-platform-lanes' of https://github.com/QwenLM…
qwen-code-dev-bot Aug 18, 2026
92eec8f
Merge branch 'main' into fix/revive-platform-lanes
qwen-code-dev-bot Aug 18, 2026
97544b3
fix(ci): host-probe the macOS lane's bash and GNU dependencies (#9370)
qwen-code-dev-bot Aug 18, 2026
462962e
fix(ci): stop the subsystem rule matching compounds that name somethi…
wenshao Aug 19, 2026
7f7223a
test(ci): pin the gate as a disjunction and the watcher's name binding
wenshao Aug 19, 2026
14d138d
fix(ci): close the classifier's CRLF gap and widen the lane step scan
wenshao Aug 19, 2026
b9de49d
Merge branch 'main' into fix/revive-platform-lanes
wenshao Aug 19, 2026
ba1e80c
style(ci): format the runner-routing suite
wenshao Aug 19, 2026
4fefc81
Merge branch 'main' into fix/revive-platform-lanes
wenshao Aug 20, 2026
fcdf317
Merge branch 'main' into fix/revive-platform-lanes
wenshao Aug 20, 2026
2f871fb
Merge branch 'main' into fix/revive-platform-lanes
wenshao Aug 20, 2026
bb599f6
Merge branch 'main' into fix/revive-platform-lanes
wenshao Aug 20, 2026
6073afb
Merge remote-tracking branch 'origin/main' into fix/revive-platform-l…
qwen-code-dev-bot Aug 20, 2026
bcaa9a3
Merge remote-tracking branch 'origin/main' into fix/revive-platform-l…
qwen-code-dev-bot Aug 21, 2026
bab6596
fix(ci): gate the two mapfile-crossing gate tests on the host probe (…
qwen-code-dev-bot Aug 21, 2026
07040ac
Merge branch 'main' into fix/revive-platform-lanes
wenshao Aug 22, 2026
6f73fbf
fix(ci): keep pull requests off the persistent Windows pool and cover…
qwen-code-dev-bot Aug 22, 2026
b66a4fa
Merge branch 'main' into fix/revive-platform-lanes
qwen-code-dev-bot Aug 25, 2026
bb5c28b
Merge branch 'main' into fix/revive-platform-lanes
wenshao Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 124 additions & 9 deletions .github/scripts/ci-runner-routing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
// 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.
//
// 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
// expression text and asserts the only enforceable shape: every pull request
// stays hosted, and only the merge queue, schedule and dispatch reach the
// persistent pool.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
Expand All @@ -28,8 +35,11 @@ const serveAbDoc = parse(
const TRUSTED = ['OWNER', 'MEMBER', 'COLLABORATOR'];
const ECS = '["self-hosted", "linux", "x64", "ecs-qwen"]';
const HOSTED = '["ubuntu-latest"]';
const WIN_ECS = ['self-hosted', 'Windows', 'X64', 'ecs-win'];
const WIN_HOSTED = ['windows-2022'];

const classifyRunsOn = String(ciDoc.jobs.classify_pr['runs-on']);
const windowsRunsOn = String(ciDoc.jobs.test_windows['runs-on']);
const pickRunner = ciDoc.jobs.classify_pr.steps.find(
(s) => s.id === 'pick_runner',
);
Expand All @@ -44,6 +54,48 @@ function simulateRunsOn({ ecsDisabled, sameRepo, assoc, mergeGroup }) {
return ecs ? ECS : HOSTED;
}

// Evaluates a real `runs-on` expression text with the routing inputs
// substituted, leaving only the &&/||/parenthesis skeleton — which matches
// GitHub's operator semantics closely enough for this fixed shape: both
// return the winning operand, and the winning operand is a fromJSON runner
// label, unwrapped here to the array it names. Any term the substitutions
// do not recognise fails loud, so an edited expression is re-read here
// instead of silently outgrowing the matrix.
function evalRunsOn(expression, { ecsDisabled, eventName, sameRepo, assoc }) {
const substitutions = [
[/vars\.MAINTAINER_ECS_RUNNER_DISABLED != 'true'/, String(!ecsDisabled)],
[
/github\.event_name == 'merge_group'/,
String(eventName === 'merge_group'),
],
[
/github\.event_name != 'pull_request'/,
String(eventName !== 'pull_request'),
],
[
/github\.event\.pull_request\.head\.repo\.full_name == github\.repository/,
String(sameRepo),
],
[
/contains\(fromJSON\('\["OWNER","MEMBER","COLLABORATOR"\]'\), github\.event\.pull_request\.author_association\)/,
String(TRUSTED.includes(assoc)),
],
];
let expr = expression.replace(/^\$\{\{\s*/, '').replace(/\s*\}\}$/, '');
for (const [term, value] of substitutions) {
expr = expr.replace(term, value);
}
expr = expr.replace(/fromJSON\('(\[[^\]]*\])'\)/g, '$1');
assert.doesNotMatch(
expr,
/github\.|vars\.|contains\(|fromJSON\(/,
`routing expression carries a term the matrix does not model: ${expr}`,
);
const selected = new Function(`return (${expr});`)();
assert.ok(Array.isArray(selected), `no runner label selected: ${expr}`);
return selected;
}

// 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 }) {
Expand All @@ -69,18 +121,19 @@ function runPickRunner({ ecsDisabled, sameRepo, assoc, eventName, dispatch }) {
return line.slice('Selected Linux runner: '.length);
}

const ASSOCIATIONS = [
...TRUSTED,
'CONTRIBUTOR',
'FIRST_TIME_CONTRIBUTOR',
'FIRST_TIMER',
'NONE',
'',
];

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) {
for (const assoc of ASSOCIATIONS) {
const expected = simulateRunsOn({
ecsDisabled: false,
sameRepo,
Expand Down Expand Up @@ -176,6 +229,68 @@ describe('ci.yml classify_pr runner routing', () => {
});
});

describe('ci.yml test_windows runner routing', () => {
it('keeps every pull request hosted, whoever opens it', () => {
// A pull_request run executes the workflow YAML from the PR's own merge
// commit: any PR this lane admits could rewrite `runs-on` in the same
// diff (editing this file is what classifies it platform-sensitive), so
// no trust clause evaluated on that event is enforceable. The enforceable
// shape is unconditional — pull requests never reach the persistent pool.
for (const sameRepo of [true, false]) {
for (const assoc of ASSOCIATIONS) {
assert.deepEqual(
evalRunsOn(windowsRunsOn, {
ecsDisabled: false,
eventName: 'pull_request',
sameRepo,
assoc,
}),
WIN_HOSTED,
`pull_request sameRepo=${sameRepo} assoc='${assoc}' must stay hosted`,
);
}
}
});

it('keeps the pool for every non-pull-request trigger', () => {
// The denial form exists so the queue, the nightly and dispatch runs stay
// on the pool without a pull_request context to read; an && / || flip in
// the gate must not exile them to hosted runners.
for (const eventName of ['merge_group', 'schedule', 'workflow_dispatch']) {
assert.deepEqual(
evalRunsOn(windowsRunsOn, {
ecsDisabled: false,
eventName,
sameRepo: false,
assoc: '',
}),
WIN_ECS,
`${eventName} must keep the pool`,
);
}
});

it('the kill-switch wins on every event', () => {
for (const eventName of [
'pull_request',
'merge_group',
'schedule',
'workflow_dispatch',
]) {
assert.deepEqual(
evalRunsOn(windowsRunsOn, {
ecsDisabled: true,
eventName,
sameRepo: true,
assoc: 'OWNER',
}),
WIN_HOSTED,
`kill-switch must win on ${eventName}`,
);
}
});
});

describe('serve-ab.yml runner routing', () => {
const runsOn = String(serveAbDoc.jobs.ab['runs-on']);

Expand Down
157 changes: 157 additions & 0 deletions .github/scripts/ci/classify-platform-sensitivity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';

// Does this change need the macOS and Windows lanes?
//
// Those lanes are the only signal this repository has about a host that is not
// Linux with a GNU userland, and they are expensive, so they run on the diffs
// whose behaviour the HOST decides rather than the code: shell scripts and the
// CI definitions that embed shell (a GNU-only flag, a BSD `sed`, a path
// separator), the script layer and its tests, the test-runner configuration
// that says which suites run where, and the handful of source subtrees named
// after a platform-coupled subsystem.
//
// This list is deliberately a NET, not a proof. It cannot see a platform
// assumption inside an ordinary source file — one that resolves a path, spawns
// a process, or compares two spellings of the same directory — and no
// path-based rule ever will. That gap is what the scheduled run on `main`
// exists for: this classifier buys an early signal on the diffs that carry the
// known failure class, and the nightly catches the rest a day later. Widening
// the list until it matches everything would just restore the cost the lanes
// were moved off pull requests to avoid.
//
// Fail-safe in every direction: an unreadable list, an unparsable entry, or no
// entries at all classifies as sensitive. A missed lane is a defect that ships;
// a needless lane is twenty minutes.

export const PLATFORM_SENSITIVE = 'true';
export const PLATFORM_INSENSITIVE = 'false';

// Anything a shell interprets. `.ps1`/`.bat`/`.cmd` are here for the same
// reason as `.sh`: they are the Windows lane's subject, not an exception to it.
const SHELL_SCRIPT = /\.(?:sh|bash|zsh|ps1|bat|cmd)$/i;

// Workflow and composite-action YAML embeds shell in `run:` blocks, and the
// scripts those blocks call are part of the same program.
const CI_DEFINITION = /^\.github\/(?:workflows|actions|scripts)\//;

// The script layer and its tests: this repository's build, release and CI
// helpers, the suites that drive them, and the fixtures those suites build out
// of real filesystem paths.
const SCRIPT_LAYER = /^scripts\//;

// Which suites run on which lane is itself platform-deciding: an exclusion list
// keyed on `process.platform` is exactly how a suite ends up unrun on one host
// and red on another.
const RUNNER_CONFIG = /(?:^|\/)vitest(?:\.[^/]*)?\.config\.[cm]?[jt]s$/i;

// The dependency and script manifests: a changed `test:ci`, a native module, or
// an optional per-platform dependency changes what each lane executes.
const MANIFEST = new Set(['package.json', 'package-lock.json']);

// Source subtrees whose subject IS the host.
//
// A keyword counts when it NAMES the thing: a whole path segment
// (`src/sandbox/index.ts`, `src/platform/paths.ts`) or the head of a file's
// stem (`pty-host.ts`, `win32.ts`, `shell.ts`). It does not count inside a
// compound that names something else — `packages/web-shell/**` is a browser
// UI, not a shell, and matching it there summoned both expensive lanes on
// every change to one of this repository's largest packages. Nor inside a
// longer word: `Shellfish.tsx`, `plateauDetector.ts`, `cryptic.ts`.
const SUBSYSTEMS =
'pty|tty|sandbox|seatbelt|shell|terminal|clipboard|platform|posix|darwin|macos|windows|win32|linux|keychain|codesign|installer|filesystem|audio';
// A directory or file segment that IS the keyword (optionally with an
// extension): `sandbox/`, `shell.ts`, `win32.test.ts`.
const SUBSYSTEM_SEGMENT = new RegExp(
`(?:^|/)(?:${SUBSYSTEMS})(?:\\.[^/]*)?(?:/|$)`,
'i',
);
// Or the keyword as the head of a hyphen/underscore-separated stem:
// `pty-host.ts`, `shell_exec.ts`. The head only — a trailing part belongs to
// whatever the leading word names.
const SUBSYSTEM_STEM_HEAD = new RegExp(
`(?:^|/)(?:${SUBSYSTEMS})[-_][^/]*(?:/|$)`,
'i',
);

function isSensitivePath(file) {
const p = String(file).replace(/\\/g, '/').replace(/^\.\//, '');
if (!p) return true;
return (
SHELL_SCRIPT.test(p) ||
CI_DEFINITION.test(p) ||
SCRIPT_LAYER.test(p) ||
RUNNER_CONFIG.test(p) ||
MANIFEST.has(p) ||
SUBSYSTEM_SEGMENT.test(p) ||
SUBSYSTEM_STEM_HEAD.test(p)
);
}

/**
* Every name an entry touches. A rename moves a file between two paths, and
* either side can be the sensitive one — a script moved out of `scripts/` is
* still a script change on the lane that ran it.
*/
function namesOf(entry) {
if (typeof entry === 'string') return [entry];
if (!entry || typeof entry !== 'object') return [];
return [entry.filename, entry.previous_filename].filter(
(n) => typeof n === 'string' && n.length > 0,
);
}

export function classifyChangedFiles(entries) {
if (!Array.isArray(entries) || entries.length === 0)
return PLATFORM_SENSITIVE;
for (const entry of entries) {
const names = namesOf(entry);
// An entry that carries no usable name is an unknown change, and an
// unknown change is sensitive.
if (names.length === 0) return PLATFORM_SENSITIVE;
if (names.some(isSensitivePath)) return PLATFORM_SENSITIVE;
}
return PLATFORM_INSENSITIVE;
}

/** The JSONL contract of classify-pr-profile.sh: one projected entry per line. */
export function parseChangedFiles(text) {
return (
String(text)
// `\r?\n`, matching the sibling classifier's reader: a CRLF listing would
// otherwise leave a trailing `\r` on every filename and defeat the
// end-anchored suffix rules above.
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
// Not JSON: treat the raw line as a filename rather than dropping it.
return line;
}
})
);
}

function main() {
const filePath = process.argv[2];
if (!filePath) {
console.log(PLATFORM_SENSITIVE);
return;
}
try {
console.log(
classifyChangedFiles(parseChangedFiles(readFileSync(filePath, 'utf8'))),
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`::warning::Failed to read changed files: ${message}`);
console.log(PLATFORM_SENSITIVE);
}
}

if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
Loading
Loading