Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
132 changes: 132 additions & 0 deletions .github/scripts/ci/classify-platform-sensitivity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/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. Matched on whole path segments and
// on the dot/dash/underscore-separated parts of a basename, so `pty-host.ts`
// and `src/sandbox/index.ts` match while `computed.ts` and `spatial/` do not.
const PLATFORM_SUBSYSTEM =
/(?:^|[/\-._])(?:pty|tty|sandbox|seatbelt|shell|terminal|clipboard|platform|posix|darwin|macos|windows|win32|linux|keychain|codesign|installer|filesystem)(?:[/\-._]|$)/i;
Comment thread
wenshao marked this conversation as resolved.
Outdated
Comment thread
wenshao marked this conversation as resolved.
Outdated

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) ||
PLATFORM_SUBSYSTEM.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)
.split('\n')
Comment thread
wenshao marked this conversation as resolved.
Outdated
Comment thread
wenshao marked this conversation as resolved.
Outdated
.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();
}
179 changes: 179 additions & 0 deletions .github/scripts/ci/classify-platform-sensitivity.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
PLATFORM_INSENSITIVE,
PLATFORM_SENSITIVE,
classifyChangedFiles,
parseChangedFiles,
} from './classify-platform-sensitivity.mjs';

test('an ordinary source change does not summon the expensive lanes', () => {
// The whole point of a gate: the common pull request pays nothing. If this
// ever flips, the lanes are back on every PR and the cost that moved them
// off is back with them.
assert.equal(
classifyChangedFiles([
'packages/cli/src/ui/components/Header.tsx',
'packages/core/src/prompts/system.ts',
'docs/users/configuration.md',
]),
PLATFORM_INSENSITIVE,
);
});

test('shell scripts pull in the lanes, on every dialect', () => {
for (const file of [
'scripts/build.sh',
'tools/release.bash',
'installer/setup.ps1',
'ci/run.bat',
'ci/run.cmd',
'deep/nested/dir/helper.SH',
]) {
assert.equal(
classifyChangedFiles([`packages/core/src/x.ts`, file]),
PLATFORM_SENSITIVE,
file,
);
}
});

test('CI definitions and the scripts they call are shell too', () => {
// The failure this gate exists for lived in a workflow's `run:` block and
// in the suite that drove it: `realpath -m` is GNU-only, so the guard it
// canonicalized with silently did nothing on macOS.
for (const file of [
'.github/workflows/ci.yml',
'.github/actions/configure-windows-runner/action.yml',
'.github/scripts/ci/classify-profile.mjs',
'scripts/tests/qwen-pr-review-workflow.test.js',
'scripts/version.js',
]) {
assert.equal(classifyChangedFiles([file]), PLATFORM_SENSITIVE, file);
}
});

test('the runner configuration decides which lane runs what', () => {
// An exclusion keyed on process.platform is how a suite ends up unrun on
// one host and red on another; a change to it must be seen by both lanes.
for (const file of [
'vitest.config.ts',
'scripts/tests/vitest.config.ts',
'vitest.terminal-bench.config.ts',
'packages/cli/vitest.config.mts',
]) {
assert.equal(classifyChangedFiles([file]), PLATFORM_SENSITIVE, file);
}
// Not every config is the runner's.
assert.equal(
classifyChangedFiles(['packages/cli/eslint.config.js']),
PLATFORM_INSENSITIVE,
);
});

test('the manifests change what each lane executes', () => {
assert.equal(classifyChangedFiles(['package.json']), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles(['package-lock.json']), PLATFORM_SENSITIVE);
// A workspace manifest is not the root one; it reaches the lanes through
// the subsystem rules or not at all.
assert.equal(
classifyChangedFiles(['packages/webui/package.json']),
PLATFORM_INSENSITIVE,
);
});

test('platform-coupled subsystems match on segments, not substrings', () => {
for (const file of [
'packages/core/src/sandbox/index.ts',
'packages/cli/src/ui/pty-host.ts',
'packages/cli/src/utils/clipboard.ts',
'packages/core/src/tools/shell.ts',
'packages/cli/src/platform/paths.ts',
'packages/cli/src/utils/win32.ts',
]) {
assert.equal(classifyChangedFiles([file]), PLATFORM_SENSITIVE, file);
}
// The substring trap: these contain "shell", "pty", "os" or "platform"
// inside a longer word and must NOT drag both lanes in.
for (const file of [
'packages/webui/src/components/Shellfish.tsx',
'packages/core/src/utils/cryptic.ts',
'packages/cli/src/ui/emptyState.ts',
'packages/core/src/telemetry/uploader.ts',
'packages/cli/src/services/plateauDetector.ts',
]) {
assert.equal(classifyChangedFiles([file]), PLATFORM_INSENSITIVE, file);
}
});

test('a rename is judged on both of its names', () => {
// A script moved out of the script layer is still a script change on the
// lane that used to run it — and one moved in is a new one to run.
assert.equal(
classifyChangedFiles([
{
filename: 'tools/build.mjs',
status: 'renamed',
previous_filename: 'scripts/build.mjs',
},
]),
PLATFORM_SENSITIVE,
);
assert.equal(
classifyChangedFiles([
{
filename: 'scripts/build.mjs',
status: 'renamed',
previous_filename: 'tools/build.mjs',
},
]),
PLATFORM_SENSITIVE,
);
assert.equal(
classifyChangedFiles([
{
filename: 'src/b.ts',
status: 'renamed',
previous_filename: 'src/a.ts',
},
]),
PLATFORM_INSENSITIVE,
);
});

test('every unknown answers sensitive, never insensitive', () => {
// A gate that fails open silently stops testing. Each of these is a way the
// input can arrive broken, and each one must still run the lanes.
assert.equal(classifyChangedFiles([]), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles(null), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles(undefined), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles('scripts/x.sh'), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles([{ status: 'added' }]), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles([null]), PLATFORM_SENSITIVE);
assert.equal(classifyChangedFiles([{ filename: '' }]), PLATFORM_SENSITIVE);
});

test('parses the wrapper JSONL contract, and survives a non-JSON line', () => {
const parsed = parseChangedFiles(
[
'{"filename":"src/a.ts","status":"modified","previous_filename":null}',
'',
'scripts/raw-line.sh',
].join('\n'),
);
assert.equal(parsed.length, 2);
assert.equal(parsed[0].filename, 'src/a.ts');
assert.equal(parsed[1], 'scripts/raw-line.sh');
assert.equal(classifyChangedFiles(parsed), PLATFORM_SENSITIVE);
});

test('windows path separators classify the same as posix ones', () => {
// The listing is API-shaped and uses forward slashes, but a caller feeding
// this from a local `git diff` on Windows must not silently classify a
// script layer change as ordinary source.
assert.equal(
classifyChangedFiles(['scripts\\tests\\install-script.test.js']),
PLATFORM_SENSITIVE,
);
});
29 changes: 21 additions & 8 deletions .github/scripts/ci/classify-pr-profile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,25 @@
# meant the same PR could classify differently in each workflow, silently,
# because both fall back to `full` on their own errors.
#
# Usage: classify-pr-profile.sh <owner/repo> <pr-number>
# Prints the profile (docs_only | github_ci_only | full) on stdout.
# Usage: classify-pr-profile.sh <owner/repo> <pr-number> [mode]
# mode `profile` (default) prints docs_only | github_ci_only | full.
# mode `platform` prints true | false — whether the macOS and Windows lanes
# need to run for this PR. Both modes take the SAME listing through this
# script, for the reason above: a second copy of the listing contract is a
# second way for two call sites to see different files for the same PR.
# Exit codes: 0 classified; 2 file listing failed; 3 classifier failed.
set -euo pipefail

repo="${1:?usage: classify-pr-profile.sh <owner/repo> <pr-number>}"
pr="${2:?usage: classify-pr-profile.sh <owner/repo> <pr-number>}"
repo="${1:?usage: classify-pr-profile.sh <owner/repo> <pr-number> [mode]}"
pr="${2:?usage: classify-pr-profile.sh <owner/repo> <pr-number> [mode]}"
mode="${3:-profile}"
# Each mode also names what a TRUNCATED listing must fall back to — the
# conservative answer differs per mode (run everything vs. run the lanes).
case "$mode" in
profile) classifier='classify-profile.mjs'; truncated='full' ;;
platform) classifier='classify-platform-sensitivity.mjs'; truncated='true' ;;
*) echo "classify-pr-profile: unknown mode '${mode}'" >&2; exit 3 ;;
esac

# mktemp + trap, not a fixed name: the self-hosted pool is persistent and
# shared, so a predictable path is a leftover-file landmine, and ci.yml's
Expand All @@ -33,13 +45,14 @@ fi

# The list-files endpoint caps at 3,000 entries. A truncated listing can be
# all docs while an omitted later entry is source, so any mismatch against
# the PR's own changed-file count conservatively classifies as `full`.
# the PR's own changed-file count conservatively classifies as the mode's
# run-everything answer.
declared="$(gh api "repos/${repo}/pulls/${pr}" --jq '.changed_files')" || exit 2
retrieved="$(wc -l < "${files}")"
if [ "${retrieved}" -ne "${declared}" ]; then
echo "classify-pr-profile: retrieved ${retrieved} file entries but PR declares ${declared}; classifying full." >&2
echo "full"
echo "classify-pr-profile: retrieved ${retrieved} file entries but PR declares ${declared}; classifying ${truncated}." >&2
echo "${truncated}"
exit 0
fi

node "$(dirname "$0")/classify-profile.mjs" "${files}" || exit 3
node "$(dirname "$0")/${classifier}" "${files}" || exit 3
Loading
Loading