mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* ci: shard the Linux unit-test lane eight ways behind one build The Linux Test job ran 45-59 minutes: a ten-minute prepare build, then the CLI and core suites back to back. A new test_build job runs the prepare build once and uploads its outputs; eight shards install without the build, unpack them and run --shard=N/8 of every Vitest suite. Test (ubuntu-latest, Node 22.x) becomes an aggregate that passes only when the build and every shard pass. * ci: record ci.yml's growth from the test_build prelude test_build carries the same pre-checkout cleanup, checkout and install prelude as the test shards; ci-platform-lanes.test.js pins the two copies identical. * ci: record the Linux lane's two new jobs in the PR-stage budget test_build and the test_ubuntu aggregate add two jobs; the declared timeouts drop from 305 to 295 minutes. * ci: shard the web-shell E2E smoke four ways The smoke lane ran 44-46 minutes on every recent PR, about 33 of them in the browser tests at two workers, and is the longest lane once the Linux unit tests are sharded. Each shard runs --shard=N/4 of the smoke; the transcript gate runs on the first. web-shell E2E Smoke (ubuntu-latest, Node 22.x) becomes an aggregate over the shards. * ci: bound the unit shards' tree download and retry it once A pool host stalled 17 minutes in download-artifact while the other shards took under a minute. * ci: rebuild only the workspaces a change reaches scripts/ci-build-cache.mjs hashes each workspace's tracked build inputs (tests excluded) with its workspace dependencies' hashes and the shared root inputs. test_build and the web-shell smoke shards restore the newest cached build and rebuild only workspaces whose hash changed. A push to main always builds from scratch and saves, so a pull request starts from a clean build of main. * docs(ci): describe the incremental build cache * ci: keep the build cache path the same on every pool host actions/cache versions an entry by its path; under runner.temp the path differs per host, so a run could never restore another host's save. * ci: save the ARM64 build cache from the smoke lane, and run the aggregates on the shards' runners The smoke shards run on the ARM64 pool, which test_build may not, so the first shard saves the clean build it made when its restore found nothing. The two aggregate checks queued 9 and 13 minutes for a hosted runner after their shards finished; they now take the shards' runners. * ci: bound the sharded test step and skip shards when the build fails Address the first automated review round on the Linux sharding lane: - Gate every build-consuming shard step on `test_build` success. `!cancelled()` stays true when a needed job fails, so a failed build still started all eight shards, each paying a full install before dying at the missing artifact and reporting artifact plumbing as its failing step. - Drop the test step cap to 45 minutes. The job cap is 60, so the old 110 could never fire; a step timeout keeps the diagnostic steps below it alive while a job timeout does not. - Keep the built-tree artifact for a week so a late "re-run failed jobs" still finds it, and correct the trigger comment that still named three post-merge jobs when five now accept push. Co-authored-by: Qwen-Coder <[email protected]> * ci: keep disk telemetry for a failed build and correct the design doc `test_build` installs with the disk sampler and then builds for up to its whole timeout, but the sampler dies with the install step's trap and no collector ran, so an ENOSPC build (or the OOM that took a shard host) kept no evidence. Give it the same failure-gated dump and last-step upload as `lint_and_static`, under its own artifact name. The incremental-lint design still said the test job keeps normal prepare behaviour; this lane now installs with `QWEN_SKIP_PREPARE=1` and unpacks the tree built once by `test_build`. Correct both language versions. Co-authored-by: Qwen-Coder <[email protected]> * ci: share the built-tree definition and the build preflight, harden the guards The archive step re-implemented `builtPaths()` from `scripts/ci-build-cache.mjs`, so the tree the shards unpack had a second definition that could drift from the one `pack` stores. A `paths` mode now prints that list for `tar -T -`, and a new test pins the four artifact-name literals that tie the producer to the eight consumers. The cached branch of `build` also hand-reproduced a subset of `scripts/build.js` and so skipped its ink-patch preflight: the same missing `frame-controller.d.ts` self-healed on a cache miss and surfaced as ~50 tsc errors inside ink's declarations on a hit. The preflight is now a function reachable as `scripts/build.js --preflight` and runs before both branches. The remaining review suggestions were all meta-test gaps: the standalone-runtime expression and the `test_build`/`test` prelude were guarded by nothing that compares them in order, `ci-build-cache.mjs`'s three CI modes were never executed by a test, and the infra-classification filter's step name was not joined to `ci.yml`. Each now has an assertion that goes red when the guard is removed. Co-authored-by: Qwen-Coder <[email protected]> * ci: bound the build steps so the failure collectors can run Round-2 review found four issues in the previous round's fixes and one gap in the new build tests. A job timeout cancels the job and skips the failure-gated collectors, so the `test_build` steps that can block now carry caps below the job's 45 minutes: the install, the build, the pack and the archive. The install caps are identical across `test_build`, `test` and `lint_and_static` so the prelude parity guard still compares the three copies field for field. A test pins every blocking cap under the job ceiling. The `--preflight` early exit was only asserted as a string, so it could be renamed with nothing going red while the cache-hit path silently ran the full build. A spawned case now copies `scripts/build.js` into the fixture and asserts the flag exits without generate, a workspace build or the schema step. The CLI mode tests write extension-less `#!/bin/sh` stubs and join PATH with ':', which cmd.exe cannot resolve; the Windows lane collects that file and downgrades its failure to a warning, so it would have carried a permanently red block. The suite is excluded on win32 alongside the other bash-fixture suites. The `test_build`/`test` intersection grew to 16 with the disk collector, but the floor stayed at 15, leaving exactly one shared step uncompared; the floor now matches the membership. The cache-hit-with-changes branch — the code that decides what is rebuilt and what is deleted — gains a case: a changed workspace is purged and rebuilt by directory, and an untouched workspace keeps what the cache restored. ci.yml's recorded size baseline is updated to the current file so the next PR gets the full growth allowance back. Co-authored-by: Qwen-Coder <[email protected]> * test(ci): keep cache fixture call logs outside the archived tree Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: 易良 <[email protected]> Co-authored-by: Qwen-Coder <[email protected]>
244 lines
8.1 KiB
JavaScript
244 lines
8.1 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
// Incremental `prepare` build for CI.
|
|
//
|
|
// key print a hash of every workspace's build inputs
|
|
// build <dir> build the tree, reusing what <dir> holds from an earlier
|
|
// run: only workspaces whose inputs changed rebuild
|
|
// pack <dir> store the built tree and its input hashes in <dir>
|
|
// paths print the built paths, NUL-separated, for `tar -T -`
|
|
//
|
|
// A workspace's hash covers its tracked files (tests excluded: no build
|
|
// reads them), the global inputs every build shares, and the hashes of the
|
|
// workspaces it depends on, so a change rebuilds its dependents too.
|
|
|
|
import { execFileSync, execSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
// The archive step in ci.yml shares `builtPaths()` with `pack` through the
|
|
// `paths` mode, so the tree the shards unpack is defined once. The override
|
|
// exists so the mode tests can point the script at a fixture repository.
|
|
const root = resolve(
|
|
process.env.CI_BUILD_CACHE_ROOT ?? join(import.meta.dirname, '..'),
|
|
);
|
|
const TREE = 'tree.tgz';
|
|
const HASHES = 'hashes.json';
|
|
|
|
// Not built by the root build, as in scripts/build.js.
|
|
const UNBUILT = new Set(['@qwen-code/mobile-mcp']);
|
|
|
|
export const isTestFile = (file) =>
|
|
/\.(test|spec)\.[cm]?[jt]sx?$/.test(file) ||
|
|
/(^|\/)(__tests__|__snapshots__)\//.test(file);
|
|
|
|
// Root-level files and the shared build scripts every workspace build reads.
|
|
export const isGlobalInput = (file) =>
|
|
!file.includes('/') ||
|
|
file.startsWith('patches/') ||
|
|
(file.startsWith('scripts/') && !file.startsWith('scripts/tests/'));
|
|
|
|
/**
|
|
* @param {{name: string, dir: string, deps: string[]}[]} workspaces
|
|
* @param {{path: string, blob: string}[]} files tracked files and blob ids
|
|
* @returns {Record<string, string>} workspace name -> input hash
|
|
*/
|
|
export function hashWorkspaces(workspaces, files) {
|
|
const global = createHash('sha256');
|
|
const own = new Map(workspaces.map((w) => [w.name, createHash('sha256')]));
|
|
// Longest directory first, so a nested workspace claims its own files.
|
|
const byDir = [...workspaces].sort((a, b) => b.dir.length - a.dir.length);
|
|
for (const { path, blob } of files) {
|
|
if (isTestFile(path)) continue;
|
|
const owner = byDir.find((w) => path.startsWith(`${w.dir}/`));
|
|
if (owner) own.get(owner.name).update(`${path}\0${blob}\n`);
|
|
else if (isGlobalInput(path)) global.update(`${path}\0${blob}\n`);
|
|
}
|
|
const globalHash = global.digest('hex');
|
|
const byName = new Map(workspaces.map((w) => [w.name, w]));
|
|
const hashes = {};
|
|
const visit = (name) => {
|
|
if (hashes[name]) return hashes[name];
|
|
const hash = createHash('sha256')
|
|
.update(globalHash)
|
|
.update(own.get(name).copy().digest('hex'));
|
|
for (const dep of [...byName.get(name).deps].sort()) {
|
|
if (byName.has(dep)) hash.update(`${dep}\0${visit(dep)}\n`);
|
|
}
|
|
return (hashes[name] = hash.digest('hex'));
|
|
};
|
|
for (const { name } of workspaces) visit(name);
|
|
return hashes;
|
|
}
|
|
|
|
/** Workspaces whose hash differs from the stored one, or has none. */
|
|
export const changedWorkspaces = (current, stored) =>
|
|
Object.keys(current).filter((name) => current[name] !== stored?.[name]);
|
|
|
|
function listWorkspaces() {
|
|
const listed = JSON.parse(
|
|
execSync('corepack pnpm ls -r --depth -1 --json', {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
}),
|
|
);
|
|
return listed
|
|
.filter((w) => resolve(w.path) !== root && !UNBUILT.has(w.name))
|
|
.map((w) => {
|
|
const manifest = JSON.parse(
|
|
readFileSync(join(w.path, 'package.json'), 'utf8'),
|
|
);
|
|
const deps = Object.keys({
|
|
...manifest.dependencies,
|
|
...manifest.devDependencies,
|
|
...manifest.peerDependencies,
|
|
...manifest.optionalDependencies,
|
|
});
|
|
const dir = w.path
|
|
.slice(root.length + 1)
|
|
.split('\\')
|
|
.join('/');
|
|
return { name: w.name, dir, deps };
|
|
});
|
|
}
|
|
|
|
function trackedFiles() {
|
|
return execFileSync('git', ['ls-files', '-s', '-z'], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
maxBuffer: 256 * 1024 * 1024,
|
|
})
|
|
.split('\0')
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [meta, path] = line.split('\t');
|
|
return { path, blob: meta.split(' ')[1] };
|
|
});
|
|
}
|
|
|
|
// The build's outputs: ignored, untracked paths outside node_modules.
|
|
function builtPaths(dirs = []) {
|
|
return execFileSync(
|
|
'git',
|
|
[
|
|
'ls-files',
|
|
'-z',
|
|
'--others',
|
|
'--ignored',
|
|
'--exclude-standard',
|
|
'--directory',
|
|
'--',
|
|
...dirs,
|
|
],
|
|
{ cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
|
|
)
|
|
.split('\0')
|
|
.filter((path) => path && !/(^|\/)node_modules\/$/.test(path));
|
|
}
|
|
|
|
const run = (command) => execSync(command, { cwd: root, stdio: 'inherit' });
|
|
|
|
function currentHashes() {
|
|
return hashWorkspaces(listWorkspaces(), trackedFiles());
|
|
}
|
|
|
|
export function main(argv) {
|
|
const [mode, dir] = argv;
|
|
if (mode === 'key') {
|
|
const hashes = currentHashes();
|
|
const combined = createHash('sha256');
|
|
for (const name of Object.keys(hashes).sort()) {
|
|
combined.update(`${name}\0${hashes[name]}\n`);
|
|
}
|
|
console.log(combined.digest('hex'));
|
|
return;
|
|
}
|
|
if (mode === 'paths') {
|
|
process.stdout.write(
|
|
builtPaths()
|
|
.map((path) => `${path}\0`)
|
|
.join(''),
|
|
);
|
|
return;
|
|
}
|
|
if (!dir || (mode !== 'build' && mode !== 'pack')) {
|
|
throw new Error(
|
|
'usage: ci-build-cache.mjs key | build <dir> | pack <dir> | paths',
|
|
);
|
|
}
|
|
const cacheDir = resolve(dir);
|
|
const workspaces = listWorkspaces();
|
|
const hashes = hashWorkspaces(workspaces, trackedFiles());
|
|
|
|
if (mode === 'pack') {
|
|
mkdirSync(cacheDir, { recursive: true });
|
|
const paths = builtPaths();
|
|
execFileSync('tar', ['-czf', join(cacheDir, TREE), '--null', '-T', '-'], {
|
|
cwd: root,
|
|
input: paths.join('\0'),
|
|
stdio: ['pipe', 'inherit', 'inherit'],
|
|
});
|
|
writeFileSync(join(cacheDir, HASHES), JSON.stringify(hashes, null, 2));
|
|
return;
|
|
}
|
|
|
|
// `scripts/build.js` guards the ink patch its typecheck depends on, but the
|
|
// cached branch below deliberately does not run that file — it rebuilds only
|
|
// the changed workspaces. Without this, the same missing
|
|
// `frame-controller.d.ts` self-heals or explains itself on a cache miss and
|
|
// surfaces as ~50 tsc errors inside ink's declarations on a hit.
|
|
run('node scripts/build.js --preflight');
|
|
|
|
const hashFile = join(cacheDir, HASHES);
|
|
if (!existsSync(hashFile) || !existsSync(join(cacheDir, TREE))) {
|
|
console.log('No cached build; building every workspace.');
|
|
run('npm run build');
|
|
run('npm run bundle');
|
|
return;
|
|
}
|
|
run(`tar -xzf "${join(cacheDir, TREE)}"`);
|
|
const changed = changedWorkspaces(
|
|
hashes,
|
|
JSON.parse(readFileSync(hashFile, 'utf8')),
|
|
);
|
|
console.log(
|
|
`Cached build restored; ${changed.length} of ${workspaces.length} workspaces changed${changed.length ? `: ${changed.join(', ')}` : ''}.`,
|
|
);
|
|
// Remove a changed workspace's stale outputs, then rebuild it in
|
|
// dependency order; the rest keep the restored outputs.
|
|
const dirs = workspaces
|
|
.filter((w) => changed.includes(w.name))
|
|
.map((w) => w.dir);
|
|
const stale = dirs.length ? builtPaths(dirs) : [];
|
|
if (stale.length) {
|
|
execFileSync('rm', ['-rf', '--', ...stale], { cwd: root });
|
|
}
|
|
run('npm run generate');
|
|
if (changed.length) {
|
|
// By directory: the repository root shares the CLI's package name, and
|
|
// selecting the root runs scripts/build.js, every workspace again.
|
|
const filters = dirs.map((dir) => `--filter "{./${dir}}"`).join(' ');
|
|
// The heap `npm run build` gives the same pnpm run.
|
|
execSync(`corepack pnpm -r ${filters} run build`, {
|
|
cwd: root,
|
|
stdio: 'inherit',
|
|
env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=6144' },
|
|
});
|
|
}
|
|
run('node --import tsx/esm scripts/generate-settings-schema.ts');
|
|
run('npm run bundle');
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
) {
|
|
main(process.argv.slice(2));
|
|
}
|