187 lines
9.5 KiB
JavaScript
187 lines
9.5 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const releaseRoot = path.join(repoRoot, "release");
|
|
const lane = process.argv[2];
|
|
const selfTestFailure = process.argv.includes("--self-test-failure");
|
|
assert.ok(["quick", "chromium", "release"].includes(lane), "usage: run-ci-lane.mjs <quick|chromium|release>");
|
|
|
|
const laneCommands = {
|
|
quick: [
|
|
["lockfile-install", "npm", ["--prefix", "web", "ci", "--ignore-scripts"]],
|
|
["typecheck", "npm", ["--prefix", "web", "run", "typecheck"]],
|
|
["lint", "npm", ["--prefix", "web", "run", "lint"]],
|
|
["unit", "npm", ["--prefix", "web", "test"]],
|
|
["status", "npm", ["--prefix", "web", "run", "test:status-consistency"]],
|
|
["evidence-schema", "npm", ["--prefix", "web", "run", "test:release-evidence"]],
|
|
["rc-docs", "npm", ["--prefix", "web", "run", "test:rc-docs"]],
|
|
],
|
|
chromium: [
|
|
["lockfile-install", "npm", ["--prefix", "web", "ci", "--ignore-scripts"]],
|
|
["p0-user-loop", "npm", ["--prefix", "web", "run", "test:v1-user-loop"], "browser"],
|
|
["offline-browser-smoke", "npm", ["--prefix", "web", "run", "test:browser"], "browser"],
|
|
["network-interruption", "npm", ["--prefix", "web", "run", "test:network-interruption"], "browser"],
|
|
["device-loss", "npm", ["--prefix", "web", "run", "test:device-loss"], "browser"],
|
|
["oom-recovery", "npm", ["--prefix", "web", "run", "test:oom-recovery"], "browser"],
|
|
["opfs-quota", "npm", ["--prefix", "web", "run", "test:storage-quota"], "browser"],
|
|
["malicious-blend", "npm", ["--prefix", "web", "run", "test:malicious-blends"]],
|
|
["malicious-archive", "npm", ["--prefix", "web", "run", "test:asset-library"], "browser"],
|
|
],
|
|
release: [
|
|
["lockfile-install", "npm", ["--prefix", "web", "ci", "--ignore-scripts"]],
|
|
["full-e2e", "npm", ["--prefix", "web", "run", "test:e2e", "--", "--workers=1"], "browser"],
|
|
["performance-100k-1m", "npm", ["--prefix", "web", "run", "test:release-performance"]],
|
|
["performance-10m", "npm", ["--prefix", "web", "run", "test:geometry-10m-performance"], "browser"],
|
|
["performance-texture-4k", "npm", ["--prefix", "web", "run", "test:texture-4k-performance"], "browser"],
|
|
["performance-texture-8k", "npm", ["--prefix", "web", "run", "test:texture-8k-performance"], "browser"],
|
|
["performance-simulation", "npm", ["--prefix", "web", "run", "test:simulation-cache-performance"], "browser"],
|
|
["performance-long-media", "npm", ["--prefix", "web", "run", "test:long-media-performance"], "browser"],
|
|
["vdb-native", "npm", ["--prefix", "web", "run", "test:vdb-native"]],
|
|
["vdb-server", "npm", ["--prefix", "web", "run", "test:vdb-server"]],
|
|
["vdb-opfs", "npm", ["--prefix", "web", "run", "test:vdb-opfs"], "browser"],
|
|
["vdb-webgpu", "npm", ["--prefix", "web", "run", "test:vdb-webgpu"], "browser"],
|
|
["vdb-viewport", "npm", ["--prefix", "web", "run", "test:vdb-viewport"], "browser"],
|
|
["vdb-faults", "npm", ["--prefix", "web", "run", "test:vdb-faults"], "browser"],
|
|
["v1-acceptance", "npm", ["--prefix", "web", "run", "release:v1-acceptance"], "acceptance"],
|
|
["offline-reproducibility", "npm", ["--prefix", "web", "run", "release:offline"]],
|
|
["archive-browser", "npm", ["--prefix", "web", "run", "test:archive-offline"], "browser"],
|
|
["binary-archive", "npm", ["--prefix", "web", "run", "test:binary-archive"]],
|
|
["source-archive", "npm", ["--prefix", "web", "run", "test:source-archive"]],
|
|
["deployment-runbook", "npm", ["--prefix", "web", "run", "test:deployment-runbook"]],
|
|
["upgrade-runbook", "npm", ["--prefix", "web", "run", "test:upgrade-runbook"]],
|
|
["rollback-runbook", "npm", ["--prefix", "web", "run", "test:rollback-runbook"]],
|
|
["operations-diagnostics", "npm", ["--prefix", "web", "run", "test:operations-diagnostics"]],
|
|
["operations-rehearsal", "npm", ["--prefix", "web", "run", "test:operations-rehearsal"]],
|
|
["rc-manifest", "npm", ["--prefix", "web", "run", "release:rc-manifest"]],
|
|
],
|
|
};
|
|
|
|
const commands = selfTestFailure
|
|
? [["injected-failure", process.execPath, ["-e", "process.stderr.write('injected CI lane failure\\n'); process.exit(17)"]]]
|
|
: laneCommands[lane];
|
|
const reportPath = path.resolve(process.env.CI_REPORT_PATH ?? path.join(releaseRoot, "ci-reports", `${lane}.json`));
|
|
const logRoot = path.join(path.dirname(reportPath), `${path.basename(reportPath, ".json")}-logs`);
|
|
fs.mkdirSync(logRoot, { recursive: true });
|
|
|
|
const digest = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
const fileDigest = (file) => fs.existsSync(file) && fs.statSync(file).isFile() ? digest(fs.readFileSync(file)) : null;
|
|
|
|
function capture(command, args) {
|
|
const result = spawnSync(command, args, { cwd: repoRoot, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }, maxBuffer: 8 * 1024 * 1024 });
|
|
return result.status === 0 ? `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() : "unavailable";
|
|
}
|
|
|
|
async function freePortRange(length = 1) {
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
const base = 20_000 + Math.floor(Math.random() * (40_000 - length));
|
|
const servers = [];
|
|
try {
|
|
for (let offset = 0; offset < length; offset++) {
|
|
const server = net.createServer();
|
|
await new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(base + offset, "127.0.0.1", resolve);
|
|
});
|
|
servers.push(server);
|
|
}
|
|
return base;
|
|
}
|
|
catch {
|
|
// Try another contiguous range.
|
|
}
|
|
finally {
|
|
await Promise.all(servers.map((server) => new Promise((resolve) => server.close(resolve))));
|
|
}
|
|
}
|
|
throw new Error(`unable to allocate ${length} loopback port(s)`);
|
|
}
|
|
|
|
function artifactBindings() {
|
|
const files = {
|
|
packageJson: "web/package.json",
|
|
lockfile: "web/package-lock.json",
|
|
ledger: "docs/status/parity-ledger.json",
|
|
engineManifest: "web/app/public/engine-manifest.json",
|
|
releaseNotes: "docs/web/RELEASE_NOTES.md",
|
|
knownLimitations: "docs/web/KNOWN_LIMITATIONS.md",
|
|
releaseRecovery: "docs/web/RELEASE_RECOVERY.md",
|
|
sbom: "docs/web/sbom.spdx.json",
|
|
binaryArchive: "release/blender-web-offline.tar.gz",
|
|
sourceArchive: "release/blender-web-corresponding-source.tar.gz",
|
|
sha256Sums: "release/SHA256SUMS.txt",
|
|
rcManifest: "release/RC_MANIFEST.json",
|
|
};
|
|
return Object.fromEntries(Object.entries(files).map(([name, relative]) => {
|
|
const releaseOnly = ["binaryArchive", "sourceArchive", "sha256Sums", "rcManifest"].includes(name);
|
|
return [name, { path: relative, sha256: releaseOnly && lane !== "release" ? null : fileDigest(path.join(repoRoot, relative)) }];
|
|
}));
|
|
}
|
|
|
|
const environment = {
|
|
os: `${os.platform()} ${os.release()} ${os.arch()}`,
|
|
node: process.version,
|
|
npm: capture("npm", ["--version"]),
|
|
chrome: capture(process.env.CHROME_PATH ?? "google-chrome", ["--version"]),
|
|
blender: capture(process.env.BLENDER_BIN ?? "blender", ["--version"]).split("\n")[0],
|
|
emscripten: capture("bash", ["-lc", "source tools/web/emscripten-env.sh >/dev/null 2>&1 && emcc --version | head -1"]),
|
|
};
|
|
const commit = capture("git", ["rev-parse", "HEAD"]);
|
|
const records = [];
|
|
|
|
for (const [index, definition] of commands.entries()) {
|
|
const [id, command, args, portMode] = definition;
|
|
const env = { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" };
|
|
let port;
|
|
if (portMode === "browser") {
|
|
port = await freePortRange();
|
|
env.WEB_TEST_PORT = String(port);
|
|
}
|
|
else if (portMode === "acceptance") {
|
|
port = await freePortRange(80);
|
|
env.WEB_ACCEPTANCE_PORT = String(port);
|
|
}
|
|
const started = Date.now();
|
|
const result = spawnSync(command, args, { cwd: repoRoot, encoding: "utf8", env, maxBuffer: 64 * 1024 * 1024 });
|
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
const logPath = path.join(logRoot, `${String(index + 1).padStart(2, "0")}-${id}.log`);
|
|
fs.writeFileSync(logPath, `${output}\n`);
|
|
const record = {
|
|
id,
|
|
command: [command, ...args].join(" "),
|
|
exitCode: result.status ?? 1,
|
|
durationMs: Date.now() - started,
|
|
...(port ? { loopbackPort: port } : {}),
|
|
output: { path: path.relative(repoRoot, logPath).replaceAll(path.sep, "/"), bytes: fs.statSync(logPath).size, sha256: fileDigest(logPath) },
|
|
};
|
|
records.push(record);
|
|
process.stdout.write(`[${lane} ${index + 1}/${commands.length}] ${id} exit=${record.exitCode} durationMs=${record.durationMs}${port ? ` port=${port}` : ""}\n`);
|
|
if (record.exitCode !== 0) break;
|
|
}
|
|
|
|
const status = records.length === commands.length && records.every((record) => record.exitCode === 0) ? "READY" : "FAILED";
|
|
const report = {
|
|
schemaVersion: 1,
|
|
lane,
|
|
status,
|
|
generatedAt: new Date().toISOString(),
|
|
commit,
|
|
environment,
|
|
bindings: artifactBindings(),
|
|
records,
|
|
retentionDays: lane === "quick" ? 7 : lane === "chromium" ? 14 : 30,
|
|
};
|
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
const temporaryReport = `${reportPath}.tmp-${process.pid}`;
|
|
fs.writeFileSync(temporaryReport, `${JSON.stringify(report, null, 2)}\n`);
|
|
fs.renameSync(temporaryReport, reportPath);
|
|
fs.writeFileSync(`${reportPath}.sha256`, `${fileDigest(reportPath)} ${path.basename(reportPath)}\n`);
|
|
process.stdout.write(`ci-lane-report lane=${lane} status=${status} records=${records.length} path=${reportPath}\n`);
|
|
if (status !== "READY") process.exitCode = 1;
|