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 "); 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;