Files
qwen-code/scripts/tests/ci-platform-lanes.test.js
T
易良andyiliang114 708ba347d3 feat(ci): add disposable external PR runners (#16513)
* feat(ci): add disposable external PR runners

* test(ci): align platform lanes with disposable runner routing

---------

Co-authored-by: yiliang114 <[email protected]>
2026-11-07 14:24:33 +00:00

2153 lines
95 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// The macOS and Windows lanes, and the gate that decides when they run.
//
// They were gated on `merge_group` alone while no merge queue was enabled, so
// they had not run since 2026-07-02: reported as "skipped" on every pull
// request — which reads as agreement — and never reached afterwards. The
// repository's only non-Linux, non-GNU signal was silently off, and a macOS
// failure shipped and sat in `main` (#9220). Nothing here can prove a lane
// ran; what these tests hold is the wiring that lets it: the triggers, the
// nightly's blast radius, and the alerting that makes a nightly failure
// visible.
//
// The pull-request trigger and its platform-sensitivity classifier are OFF
// while the standing Windows failures are being fixed (see the note above
// test_macos in ci.yml): on pull requests the Windows lane was reporting
// failures on every PR for defects no PR caused, and neither lane gates a
// merge. That leaves the nightly as the lanes' ONLY live trigger, so the
// assertions here are the wiring that keeps it alive — the schedule exists,
// both lanes accept it, nothing else rides it, and its failure files an
// issue. Restoring the pull-request path means reverting the commit that
// carried this change; the classifier script and its tests were left in
// place so that stays a revert.
import { execFileSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
const ci = parse(readFileSync('.github/workflows/ci.yml', 'utf8'));
const failureIssue = parse(
readFileSync('.github/workflows/main-ci-failure-issue.yml', 'utf8'),
);
// `on:` parses as the boolean true in YAML 1.1.
const triggers = ci[true] ?? ci['on'];
const LANES = ['test_macos_shards', 'test_windows'];
const PUSH_JOBS = [
'classify_pr',
'test_build',
'test',
'test_ubuntu',
'lint_and_static',
];
const condOf = (job) => String(ci.jobs[job].if ?? '');
describe('complete macOS test groups', () => {
it('partitions both heavy suites and retains every remaining test group', () => {
const job = ci.jobs.test_macos_shards;
expect(job.strategy['fail-fast']).toBe(false);
expect(job.strategy.matrix.include).toEqual([
{ group: 'cli', shard: '1/3' },
{ group: 'cli', shard: '2/3' },
{ group: 'cli', shard: '3/3' },
{ group: 'core', shard: '1/2' },
{ group: 'core', shard: '2/2' },
{ group: 'rest', shard: '' },
{ group: 'scripts', shard: '' },
]);
const step = job.steps.find(
(candidate) => candidate.name === 'Run tests and generate reports',
);
expect(step.env.MACOS_TEST_GROUP).toBe('${{ matrix.group }}');
expect(step.env.MACOS_TEST_SHARD).toBe('${{ matrix.shard }}');
expect(step.run).toContain(
'node scripts/run-macos-ci-group.js "$MACOS_TEST_GROUP" "$MACOS_TEST_SHARD"',
);
});
it('keeps the original check name and rejects incomplete matrix results', () => {
const aggregate = ci.jobs.test_macos;
expect(aggregate.name).toBe('Test (macos-latest, Node 22.x)');
expect(aggregate.needs).toBe('test_macos_shards');
expect(aggregate.if).toContain('always()');
const [step] = aggregate.steps;
expect(step.env.MACOS_RESULT).toBe('${{ needs.test_macos_shards.result }}');
expect(step.run).toBe('test "$MACOS_RESULT" = success');
for (const result of ['failure', 'cancelled', 'skipped', '']) {
expect(() =>
execFileSync('bash', ['-c', step.run], {
env: { ...process.env, MACOS_RESULT: result },
stdio: 'pipe',
}),
).toThrow();
}
});
it('keeps the aggregate gate on exactly the events the shards run on', () => {
// The aggregate only reads `needs.test_macos_shards.result`, so a gate
// that drifts from the shards' makes the preserved check name report
// `skipped` while the seven legs run and fail.
for (const event of ['merge_group', 'schedule', 'workflow_dispatch']) {
expect(gateRunsOn(condOf('test_macos'), event)).toBe(
gateRunsOn(condOf('test_macos_shards'), event),
);
}
expect(gateRunsOn(condOf('test_macos'), 'pull_request')).toBe(false);
});
});
// The extended ceilings below, except the test lane's, are scoped to the
// self-hosted pool: `runs-on` falls back to ubuntu-latest when
// MAINTAINER_ECS_RUNNER_DISABLED=true (and, for the association-gated lanes,
// for untrusted fork PRs with the disposable pool disabled), so 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
// substitute-then-evaluate technique
// `.github/scripts/ci-runner-routing.test.mjs` uses on `runs-on` -- so a
// regression to an unconditional ceiling fails here instead of reading as a
// passing constant.
const ECS_RUNNER = '["self-hosted", "linux", "x64", "ecs-qwen"]';
const ARM_RUNNER = '["self-hosted", "linux", "arm64"]';
const HOSTED_RUNNER = '["ubuntu-latest"]';
// 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. 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 and
// integration_no_ak.
const effective = runner || HOSTED_RUNNER;
const output = runnerOutputOf(job);
let expr = String(ci.jobs[job]['timeout-minutes'])
.replace(/^\$\{\{\s*/, '')
.replace(/\s*\}\}$/, '')
.replace(
new RegExp(
`contains\\(needs\\.classify_pr\\.outputs\\.${output} \\|\\| '\\["ubuntu-latest"\\]', 'self-hosted'\\)`,
'g',
),
String(effective.includes('self-hosted')),
)
.replace(/fromJSON\(/g, '(');
if (/needs\.|contains\(|fromJSON\(/.test(expr)) {
throw new Error(
`${job} timeout carries a term this guard does not model: ${expr}`,
);
}
return Number(new Function(`return (${expr});`)());
};
describe('raw JUnit report retention', () => {
const steps = ci.jobs.test.steps;
const upload = steps.find(
(step) => step.name === 'Upload Test Results Artifact',
);
// The rule `gateRunsOn` states for itself applies here too: a context term
// these stubs do not model must throw rather than read as `undefined`,
// where an inequality such as `x != true` would silently evaluate true.
const loud = (value, path) =>
typeof value !== 'object' || value === null
? value
: new Proxy(value, {
get: (target, key) => {
if (typeof key === 'symbol' || key in target) {
return loud(target[key], `${path}${String(key)}.`);
}
throw new Error(
`gate carries a term this guard does not model: ${path}${String(key)}`,
);
},
});
const evaluate = (expression, event, headRepo, skipCi, profile) =>
new Function(
'github',
'needs',
'steps',
'always',
`return (${expression.replace(/^\$\{\{\s*/, '').replace(/\s*\}\}$/, '')});`,
)(
loud(
{
event_name: event,
repository: 'QwenLM/qwen-code',
event: { pull_request: { head: { repo: { full_name: headRepo } } } },
},
'github.',
),
loud({ classify_pr: { outputs: { skip_ci: skipCi } } }, 'needs.'),
loud({ ci_profile: { outputs: { ci_profile: profile } } }, 'steps.'),
() => true,
);
it.each([
[
'pull_request',
'contributor/qwen-code',
'test-results-fork-22.x-ubuntu-latest',
],
['pull_request', 'QwenLM/qwen-code', 'test-results-22.x-ubuntu-latest'],
['push', '', 'test-results-22.x-ubuntu-latest'],
])(
'retains reports for %s from %s, including failed runs',
(event, repo, name) => {
expect(upload.if).toContain('always()');
expect(evaluate(upload.if, event, repo, 'false', 'full')).toBe(true);
// Each shard uploads under its own name: upload-artifact v4+ rejects
// a duplicate within the run.
const shardSuffix = '-shard-${{ matrix.shard }}';
expect(upload.with.name.endsWith(shardSuffix)).toBe(true);
const prefix = upload.with.name.slice(0, -shardSuffix.length);
expect(evaluate(prefix, event, repo, 'false', 'full')).toBe(name);
expect(evaluate(upload.if, event, repo, 'true', 'full')).toBe(false);
expect(evaluate(upload.if, event, repo, 'false', 'web-shell-only')).toBe(
false,
);
expect(evaluate(upload.if, event, repo, 'false', '')).toBe(false);
},
);
it('uploads raw XML for seven days without requiring a report to exist', () => {
// Pinned to the 40-hex SHA, not the bare action name: this step now runs
// on every full-profile lane inside a job holding `checks: write`, so a
// relaxation to a mutable tag must not pass unnoticed.
expect(upload.uses).toMatch(/^actions\/upload-artifact@[0-9a-f]{40}$/);
expect(upload.with.path).toBe('packages/*/junit.xml');
expect(upload.with['retention-days']).toBe(7);
expect(upload.with['if-no-files-found']).toBe('ignore');
});
it('throws on a context term the stubs do not model', () => {
// An inequality over an unmodelled term would otherwise read `undefined`
// and pass: `undefined != true` is true, so all three rows above would
// stay green while asserting nothing about the new term.
expect(() =>
evaluate(
'${{ github.event.pull_request.draft != true }}',
'push',
'',
'false',
'full',
),
).toThrow(/does not model/);
});
it('keeps report publication advisory and unit-test failures blocking', () => {
const publisher = steps.find(
(step) => step.name === 'Publish Test Report (for non-forks)',
);
const unitTests = steps.find((step) => step.id === 'unit_tests');
expect(publisher['continue-on-error']).toBe(true);
expect(publisher.with['fail-on-error']).toBe('false');
expect(upload['continue-on-error']).toBe(true);
expect(unitTests['continue-on-error']).not.toBe(true);
expect(unitTests.run).toContain('exit "$RC"');
expect(steps.indexOf(upload)).toBeGreaterThan(steps.indexOf(publisher));
});
});
// The smoke lane keeps the classifier's trust decision and hosted overrides
// 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, runner) => {
const output = runnerOutputOf(job);
const expression = String(ci.jobs[job]['runs-on'])
.replace(/^\$\{\{\s*/, '')
.replace(/\s*\}\}$/, '');
return new Function(
'needs',
'fromJSON',
'contains',
`return (${expression});`,
)(
{ classify_pr: { outputs: { [output]: runner } } },
JSON.parse,
(labels, label) => labels.includes(label),
);
};
it('gives the shared Linux test lane one ceiling on both pools', () => {
// The unsharded suite outgrew 60 minutes on hosted runners: on 2026-10-17,
// 17 of the 30 hosted runs that ended on their own were cancelled at 60.
// Each shard runs an eighth of it, beside one build of the tree.
for (const runner of [ECS_RUNNER, HOSTED_RUNNER, '']) {
expect(timeoutMinutesOn('test_build', runner)).toBe(45);
expect(timeoutMinutesOn('test', runner)).toBe(60);
}
expect(ci.jobs.test.strategy['fail-fast']).toBe(false);
expect(ci.jobs.test.strategy.matrix.shard).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
});
it('skips every build-consuming shard step when test_build failed', () => {
// `!cancelled()` is true when a needed job fails, so without this term a
// failed build still starts all eight shards: each pays a full install and
// then dies at the missing artifact, and every shard reports artifact
// plumbing as its failing step instead of the build that broke. The steps
// are gated, not the job or the matrix: `test_ubuntu` compares
// `needs.test.result` to `success`, so a shard must still conclude success
// when the build failed and let the aggregate report the failure.
const steps = new Map(
(ci.jobs.test.steps ?? []).map((step) => [step.name, step]),
);
for (const name of [
'Install dependencies',
'Download the built tree',
'Unpack the built tree',
'Run tests and generate reports',
'Package and scan Chrome extension artifacts',
]) {
expect(String(steps.get(name)?.if ?? ''), name).toContain(
"needs.test_build.result == 'success'",
);
}
});
it('keeps every blocking test_build step under the job ceiling', () => {
// The job cap cancels the job, and a cancelled job skips the failure-gated
// collectors at the end — the file they upload is the reason this job has
// them. A step cap fails only that step, so the collectors still run. Each
// cap therefore has to fire before the job's.
const jobCap = Number(ci.jobs.test_build['timeout-minutes']);
const steps = new Map(
(ci.jobs.test_build.steps ?? []).map((step) => [step.name, step]),
);
for (const name of [
'Install dependencies',
'Build',
'Pack the build cache',
'Archive the built tree',
]) {
const minutes = steps.get(name)?.['timeout-minutes'];
expect(typeof minutes, `${name} has no step timeout`).toBe('number');
expect(minutes, `${name} cannot outlast the job`).toBeLessThan(jobCap);
}
});
it('keeps every shard step cap under the job ceiling', () => {
// Same reason as the build job: the job cap cancels the job, and a cancelled
// job skips the failure-gated disk-pressure collector at the end. The caps
// are pinned one by one rather than summed — they are alternatives in
// practice, since a step only runs when the previous one left work to do,
// and install plus the test step already add up past the ceiling by design.
// The job cap therefore stays the outer backstop, not the sum of the parts.
const jobCap = Number(ci.jobs.test['timeout-minutes']);
const steps = new Map(
(ci.jobs.test.steps ?? []).map((step) => [step.name, step]),
);
for (const name of [
'Install dependencies',
'Download the built tree',
'Retry downloading the built tree',
'Run tests and generate reports',
]) {
const minutes = steps.get(name)?.['timeout-minutes'];
expect(typeof minutes, `${name} has no step timeout`).toBe('number');
expect(minutes, `${name} cannot outlast the job`).toBeLessThan(jobCap);
}
});
it('reports the required Linux check only when the build and every shard pass', () => {
const gate = ci.jobs.test_ubuntu;
expect(gate.name).toBe('Test (ubuntu-latest, Node 22.x)');
expect(gate.needs).toEqual(['classify_pr', 'test_build', 'test']);
// The gate remains on the original association-gated runner, while test
// shards can opt into the disposable pool for untrusted forks.
expect(gate['runs-on']).toBe(
'${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || \'["ubuntu-latest"]\') }}',
);
expect(condOf('test_ubuntu')).toBe(condOf('test'));
expect(gate.steps[0].run).toBe(
'test "$BUILD_RESULT" = success && test "$SHARDS_RESULT" = success',
);
// The shard count in the step's --shard has to match the matrix.
const run = ci.jobs.test.steps.find(
(step) => step.name === 'Run tests and generate reports',
);
expect(run.env.UNIT_TEST_SHARD).toBe(
`\${{ matrix.shard }}/${ci.jobs.test.strategy.matrix.shard.length}`,
);
});
it('pins the build-to-shard artifact handoff on both ends', () => {
const built = new Map(
(ci.jobs.test_build.steps ?? []).map((step) => [step.name, step]),
);
const shard = new Map(
(ci.jobs.test.steps ?? []).map((step) => [step.name, step]),
);
// The shards install with QWEN_SKIP_PREPARE and never build packages/*/dist
// themselves, so the archive must define the tree with the same path list
// `pack` uses — a local copy that drifts ships a tree nothing tested.
const archive = built.get('Archive the built tree').run;
expect(archive).toContain('node scripts/ci-build-cache.mjs paths');
expect(archive).not.toContain('git ls-files');
// Four coupled literals tie the producer to the consumers. Renaming any one
// fails all eight shards at unpack after a full build, on the required
// check, so the name has to be pinned in one place.
const name = built.get('Upload the built tree').with.name;
expect(name).toBeTruthy();
expect(shard.get('Download the built tree').with.name).toBe(name);
expect(shard.get('Retry downloading the built tree').with.name).toBe(name);
// Both attempts have to outlast the slow tail, not the median: eight shards
// fetch this same artifact at once, the slowest completed one on main took
// just under five minutes, and one exceeded the old five-minute cap on two
// runs in a row, skipping the unpack entirely.
for (const step of [
'Download the built tree',
'Retry downloading the built tree',
]) {
expect(shard.get(step)?.['timeout-minutes'], step).toBe(15);
}
expect(shard.get('Unpack the built tree').run).toContain(
`\${RUNNER_TEMP}/${name}/${name}.tgz`,
);
});
it
.skipIf(process.platform === 'win32')
.each(['test', 'web_shell_e2e_smoke', 'integration_no_ak'])(
'refreshes host assets on older PR checkouts in %s',
(job) => {
const root = mkdtempSync(join(tmpdir(), 'ci-host-assets-'));
try {
mkdirSync(join(root, 'scripts'));
mkdirSync(join(root, 'dist', 'opentui-assets'), { recursive: true });
writeFileSync(join(root, 'package.json'), '{"type":"module"}');
writeFileSync(
join(root, 'scripts', 'ci-build-cache.mjs'),
'throw new Error("usage: ci-build-cache.mjs key | build <dir> | pack <dir> | paths");',
);
writeFileSync(
join(root, 'scripts', 'copy_bundle_assets.js'),
`import { copyFileSync } from 'node:fs';
export function copyOpenTuiAssets() {
copyFileSync('host-native.so', 'dist/opentui-assets/native.so');
}
`,
);
writeFileSync(join(root, 'host-native.so'), 'consumer-host');
writeFileSync(join(root, 'dist', 'cli.js'), 'built-bundle');
writeFileSync(
join(root, 'dist', 'opentui-assets', 'native.so'),
'build-host',
);
const runnerTemp = join(root, 'runner-temp');
const artifact = join(runnerTemp, 'unit-test-tree');
mkdirSync(artifact, { recursive: true });
execFileSync(
'tar',
['-czf', join(artifact, 'unit-test-tree.tgz'), 'dist'],
{
cwd: root,
},
);
rmSync(join(root, 'dist'), { recursive: true });
const unpack = ci.jobs[job].steps.find(
(step) => step.name === 'Unpack the built tree',
);
execFileSync('bash', ['-e', '-o', 'pipefail', '-c', unpack.run], {
cwd: root,
env: { ...process.env, RUNNER_TEMP: runnerTemp },
});
expect(readFileSync(join(root, 'dist', 'cli.js'), 'utf8')).toBe(
'built-bundle',
);
expect(
readFileSync(join(root, 'dist', 'opentui-assets', 'native.so'), 'utf8'),
).toBe('consumer-host');
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
it('keeps the no-AK gate above its dependency-install budget on ECS', () => {
expect(timeoutMinutesOn('integration_no_ak', ECS_RUNNER)).toBe(60);
});
it('keeps the no-AK gate on its pre-contention ceiling when hosted', () => {
expect(timeoutMinutesOn('integration_no_ak', HOSTED_RUNNER)).toBe(30);
expect(timeoutMinutesOn('integration_no_ak', '')).toBe(30);
});
it('keeps lint_and_static sized for cold-cache pool runs', () => {
// Both numbers are measured, not predicted: a flat 45 was tried first and
// the lane was beheaded twice running honestly on cold-cache hk3 pool
// runners — at 30 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). Routed like test's 120/60 and no_ak's 60/30 for the same
// pool-contention reason; hosted keeps the tighter ceiling so a genuine
// hang there does not burn the ECS allowance.
expect(timeoutMinutesOn('lint_and_static', ECS_RUNNER)).toBe(90);
expect(timeoutMinutesOn('lint_and_static', HOSTED_RUNNER)).toBe(45);
expect(timeoutMinutesOn('lint_and_static', '')).toBe(45);
});
it('gives the ARM64 fleet the same contended-pool ceilings as the x64 pool', () => {
// `LINUX_RUNNER_FLEET=arm64` renders ["self-hosted", "linux", "arm64"] — the
// same reserved, shared hosts without the x64 fleet's `ecs-qwen` label. A
// fleet-name scope would price them as hosted and behead lint at 45 / no_ak
// at 30, which reads as an ARM compatibility failure.
expect(timeoutMinutesOn('lint_and_static', ARM_RUNNER)).toBe(90);
expect(timeoutMinutesOn('integration_no_ak', ARM_RUNNER)).toBe(60);
});
it('routes the browser smoke to ARM64 independently of the shared Linux test lane', () => {
// The lane keeps the classifier's trust decision and hosted overrides, but
// it no longer inherits the ordinary Linux fleet's architecture.
expect(runnerOn('web_shell_e2e_smoke', ECS_RUNNER)).toEqual([
'self-hosted',
'linux',
'arm64',
]);
expect(runnerOn('web_shell_e2e_smoke', HOSTED_RUNNER)).toEqual([
'ubuntu-latest',
]);
// A missing classifier output (classify_pr skipped) is the hosted routing.
expect(runnerOn('web_shell_e2e_smoke', '')).toEqual(['ubuntu-latest']);
const smoke = ci.jobs.web_shell_e2e_smoke.steps.find(
(step) => step.name === 'Run web-shell browser smoke',
);
// Each of the four shards runs a quarter of the smoke at the hosted
// worker count; the transcript gate runs once, on the first shard.
expect(smoke.run).toMatch(
/ -- --shard=\$\{\{ matrix\.shard \}\}\/4 --workers=2$/,
);
expect(ci.jobs.web_shell_e2e_smoke.strategy.matrix.shard).toEqual([
1, 2, 3, 4,
]);
expect(ci.jobs.web_shell_e2e_smoke.strategy['fail-fast']).toBe(false);
const result = ci.jobs.web_shell_e2e_smoke_result;
expect(result.name).toBe('web-shell E2E Smoke (ubuntu-latest, Node 22.x)');
expect(result.needs).toEqual([
'classify_pr',
'test_build',
'web_shell_e2e_smoke',
]);
// The smoke tests test_build's tree instead of building its own, and a
// draft skips it along with the no-AK integration gate.
expect(ci.jobs.web_shell_e2e_smoke.needs).toEqual([
'classify_pr',
'test_build',
]);
expect(condOf('web_shell_e2e_smoke')).toContain(
"needs.test_build.result == 'success'",
);
expect(condOf('web_shell_e2e_smoke')).toContain(
'!github.event.pull_request.draft',
);
const smokeSteps = ci.jobs.web_shell_e2e_smoke.steps.map((x) => x.name);
expect(smokeSteps).not.toContain('Build');
expect(smokeSteps).toContain('Unpack the built tree');
expect(result['runs-on']).toBe(ci.jobs.web_shell_e2e_smoke['runs-on']);
expect(condOf('web_shell_e2e_smoke_result')).toBe(
condOf('web_shell_e2e_smoke'),
);
expect(result.steps[0].run).toBe('test "$SMOKE_RESULT" = success');
expect(smoke.env.TMPDIR).toBe('${{ runner.temp }}');
const transcript = ci.jobs.web_shell_e2e_smoke.steps.find(
(step) => step.name === 'Run transcript document browser gate',
);
expect(transcript.env.TMPDIR).toBe('${{ runner.temp }}');
expect(transcript.if).toBe('${{ matrix.shard == 1 }}');
// Slow hosted browser installs took over 23 min, so reserve time in the
// 60-minute job for transcript/smoke tests and artifact upload, while the
// install step itself stays bounded.
expect(timeoutMinutesOn('web_shell_e2e_smoke', ECS_RUNNER)).toBe(60);
expect(timeoutMinutesOn('web_shell_e2e_smoke', HOSTED_RUNNER)).toBe(60);
expect(timeoutMinutesOn('web_shell_e2e_smoke', '')).toBe(60);
const hostedInstall = ci.jobs.web_shell_e2e_smoke.steps.find(
(step) => step.name === 'Install Playwright Chromium and WebKit (hosted)',
);
expect(hostedInstall['timeout-minutes']).toBe(30);
});
// One helper for both "an <event> run reaches exactly these jobs" invariants.
//
// It decides by EVALUATING each gate for the event, not by looking for tokens
// in it — the same substitute-then-evaluate technique
// `.github/scripts/ci-runner-routing.test.mjs` uses on `runs-on`. Token
// probes are connective-blind: an `&&`→`||` flip inside an excluded job, or an
// allowlisted equality paired with an event-neutral disjunct under `||`,
// changes which jobs run while every substring stays exactly where it was.
// Evaluation sees the change because it computes the answer instead of
// pattern-matching the question, and it needs no expression parser of our own.
//
// Any context term the substitutions do not model throws rather than reading
// as false, so a gate that grows a new input is re-read here instead of
// silently degrading this guard.
const gateRunsOn = (cond, event) => {
if (String(cond ?? '').trim() === '') return true;
let e = String(cond)
.replace(/\$\{\{/g, ' ')
.replace(/\}\}/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const subs = [
[/!cancelled\(\)/g, 'true'],
[/cancelled\(\)/g, 'false'],
[/always\(\)/g, 'true'],
[/github\.event_name/g, `"${event}"`],
// These pins model a normal run: a gate keyed on any other value of a
// modeled output (e.g. `ci_profile != 'full'`, `skip_ci == 'true'`)
// evaluates here at the pin and may mis-report its reachability —
// re-derive this helper's verdict before adding such a gate.
[/needs\.classify_pr\.outputs\.skip_ci/g, '"false"'],
[/needs\.classify_pr\.outputs\.ci_profile/g, '"full"'],
// A normal run: the build passed and the pull request is not a draft.
[/needs\.test_build\.result/g, '"success"'],
[/github\.event\.pull_request\.draft/g, 'false'],
[
/github\.event\.pull_request\.head\.repo\.full_name/g,
event === 'pull_request' ? '"QwenLM/qwen-code"' : '""',
],
[/github\.repository/g, '"QwenLM/qwen-code"'],
];
for (const [re, v] of subs) e = e.replace(re, v);
e = e
.replace(/!==/g, '@NE@')
.replace(/===/g, '@EQ@')
.replace(/!=/g, '@NE@')
.replace(/==/g, '@EQ@')
.replace(/@NE@/g, '!==')
.replace(/@EQ@/g, '===');
if (/github\.|needs\.|steps\.|vars\.|inputs\./.test(e)) {
throw new Error(`gate carries a term this guard does not model: ${e}`);
}
return Boolean(new Function(`return (${e});`)());
};
// Asserts both directions: the jobs that must run on `event` do, and every
// other job does not. The positive half matters as much — a gate that stops
// admitting its own event turns the lane off with nothing else going red.
const assertEventReachesOnly = (event, allowedJobs) => {
for (const [name, job] of Object.entries(ci.jobs)) {
const reached = gateRunsOn(job.if, event);
expect(
reached,
allowedJobs.includes(name)
? `${name} no longer runs on ${event}`
: `${name} would also run on ${event}`,
).toBe(allowedJobs.includes(name));
}
};
describe('platform lanes — triggers', () => {
it('gives the workflow a scheduled trigger', () => {
// Without it the lanes have no path to `main` at all: `ci.yml`'s push
// trigger is accepted only by `test`, `lint_and_static`, and the
// `classify_pr` they depend on,
// so a merge-queue-only gate on a repository with no merge queue is an off
// switch for these two.
expect(triggers.schedule).toBeDefined();
expect(Array.isArray(triggers.schedule)).toBe(true);
expect(triggers.schedule[0].cron).toMatch(/^\S+ \S+ \S+ \S+ \S+$/);
});
for (const lane of LANES) {
it(`${lane} runs on the schedule, the queue, and a dispatch`, () => {
const cond = condOf(lane);
// Presence AND the disjunction between clauses: an `&&` where a `||`
// belongs leaves the gate unsatisfiable for a trigger (event_name is
// single-valued) while a presence-only check stays green.
expect(cond).toMatch(/event_name == 'merge_group'\s*\|\|/);
expect(cond).toMatch(/event_name == 'schedule'\s*\|\|/);
expect(cond).toContain("github.event_name == 'workflow_dispatch'");
});
it(`${lane} stays off the pull-request path while it is red there`, () => {
// Half a restoration is worse than none: a pull_request arm put back
// without its classifier (or the reverse) either runs the lanes on
// every PR or consults an output no job produces. Restore the trigger
// by reverting the commit that removed it, not by editing one side.
const cond = condOf(lane);
expect(cond).not.toContain("'pull_request'");
expect(cond).not.toContain('platform_sensitive');
expect(ci.jobs[lane].needs).not.toContain('classify_platform');
});
it(`${lane}'s triggers are alternatives, not requirements`, () => {
// The clause-presence assertions above survive a connective mutation:
// `||` → `&&` between two event clauses leaves every string in place
// and makes the gate unsatisfiable for every trigger, silently turning
// both lanes off again — the exact state this PR exists to end. Read
// the event group and require it to be a disjunction.
const cond = condOf(lane).replace(/\s+/g, ' ');
// From the first event clause to the close of the group — not from the
// first `(`, which belongs to `!cancelled()`.
const group = cond.slice(
cond.indexOf('github.event_name'),
cond.lastIndexOf(')'),
);
expect(group).toContain("github.event_name == 'schedule'");
expect(group.split('||').length).toBeGreaterThanOrEqual(3);
for (const clause of group.split('||')) {
expect(
clause,
`event clause is conjoined: ${clause.trim()}`,
).not.toContain('&&');
}
});
it(`${lane} survives an upstream skip`, () => {
// classify_pr is still a `needs` edge; without `!cancelled()` a skip
// or failure there would skip the lane on the nightly too.
expect(condOf(lane)).toContain('!cancelled()');
});
}
for (const lane of LANES) {
it(`${lane} is bounded so a hang cannot burn the 360-minute default`, () => {
// The nightly's alert fires only when the run completes; a lane hung
// on a host-specific prompt otherwise sits out GitHub's default
// timeout before it fails and anyone is told.
expect(ci.jobs[lane]['timeout-minutes'], lane).toBe(60);
});
}
for (const lane of LANES) {
it(`${lane}'s steps are gated for every trigger it now has`, () => {
// The first thing the revived triggers hit was not a test failure but
// the lane's own plumbing: a `verify-checkout-head` step written when
// this lane ran in the merge queue alone, with `expected_sha` naming
// only `github.event.merge_group.head_sha`. On a pull request that
// input is empty and the step fails the lane before a single test
// runs. A step whose inputs name one event must be gated to that
// event — for every step in a job that now runs on four.
for (const step of ci.jobs[lane].steps ?? []) {
// Every place a step can read an event context, not just `with:` —
// an interpolation in `run:` or `env:` is the same defect wearing a
// different key.
const inputs = JSON.stringify({
with: step.with ?? {},
env: step.env ?? {},
run: step.run ?? '',
});
const gate = String(step.if ?? '');
for (const [context, event] of [
['github.event.merge_group', "'merge_group'"],
['github.event.pull_request', "'pull_request'"],
]) {
if (!inputs.includes(context)) continue;
const guarded =
inputs.includes(`github.event_name == ${event}`) ||
gate.includes(`github.event_name == ${event}`);
expect(
guarded,
`${lane} step "${step.name}" reads ${context} on every trigger`,
).toBe(true);
}
}
});
}
it('keeps a nightly run to exactly the two lanes', () => {
// A `schedule:` trigger fires the whole workflow. Every other job must
// therefore either exclude `schedule` outright or gate on an event
// allowlist that cannot contain it — otherwise the nightly quietly
// becomes a full CI run every day.
assertEventReachesOnly('schedule', [...LANES, 'test_macos']);
});
});
// The post-merge push lane on `main`.
//
// `ci.yml` carried no push trigger while no merge queue was enabled, so
// nothing validated the merged tree before it landed and the only check after
// it was the ~40-minute E2E — a regression could sit in `main` for hours. The
// trigger is back, taken by `test`, `lint_and_static`, and the `classify_pr`
// they depend on.
//
// The runner-routing tests in `.github/scripts/ci-runner-routing.test.mjs`
// cannot reach any of this: they drive `pick_runner`'s shell directly with
// EVENT_NAME=push, bypassing the trigger and all three job gates, so they stay
// green if either loses its push arm. What is pinned here is the YAML half —
// the same class of invariant as `keeps a nightly run to exactly the two
// lanes` above, and for the same reason: every mutation below leaves the whole
// suite green while silently turning the lane off, widening it, or pointing it
// at the wrong tree.
describe('post-merge push lane', () => {
const stepOf = (job, name) =>
(ci.jobs[job].steps ?? []).find((s) => s.name === name);
it('is triggered on main and nowhere else', () => {
// Drop the trigger and the post-merge signal is gone with it; widen the
// branch list and every dev branch push spends a pool runner.
// The whole object, not just `branches`: a sibling `tags:` key would
// widen the lane to every tag push while `branches` still read `['main']`.
// That also breaks the size ratchet — on a tag-creation push
// `github.event.before` is all zeros, so WORKFLOW_SIZE_BASE_SHA is
// unresolvable and both enforcers take their strict arm — and
// main-ci-failure-issue.yml's `head_branch == 'main'` filter means no
// issue is filed for tag runs: a silent red on release day.
expect(triggers.push).toEqual({ branches: ['main'] });
});
it("lint_and_static shares test's exact gate", () => {
// The split exists so this job can become a required check on its own;
// its gate is therefore as security-relevant as test's. One equality
// rides on test's whole-literal pin below: if the two gates ever
// diverge — someone widening or narrowing the lint lane independently —
// this fails before the divergence ships, and the literal pin still
// guards the shared shape.
expect(condOf('lint_and_static')).toBe(condOf('test'));
});
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 publishes TWO
// routings: ubuntu_runner keeps the fork-trust boundary for test and
// integration_cli, while lint_runner routes every pull request —
// untrusted forks included — to the fleet (maintainer decision
// 2026-10-31, shared-pool code-execution risk accepted, extended to
// integration_no_ak 2026-11-02). 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(
'${{ 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
// decouples from the classifier: runs-on falls through the `|| '…'` to
// hosted runners, skip_ci reads '' so a release-sync PR promised a no-op
// pass runs the full battery, and `Use trusted CI profile` takes its
// documented degraded empty→full fallback.
expect(
[].concat(ci.jobs.lint_and_static.needs ?? []),
'lint_and_static must consume the classifier',
).toContain('classify_pr');
});
it('keeps test on the opt-in disposable output', () => {
// The classifier defaults this output to ubuntu_runner, but selects the
// disposable pool for untrusted forks only when explicitly enabled.
expect(String(ci.jobs.test['runs-on'])).toBe(
'${{ fromJSON(needs.classify_pr.outputs.pr_test_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`
// executes the PR's lifecycle scripts) on same-repo PRs, merge_group,
// dispatch and push; widening this — say by copying `test`'s block when
// adding a reporter — hands write tokens to that code. `test`'s own
// wider block is legitimate: dorny/test-reporter needs `checks: write`.
// The contract guard reads the PR file list; it needs no write access.
expect(ci.jobs.lint_and_static.permissions).toEqual({
contents: 'read',
'pull-requests': 'read',
});
});
it('keeps every shared prelude step identical between test and lint_and_static', () => {
// The split copied the checkout/node/install prelude into the new job,
// and copies drift: the first merge after the split brought #10799's
// trusted-classifier rework, which updated `test`'s copy and left the
// lint one stale — resolved by hand that time. Field-for-field equality
// over every same-named step turns the next drift into a red test
// instead of a review archaeology exercise.
// Recursive canonicalisation — NOT JSON.stringify's array replacer,
// which is a property allowlist applied at every nesting level: with it,
// each step's nested `with:` and `env:` serialised as `{}`, so the guard
// was blind to drift in Checkout's ref/fetch-depth, setup-node's
// versions, and — the consequential one — `Use trusted CI profile`'s
// TRUSTED_CI_PROFILE binding, whose drift to a literal would skip every
// profile-gated step while reporting green.
const canon = (v) =>
Array.isArray(v)
? v.map(canon)
: v && typeof v === 'object'
? Object.fromEntries(
Object.keys(v)
.sort()
.map((k) => [k, canon(v[k])]),
)
: v;
// Exempt the FIELD that must differ, not the whole step: the collector's
// artifact name is per-job (upload-artifact v4+ rejects duplicates when
// both jobs fail in one run; ci-disk-pressure.test.mjs asserts the
// notEqual), but a whole-step exemption left its `uses:` revision — the
// one action pin in the job — compared by nothing.
const byName = (job) =>
new Map(
(ci.jobs[job].steps ?? []).map((s) => {
const { name: _artifactName, ...restWith } = s.with ?? {};
// Both install without the prepare build: lint needs none, and
// the test shards unpack the one test_build made. Their `if`s now
// differ on purpose — a shard's install must not run when the
// build failed, while lint has no such dependency — so it is
// stripped here and each side is pinned explicitly below.
if (job !== 'test_build' && s.name === 'Install dependencies') {
if (job === 'test') {
expect(s.env).toEqual({
QWEN_SKIP_PREPARE: '1',
QWEN_SKIP_NOTICE_GENERATION: '1',
// The shards' test step overrides HOME, which would hide the
// corepack cache install warms here; pin COREPACK_HOME to a
// fixed path under runner.temp so the warmup hits it (the
// install body clears that exact path first). lint_and_static
// routes HOME and COREPACK_HOME together via GITHUB_ENV, so
// its install needs no step-level override.
COREPACK_HOME: '${{ runner.temp }}/qwen-ci-corepack',
});
} else {
expect(s.env).toEqual({
QWEN_SKIP_PREPARE: '1',
QWEN_SKIP_NOTICE_GENERATION: '1',
});
}
const { env: _installEnv, if: _installIf, ...install } = s;
return [
s.name,
JSON.stringify(canon({ ...install, with: restWith })),
];
}
return [s.name, JSON.stringify(canon({ ...s, with: restWith }))];
}),
);
const t = byName('test');
const l = byName('lint_and_static');
// test_build carries the test shards' whole prelude, up to the install.
const b = byName('test_build');
const buildShared = [...b.keys()].filter((n) => t.has(n));
// The number is the real intersection, not a floor to drift above: with
// 15 written here while the intersection is 16, renaming one shared step
// in `test` alone drops it back to 15, the loop stops comparing it, and
// `test_build`'s copy can change freely. Raise it with the membership.
expect(buildShared.length).toBeGreaterThanOrEqual(16);
for (const n of buildShared) {
if (n === 'Install dependencies') continue;
expect(b.get(n), `step "${n}" drifted between test_build and test`).toBe(
t.get(n),
);
}
// The one step pair the loop above skips is the only body duplicated
// verbatim between the two jobs: the sampler, the kill trap and the
// install command must stay identical, or the shards install differently
// from how the build installed. Only `env` and `if` may differ, and each
// difference is stated below instead of hidden by the skip.
const install = (job) =>
(ci.jobs[job].steps ?? []).find(
(candidate) => candidate.name === 'Install dependencies',
);
const withoutVariants = (job) => {
const { env: _env, if: _if, ...rest } = install(job);
return JSON.stringify(canon(rest));
};
expect(
withoutVariants('test_build'),
'Install dependencies drifted between test_build and test',
).toBe(withoutVariants('test'));
expect(install('test_build').env).toEqual({ QWEN_SKIP_PREPARE: '1' });
expect(install('test').env).toEqual({
QWEN_SKIP_PREPARE: '1',
QWEN_SKIP_NOTICE_GENERATION: '1',
// The test step's HOME override would hide the corepack cache install
// warms here; pin COREPACK_HOME to a fixed path under runner.temp so
// the warmup hits it (the install body clears that exact path first).
COREPACK_HOME: '${{ runner.temp }}/qwen-ci-corepack',
});
// Observed on the fleet: the pinned cache entry existed with its pnpm
// binary missing, corepack trusts an existing entry without re-verifying
// its files, and Install dependencies died instantly with
// MODULE_NOT_FOUND (main runs 41876940141, 41889761157, 41927887452).
// What left the entry incomplete is undetermined; _work/_temp survives
// across jobs on self-hosted runners (the lint job's isolated-HOME step
// documents the same fact), so such state persists. Every copy of the
// shared install body must clear the cache before corepack runs, and the
// guard must match ONLY the shard-owned path: a runner-exported shared
// COREPACK_HOME (test_build) and lint's run-unique home — already warmed
// by the hosted pnpm-store step — must never be deletion targets. The
// execution test below holds the semantics; these pins hold the spelling.
for (const job of ['test_build', 'test', 'lint_and_static']) {
const body = String(install(job).run ?? '');
expect(
body,
`${job} must scope the reset to the shard-owned path`,
).toContain(
'if [ -n "${RUNNER_TEMP:-}" ] && [ "${COREPACK_HOME:-}" = "${RUNNER_TEMP:-}/qwen-ci-corepack" ]; then',
);
const reset = body.indexOf('rm -rf -- "${COREPACK_HOME}"');
expect(
reset,
`${job} must reset the pinned corepack cache`,
).toBeGreaterThanOrEqual(0);
expect(
body.indexOf('corepack pnpm install'),
`${job} must reset the cache before corepack runs`,
).toBeGreaterThan(reset);
}
// The test step must keep sharing the cache install warms: a drifted
// COREPACK_HOME sends the vitest globalSetup warmup back to the cold
// npmjs download whose ETIMEDOUT flake #16328 removed.
expect(
ci.jobs.test.steps.find(
(s) => s.name === 'Run tests and generate reports',
).env.COREPACK_HOME,
).toBe(install('test').env.COREPACK_HOME);
const namesIn = (job) => (ci.jobs[job].steps ?? []).map((x) => x.name);
// Values alone are order-blind, and order is load-bearing here: `steps`
// exposes only steps that already ran, and a missing property reads as
// ''. Moving `Use trusted CI profile` below its consumers changes no
// serialized value while every `ci_profile == 'full'` gate reads '' and
// the lane completes green having built and uploaded nothing.
const buildSharedSet = new Set(buildShared);
expect(
namesIn('test_build').filter((n) => t.has(n)),
'shared prelude reordered between test_build and test',
).toEqual(namesIn('test').filter((n) => buildSharedSet.has(n)));
// The serializer is the guard's eyes: both sides of every comparison below
// flow through it, so a regression that drops nested fields compares `{}`
// to `{}` and the drift loop reports green — the exact array-replacer blind
// spot the recursion above replaced. Pin a known nested key on the
// serialized OUTPUT rather than on canon alone, because the regression that
// matters is a call site swapped back to an allowlist, which a canon-only
// fixture cannot see.
expect(t.get('Checkout')).toContain('"fetch-depth":1');
const shared = [...t.keys()].filter((n) => l.has(n));
// The prelude is what is duplicated; if this floor ever drops, steps
// were renamed apart and the guard is no longer guarding anything. 13 and
// not 12: the collector is deliberately in both jobs, so it is in the
// intersection, and a floor below the real count lets any one shared step
// be deleted out of either job without turning this red.
expect(shared.length).toBeGreaterThanOrEqual(13);
// One deliberate divergence: lint maps java_only to its full payload
// and adds the scripts/tests lane, where every other job skips. The
// step must equal test's in everything else.
const profileStep = 'Use trusted CI profile';
const stepOf = (job, name) =>
(ci.jobs[job].steps ?? []).find((x) => x.name === name);
const lintProfile = stepOf('lint_and_static', profileStep);
const testProfile = stepOf('test', profileStep);
const { run: lintRun, ...lintRest } = lintProfile;
const { run: testRun, ...testRest } = testProfile;
expect(lintRest).toEqual(testRest);
expect(lintRun).toContain(
'java_only)\n profile=full\n scripts_unit=true',
);
expect(testRun).toContain('docs_only|github_ci_only|java_only|full) ;;');
// Strip lint's java_only additions; what is left is test's step.
const lintBase = lintRun
.replace('scripts_unit=false\n', '')
.replace(/\n {2}# A change confined[\s\S]*?java_only\)\n[\s\S]*?;;/, '')
.replace(
'docs_only|github_ci_only|full',
'docs_only|github_ci_only|java_only|full',
)
.replace(
'echo "scripts_unit=${scripts_unit}" >> "${GITHUB_OUTPUT}"\n',
'',
);
expect(lintBase).toBe(testRun);
for (const n of shared) {
if (n === profileStep) continue;
expect(l.get(n), `step "${n}" drifted between the two jobs`).toBe(
t.get(n),
);
}
// The exemption above is only for the field that must differ: the shard
// install carries the build guard, and the lint install must not.
expect(String(install('test')?.if ?? '')).toContain(
"needs.test_build.result == 'success'",
);
expect(String(install('lint_and_static')?.if ?? '')).not.toContain(
'needs.test_build',
);
// Name-keyed maps are order-blind, and order is load-bearing here: the
// `steps` context only exposes steps that have already run, and a
// nonexistent property reads as '' — so if `Use trusted CI profile`
// (id: ci_profile) ever lands below its consumers, every
// `ci_profile == 'full'` gate evaluates '' == 'full' and the lane
// completes green having executed nothing.
const sharedSet = new Set(shared);
expect(
namesIn('lint_and_static').filter((n) => sharedSet.has(n)),
'shared prelude reordered between the two jobs',
).toEqual(namesIn('test').filter((n) => sharedSet.has(n)));
const lintNames = namesIn('lint_and_static');
expect(ci.jobs.lint_and_static.env?.QWEN_SKIP_PREPARE).toBeUndefined();
expect(
ci.jobs.lint_and_static.env?.QWEN_SKIP_NOTICE_GENERATION,
).toBeUndefined();
expect(
lintNames.indexOf('Check serve fast-path bundle closure'),
).toBeLessThan(lintNames.indexOf('Run i18n check'));
expect(lintNames.indexOf('Use trusted CI profile')).toBeLessThan(
lintNames.indexOf('Install dependencies'),
);
// The incremental opt-in is two pieces — the job-level LINT_BASE_SHA
// binding and the history fetch — and neither was pinned: dropping the
// env, deleting the step or reordering it left every suite green while
// each PR quietly paid for the whole-repository scan this feature exists
// to remove (full scanning IS the designed fallback).
expect(String(ci.jobs.lint_and_static.env?.LINT_BASE_SHA)).toContain(
'github.event.pull_request.base.sha',
);
const lintStep = (name) => {
const index = lintNames.indexOf(name);
expect(index, `step "${name}" is missing`).toBeGreaterThan(-1);
return index;
};
// Both steps above refetch shallow history, and a depth-1 fetch of the
// base ref grafts the very commit LINT_BASE_SHA names: `merge-base` then
// has no ancestry to walk and lint-scope falls back to a full scan.
for (const refetch of [
'Check the API contract version advances',
'Report test growth',
]) {
expect(
lintStep('Fetch incremental lint history'),
`"Fetch incremental lint history" must stay below "${refetch}"`,
).toBeGreaterThan(lintStep(refetch));
}
expect(lintStep('Fetch incremental lint history')).toBeLessThan(
lintStep('Install linters'),
);
// Blobless is load-bearing, not an optimization: the job may already hold
// a blobless merge base from `Report test growth`, and a plain fetch then
// dies with `fatal: missing blob object` — silently, because this step is
// continue-on-error — leaving every lane on the full scan.
const fetchStep = (ci.jobs.lint_and_static.steps ?? []).find(
(s) => s.name === 'Fetch incremental lint history',
);
// Comments stripped: the rationale above the command names the flag too,
// and a pin satisfied by prose would go green with the flag deleted.
const fetchCode = String(fetchStep?.run)
.split('\n')
.filter((line) => !line.trim().startsWith('#'))
.join('\n');
expect(fetchCode).toContain(
'fetch --no-tags --depth=100 --filter=blob:none',
);
expect(fetchCode).toContain(
'git config --unset remote.origin.partialclonefilter',
);
// A fixed window is not enough: main moves 50-100 commits a day, so a
// 100-commit depth expires in about a day and every longer-lived PR paid
// for a full scan. The deepen retry is what keeps them incremental.
expect(fetchCode).toContain('--deepen=');
// Pin the mechanism, not the flag: each of these can be deleted while a
// bare `--deepen=` substring survives.
expect(fetchCode).toContain(
'fetch --no-tags --deepen=300 --filter=blob:none',
);
expect(fetchCode).toContain('git merge-base --all "${LINT_BASE_SHA}" HEAD');
expect(fetchCode).toContain('history_usable || state=$?');
expect(fetchCode).toContain("reason='deepen fetch aborted'; break;");
expect(fetchCode).toContain(
'git rev-list "${merge_base}..HEAD" "${merge_base}..${LINT_BASE_SHA}"',
);
expect(fetchCode).toContain('grep -qxF -f "${shallow}" <<< "${above}"');
expect(fetchCode).toMatch(/for _attempt in 1 2 3/);
expect(fetchCode).toContain('Incremental lint history:');
});
it.skipIf(process.platform === 'win32')(
'executes the shared corepack reset only against the shard-owned path',
() => {
// Semantic witness for the reset guard. The string pins above hold its
// spelling, but a guard that never executes satisfies every one of
// them (the verify-pr round on 01d2b116 survived exactly that mutant),
// so run the shipped fragment — sliced from the real install body, not
// a copy — and watch what it deletes. The environment is built from
// scratch: this suite also runs inside CI shards that export their own
// COREPACK_HOME and RUNNER_TEMP, and inheriting those would point the
// cleanup at the running job's own directories.
const step = ci.jobs.test.steps.find(
(s) => s.name === 'Install dependencies',
);
const body = String(step.run ?? '');
const start = body.indexOf('if [ -n "${RUNNER_TEMP:-}" ]');
const end = body.indexOf('corepack pnpm install');
expect(
start,
'reset guard is missing from the install body',
).toBeGreaterThanOrEqual(0);
expect(end, 'reset must sit before the corepack install').toBeGreaterThan(
start,
);
const fragment = body.slice(start, end);
const cells = [
{
name: 'shard-owned cache is cleared',
home: (temp) => join(temp, 'qwen-ci-corepack'),
cleared: true,
},
{
name: 'runner-exported shared cache survives',
home: (_temp, root) => join(root, 'shared-corepack'),
cleared: false,
},
{
name: 'lint run-unique warmed home survives',
home: (temp) =>
join(temp, 'qwen-lint-home-42-1', '.cache', 'node', 'corepack'),
cleared: false,
},
{
name: 'unset variable leaves the default cache alone',
home: null,
cleared: false,
},
{
name: 'empty variable is not a deletion target',
home: () => '',
cleared: false,
},
];
for (const cell of cells) {
const root = mkdtempSync(join(tmpdir(), 'corepack-reset-'));
try {
const temp = join(root, 'temp');
const home = join(root, 'home');
mkdirSync(temp, { recursive: true });
mkdirSync(home, { recursive: true });
// With no COREPACK_HOME the default cache is the deletion target
// a naive guard would reach; give every cell a marker to watch.
const target =
cell.home === null
? join(home, '.cache', 'node', 'corepack')
: cell.home(temp, root);
const marker = join(target, 'v1', 'pnpm', '11.24.0', '.corepack');
if (target) {
mkdirSync(join(target, 'v1', 'pnpm', '11.24.0'), {
recursive: true,
});
writeFileSync(marker, '{}');
}
const bystander = join(root, 'bystander');
mkdirSync(bystander, { recursive: true });
writeFileSync(join(bystander, 'sentinel'), 'keep');
const env = {
PATH: process.env.PATH,
HOME: home,
RUNNER_TEMP: temp,
};
if (cell.home !== null) env.COREPACK_HOME = target;
execFileSync(
'bash',
['--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', fragment],
{ env, cwd: root },
);
if (cell.cleared) {
expect(existsSync(target), cell.name).toBe(false);
} else if (target) {
expect(existsSync(marker), cell.name).toBe(true);
}
expect(existsSync(join(bystander, 'sentinel')), cell.name).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
}
},
);
it.each([
['first fetch aborts', 1, 99, 99, 1, 0, 'deepen fetch aborted'],
['third fetch aborts', 3, 99, 99, 3, 2, 'deepen fetch aborted'],
['cap exhausted', 0, 99, 99, 3, 3, 'deepen cap reached'],
['initially ambiguous', 0, 99, 0, 0, 0, 'ambiguous merge base'],
['ambiguous after final deepen', 0, 99, 3, 3, 3, 'ambiguous merge base'],
['already usable', 0, 0, 99, 0, 0, null],
['shallow boundary recovered', 0, 1, 99, 1, 1, null],
])(
'reports incremental history: %s',
(
_label,
abortAt,
readyAfter,
ambiguousAfter,
attempts,
deepens,
reason,
) => {
const dir = mkdtempSync(join(tmpdir(), 'qwen-lint-history-'));
try {
const summary = join(dir, 'summary');
const shallow = join(dir, 'shallow');
writeFileSync(summary, '');
writeFileSync(shallow, `${'e'.repeat(40)}\n`);
const run = ci.jobs.lint_and_static.steps.find(
(step) => step.name === 'Fetch incremental lint history',
).run;
const gitShim = String.raw`
attempts=0
successes=0
git() {
case "$*" in
*'fetch --no-tags --depth=100'*) return 0 ;;
*'fetch --no-tags --deepen=300'*)
attempts=$((attempts + 1))
[ "$attempts" -ne "$ABORT_AT" ] || return 1
successes=$((successes + 1)) ;;
'merge-base --all '*)
printf '%s\n' "$MERGE_BASE"
if [ "$successes" -ge "$AMBIGUOUS_AFTER" ]; then
printf '%s\n' "$LINT_BASE_SHA"
fi ;;
'rev-parse --is-shallow-repository')
if [ "$successes" -ge "$READY_AFTER" ]; then
echo false
else
echo true
fi ;;
'rev-parse --git-path shallow') echo "$SHALLOW_PATH" ;;
'rev-list --count '*) echo "$((100 + 300 * successes))" ;;
'rev-list '*) printf '%s\n' "$BOUNDARY" ;;
'config --unset remote.origin.partialclonefilter') return 0 ;;
*) echo "unexpected git command: $*" >&2; return 97 ;;
esac
}
`;
const output = execFileSync(
'bash',
[
'--noprofile',
'--norc',
'-e',
'-o',
'pipefail',
'-c',
`${gitShim}\n${run}\nprintf 'attempts=%s\\n' "$attempts"`,
],
{
encoding: 'utf8',
env: {
...process.env,
LINT_BASE_SHA: 'a'.repeat(40),
LINT_HEAD_SHA: 'b'.repeat(40),
MERGE_BASE: 'c'.repeat(40),
BOUNDARY: 'e'.repeat(40),
SHALLOW_PATH: shallow,
GITHUB_STEP_SUMMARY: summary,
ABORT_AT: String(abortAt),
READY_AFTER: String(readyAfter),
AMBIGUOUS_AFTER: String(ambiguousAfter),
},
},
);
expect(output).toContain(`attempts=${attempts}\n`);
if (reason) {
const message = `Incremental lint history: ${deepens} deepen(s), base depth ${100 + 300 * deepens} — ${reason}`;
expect(output).toContain(`::warning::${message}\n`);
expect(readFileSync(summary, 'utf8')).toBe(`- ${message}\n`);
} else {
expect(output).not.toContain('::warning::');
expect(readFileSync(summary, 'utf8')).toBe('');
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
it("pins the disk-pressure collector's upload-artifact revision", () => {
// The equality loop above compares the two jobs to EACH OTHER, so it is
// structurally blind to both collectors moving together — exempting only
// `with.name` put `uses:` back in scope for the one-sided case, but a
// coordinated re-pin leaves both serializations equal and reports green.
// The SHA-format regex on `Upload Test Results Artifact` is not a witness
// either: it accepts any 40-hex revision, on a different step. Pin the
// literal revision, resolved per job rather than through a single
// name-keyed map, because the step carries the SAME name in both jobs.
const PIN =
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a';
for (const job of ['test', 'lint_and_static']) {
const collector = (ci.jobs[job].steps ?? []).find(
(s) => s.name === 'Upload disk-pressure samples',
);
expect(
collector?.uses,
`${job}: 'Upload disk-pressure samples' must stay on ${PIN}`,
).toBe(PIN);
// Same coordinated-edit blindness, second field: no suite pins the
// sample window. Literal 14, not a shared constant — the JUnit
// `Upload Test Results Artifact` step deliberately retains 7.
expect(
collector?.with?.['retention-days'],
`${job}: disk-pressure samples must stay retained for 14 days`,
).toBe(14);
}
});
it('keeps the moved lint/static payload present, gated, and out of test', () => {
// The shared-prelude equality above only sees the name INTERSECTION; the
// twenty moved steps — the payload this job exists to run — were pinned
// by nothing: deleting `Run Prettier`, flipping its gate to
// `docs_only`, or re-adding a copy to `test` all left every suite
// green. A required check that silently stops running ESLint or the
// lockfile audit still reports green on every PR; pin the payload by
// name, order and gate. Two members deliberately carry different gates
// and are asserted as such below — do not "normalise" them: the size
// gate must run on github_ci_only PRs (a .github/-only PR is exactly
// the one most likely to breach the 500 KB workflow limit), and the
// helper-checks step IS the github_ci_only fast path.
const FULL_PAYLOAD = [
'Audit critical runtime dependencies',
'Check lockfile',
'Check desktop workspace isolation',
'Check TUI dependency direction',
'Install linters',
'Run ESLint',
'Run actionlint',
'Run shellcheck',
'Run yamllint',
'Run Prettier',
'Run sensitive keyword linter',
'Run i18n check',
'Generate Web Shell and VS Code companion notices',
'Check Web Shell and VS Code companion notices are up-to-date',
'Run .github/scripts helper tests',
];
const steps = ci.jobs.lint_and_static.steps ?? [];
const stepByName = new Map(steps.map((x) => [x.name, x]));
// The two checks that read the built tree moved to test_build, which
// builds it anyway; Lint & Static used to build and bundle the CLI a
// second time for them. They stay full-gated and required through
// `Test (ubuntu-latest, Node 22.x)`.
const buildSteps = new Map(
(ci.jobs.test_build.steps ?? []).map((x) => [x.name, x]),
);
for (const [name, command] of [
[
'Check serve fast-path bundle closure',
'node scripts/check-serve-fast-path-bundle.js',
],
[
'Check core subpath exports resolve',
'npm run check:core-subpath-exports',
],
// The generator imports core's dist.
['Generate settings schema', 'npm run generate:settings-schema'],
[
'Check settings schema is up-to-date',
'git status --porcelain packages/vscode-ide-companion/schemas/settings.schema.json',
],
]) {
expect(stepByName.has(name), name).toBe(false);
expect(buildSteps.get(name)?.run, name).toContain(command);
expect(buildSteps.get(name)?.if, name).toContain("ci_profile == 'full'");
}
expect(buildSteps.get('Build').env?.DEV).toBe('true');
const buildOrder = [...buildSteps.keys()];
expect(buildOrder.indexOf('Check serve fast-path bundle closure')).toBe(
buildOrder.indexOf('Build') + 1,
);
// A java_only change runs scripts/tests here instead of in the shards.
const javaSuites = stepByName.get(
'Run scripts/tests for a Java-only change',
);
expect(javaSuites.if).toBe(
"${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.scripts_unit == 'true' }}",
);
expect(javaSuites.run).toContain(
"git grep -l -E 'managed-agent-server/|sdk-java/' -- 'scripts/tests/*.test.*'",
);
expect(javaSuites.run).toContain(
'npm run test:scripts:unit -- "${suites[@]}"',
);
const testNames = new Set((ci.jobs.test.steps ?? []).map((x) => x.name));
const prelude = new Set((ci.jobs.test.steps ?? []).map((x) => x.name));
// Order and completeness in one shot: the full-gated steps that are not
// part of the shared prelude must be exactly this list, in this order.
expect(
steps
.filter(
(x) =>
String(x.if ?? '').includes("ci_profile == 'full'") &&
!prelude.has(x.name),
)
.map((x) => x.name),
).toEqual(FULL_PAYLOAD);
const generateNotices = stepByName.get(
'Generate Web Shell and VS Code companion notices',
).run;
expect(generateNotices).toContain(
'npm run generate:notices --workspace=@qwen-code/web-shell',
);
expect(generateNotices).toContain(
'npm run generate:notices --workspace=qwen-code-vscode-ide-companion',
);
expect(
stepByName.get(
'Check Web Shell and VS Code companion notices are up-to-date',
).run,
).toContain(
'git status --porcelain packages/vscode-ide-companion/NOTICES.txt packages/web-shell/NOTICES.txt',
);
const checkNotices = stepByName.get(
'Check Web Shell and VS Code companion notices are up-to-date',
);
expect(checkNotices['continue-on-error']).toBeUndefined();
expect(checkNotices.run).toContain(
'if [[ -n $(git status --porcelain packages/vscode-ide-companion/NOTICES.txt packages/web-shell/NOTICES.txt) ]]; then',
);
expect(checkNotices.run).toContain('exit 1');
// Name, order and gate were pinned, but only the two notices steps had a
// command body pinned: emptying `Run ESLint`'s `run:` or repointing it at
// `true` left every suite green while the required check stopped running
// the tool it is named for. Pin the load-bearing substring of each body —
// NOT whitespace-exact text, so reformatting one of the multi-line blocks
// stays benign. Totality is asserted below, not left to a missing key:
// `toContain` coerces `undefined` to the literal string "undefined", so a
// body containing that word would pass with its command unpinned.
const LOAD_BEARING_RUN = {
'Audit critical runtime dependencies': 'npm run audit:runtime:critical',
'Check lockfile': 'npm run check:lockfile',
'Check desktop workspace isolation': 'npm run check:desktop-isolation',
'Check TUI dependency direction': 'npm run check:tui-dep-direction',
'Install linters': 'node scripts/lint.js --setup',
'Run ESLint': 'node scripts/lint.js --eslint',
'Run actionlint': 'node scripts/lint.js --actionlint',
'Run shellcheck': 'node scripts/lint.js --shellcheck',
'Run yamllint': 'node scripts/lint.js --yamllint',
'Run Prettier': 'node scripts/lint.js --prettier',
'Run sensitive keyword linter':
'node scripts/lint.js --sensitive-keywords',
'Run i18n check': 'npm run check-i18n',
'Generate Web Shell and VS Code companion notices':
'npm run generate:notices --workspace=@qwen-code/web-shell',
'Check Web Shell and VS Code companion notices are up-to-date':
'git status --porcelain packages/vscode-ide-companion/NOTICES.txt',
'Run .github/scripts helper tests':
'node --test --test-concurrency=1 ${{ env.HELPER_TESTS }}',
};
expect(
Object.keys(LOAD_BEARING_RUN).sort(),
'LOAD_BEARING_RUN drifted from FULL_PAYLOAD',
).toEqual([...FULL_PAYLOAD].sort());
// Whole literal, not the `ci_profile == 'full'` substring filtered above:
// an appended `&&` clause still matches that substring while dropping the
// step from the unattended post-merge lane. All 15 gates are identical.
const FULL_GATE =
"${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}";
for (const n of FULL_PAYLOAD) {
const step = stepByName.get(n);
expect(testNames.has(n), `${n} leaked back into test`).toBe(false);
expect(String(step.if), `${n} gate drifted`).toBe(FULL_GATE);
expect(
String(step.run ?? ''),
`${n} no longer runs its load-bearing command`,
).toContain(LOAD_BEARING_RUN[n]);
// Containment says the command is present, not that its failure reaches
// the job. The body guard is `|| true`/`|| :`, not a bare `||`: the
// helper-tests retry legitimately uses `|| {`. Both stay inside this
// loop — two non-payload steps set continue-on-error on purpose.
expect(
step['continue-on-error'],
`${n} must not swallow its own failure`,
).toBeUndefined();
expect(
String(step.run ?? ''),
`${n} swallows its exit status`,
).not.toMatch(/\|\|\s*(true|:)/);
}
expect(
String(buildSteps.get('Check settings schema is up-to-date').run),
'settings-schema gate lost its exit 1',
).toContain('exit 1');
// The two deliberately-different gates, pinned as they are.
expect(String(stepByName.get('Check workflow file size').if)).toBe(
"${{ needs.classify_pr.outputs.skip_ci != 'true' }}",
);
expect(String(stepByName.get('GitHub CI helper checks').if)).toContain(
"ci_profile == 'github_ci_only'",
);
expect(testNames.has('Check workflow file size')).toBe(false);
expect(testNames.has('GitHub CI helper checks')).toBe(false);
});
it('classify_pr admits push in its event allowlist', () => {
// `test` reads `needs.classify_pr.outputs.ubuntu_runner`. If classify_pr
// stops accepting push, `test` still runs but that output is empty and
// `fromJSON(... || '["ubuntu-latest"]')` silently demotes every
// post-merge run to a scarce hosted runner.
// Whole-literal, not a substring: `||`→`&&` anywhere in this allowlist
// keeps every token in place while skipping classify_pr on a push. `test`
// would still run (its own gate is `!cancelled()`-shaped), but with
// `ubuntu_runner` empty it falls back through
// `fromJSON(... || '["ubuntu-latest"]')` — every post-merge run silently
// demoted to a scarce hosted runner, and `skip_ci`/`ci_profile` empty.
expect(condOf('classify_pr')).toBe(
"${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' }}",
);
});
it('test accepts push while still excluding the nightly', () => {
// The allowlist rewrite this guard exists to foreclose — `!cancelled()
// && event != 'schedule' && (event == 'pull_request' || event ==
// 'merge_group' || event == 'workflow_dispatch')` — contains no
// `!= 'push'`, so it passes any exclusion-shaped check; the exclusivity
// helper's positive half catches it instead (test is in PUSH_JOBS, so it
// must reach push). The literal pin below covers what the evaluator
// cannot: `!cancelled()` is substituted to a constant, so a gate that
// loses that clause evaluates identically on every event and only the
// whole-literal pin catches the change.
expect(condOf('test')).toBe(
"${{ !cancelled() && github.event_name != 'schedule' }}",
);
});
it('keeps a post-merge run to exactly its expected jobs', () => {
// The mirror of the nightly assertion, through the same helper: a `push:`
// trigger fires the whole workflow, so every other job must exclude push
// outright or gate on an allowlist that cannot contain it. Otherwise a
// merge quietly becomes a full CI run — desktop_shell's cargo build
// included.
assertEventReachesOnly('push', PUSH_JOBS);
});
it('checks out the commit the push reported, not the branch tip', () => {
// `github.ref` resolves `refs/heads/main` at fetch time — a moving tip —
// while the check run attaches to `github.sha`. Without a push arm the
// lane can validate a tree it does not report on, and nothing detects it:
// `Verify checkout includes expected head commit` is gated to
// pull_request / merge_group, and its `merge-base --is-ancestor` check
// passes for a newer tip regardless. Order matters as much as presence —
// the arm has to sit before the `github.ref` fallback to ever be reached.
for (const job of ['test', 'lint_and_static']) {
const checkout = (ci.jobs[job].steps ?? []).find((s) =>
String(s.uses ?? '').startsWith('actions/checkout'),
);
expect(checkout, job).toBeDefined();
expect(String(checkout.with?.ref ?? ''), job).toBe(
"${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || (github.event_name == 'push' && github.sha) || github.ref }}",
);
}
});
it('pins the two PR-only lanes to their whole checkout ref', () => {
// Same order contract as above, for the two lanes that carry no push arm
// and so cannot join that loop. Whole literal, not a substring: moving
// `|| github.ref` ahead of the pull-request arm keeps every token in
// place — `toContain(head.sha)` and `not.toContain('refs/pull/')` both
// stay green — while the lane silently checks out the asynchronously
// rebuilt merge ref this PR exists to avoid, and the ancestor guard still
// passes because a newer merge tree contains the expected head.
const expectedRefs = {
web_shell_e2e_smoke:
"${{ github.event.inputs.branch_ref || (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || github.ref }}",
integration_no_ak:
"${{ (github.event_name == 'pull_request' && github.event.pull_request.head.sha) || (github.event_name == 'merge_group' && github.event.merge_group.head_sha) || github.ref }}",
};
for (const [job, ref] of Object.entries(expectedRefs)) {
const checkout = (ci.jobs[job].steps ?? []).find((s) =>
String(s.uses ?? '').startsWith('actions/checkout'),
);
expect(checkout, job).toBeDefined();
expect(String(checkout.with?.ref ?? ''), job).toBe(ref);
}
});
it('collects and uploads coverage only from the post-merge run', () => {
// Coverage is switched on by QWEN_CI_COVERAGE, which the unit-test step
// sets only for push (main): pull-request runs had no reader for the
// reports and paid about a fifth of the suite's wall time collecting
// them. The upload gate must agree with the switch, or a PR run uploads
// an empty artifact and a push run collects reports nobody keeps.
const run = stepOf('test', 'Run tests and generate reports');
expect(run).toBeDefined();
expect(String(run.env?.QWEN_CI_COVERAGE)).toBe(
"${{ github.event_name == 'push' && '1' || '' }}",
);
const step = stepOf('test', 'Upload coverage reports');
expect(step).toBeDefined();
expect(String(step.if)).toBe(
"${{ always() && needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && github.event_name == 'push' }}",
);
// The pull-request coverage comment was the artifact's only reader; with
// no PR-side reports it has nothing to post and must stay gone.
expect(ci.jobs.post_coverage_comment).toBeUndefined();
});
it('scopes the concurrency group and keeps main uncancellable', () => {
expect(String(ci.concurrency.group).trim()).toBe(
"${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.sha || github.head_ref || github.ref }}",
);
expect(String(ci.concurrency['cancel-in-progress']).trim()).toBe(
"${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }}",
);
});
it.each([
['push', 'refs/heads/main', '', true],
['push', 'refs/heads/feat/e2e/example', '', false],
['push', 'refs/heads/release/example', '', false],
['schedule', 'refs/heads/main', '', false],
['workflow_dispatch', 'refs/heads/main', '', false],
['workflow_dispatch', 'refs/heads/release/example', '', false],
['pull_request', 'refs/pull/1/merge', 'feature', false],
['merge_group', 'refs/heads/gh-readonly-queue/main/pr-1', '', false],
])(
'groups %s on %s without changing other events',
(event, ref, headRef, perSha) => {
const workflows = [ci];
if (['push', 'schedule', 'workflow_dispatch'].includes(event)) {
workflows.push(
parse(readFileSync('.github/workflows/e2e.yml', 'utf8')),
);
}
for (const workflow of workflows) {
for (const sourceRepo of event === 'pull_request'
? ['one/fork', 'two/fork']
: ['']) {
for (const sha of ['a'.repeat(40), 'b'.repeat(40)]) {
for (const attempt of [1, 2]) {
const github = {
workflow: workflow.name,
repository: 'QwenLM/qwen-code',
event_name: event,
event: {
pull_request: { head: { repo: { full_name: sourceRepo } } },
},
ref,
ref_name: ref.replace('refs/heads/', ''),
head_ref: headRef,
sha,
run_id: attempt,
run_attempt: attempt,
};
// Evaluate the actual YAML's string/boolean expressions. Exact
// expression pins above and in e2e-workflow constrain this subset.
const group = workflow.concurrency.group
.trim()
.replace(/\$\{\{(.*?)\}\}/g, (_, expression) =>
new Function('github', `return (${expression});`)(github),
);
const isCi = workflow === ci;
const repoPart = isCi
? `${sourceRepo || github.repository}-`
: '';
const refPart = headRef || (isCi ? ref : github.ref_name);
expect(group).toBe(
`${workflow.name}-${event}-${repoPart}${perSha ? sha : refPart}`,
);
}
}
}
}
},
);
});
describe('platform lanes — the retired sensitivity classifier', () => {
it('is gone from the workflow, whole', () => {
// Off with the pull-request trigger it fed: nothing consumes its output,
// so a surviving job would spend a hosted runner per pull request on a
// classification no gate reads — and a surviving reference would consult
// a job that no longer exists. Deleted means deleted everywhere.
expect(ci.jobs.classify_platform).toBeUndefined();
expect(JSON.stringify(ci)).not.toContain('classify_platform');
});
it('keeps its classifier script tested for the restoration', () => {
// The script layer stayed in place precisely so restoring the
// pull-request trigger is a revert. A classifier that rotted untested in
// the meantime would make that revert a regression instead.
expect(ci.env.HELPER_TESTS).toContain(
'.github/scripts/ci/classify-platform-sensitivity.test.mjs',
);
expect(ci.env.HELPER_TESTS).toContain(
'.github/scripts/ci/classify-pr-profile.test.mjs',
);
});
});
describe('GitHub helper tests', () => {
it('includes the disk-pressure contract suite', () => {
expect(ci.env.HELPER_TESTS).toContain(
'.github/scripts/ci-disk-pressure.test.mjs',
);
});
it('runs every invocation serially', () => {
const helperSteps = Object.values(ci.jobs)
.flatMap((job) => job.steps ?? [])
.filter((step) => String(step.run ?? '').includes('env.HELPER_TESTS'));
expect(helperSteps).not.toHaveLength(0);
for (const step of helperSteps) {
// Split at each `node --test` occurrence, not per line: a second
// invocation on the SAME line still gets its own segment.
const invocations = String(step.run)
.split(/(?=node --test)/)
.filter((segment) => segment.startsWith('node --test'));
expect(invocations, step.name).not.toHaveLength(0);
for (const invocation of invocations) {
expect(invocation, step.name).toContain('--test-concurrency=1');
}
}
});
it('routes the battery tmpdir off the host /tmp tmpfs', () => {
// #15965: e2e-build.test.mjs mkdtemps thousands of chunk files via
// os.tmpdir(); on the ECS pool that defaulted to the shared /tmp tmpfs,
// whose ~1M inode quota filled up and failed the lane with ENOSPC while
// 202G of capacity stayed free. RUNNER_TEMP sits on the roomy data disk,
// same routing the unit lanes already use (#10035).
const step = (ci.jobs.lint_and_static.steps ?? []).find(
(candidate) => candidate.name === 'Run .github/scripts helper tests',
);
expect(step, 'helper-tests step missing').toBeDefined();
// Comments stripped: the rationale above the export names it too, so a pin
// satisfied by prose would stay green with the routing commented out.
const run = String(step.run)
.split('\n')
.filter((line) => !line.trim().startsWith('#'))
.join('\n');
expect(run).toContain('export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"');
expect(run.indexOf('export TMPDIR')).toBeLessThan(
run.indexOf('node --test'),
);
// The routed dir is private to the run and reaped on exit: RUNNER_TEMP
// itself is shared with every other job on the host.
expect(run).toContain('mktemp -d "${TMPDIR}/helper-tests-XXXXXX"');
expect(run).toContain('trap \'rm -rf "$HELPER_TMPDIR"\' EXIT');
});
it('retries the full-profile battery once against pool contention', () => {
// #12772: the battery spawns real subprocess loops on the shared ECS
// pool, and a push to main has no flaky-rerun patrol — one host hiccup
// failed the lane on a CSS-only commit. The unit lane got VITEST_RETRY
// for the same fleet condition (#10868); this lane retries inline. The
// github_ci_only fast lane stays single-attempt: it runs only on PRs, so
// a red check is visible to its author and manually re-runnable. (The
// flaky-rerun patrol is PR-scoped but best-effort — do not rely on it.)
const step = (ci.jobs.lint_and_static.steps ?? []).find(
(candidate) => candidate.name === 'Run .github/scripts helper tests',
);
expect(step, 'helper-tests step missing').toBeDefined();
const invocations =
String(step.run).match(/node --test --test-concurrency=1/g) ?? [];
expect(invocations).toHaveLength(2);
expect(String(step.run)).toContain('||');
// The retry re-runs the SAME battery, not a cheaper subset.
expect(String(step.run)).not.toContain('HELPER_TESTS_DEP_FREE');
// The retry's exit status decides the step: nothing swallows it.
expect(String(step.run)).not.toMatch(/\|\|\s*(true|:)/);
expect(String(step.run).trimEnd().endsWith('}')).toBe(true);
expect(step['continue-on-error']).toBeUndefined();
// The warning fires only when the retry absorbed the flake, and names
// the attempt-1 failures (the datum #12772 asked for).
expect(String(step.run)).toMatch(/&&\s+echo "::warning::/);
expect(String(step.run)).toMatch(
/tee "\$\{RUNNER_TEMP\}\/helper-attempt1\.log"/,
);
expect(String(step.run)).toMatch(/::warning::.*\$\(/);
});
it('keeps the dependency-free fast lane off npm-package suites', () => {
// The github_ci_only helper step runs before ANY dependency install (the
// setup-node and `npm ci` steps are gated on the full profile), so every
// suite it lists must import node: builtins only. These 11 suites import
// the `yaml` npm package; letting the fast lane run the full list made an
// ECS-updater-only fork PR fail closed with ERR_MODULE_NOT_FOUND on a
// fresh hosted runner (#10548 review R6-1). The full-profile helper step
// still runs every suite after `npm ci`, and a push to main always
// classifies `full`, so what the fast lane skips is re-checked there.
const depFree = String(ci.env.HELPER_TESTS_DEP_FREE).trim();
const fullSuites = String(ci.env.HELPER_TESTS).trim().split(/\s+/);
expect(depFree).not.toBe('');
expect(depFree).not.toBe('undefined');
const depFreeSuites = depFree.split(/\s+/);
for (const suite of depFreeSuites) {
expect(fullSuites, suite).toContain(suite);
}
const yamlSuites = [
'.github/scripts/classify-release-notes.test.mjs',
'.github/scripts/resolve-sandbox-image.test.mjs',
'.github/scripts/web-shell-visuals-publish.test.mjs',
'.github/scripts/qwen-triage-workflow.test.mjs',
'.github/scripts/assign-issue-owner.test.mjs',
'.github/scripts/auto-minimize-spam.test.mjs',
'.github/scripts/ci-runner-routing.test.mjs',
'.github/scripts/assign-pr-owner.test.mjs',
'.github/scripts/ci-disk-pressure.test.mjs',
'.github/scripts/e2e-build.test.mjs',
'.github/scripts/ecs-runner/review-scratch-cleanup.test.mjs',
];
for (const suite of yamlSuites) {
expect(depFreeSuites, suite).not.toContain(suite);
expect(fullSuites, suite).toContain(suite);
}
// The fast lane must consume the dep-free list, not the full one. Select
// it by its GATE, never by the list it already consumes: a step that
// regressed back to env.HELPER_TESTS would drop out of a content-based
// filter, and the hole the filter exists to catch would report green
// (#10548 review R6-1 — the first version of this assertion filtered on
// `HELPER_TESTS_DEP_FREE` and so pinned the incomplete fix in place while
// the sibling `lint_and_static` step still ran the full list with nothing
// installed).
const fastLaneSteps = Object.entries(ci.jobs)
.flatMap(([jobName, job]) =>
(job.steps ?? []).map((step) => ({ jobName, step })),
)
.filter(
({ step }) =>
String(step.if ?? '').includes("ci_profile == 'github_ci_only'") &&
String(step.run ?? '').includes('node --test'),
);
// One job owns the fast lane; a second copy is duplicated lint bootstrap
// and, when it shares the name, a byte-identity break against the shared
// prelude guard above.
expect(fastLaneSteps).toHaveLength(1);
for (const { jobName, step } of fastLaneSteps) {
const where = `${jobName}/${step.name}`;
expect(String(step.run), where).toContain('env.HELPER_TESTS_DEP_FREE');
// Strip the dep-free name first: `env.HELPER_TESTS` is a substring of
// `env.HELPER_TESTS_DEP_FREE`, so an unstripped check is vacuous.
expect(
String(step.run).replaceAll('HELPER_TESTS_DEP_FREE', ''),
where,
).not.toContain('env.HELPER_TESTS');
}
});
});
describe('platform lanes — a failing nightly is visible', () => {
it('files an issue when the scheduled CI run fails on main', () => {
// A nightly nobody is told about is the same silence the merge-queue gate
// produced: the run goes red on a branch nobody watches and the lane is
// effectively off again.
const wr = (failureIssue[true] ?? failureIssue['on']).workflow_run;
expect(wr.workflows).toContain('Qwen Code CI');
// Both sides of the binding: `workflow_run.workflows` matches the watched
// workflow's `name:`, so renaming ci.yml silently unhooks the watcher and
// the nightly goes back to failing where nobody is told.
expect(ci.name).toBe('Qwen Code CI');
// `workflow_run.workflows` matches the watched workflow's `name:` key:
// pin the coupling itself, so renaming ci.yml's name fails here instead
// of silently stopping the nightly's workflow_run events.
expect(wr.workflows).toContain(ci.name);
const cond = String(failureIssue.jobs.analyze.if);
expect(cond).toContain("workflow_run.event == 'schedule'");
expect(cond).toContain("workflow_run.head_branch == 'main'");
expect(cond).toContain("workflow_run.conclusion == 'failure'");
});
});
describe('run-local pnpm cache startup', () => {
const install = ci.jobs.test.steps.find(
(step) => step.name === 'Install dependencies',
);
const version = JSON.parse(
readFileSync('package.json', 'utf8'),
).packageManager.match(/^pnpm@([^+]+)/)[1];
// Exercise installation without the unrelated Linux disk sampler.
const [, commandChain] = install.run.split(/\ntrap [^\n]*\n/);
for (const scenario of [
{
name: 'recreates a broken run-local cache before installing',
local: true,
healthy: false,
succeeds: true,
},
{
name: 'recreates a healthy run-local cache before installing',
local: true,
healthy: true,
succeeds: true,
},
{
name: 'fails installation when the fresh package manager is unavailable',
local: true,
healthy: false,
bootstrapFails: true,
succeeds: false,
},
{
name: 'leaves a broken shared cache untouched',
local: false,
healthy: false,
succeeds: false,
},
{
name: 'installs from a healthy shared cache without rewriting it',
local: false,
healthy: true,
succeeds: true,
},
]) {
it(scenario.name, () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'pnpm-startup-')));
const runnerTemp = join(root, 'runner-temp');
const home = scenario.local
? `${runnerTemp}/qwen-ci-corepack`
: join(root, 'shared-corepack');
const cache = join(home, 'v1', 'pnpm', version);
const entry = join(cache, 'bin', 'pnpm.mjs');
const bin = join(root, 'bin');
const seed = join(root, 'pnpm.mjs');
const marker = join(root, 'installed');
const trace = join(root, 'trace.jsonl');
const oldEntryMarker = join(root, 'old-entry-ran');
const sentinel = join(root, 'bystander');
const cacheNeighbor = join(home, 'v1', 'pnpm', 'other-version');
try {
mkdirSync(join(cache, 'bin'), { recursive: true });
mkdirSync(runnerTemp, { recursive: true });
mkdirSync(bin);
writeFileSync(
join(cache, '.corepack'),
'{"bin":{"pnpm":"bin/pnpm.mjs"}}',
);
writeFileSync(sentinel, 'keep');
writeFileSync(cacheNeighbor, 'old-cache');
writeFileSync(
seed,
String.raw`
import { appendFileSync, writeFileSync } from 'node:fs';
const args = process.argv.slice(2);
appendFileSync(process.env.FIXTURE_TRACE, JSON.stringify({ stage: 'pnpm', args }) + '\n');
if (args.length === 1 && args[0] === '--version') {
console.log(process.env.FIXTURE_PINNED_VERSION);
} else if (JSON.stringify(args) === JSON.stringify(['install', '--frozen-lockfile', '--prefer-offline', '--reporter=append-only'])) {
writeFileSync(process.env.FIXTURE_INSTALL_MARKER, 'installed');
} else {
process.exit(2);
}
`,
);
if (scenario.healthy) {
writeFileSync(
entry,
`${readFileSync(seed, 'utf8')}\nwriteFileSync(process.env.FIXTURE_OLD_ENTRY_MARKER, 'ran');\n`,
);
}
writeFileSync(
join(bin, 'corepack'),
String.raw`#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = fs.realpathSync(process.env.FIXTURE_ROOT);
const configuredHome = path.resolve(process.env.COREPACK_HOME);
const parent = fs.realpathSync(path.dirname(configuredHome));
if (parent !== root && !parent.startsWith(root + path.sep)) throw new Error('Not a private fixture parent');
fs.mkdirSync(configuredHome, { recursive: true });
const home = fs.realpathSync(configuredHome);
if (!home.startsWith(root + path.sep)) throw new Error('Not a private fixture');
const args = process.argv.slice(2);
fs.appendFileSync(process.env.FIXTURE_TRACE, JSON.stringify({ stage: 'corepack', args }) + '\n');
if (args[0] !== 'pnpm') throw new Error('Unexpected package manager');
const cache = path.join(home, 'v1', 'pnpm', process.env.FIXTURE_PINNED_VERSION);
const entry = path.join(cache, 'bin', 'pnpm.mjs');
if (!fs.existsSync(cache)) {
fs.appendFileSync(process.env.FIXTURE_TRACE, JSON.stringify({ stage: 'hydrate' }) + '\n');
fs.mkdirSync(path.dirname(entry), { recursive: true });
fs.writeFileSync(path.join(cache, '.corepack'), '{"bin":{"pnpm":"bin/pnpm.mjs"}}');
if (process.env.FIXTURE_BOOTSTRAP_FAILS !== '1') fs.copyFileSync(process.env.FIXTURE_SEED, entry);
}
const result = spawnSync(process.execPath, [entry, ...args.slice(1)], { stdio: 'inherit' });
process.exit(result.status ?? 1);
`,
);
chmodSync(join(bin, 'corepack'), 0o755);
writeFileSync(
join(bin, 'corepack.cmd'),
`@"${process.execPath}" "%~dp0corepack" %*\r\n`,
);
const before = statSync(cache).ino;
const run = () =>
execFileSync('bash', ['-e', '-o', 'pipefail', '-c', commandChain], {
cwd: process.cwd(),
env: {
...process.env,
PATH: `${bin}${delimiter}${process.env.PATH}`,
COREPACK_ENABLE_NETWORK: '0',
COREPACK_HOME: home,
RUNNER_TEMP: runnerTemp,
FIXTURE_ROOT: root,
FIXTURE_TRACE: trace,
FIXTURE_OLD_ENTRY_MARKER: oldEntryMarker,
FIXTURE_PINNED_VERSION: version,
FIXTURE_INSTALL_MARKER: marker,
FIXTURE_SEED: seed,
FIXTURE_BOOTSTRAP_FAILS: scenario.bootstrapFails ? '1' : '0',
},
stdio: 'pipe',
});
expect(commandChain).toBeTruthy();
if (scenario.succeeds) expect(run).not.toThrow();
else expect(run).toThrow();
expect(existsSync(marker)).toBe(scenario.succeeds);
expect(readFileSync(sentinel, 'utf8')).toBe('keep');
expect(existsSync(cacheNeighbor)).toBe(!scenario.local);
expect(existsSync(oldEntryMarker)).toBe(
!scenario.local && scenario.healthy,
);
const installArgs = [
'install',
'--frozen-lockfile',
'--prefer-offline',
'--reporter=append-only',
];
const events = readFileSync(trace, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line));
expect(events).toEqual([
{ stage: 'corepack', args: ['pnpm', ...installArgs] },
...(scenario.local ? [{ stage: 'hydrate' }] : []),
...(scenario.succeeds ? [{ stage: 'pnpm', args: installArgs }] : []),
]);
if (!scenario.local) {
expect(statSync(cache).ino).toBe(before);
expect(readFileSync(join(cache, '.corepack'), 'utf8')).toBe(
'{"bin":{"pnpm":"bin/pnpm.mjs"}}',
);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}
});