mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(ci): pin and cache the standalone runtime archives The standalone runtime regression downloaded Node 24.19.0 and Bun 1.3.14 on every run, after first fetching Node's checksum list and Bun's npm metadata, with no retry. That cost minutes per run on the shared runners and turned an npm 502 into a red required check on an unrelated change. Pin the published digests for the four supported platforms so archives are verified without metadata requests, keep verified archives in a cache directory when one is configured, and retry downloads. The required Linux unit-test job points the cache at a host-wide directory on persistent self-hosted runners. * fix(ci): keep a verified runtime download when the cache is unusable If the cache directory, or a directory on its path, cannot be searched, writing the archive fails as intended, but removing the temporary file then throws too (EACCES, or ENOTDIR when the path runs through a file): `force` only ignores a missing file. The verified archive already in memory was discarded, the regression never started, and the script tests chained after it were skipped. Make that cleanup best-effort like the rest of the cache write. The download-and-cache logic moves unchanged into scripts/lib so it can be tested; the tests cover cache reuse, replacing a copy that fails verification, a cache path through a file, and an unsearchable cache directory (skipped as root, where permissions do not apply). * fix(scripts): retry unverified runtime downloads and sweep the host cache Verify each download attempt, so a truncated or stale 200 body is retried like a failed request, and name both digests when the pin never matches. Retry logs now include the undici error cause, and cache hits and writes each log a line. The CI step reports the cache path and size. Entries no run has used for a week (previous pins, partial files left by a killed run) are removed after each successful run; a run refreshes the mtime of the archive it uses, so revisions still in flight keep theirs. Tests cover retries, verification per attempt, the uncached path, the sweep, the CI wiring, and gate the permission case on the shared permission-bits probe so it also runs on root lanes with DAC caps dropped. * fix(ci): sweep the runtime cache only after a run fetched both archives The Node fetch swept every other entry unused for a week, so after a quiet week it removed the Bun archive the same run was about to reuse and Bun was downloaded again. Fetches now only mark their archive as used; ci() sweeps once after both fetches, sparing the archives it used. The sweep skips non-files and keeps going past an entry it cannot remove, and a cached copy that fails verification is logged. Also: - export the same host-wide cache to release.yml's quality_scripts, which runs the same regression on a persistent host - move the pinned versions and digests to scripts/lib/standalone-runtime-pins.js and check their shape - pin the wiring itself: the export step's gate, its GITHUB_ENV write and its position before the consuming step, in both workflows - cover the write-then-rename into the cache
136 lines
4.1 KiB
JavaScript
136 lines
4.1 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const UNUSED_ENTRY_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* Returns the bytes at `url` once `digest` of them equals `expected`. With a
|
|
* `cacheDir`, a copy there is used only if it matches, and a fresh download is
|
|
* written back. The cache only saves time: no cache failure reaches the caller.
|
|
*/
|
|
export async function fetchVerifiedArchive(
|
|
url,
|
|
filename,
|
|
{ digest, expected },
|
|
{
|
|
cacheDir,
|
|
fetchImpl = fetch,
|
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
} = {},
|
|
) {
|
|
const cached = cacheDir && path.join(cacheDir, filename);
|
|
if (cached) {
|
|
try {
|
|
const bytes = fs.readFileSync(cached);
|
|
if (digest(bytes) === expected) {
|
|
console.log(`runtime archive reused from cache: ${cached}`);
|
|
markUsed(cached);
|
|
return bytes;
|
|
}
|
|
// Replaced by the download below.
|
|
console.log(`runtime archive in cache failed verification: ${cached}`);
|
|
} catch {
|
|
// Not cached yet, or unreadable: download it below.
|
|
}
|
|
}
|
|
let bytes;
|
|
for (let attempt = 1; !bytes; attempt++) {
|
|
try {
|
|
const response = await fetchImpl(url, {
|
|
signal: AbortSignal.timeout(300_000),
|
|
});
|
|
assert.equal(
|
|
response.status,
|
|
200,
|
|
`runtime download: ${url} returned ${response.status}`,
|
|
);
|
|
const body = Buffer.from(await response.arrayBuffer());
|
|
// Verified per attempt: a truncated or stale body is retried like a
|
|
// failed request.
|
|
const actual = digest(body);
|
|
assert(
|
|
actual === expected,
|
|
`runtime archive digest mismatch: ${url} is ${actual}, pinned ${expected}`,
|
|
);
|
|
bytes = body;
|
|
} catch (error) {
|
|
if (attempt === 3) throw error;
|
|
// undici reports only "fetch failed"; the cause says why.
|
|
const cause =
|
|
error instanceof Error && error.cause instanceof Error
|
|
? ` (cause: ${error.cause.message})`
|
|
: '';
|
|
console.log(
|
|
`runtime download attempt ${attempt} failed: ${error}${cause}`,
|
|
);
|
|
await sleep(attempt * 5_000);
|
|
}
|
|
}
|
|
if (cached) {
|
|
// Rename into place so a concurrent job never reads a partial file.
|
|
const partial = `${cached}.${process.pid}.tmp`;
|
|
try {
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
fs.writeFileSync(partial, bytes);
|
|
fs.renameSync(partial, cached);
|
|
console.log(`runtime archive cached: ${cached}`);
|
|
} catch (error) {
|
|
try {
|
|
fs.rmSync(partial, { force: true });
|
|
} catch {
|
|
// A directory that refused the write can refuse the cleanup too.
|
|
}
|
|
console.log(`runtime archive not cached: ${error}`);
|
|
}
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function markUsed(file) {
|
|
try {
|
|
const now = new Date();
|
|
fs.utimesSync(file, now, now);
|
|
} catch {
|
|
// The sweep below also spares the archives a run names as used.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes what no run has used for a week: archives that only other revisions
|
|
* pin, and partial files a killed run left behind. Call it once a run has
|
|
* fetched every archive it needs, naming them in `used`; until then, an
|
|
* archive the run fetches later may look just as unused. Each fetch marks its
|
|
* archive as used, so revisions still running keep theirs, and an in-flight
|
|
* partial file is always newer than a week.
|
|
*/
|
|
export function sweepRuntimeCache(cacheDir, used) {
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(cacheDir);
|
|
} catch {
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
for (const entry of entries) {
|
|
if (used.includes(entry)) continue;
|
|
try {
|
|
const file = path.join(cacheDir, entry);
|
|
// Another job may have removed it since the listing.
|
|
const stats = fs.statSync(file, { throwIfNoEntry: false });
|
|
if (stats?.isFile() && now - stats.mtimeMs > UNUSED_ENTRY_MAX_AGE_MS) {
|
|
fs.rmSync(file, { force: true });
|
|
}
|
|
} catch {
|
|
// Sweeping only saves space; an entry it cannot remove does not stop
|
|
// it from removing the rest.
|
|
}
|
|
}
|
|
}
|