Files
rea/scripts/ci/portable-build-cache.mjs
YsopwnableandShuaixinHuang 3ddb2e4cf7 fix(ci): handle cache eviction and composed cleanup assertions (#4824)
* fix(ci): handle eviction when staging portable build cache

* docs(readme): fix French heading spacing

(cherry picked from commit 0ccd0e6999)

* test(ci): verify observed cache coverage during eviction

* test(electron): assert composed launch cleanup diagnostics

(cherry picked from commit e1466dc480e715b427cddfc3569b2baf03574405)

---------

Co-authored-by: ShuaixinHuang <1836656514@qq.com>
2026-10-18 01:08:14 +08:00

149 lines
4.6 KiB
JavaScript

import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { copyFile, lstat, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
const output = ".cache/ci-build-cache";
const plan = ".cache/ci-build-plan.json";
function readTasks() {
const { tasks } = z
.object({
tasks: z
.array(
z.object({
taskId: z.string().min(1),
// The pinned Turbo version uses eager 64-bit hexadecimal hashes.
hash: z.string().regex(/^[0-9a-f]{16}$/u),
}),
)
.min(1),
})
.parse(JSON.parse(readFileSync(0, "utf8")));
const identities = new Set();
for (const { taskId } of tasks) {
if (identities.has(taskId))
throw new Error(`Duplicate portable build task: ${taskId}`);
identities.add(taskId);
}
return tasks;
}
async function copyEntry(hash) {
const entries = [];
for (const suffix of [".tar.zst", "-meta.json", "-manifest.json"]) {
const name = `${hash}${suffix}`;
const source = join(".turbo/cache", name);
const entry = await lstat(source).catch((error) => {
// Eviction can remove a cache hit even during the successful build.
// Manifests are also optional in supported local cache entries.
if (error.code === "ENOENT") return undefined;
throw error;
});
if (!entry) continue;
if (!entry.isFile())
throw new Error(
`Portable build cache entry must be a regular file: ${source}`,
);
entries.push({ suffix, name, source, size: entry.size });
}
// Publish whole reusable entries only. Consumers rebuild missing tasks.
if (
![".tar.zst", "-meta.json"].every((suffix) =>
entries.some((entry) => entry.suffix === suffix),
)
)
return undefined;
const copied = [];
let missing = false;
for (const entry of entries) {
try {
await copyFile(entry.source, join(output, entry.name));
copied.push(entry);
} catch (error) {
if (error.code !== "ENOENT") throw error;
if (entry.suffix === "-manifest.json") continue;
missing = true;
break;
}
}
if (missing) {
for (const entry of copied) await rm(join(output, entry.name));
return undefined;
}
return {
files: copied.length,
bytes: copied.reduce((sum, entry) => sum + entry.size, 0),
};
}
async function snapshot(tasks) {
await mkdir(output, { recursive: true });
let files = 0;
let bytes = 0;
const available = new Set();
try {
for (const hash of new Set(tasks.map((task) => task.hash))) {
const entry = await copyEntry(hash);
if (!entry) continue;
available.add(hash);
files += entry.files;
bytes += entry.bytes;
}
// A fully evicted cache still has an explicit artifact and coverage record;
// required build outputs and downstream verification remain separate gates.
const manifest = `${JSON.stringify({
version: 1,
tasks: tasks.map((task) => ({
...task,
available: available.has(task.hash),
})),
})}\n`;
await writeFile(join(output, "portable-build-cache.json"), manifest);
files++;
bytes += Buffer.byteLength(manifest);
} catch (error) {
await rm(output, { recursive: true, force: true });
throw error;
}
console.log(
JSON.stringify({
tasks: tasks.length,
files,
bytes,
path: output,
missing_tasks: tasks
.filter((task) => !available.has(task.hash))
.map((task) => task.taskId),
}),
);
}
const [mode, ...extra] = process.argv.slice(2);
if (extra.length || !["key", "snapshot"].includes(mode))
throw new Error(
"Usage: portable-build-cache.mjs key|snapshot < turbo-dry-plan.json",
);
// Invalidate this mode's previous result before parsing its input. Failed
// validation must not leave stale plans or snapshots for downstream reuse.
if (mode === "snapshot") await rm(output, { recursive: true, force: true });
else await rm(plan, { force: true });
const tasks = readTasks();
if (mode === "key") {
const prefix = z
.string()
.min(1)
.regex(/^[^\r\n]+$/u)
.parse(process.env.BUILD_CACHE_PREFIX);
const hashes = tasks.map(({ taskId, hash }) => `${taskId}:${hash}`).sort();
const digest = createHash("sha256").update(hashes.join("\n")).digest("hex");
// Retain only validated identities for the later snapshot. Running Turbo
// again after the build can evict cache entries before they are copied.
await mkdir(".cache", { recursive: true });
await writeFile(plan, `${JSON.stringify({ tasks })}\n`);
console.log(`key=${prefix}-${digest}\nprefix=${prefix}-`);
} else {
await snapshot(tasks);
}