Advance M7 workflows and release operations
This commit is contained in:
186
tools/web/run-ci-lane.mjs
Normal file
186
tools/web/run-ci-lane.mjs
Normal file
@@ -0,0 +1,186 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user