Advance M7 workflows and release operations
This commit is contained in:
70
tools/web/archive-deployment-server.mjs
Normal file
70
tools/web/archive-deployment-server.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createDeploymentHttpServer,
|
||||
listenDeploymentHttpServer,
|
||||
loadDeploymentContract,
|
||||
} from "./deployment-http-server.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
function option(name, fallback) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function assertSafeArchiveEntries(archive) {
|
||||
const entries = execFileSync("tar", ["-tzf", archive], { encoding: "utf8" })
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
assert.ok(entries.length > 0, "binary archive is empty");
|
||||
for (const entry of entries) {
|
||||
assert.ok(!entry.startsWith("/"), `binary archive contains an absolute path: ${entry}`);
|
||||
assert.ok(!entry.split("/").includes(".."), `binary archive contains path traversal: ${entry}`);
|
||||
assert.ok(entry === "blender-web-offline/" || entry.startsWith("blender-web-offline/"), `unexpected binary archive root: ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
const archive = path.resolve(option("--archive", path.join(repoRoot, "release/blender-web-offline.tar.gz")));
|
||||
const port = Number.parseInt(option("--port", process.env.WEB_TEST_PORT ?? "5189"), 10);
|
||||
assert.ok(fs.statSync(archive).isFile(), `binary archive is missing: ${archive}`);
|
||||
assert.ok(Number.isSafeInteger(port) && port > 0 && port < 65536, `invalid deployment port: ${port}`);
|
||||
assertSafeArchiveEntries(archive);
|
||||
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-archive-server-"));
|
||||
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", archive, "-C", workspace]);
|
||||
const bundleRoot = path.join(workspace, "blender-web-offline");
|
||||
const staticRoot = path.join(bundleRoot, "app");
|
||||
assert.ok(fs.statSync(path.join(staticRoot, "index.html")).isFile(), "extracted binary archive has no app/index.html");
|
||||
assert.ok(!staticRoot.startsWith(`${repoRoot}${path.sep}`), "archive server must not serve a workspace directory");
|
||||
|
||||
const contract = loadDeploymentContract(path.join(bundleRoot, "deployment-contract.json"));
|
||||
const server = createDeploymentHttpServer({ root: staticRoot, contract });
|
||||
let closing = false;
|
||||
|
||||
async function close(exitCode = 0) {
|
||||
if (closing) return;
|
||||
closing = true;
|
||||
await new Promise((resolve) => server.close(() => resolve()));
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
process.once("SIGINT", () => void close(0));
|
||||
process.once("SIGTERM", () => void close(0));
|
||||
process.once("uncaughtException", (error) => {
|
||||
process.stderr.write(`${error.stack ?? error}\n`);
|
||||
void close(1);
|
||||
});
|
||||
process.once("unhandledRejection", (error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
void close(1);
|
||||
});
|
||||
|
||||
const origin = await listenDeploymentHttpServer(server, { port });
|
||||
process.stdout.write(`archive-deployment-server ${origin} root=${staticRoot} archive=${archive}\n`);
|
||||
|
||||
139
tools/web/check-binary-archive.mjs
Normal file
139
tools/web/check-binary-archive.mjs
Normal file
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } 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 archive = path.resolve(process.env.M6_BINARY_ARCHIVE ?? path.join(releaseRoot, "blender-web-offline.tar.gz"));
|
||||
const sourceArchive = path.resolve(process.env.M6_SOURCE_ARCHIVE ?? path.join(releaseRoot, "blender-web-corresponding-source.tar.gz"));
|
||||
const sumsPath = path.join(releaseRoot, "SHA256SUMS.txt");
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-binary-archive-"));
|
||||
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
function walk(directory, base = directory) {
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walk(absolute, base));
|
||||
else files.push(path.relative(base, absolute).replaceAll(path.sep, "/"));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function safeEntries(file) {
|
||||
const verbose = execFileSync("tar", ["-tvzf", file], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
||||
for (const line of verbose.split("\n").filter(Boolean)) {
|
||||
assert.ok(["-", "d"].includes(line[0]), `binary archive contains a non-regular entry: ${line}`);
|
||||
}
|
||||
const entries = execFileSync("tar", ["-tzf", file], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }).split("\n").filter(Boolean);
|
||||
assert.ok(entries.length > 0, "binary archive is empty");
|
||||
for (const entry of entries) {
|
||||
assert.ok(!entry.startsWith("/") && !entry.split("/").includes(".."), `unsafe binary archive path: ${entry}`);
|
||||
assert.ok(entry === "blender-web-offline/" || entry.startsWith("blender-web-offline/"), `unexpected binary archive root: ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
assert.ok(fs.statSync(archive).isFile(), `binary archive is missing: ${archive}`);
|
||||
assert.ok(fs.statSync(sourceArchive).isFile(), `source archive is missing: ${sourceArchive}`);
|
||||
assert.ok(fs.statSync(sumsPath).isFile(), `release checksum file is missing: ${sumsPath}`);
|
||||
execFileSync("sha256sum", ["-c", path.basename(sumsPath)], { cwd: releaseRoot, stdio: "pipe" });
|
||||
safeEntries(archive);
|
||||
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", archive, "-C", workspace]);
|
||||
const bundle = path.join(workspace, "blender-web-offline");
|
||||
assert.ok(!fs.realpathSync(bundle).startsWith(`${repoRoot}${path.sep}`), "binary archive was not extracted independently");
|
||||
|
||||
const required = [
|
||||
"COPYING",
|
||||
"DEPLOYMENT.md",
|
||||
"SOURCE_OFFER.txt",
|
||||
"deployment-contract.json",
|
||||
"manifest.json",
|
||||
"KNOWN_LIMITATIONS.md",
|
||||
"operations-diagnostics.json",
|
||||
"parity-ledger.json",
|
||||
"RELEASE_NOTES.md",
|
||||
"RELEASE_RECOVERY.md",
|
||||
"release-metadata.json",
|
||||
"sbom.spdx.json",
|
||||
"third-party-notices.json",
|
||||
"V1_SCOPE.md",
|
||||
"app/index.html",
|
||||
"app/engine-manifest.json",
|
||||
];
|
||||
for (const relative of required) assert.ok(fs.statSync(path.join(bundle, relative)).isFile(), `binary archive omits ${relative}`);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(bundle, "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.schemaVersion, 1, "binary manifest schema drifted");
|
||||
assert.ok(Array.isArray(manifest.files) && manifest.files.length > 0, "binary manifest has no files");
|
||||
const actualFiles = walk(bundle).filter((relative) => relative !== "manifest.json");
|
||||
assert.deepEqual(manifest.files.map((entry) => entry.path), actualFiles, "binary manifest file list drifted");
|
||||
for (const entry of manifest.files) {
|
||||
const file = path.join(bundle, entry.path);
|
||||
assert.equal(fs.statSync(file).size, entry.bytes, `binary manifest byte length drifted: ${entry.path}`);
|
||||
assert.equal(sha256(file), entry.sha256, `binary manifest SHA-256 drifted: ${entry.path}`);
|
||||
}
|
||||
|
||||
const contract = JSON.parse(fs.readFileSync(path.join(bundle, "deployment-contract.json"), "utf8"));
|
||||
assert.equal(contract.schemaVersion, 1);
|
||||
assert.equal(contract.transport.production, "https");
|
||||
assert.equal(contract.transport.localDevelopment, "http://127.0.0.1");
|
||||
assert.equal(contract.transport.fileProtocolSupported, false);
|
||||
assert.deepEqual(contract.methods, ["GET", "HEAD"]);
|
||||
|
||||
const sbom = JSON.parse(fs.readFileSync(path.join(bundle, "sbom.spdx.json"), "utf8"));
|
||||
assert.equal(sbom.spdxVersion, "SPDX-2.3");
|
||||
assert.ok(sbom.packages.length > 0, "binary SBOM has no packages");
|
||||
const engine = JSON.parse(fs.readFileSync(path.join(bundle, "app/engine-manifest.json"), "utf8"));
|
||||
assert.equal(engine.schemaVersion, 2);
|
||||
assert.equal(new Set(engine.variants.map((variant) => variant.id)).size, 2);
|
||||
for (const variant of engine.variants) {
|
||||
for (const resource of Object.values(variant.resources)) {
|
||||
const file = path.join(bundle, "app", new URL(resource.url, "http://archive.local").pathname.slice(1));
|
||||
assert.equal(sha256(file), resource.sha256, `${variant.id} ${resource.url} hash drifted`);
|
||||
}
|
||||
}
|
||||
const releaseMetadata = JSON.parse(fs.readFileSync(path.join(bundle, "release-metadata.json"), "utf8"));
|
||||
assert.equal(releaseMetadata.schemaVersion, 1);
|
||||
assert.equal(releaseMetadata.engineReleaseId, engine.releaseId);
|
||||
assert.equal(releaseMetadata.storage.indexedDbSchemaVersion, 6);
|
||||
assert.equal(releaseMetadata.storage.opfsProjectManifestSchemaVersion, 1);
|
||||
assert.equal(releaseMetadata.storage.migrationDirection, "forward-only");
|
||||
assert.equal(releaseMetadata.storage.originBound, true);
|
||||
assert.ok(releaseMetadata.app.workerAssets.length >= 3);
|
||||
for (const relative of [...releaseMetadata.app.workerAssets, ...releaseMetadata.app.entryAssets]) {
|
||||
assert.ok(fs.statSync(path.join(bundle, "app", relative)).isFile(), `release metadata asset is missing: ${relative}`);
|
||||
}
|
||||
const diagnostics = JSON.parse(fs.readFileSync(path.join(bundle, "operations-diagnostics.json"), "utf8"));
|
||||
assert.deepEqual(diagnostics.entries.map((entry) => entry.domain).sort(), ["gpu", "hash", "isolation", "mime", "quota", "range", "worker"]);
|
||||
const releaseNotes = fs.readFileSync(path.join(bundle, "RELEASE_NOTES.md"), "utf8");
|
||||
assert.match(releaseNotes, /All 12 V1 family release slices are `READY`/);
|
||||
assert.match(releaseNotes, /all 12 complete Blender 5\.2 family parity states are\s+`BLOCKED`/);
|
||||
assert.match(releaseNotes, /\[Known limitations\]\(KNOWN_LIMITATIONS\.md\)/);
|
||||
assert.match(releaseNotes, /\[Release recovery\]\(RELEASE_RECOVERY\.md\)/);
|
||||
assert.match(fs.readFileSync(path.join(bundle, "KNOWN_LIMITATIONS.md"), "utf8"), /32,768-page ceiling \(2 GiB\)/);
|
||||
assert.match(fs.readFileSync(path.join(bundle, "RELEASE_RECOVERY.md"), "utf8"), /sha256sum --check RC_MANIFEST\.json\.sha256/);
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M6-11",
|
||||
status: "READY",
|
||||
archive: { path: path.basename(archive), bytes: fs.statSync(archive).size, sha256: sha256(archive) },
|
||||
correspondingSource: { path: path.basename(sourceArchive), bytes: fs.statSync(sourceArchive).size, sha256: sha256(sourceArchive) },
|
||||
extractedRootOutsideWorkspace: true,
|
||||
filesVerified: manifest.files.length,
|
||||
keyFiles: Object.fromEntries(required.map((relative) => [relative, sha256(path.join(bundle, relative))])),
|
||||
};
|
||||
const reportRoot = path.join(releaseRoot, "archive-reports");
|
||||
fs.mkdirSync(reportRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(reportRoot, "binary.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`binary-archive-ok files=${report.filesVerified} sha256=${report.archive.sha256}\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
26
tools/web/check-ci-failure-report.mjs
Normal file
26
tools/web/check-ci-failure-report.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "blender-ci-failure-"));
|
||||
const reportPath = path.join(temporary, "quick-failure.json");
|
||||
try {
|
||||
const result = spawnSync(process.execPath, [new URL("./run-ci-lane.mjs", import.meta.url).pathname, "quick", "--self-test-failure"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, CI_REPORT_PATH: reportPath },
|
||||
});
|
||||
assert.notEqual(result.status, 0, "injected lane failure returned success");
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
||||
assert.equal(report.status, "FAILED");
|
||||
assert.equal(report.records.length, 1);
|
||||
assert.equal(report.records[0].id, "injected-failure");
|
||||
assert.equal(report.records[0].exitCode, 17);
|
||||
assert.ok(fs.existsSync(`${reportPath}.sha256`));
|
||||
process.stdout.write("ci-failure-report-ok status=FAILED exitCode=17 staleReady=false\n");
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
92
tools/web/check-ci-report.mjs
Normal file
92
tools/web/check-ci-report.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportRoot = path.join(repoRoot, "release/ci-reports");
|
||||
const requested = process.argv.slice(2);
|
||||
const paths = requested.length > 0
|
||||
? requested.map((value) => path.isAbsolute(value) ? value : path.resolve(repoRoot, value))
|
||||
: ["quick", "chromium", "release"].map((lane) => path.join(reportRoot, `${lane}.json`)).filter((file) => fs.existsSync(file));
|
||||
assert.ok(paths.length > 0, "no CI lane reports were provided or found");
|
||||
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const commonBindings = ["packageJson", "lockfile", "ledger", "engineManifest", "releaseNotes", "knownLimitations", "releaseRecovery", "sbom"];
|
||||
const releaseBindings = ["binaryArchive", "sourceArchive", "sha256Sums", "rcManifest"];
|
||||
const expectedRecordIds = {
|
||||
quick: ["lockfile-install", "typecheck", "lint", "unit", "status", "evidence-schema", "rc-docs"],
|
||||
chromium: ["lockfile-install", "p0-user-loop", "offline-browser-smoke", "network-interruption", "device-loss", "oom-recovery", "opfs-quota", "malicious-blend", "malicious-archive"],
|
||||
release: [
|
||||
"lockfile-install", "full-e2e", "performance-100k-1m", "performance-10m",
|
||||
"performance-texture-4k", "performance-texture-8k", "performance-simulation",
|
||||
"performance-long-media", "vdb-native", "vdb-server", "vdb-opfs", "vdb-webgpu",
|
||||
"vdb-viewport", "vdb-faults", "v1-acceptance", "offline-reproducibility",
|
||||
"archive-browser", "binary-archive", "source-archive", "deployment-runbook",
|
||||
"upgrade-runbook", "rollback-runbook", "operations-diagnostics", "operations-rehearsal",
|
||||
"rc-manifest",
|
||||
],
|
||||
};
|
||||
const reports = [];
|
||||
for (const reportPath of paths) {
|
||||
const bytes = fs.readFileSync(reportPath);
|
||||
const report = JSON.parse(bytes);
|
||||
assert.equal(report.schemaVersion, 1, `${reportPath} schemaVersion drifted`);
|
||||
assert.ok(["quick", "chromium", "release"].includes(report.lane), `${reportPath} lane is invalid`);
|
||||
assert.equal(report.status, "READY", `${reportPath} is not READY`);
|
||||
assert.match(report.commit, /^[a-f0-9]{40}$/);
|
||||
assert.ok(!Number.isNaN(Date.parse(report.generatedAt)), `${reportPath} generatedAt is invalid`);
|
||||
assert.equal(report.retentionDays, report.lane === "quick" ? 7 : report.lane === "chromium" ? 14 : 30);
|
||||
for (const name of ["os", "node", "npm", "chrome", "blender", "emscripten"]) assert.equal(typeof report.environment[name], "string", `${reportPath} omits environment.${name}`);
|
||||
for (const name of [...commonBindings, ...releaseBindings]) {
|
||||
const binding = report.bindings[name];
|
||||
assert.ok(binding && typeof binding.path === "string", `${reportPath} omits binding ${name}`);
|
||||
if (binding.sha256 !== null) {
|
||||
assert.match(binding.sha256, /^[a-f0-9]{64}$/);
|
||||
const file = path.join(repoRoot, binding.path);
|
||||
assert.equal(sha256(fs.readFileSync(file)), binding.sha256, `${reportPath} binding drifted: ${name}`);
|
||||
}
|
||||
}
|
||||
if (report.lane === "release") {
|
||||
for (const name of releaseBindings) assert.match(report.bindings[name].sha256, /^[a-f0-9]{64}$/);
|
||||
}
|
||||
else {
|
||||
for (const name of releaseBindings) assert.equal(report.bindings[name].sha256, null);
|
||||
}
|
||||
assert.ok(Array.isArray(report.records) && report.records.length > 0, `${reportPath} has no command records`);
|
||||
assert.deepEqual(report.records.map((record) => record.id), expectedRecordIds[report.lane], `${reportPath} command coverage drifted`);
|
||||
for (const record of report.records) {
|
||||
assert.equal(record.exitCode, 0, `${reportPath} command failed: ${record.id}`);
|
||||
assert.ok(Number.isSafeInteger(record.durationMs) && record.durationMs >= 0);
|
||||
assert.match(record.output.sha256, /^[a-f0-9]{64}$/);
|
||||
const artifact = path.join(repoRoot, record.output.path);
|
||||
assert.equal(fs.statSync(artifact).size, record.output.bytes, `${record.id} output length drifted`);
|
||||
assert.equal(sha256(fs.readFileSync(artifact)), record.output.sha256, `${record.id} output hash drifted`);
|
||||
}
|
||||
const sidecar = fs.readFileSync(`${reportPath}.sha256`, "utf8").trim().split(/\s+/)[0];
|
||||
assert.equal(sidecar, sha256(bytes), `${reportPath} sidecar hash drifted`);
|
||||
reports.push(report);
|
||||
}
|
||||
|
||||
if (reports.length > 1) {
|
||||
assert.equal(new Set(reports.map((report) => report.commit)).size, 1, "CI lane reports do not bind one commit");
|
||||
for (const binding of commonBindings) {
|
||||
assert.equal(new Set(reports.map((report) => report.bindings[binding].sha256)).size, 1, `CI lane ${binding} bindings differ`);
|
||||
}
|
||||
}
|
||||
if (reports.length === 3) {
|
||||
assert.deepEqual(new Set(reports.map((report) => report.lane)), new Set(["quick", "chromium", "release"]));
|
||||
const releaseReport = reports.find((report) => report.lane === "release");
|
||||
const rcManifest = JSON.parse(fs.readFileSync(path.join(repoRoot, releaseReport.bindings.rcManifest.path), "utf8"));
|
||||
assert.equal(rcManifest.gitCommit, releaseReport.commit, "RC manifest and CI reports bind different commits");
|
||||
assert.equal(rcManifest.bindings.packageJsonSha256, releaseReport.bindings.packageJson.sha256);
|
||||
assert.equal(rcManifest.bindings.packageLockSha256, releaseReport.bindings.lockfile.sha256);
|
||||
assert.equal(rcManifest.bindings.ledgerSha256, releaseReport.bindings.ledger.sha256);
|
||||
assert.equal(rcManifest.bindings.engineManifestSha256, releaseReport.bindings.engineManifest.sha256);
|
||||
assert.equal(rcManifest.artifacts.sbom.sha256, releaseReport.bindings.sbom.sha256);
|
||||
assert.equal(rcManifest.artifacts.binaryArchive.sha256, releaseReport.bindings.binaryArchive.sha256);
|
||||
assert.equal(rcManifest.artifacts.sourceArchive.sha256, releaseReport.bindings.sourceArchive.sha256);
|
||||
assert.equal(rcManifest.bindings.sha256SumsSha256, releaseReport.bindings.sha256Sums.sha256);
|
||||
}
|
||||
process.stdout.write(`ci-report-ok lanes=${reports.map((report) => report.lane).join(",")} commit=${reports[0].commit}\n`);
|
||||
161
tools/web/check-deployment-cache.mjs
Normal file
161
tools/web/check-deployment-cache.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createDeploymentHttpServer,
|
||||
listenDeploymentHttpServer,
|
||||
loadDeploymentContract,
|
||||
} from "./deployment-http-server.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const sourceDist = path.join(repoRoot, "web/dist");
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-deployment-cache-"));
|
||||
const root = path.join(workspace, "dist");
|
||||
const contract = loadDeploymentContract();
|
||||
let server;
|
||||
|
||||
function sha256(bytes) {
|
||||
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function route(id) {
|
||||
const value = contract.responseHeaders.routes.find((candidate) => candidate.id === id);
|
||||
assert.ok(value, `deployment contract route is missing: ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function responseBytes(response) {
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function expectCache(origin, url, expected) {
|
||||
const response = await fetch(`${origin}${url}`, { cache: "no-store" });
|
||||
assert.equal(response.status, 200, `cache probe failed url=${url} status=${response.status}`);
|
||||
assert.equal(
|
||||
response.headers.get("cache-control"),
|
||||
expected,
|
||||
`Cache-Control mismatch url=${url}`,
|
||||
);
|
||||
assert.match(response.headers.get("etag"), /^"sha256-[a-f0-9]{64}"$/);
|
||||
return response;
|
||||
}
|
||||
|
||||
function contentHashedAssets() {
|
||||
const assetRoot = path.join(root, "assets");
|
||||
const names = fs.readdirSync(assetRoot).sort();
|
||||
const cases = [
|
||||
["entry JS", names.filter((name) => name.endsWith(".js") && !name.includes(".worker-"))],
|
||||
["CSS", names.filter((name) => name.endsWith(".css"))],
|
||||
["Worker", names.filter((name) => name.includes(".worker-") && name.endsWith(".js"))],
|
||||
];
|
||||
for (const [role, roleNames] of cases) {
|
||||
assert.ok(roleNames.length > 0, `production build has no content-hashed ${role} asset`);
|
||||
for (const name of roleNames) {
|
||||
assert.match(name, /-[A-Za-z0-9_-]{8}\.(?:js|css)$/, `${role} asset is not content hashed: ${name}`);
|
||||
}
|
||||
}
|
||||
return cases.flatMap(([, namesForRole]) => namesForRole).map((name) => `/assets/${name}`);
|
||||
}
|
||||
|
||||
try {
|
||||
assert.ok(fs.statSync(path.join(sourceDist, "index.html")).size > 0, "web/dist is missing; run the production build first");
|
||||
fs.cpSync(sourceDist, root, { recursive: true });
|
||||
const manifestPath = path.join(root, "engine-manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.equal(manifest.schemaVersion, 2);
|
||||
assert.match(manifest.releaseId, /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/);
|
||||
|
||||
server = createDeploymentHttpServer({ root, contract });
|
||||
const origin = await listenDeploymentHttpServer(server);
|
||||
const noCache = route("entry-document").cacheControl;
|
||||
const manifestNoCache = route("runtime-manifest").cacheControl;
|
||||
const immutable = route("content-hashed-assets").cacheControl;
|
||||
const engineNoCache = route("stable-engine-assets").cacheControl;
|
||||
|
||||
const entry = await expectCache(origin, "/", noCache);
|
||||
const entryEtag = entry.headers.get("etag");
|
||||
assert.deepEqual(await responseBytes(entry), fs.readFileSync(path.join(root, "index.html")));
|
||||
await expectCache(origin, "/index.html", noCache);
|
||||
const unchangedEntry = await fetch(`${origin}/`, { headers: { "If-None-Match": entryEtag } });
|
||||
assert.equal(unchangedEntry.status, 304);
|
||||
assert.equal(unchangedEntry.headers.get("cache-control"), noCache);
|
||||
const nextEntryBytes = Buffer.concat([
|
||||
fs.readFileSync(path.join(root, "index.html")),
|
||||
Buffer.from("\n<!-- next release -->\n"),
|
||||
]);
|
||||
fs.writeFileSync(path.join(root, "index.html"), nextEntryBytes);
|
||||
const changedEntry = await fetch(`${origin}/`, { headers: { "If-None-Match": entryEtag } });
|
||||
assert.equal(changedEntry.status, 200);
|
||||
assert.equal(changedEntry.headers.get("cache-control"), noCache);
|
||||
assert.notEqual(changedEntry.headers.get("etag"), entryEtag);
|
||||
assert.deepEqual(await responseBytes(changedEntry), nextEntryBytes);
|
||||
|
||||
const firstManifest = await expectCache(origin, "/engine-manifest.json", manifestNoCache);
|
||||
const firstManifestEtag = firstManifest.headers.get("etag");
|
||||
assert.deepEqual(await responseBytes(firstManifest), fs.readFileSync(manifestPath));
|
||||
|
||||
const unchangedManifest = await fetch(`${origin}/engine-manifest.json`, {
|
||||
headers: { "If-None-Match": firstManifestEtag },
|
||||
});
|
||||
assert.equal(unchangedManifest.status, 304);
|
||||
assert.equal(unchangedManifest.headers.get("cache-control"), manifestNoCache);
|
||||
assert.equal((await unchangedManifest.arrayBuffer()).byteLength, 0);
|
||||
|
||||
const assetUrls = contentHashedAssets();
|
||||
for (const url of assetUrls) {
|
||||
const response = await expectCache(origin, url, immutable);
|
||||
const bytes = await responseBytes(response);
|
||||
assert.equal(response.headers.get("etag"), `"sha256-${sha256(bytes)}"`);
|
||||
}
|
||||
|
||||
const uniqueResources = [...new Map(
|
||||
manifest.variants.flatMap((variant) => Object.values(variant.resources))
|
||||
.map((resource) => [resource.url, resource]),
|
||||
).values()];
|
||||
for (const resource of uniqueResources) {
|
||||
const response = await expectCache(origin, resource.url, engineNoCache);
|
||||
assert.equal(sha256(await responseBytes(response)), resource.sha256);
|
||||
}
|
||||
|
||||
const nextManifest = { ...manifest, releaseId: `${manifest.releaseId}.next` };
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`);
|
||||
const changedManifest = await fetch(`${origin}/engine-manifest.json`, {
|
||||
headers: { "If-None-Match": firstManifestEtag },
|
||||
});
|
||||
assert.equal(changedManifest.status, 200);
|
||||
assert.equal(changedManifest.headers.get("cache-control"), manifestNoCache);
|
||||
assert.notEqual(changedManifest.headers.get("etag"), firstManifestEtag);
|
||||
assert.equal((await changedManifest.json()).releaseId, nextManifest.releaseId);
|
||||
|
||||
const stableResource = uniqueResources.find((resource) => resource.url.endsWith(".js"));
|
||||
assert.ok(stableResource, "engine manifest has no stable JS resource");
|
||||
const stablePath = path.join(root, stableResource.url.replace(/^\/+/, ""));
|
||||
const stableFirst = await fetch(`${origin}${stableResource.url}`, { cache: "no-store" });
|
||||
const stableFirstEtag = stableFirst.headers.get("etag");
|
||||
const stableUnchanged = await fetch(`${origin}${stableResource.url}`, {
|
||||
headers: { "If-None-Match": stableFirstEtag },
|
||||
});
|
||||
assert.equal(stableUnchanged.status, 304);
|
||||
assert.equal(stableUnchanged.headers.get("cache-control"), engineNoCache);
|
||||
const replacement = Buffer.from("export default () => { throw new Error('next release'); };\n");
|
||||
fs.writeFileSync(stablePath, replacement);
|
||||
const stableChanged = await fetch(`${origin}${stableResource.url}`, {
|
||||
headers: { "If-None-Match": stableFirstEtag },
|
||||
});
|
||||
assert.equal(stableChanged.status, 200);
|
||||
assert.equal(stableChanged.headers.get("cache-control"), engineNoCache);
|
||||
assert.notEqual(stableChanged.headers.get("etag"), stableFirstEtag);
|
||||
assert.equal(sha256(await responseBytes(stableChanged)), sha256(replacement));
|
||||
assert.notEqual(sha256(replacement), stableResource.sha256);
|
||||
|
||||
process.stdout.write(
|
||||
`deployment-cache-ok noCache=3 immutable=${assetUrls.length} stableEngine=${uniqueResources.length} revalidation=304/200\n`,
|
||||
);
|
||||
}
|
||||
finally {
|
||||
if (server?.listening) await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
@@ -53,7 +53,10 @@ assert.deepEqual(contract.rangeRequests.requiredSatisfiedHeaders, ["Accept-Range
|
||||
assert.equal(contract.rangeRequests.ifRangeMismatch, "return-full-200");
|
||||
|
||||
for (const value of Object.values(contract.responseHeaders.allResponses)) assert.ok(guide.includes(`\`${value}\``));
|
||||
for (const status of ["206", "416", "200"]) assert.ok(guide.includes(`\`${status}\``));
|
||||
for (const status of ["206", "416", "304", "200"]) assert.ok(guide.includes(`\`${status}\``));
|
||||
for (const route of contract.responseHeaders.routes) assert.ok(guide.includes(route.cacheControl));
|
||||
for (const section of ["Empty-directory install runbook", "Transport preflight", "Upgrade runbook", "Rollback runbook", "Failure diagnostics", "Fresh-directory rehearsal"]) assert.ok(guide.includes(`## ${section}`));
|
||||
for (const token of ["BLENDER_WEB_INSTALL_ROOT", "window.isSecureContext === true", "window.crossOriginIsolated === true"]) assert.ok(guide.includes(token));
|
||||
assert.match(vite, /deployment-contract\.json/);
|
||||
assert.match(vite, /responseHeaders\.allResponses/);
|
||||
assert.match(releaseCreator, /deployment-contract\.json/);
|
||||
|
||||
184
tools/web/check-deployment-http.mjs
Normal file
184
tools/web/check-deployment-http.mjs
Normal file
@@ -0,0 +1,184 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createDeploymentHttpServer,
|
||||
listenDeploymentHttpServer,
|
||||
loadDeploymentContract,
|
||||
} from "./deployment-http-server.mjs";
|
||||
|
||||
const contract = loadDeploymentContract();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "blender-deployment-http-"));
|
||||
|
||||
function write(relative, bytes) {
|
||||
const filePath = path.join(root, relative);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, bytes);
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function deterministicBytes(size, seed) {
|
||||
const bytes = Buffer.alloc(size);
|
||||
for (let index = 0; index < bytes.length; index += 1) bytes[index] = (index * 31 + seed) & 0xff;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const fixtures = new Map([
|
||||
["/index.html", write("index.html", "<!doctype html><title>Web Blender</title>")],
|
||||
["/assets/app.js", write("assets/app.js", "export const app = true;\n")],
|
||||
["/assets/app.css", write("assets/app.css", "body { color: black; }\n")],
|
||||
["/vendor/blender/single/web_engine.js", write("vendor/blender/single/web_engine.js", "export default () => {};\n")],
|
||||
["/vendor/blender/single/web_engine.wasm", write("vendor/blender/single/web_engine.wasm", deterministicBytes(1024, 7))],
|
||||
["/vendor/blender/pthread/web_engine.js", write("vendor/blender/pthread/web_engine.js", "export default () => {}; // pthread\n")],
|
||||
["/engine-manifest.json", write("engine-manifest.json", "{\"schemaVersion\":2}\n")],
|
||||
["/media/frame.png", write("media/frame.png", Buffer.from([0x89, 0x50, 0x4e, 0x47]))],
|
||||
["/media/silence.wav", write("media/silence.wav", Buffer.from("RIFF0000WAVE"))],
|
||||
["/fixtures/basic.blend", write("fixtures/basic.blend", deterministicBytes(4096, 11))],
|
||||
["/volumes/smoke.nvdb", write("volumes/smoke.nvdb", deterministicBytes(1024 * 1024, 19))],
|
||||
]);
|
||||
|
||||
const server = createDeploymentHttpServer({ root, contract });
|
||||
let origin;
|
||||
|
||||
async function body(response) {
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function expectMime(url, expected, method = "GET") {
|
||||
const response = await fetch(`${origin}${url}`, { method });
|
||||
const actual = response.headers.get("content-type");
|
||||
assert.equal(actual, expected, `MIME mismatch url=${url} expected=${expected} actual=${actual}`);
|
||||
assert.equal(response.status, 200, `HTTP status mismatch url=${url} expected=200 actual=${response.status}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
function expectedMime(url) {
|
||||
const extension = path.extname(new URL(url, "http://deployment.local").pathname);
|
||||
const mime = contract.mimeTypes[extension];
|
||||
assert.ok(mime, `deployment contract has no MIME for url=${url}`);
|
||||
return mime;
|
||||
}
|
||||
|
||||
async function expectRange(url, header, expectedStart, expectedEnd) {
|
||||
const source = fixtures.get(url);
|
||||
const response = await fetch(`${origin}${url}`, { headers: { Range: header } });
|
||||
assert.equal(response.status, contract.rangeRequests.satisfiedStatus, `range status url=${url} range=${header}`);
|
||||
const bytes = await body(response);
|
||||
assert.deepEqual(bytes, source.subarray(expectedStart, expectedEnd + 1), `range bytes url=${url} range=${header}`);
|
||||
for (const name of contract.rangeRequests.requiredSatisfiedHeaders) {
|
||||
assert.ok(response.headers.has(name), `missing ${name} url=${url} range=${header}`);
|
||||
}
|
||||
assert.equal(response.headers.get("content-range"), `bytes ${expectedStart}-${expectedEnd}/${source.length}`);
|
||||
assert.equal(Number(response.headers.get("content-length")), expectedEnd - expectedStart + 1);
|
||||
assert.match(response.headers.get("etag"), /^"sha256-[a-f0-9]{64}"$/);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function interruptedPrefix(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
const request = http.get(`${origin}${url}`, (response) => {
|
||||
response.once("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
response.destroy();
|
||||
resolve({ bytes: Buffer.concat(chunks), etag: response.headers.etag });
|
||||
});
|
||||
response.on("error", (error) => {
|
||||
if (error.code !== "ECONNRESET") reject(error);
|
||||
});
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
origin = await listenDeploymentHttpServer(server);
|
||||
|
||||
const mimeCases = [
|
||||
["/index.html", ".html"],
|
||||
["/assets/app.js", ".js"],
|
||||
["/assets/app.css", ".css"],
|
||||
["/vendor/blender/single/web_engine.wasm", ".wasm"],
|
||||
["/engine-manifest.json", ".json"],
|
||||
["/media/frame.png", ".png"],
|
||||
["/media/silence.wav", ".wav"],
|
||||
["/fixtures/basic.blend", ".blend"],
|
||||
["/volumes/smoke.nvdb", ".nvdb"],
|
||||
];
|
||||
for (const [url, extension] of mimeCases) {
|
||||
assert.equal(expectedMime(url), contract.mimeTypes[extension]);
|
||||
const response = await expectMime(url, contract.mimeTypes[extension]);
|
||||
assert.deepEqual(await body(response), fixtures.get(url));
|
||||
}
|
||||
|
||||
for (const url of [
|
||||
"/vendor/blender/single/web_engine.js",
|
||||
"/vendor/blender/single/web_engine.wasm",
|
||||
"/vendor/blender/pthread/web_engine.js",
|
||||
"/fixtures/basic.blend",
|
||||
]) {
|
||||
const response = await expectMime(url, expectedMime(url), "HEAD");
|
||||
assert.equal((await response.arrayBuffer()).byteLength, 0, `HEAD returned a body url=${url}`);
|
||||
assert.equal(Number(response.headers.get("content-length")), fixtures.get(url).length);
|
||||
}
|
||||
|
||||
await expectRange("/vendor/blender/single/web_engine.wasm", "bytes=100-199", 100, 199);
|
||||
await expectRange("/fixtures/basic.blend", "bytes=200-399", 200, 399);
|
||||
await expectRange("/volumes/smoke.nvdb", "bytes=300-599", 300, 599);
|
||||
await expectRange("/fixtures/basic.blend", "bytes=400-", 400, fixtures.get("/fixtures/basic.blend").length - 1);
|
||||
await expectRange("/fixtures/basic.blend", "bytes=-128", fixtures.get("/fixtures/basic.blend").length - 128, fixtures.get("/fixtures/basic.blend").length - 1);
|
||||
|
||||
for (const range of ["items=0-1", "bytes=", "bytes=20-10", "bytes=0-999999", "bytes=0-1,4-5", "bytes=-0"]) {
|
||||
const response = await fetch(`${origin}/fixtures/basic.blend`, { headers: { Range: range } });
|
||||
assert.equal(response.status, contract.rangeRequests.unsatisfiedStatus, `invalid range accepted range=${range}`);
|
||||
assert.equal((await response.arrayBuffer()).byteLength, 0);
|
||||
assert.equal(response.headers.get("content-range"), `bytes */${fixtures.get("/fixtures/basic.blend").length}`);
|
||||
}
|
||||
|
||||
const etagFirst = (await fetch(`${origin}/fixtures/basic.blend`)).headers.get("etag");
|
||||
const etagSecond = (await fetch(`${origin}/fixtures/basic.blend`)).headers.get("etag");
|
||||
assert.equal(etagFirst, etagSecond);
|
||||
const matchingIfRange = await fetch(`${origin}/fixtures/basic.blend`, {
|
||||
headers: { Range: "bytes=0-31", "If-Range": etagFirst },
|
||||
});
|
||||
assert.equal(matchingIfRange.status, 206);
|
||||
const mismatchingIfRange = await fetch(`${origin}/fixtures/basic.blend`, {
|
||||
headers: { Range: "bytes=0-31", "If-Range": '"sha256-old-revision"' },
|
||||
});
|
||||
assert.equal(mismatchingIfRange.status, 200);
|
||||
assert.deepEqual(await body(mismatchingIfRange), fixtures.get("/fixtures/basic.blend"));
|
||||
|
||||
const interrupted = await interruptedPrefix("/volumes/smoke.nvdb");
|
||||
assert.ok(interrupted.bytes.length > 0 && interrupted.bytes.length < fixtures.get("/volumes/smoke.nvdb").length);
|
||||
const resumed = await fetch(`${origin}/volumes/smoke.nvdb`, {
|
||||
headers: {
|
||||
Range: `bytes=${interrupted.bytes.length}-`,
|
||||
"If-Range": interrupted.etag,
|
||||
},
|
||||
});
|
||||
assert.equal(resumed.status, 206);
|
||||
const merged = Buffer.concat([interrupted.bytes, await body(resumed)]);
|
||||
assert.equal(sha256(merged), sha256(fixtures.get("/volumes/smoke.nvdb")));
|
||||
|
||||
const replacement = deterministicBytes(fixtures.get("/fixtures/basic.blend").length, 23);
|
||||
fs.writeFileSync(path.join(root, "fixtures/basic.blend"), replacement);
|
||||
const changed = await fetch(`${origin}/fixtures/basic.blend`, {
|
||||
headers: { Range: "bytes=32-", "If-Range": etagFirst },
|
||||
});
|
||||
assert.equal(changed.status, 200);
|
||||
assert.notEqual(changed.headers.get("etag"), etagFirst);
|
||||
assert.deepEqual(await body(changed), replacement);
|
||||
|
||||
process.stdout.write("deployment-http-ok mime=9 head=4 ranges=5 invalidRanges=6 resumeSha256=verified\n");
|
||||
}
|
||||
finally {
|
||||
if (server.listening) await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
164
tools/web/check-deployment-runbook.mjs
Normal file
164
tools/web/check-deployment-runbook.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createDeploymentHttpServer,
|
||||
listenDeploymentHttpServer,
|
||||
} from "./deployment-http-server.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const releaseRoot = path.join(repoRoot, "release");
|
||||
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
|
||||
const binaryArchive = path.join(releaseRoot, "blender-web-offline.tar.gz");
|
||||
const sumsFile = path.join(releaseRoot, "SHA256SUMS.txt");
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-deployment-runbook-"));
|
||||
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
function checksumEntries(value) {
|
||||
return Object.fromEntries(value.trim().split("\n").map((line) => {
|
||||
const match = line.match(/^([a-f0-9]{64}) ([^/]+)$/);
|
||||
assert.ok(match, `invalid SHA256SUMS entry: ${line}`);
|
||||
return [match[2], match[1]];
|
||||
}));
|
||||
}
|
||||
|
||||
function assertSafeArchive(file) {
|
||||
const verbose = execFileSync("tar", ["-tvzf", file], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
||||
for (const line of verbose.split("\n").filter(Boolean)) {
|
||||
assert.ok(["-", "d"].includes(line[0]), `deployment archive contains a link or special entry: ${line}`);
|
||||
}
|
||||
const entries = execFileSync("tar", ["-tzf", file], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
assert.ok(entries.length > 0, "deployment archive is empty");
|
||||
for (const entry of entries) {
|
||||
assert.ok(!entry.startsWith("/"), `deployment archive contains an absolute path: ${entry}`);
|
||||
assert.ok(!entry.split("/").includes(".."), `deployment archive contains path traversal: ${entry}`);
|
||||
assert.ok(entry === "blender-web-offline/" || entry.startsWith("blender-web-offline/"), `unexpected deployment archive root: ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyOrigin(value, contract) {
|
||||
let origin;
|
||||
try {
|
||||
origin = new URL(value);
|
||||
}
|
||||
catch {
|
||||
return { status: "BLOCKED", reason: "INVALID_ORIGIN" };
|
||||
}
|
||||
if (origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash) {
|
||||
return { status: "BLOCKED", reason: "UNSUPPORTED_ORIGIN_SHAPE" };
|
||||
}
|
||||
if (origin.protocol === `${contract.transport.production}:`) return { status: "READY", mode: "production-https" };
|
||||
const local = new URL(contract.transport.localDevelopment);
|
||||
if (origin.protocol === local.protocol && origin.hostname === local.hostname) {
|
||||
return { status: "READY", mode: "loopback-http" };
|
||||
}
|
||||
return { status: "BLOCKED", reason: "HTTPS_OR_EXPLICIT_LOOPBACK_REQUIRED" };
|
||||
}
|
||||
|
||||
function assertHeader(response, name, expected) {
|
||||
assert.equal(response.headers.get(name), expected, `${name} drifted at ${response.url}`);
|
||||
}
|
||||
|
||||
let server;
|
||||
try {
|
||||
const delivery = path.join(workspace, "delivery");
|
||||
const installRoot = path.join(workspace, "install");
|
||||
fs.mkdirSync(delivery);
|
||||
fs.mkdirSync(installRoot);
|
||||
for (const file of [binaryArchive, sourceArchive, sumsFile]) {
|
||||
assert.ok(fs.statSync(file).isFile(), `release delivery is missing ${path.basename(file)}`);
|
||||
fs.copyFileSync(file, path.join(delivery, path.basename(file)));
|
||||
}
|
||||
|
||||
const deliveredArchive = path.join(delivery, path.basename(binaryArchive));
|
||||
const sums = checksumEntries(fs.readFileSync(path.join(delivery, path.basename(sumsFile)), "utf8"));
|
||||
assert.equal(sha256(deliveredArchive), sums[path.basename(binaryArchive)], "binary delivery checksum drifted");
|
||||
assert.equal(sha256(path.join(delivery, path.basename(sourceArchive))), sums[path.basename(sourceArchive)], "source delivery checksum drifted");
|
||||
assertSafeArchive(deliveredArchive);
|
||||
|
||||
const releases = path.join(installRoot, "releases");
|
||||
fs.mkdirSync(releases);
|
||||
const staging = fs.mkdtempSync(path.join(installRoot, ".install."));
|
||||
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", deliveredArchive, "-C", staging]);
|
||||
const stagedBundle = path.join(staging, "blender-web-offline");
|
||||
const archiveSha256 = sha256(deliveredArchive);
|
||||
const installedRelease = path.join(releases, archiveSha256);
|
||||
assert.ok(!fs.existsSync(installedRelease), "fresh install unexpectedly contains a release");
|
||||
fs.renameSync(stagedBundle, installedRelease);
|
||||
fs.rmdirSync(staging);
|
||||
const pendingLink = path.join(installRoot, ".current.next");
|
||||
fs.symlinkSync(path.join("releases", archiveSha256), pendingLink);
|
||||
fs.renameSync(pendingLink, path.join(installRoot, "current"));
|
||||
assert.equal(fs.realpathSync(path.join(installRoot, "current")), installedRelease);
|
||||
assert.ok(!installedRelease.startsWith(`${repoRoot}${path.sep}`), "runbook installed inside the source workspace");
|
||||
|
||||
const bundleRoot = path.join(installRoot, "current");
|
||||
const appRoot = path.join(bundleRoot, "app");
|
||||
const contract = JSON.parse(fs.readFileSync(path.join(bundleRoot, "deployment-contract.json"), "utf8"));
|
||||
const engine = JSON.parse(fs.readFileSync(path.join(appRoot, "engine-manifest.json"), "utf8"));
|
||||
assert.equal(classifyOrigin("https://blender.example", contract).mode, "production-https");
|
||||
assert.equal(classifyOrigin("https://blender.example:8443", contract).mode, "production-https");
|
||||
assert.equal(classifyOrigin("http://127.0.0.1:8080", contract).mode, "loopback-http");
|
||||
for (const blocked of [
|
||||
"file:///tmp/blender/index.html",
|
||||
"http://localhost:8080",
|
||||
"http://0.0.0.0:8080",
|
||||
"http://192.168.1.10:8080",
|
||||
"https://user:secret@blender.example",
|
||||
"https://blender.example/subpath",
|
||||
]) assert.equal(classifyOrigin(blocked, contract).status, "BLOCKED", `${blocked} must be blocked`);
|
||||
|
||||
server = createDeploymentHttpServer({ root: appRoot, contract });
|
||||
const origin = await listenDeploymentHttpServer(server);
|
||||
assert.equal(classifyOrigin(origin, contract).mode, "loopback-http");
|
||||
const indexResponse = await fetch(`${origin}/`, { method: "HEAD" });
|
||||
assert.equal(indexResponse.status, 200);
|
||||
for (const [name, value] of Object.entries(contract.responseHeaders.allResponses)) assertHeader(indexResponse, name, value);
|
||||
assertHeader(indexResponse, "cache-control", "no-cache");
|
||||
assertHeader(indexResponse, "content-type", contract.mimeTypes[".html"]);
|
||||
assert.match(indexResponse.headers.get("etag") ?? "", /^"sha256-[a-f0-9]{64}"$/);
|
||||
|
||||
const wasmPath = new URL(engine.variants.find((variant) => variant.id === "single").resources.wasm.url, origin);
|
||||
const rangeResponse = await fetch(wasmPath, { headers: { Range: "bytes=0-15" } });
|
||||
assert.equal(rangeResponse.status, contract.rangeRequests.satisfiedStatus);
|
||||
assert.equal((await rangeResponse.arrayBuffer()).byteLength, 16);
|
||||
assertHeader(rangeResponse, "accept-ranges", "bytes");
|
||||
assert.match(rangeResponse.headers.get("content-range") ?? "", /^bytes 0-15\/\d+$/);
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M6-17A",
|
||||
status: "READY",
|
||||
archive: { bytes: fs.statSync(deliveredArchive).size, sha256: archiveSha256 },
|
||||
engineReleaseId: engine.releaseId,
|
||||
install: {
|
||||
emptyRoot: true,
|
||||
outsideSourceWorkspace: true,
|
||||
immutableReleaseDirectory: path.relative(installRoot, installedRelease),
|
||||
currentTarget: fs.readlinkSync(path.join(installRoot, "current")),
|
||||
},
|
||||
transport: {
|
||||
productionHttps: "READY",
|
||||
loopbackHttp: "READY",
|
||||
publicPlainHttp: "BLOCKED",
|
||||
fileProtocol: "BLOCKED",
|
||||
sameOriginRuntimeAssets: contract.transport.sameOriginRuntimeAssets,
|
||||
},
|
||||
installedServer: { status: indexResponse.status, rangeStatus: rangeResponse.status, isolationHeaders: contract.responseHeaders.allResponses },
|
||||
};
|
||||
const reportRoot = path.join(releaseRoot, "operations-reports");
|
||||
fs.mkdirSync(reportRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(reportRoot, "deploy.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`deployment-runbook-ok release=${engine.releaseId} archive=${archiveSha256} install=empty https=ready loopback=ready public-http=blocked\n`);
|
||||
}
|
||||
finally {
|
||||
if (server) await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
109
tools/web/check-engine-variant-install.mjs
Normal file
109
tools/web/check-engine-variant-install.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
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 installer = path.join(repoRoot, "tools/web/install-web-engine-assets.sh");
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "engine-variant-install-test-"));
|
||||
|
||||
function sha256(bytes) {
|
||||
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function writeBuild(directory, label, includeWasm = true) {
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "web_engine.js"), `export default ${JSON.stringify(label)};\n`);
|
||||
if (includeWasm) fs.writeFileSync(path.join(directory, "web_engine.wasm"), Buffer.from(`wasm:${label}`));
|
||||
}
|
||||
|
||||
function writeLegacyManifest(publicRoot) {
|
||||
fs.mkdirSync(publicRoot, { recursive: true });
|
||||
const manifestPath = path.join(publicRoot, "engine-manifest.json");
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
protocolVersion: 1,
|
||||
engineVersion: "blender-wasm-install-test",
|
||||
engine: "blender-wasm",
|
||||
memory: { initialPages: 256, maximumPages: 32768, shared: false },
|
||||
wasm: [],
|
||||
}, null, 2)}\n`);
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
function runInstaller(singleBuild, pthreadBuild, publicRoot) {
|
||||
return spawnSync("bash", [installer], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
WEB_ENGINE_SINGLE_BUILD_DIR: singleBuild,
|
||||
WEB_ENGINE_PTHREAD_BUILD_DIR: pthreadBuild,
|
||||
WEB_ENGINE_PUBLIC_ROOT: publicRoot,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const positive = path.join(workspace, "positive");
|
||||
const singleBuild = path.join(positive, "build-single");
|
||||
const pthreadBuild = path.join(positive, "build-pthread");
|
||||
const publicRoot = path.join(positive, "public");
|
||||
writeBuild(singleBuild, "single-output");
|
||||
writeBuild(pthreadBuild, "pthread-output");
|
||||
writeLegacyManifest(publicRoot);
|
||||
fs.mkdirSync(path.join(publicRoot, "vendor/blender"), { recursive: true });
|
||||
fs.writeFileSync(path.join(publicRoot, "vendor/blender/web_engine.js"), "legacy-loader\n");
|
||||
|
||||
const installed = runInstaller(singleBuild, pthreadBuild, publicRoot);
|
||||
assert.equal(installed.status, 0, installed.stderr || installed.stdout);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(publicRoot, "engine-manifest.json"), "utf8"));
|
||||
assert.equal(manifest.schemaVersion, 2);
|
||||
assert.equal(manifest.releaseId, "blender-wasm-install-test");
|
||||
assert.deepEqual(manifest.variants.map((variant) => variant.id), ["single", "pthread"]);
|
||||
for (const [index, buildDirectory] of [[0, singleBuild], [1, pthreadBuild]]) {
|
||||
const variant = manifest.variants[index];
|
||||
for (const role of ["js", "wasm"]) {
|
||||
const resource = variant.resources[role];
|
||||
const source = fs.readFileSync(path.join(buildDirectory, resource.fileName));
|
||||
const target = fs.readFileSync(path.join(publicRoot, resource.url));
|
||||
assert.deepEqual(target, source);
|
||||
assert.equal(resource.sha256, sha256(source));
|
||||
}
|
||||
}
|
||||
assert.equal(fs.readFileSync(path.join(publicRoot, "vendor/blender/web_engine.js"), "utf8"), "legacy-loader\n");
|
||||
assert.deepEqual(manifest.variants[1].resources.pthreadWorker, manifest.variants[1].resources.js);
|
||||
|
||||
const sameBuildRoot = path.join(workspace, "same-build-negative");
|
||||
const sameBuild = path.join(sameBuildRoot, "build");
|
||||
const samePublic = path.join(sameBuildRoot, "public");
|
||||
writeBuild(sameBuild, "same-output");
|
||||
const sameManifest = writeLegacyManifest(samePublic);
|
||||
const beforeSame = fs.readFileSync(sameManifest);
|
||||
const sameResult = runInstaller(sameBuild, sameBuild, samePublic);
|
||||
assert.notEqual(sameResult.status, 0);
|
||||
assert.match(sameResult.stderr, /independent build directories/);
|
||||
assert.deepEqual(fs.readFileSync(sameManifest), beforeSame);
|
||||
assert.equal(fs.existsSync(path.join(samePublic, "vendor/blender/single")), false);
|
||||
|
||||
const missingRoot = path.join(workspace, "missing-asset-negative");
|
||||
const completeBuild = path.join(missingRoot, "build-single");
|
||||
const incompleteBuild = path.join(missingRoot, "build-pthread");
|
||||
const missingPublic = path.join(missingRoot, "public");
|
||||
writeBuild(completeBuild, "complete-output");
|
||||
writeBuild(incompleteBuild, "incomplete-output", false);
|
||||
const missingManifest = writeLegacyManifest(missingPublic);
|
||||
const beforeMissing = fs.readFileSync(missingManifest);
|
||||
const missingResult = runInstaller(completeBuild, incompleteBuild, missingPublic);
|
||||
assert.notEqual(missingResult.status, 0);
|
||||
assert.deepEqual(fs.readFileSync(missingManifest), beforeMissing);
|
||||
assert.equal(fs.existsSync(path.join(missingPublic, "vendor/blender/single")), false);
|
||||
|
||||
process.stdout.write("engine-variant-install-ok variants=2 negativeCases=2\n");
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
@@ -33,6 +33,10 @@ test -s "${web_dir}/app/src/vendor/blender/web_engine.js"
|
||||
test -s "${web_dir}/app/src/vendor/blender/web_engine.wasm"
|
||||
test -s "${web_dir}/app/public/vendor/blender/web_engine.js"
|
||||
test -s "${web_dir}/app/public/vendor/blender/web_engine.wasm"
|
||||
test -s "${web_dir}/app/public/vendor/blender/single/web_engine.js"
|
||||
test -s "${web_dir}/app/public/vendor/blender/single/web_engine.wasm"
|
||||
test -s "${web_dir}/app/public/vendor/blender/pthread/web_engine.js"
|
||||
test -s "${web_dir}/app/public/vendor/blender/pthread/web_engine.wasm"
|
||||
cmp "${web_dir}/app/src/vendor/blender/web_engine.js" "${web_dir}/app/public/vendor/blender/web_engine.js"
|
||||
cmp "${web_dir}/app/src/vendor/blender/web_engine.wasm" "${web_dir}/app/public/vendor/blender/web_engine.wasm"
|
||||
test -s "${repo_root}/blender-5.2.0/extern/zlib/CMakeLists.txt"
|
||||
|
||||
@@ -6,12 +6,20 @@ const [manifestPath, publicRoot] = process.argv.slice(2);
|
||||
if (!manifestPath || !publicRoot) throw new Error("usage: check-manifest-assets.mjs MANIFEST PUBLIC_ROOT");
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
for (const resource of manifest.wasm ?? []) {
|
||||
if (manifest.schemaVersion === 2 &&
|
||||
(typeof manifest.releaseId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/.test(manifest.releaseId))) {
|
||||
throw new Error("schema v2 engine manifest has no valid releaseId");
|
||||
}
|
||||
const resources = manifest.schemaVersion === 2
|
||||
? manifest.variants.flatMap((variant) => Object.values(variant.resources))
|
||||
: manifest.wasm ?? [];
|
||||
const uniqueResources = [...new Map(resources.map((resource) => [resource.url, resource])).values()];
|
||||
for (const resource of uniqueResources) {
|
||||
const relativePath = resource.url.replace(/^\/+/, "");
|
||||
const filePath = path.join(publicRoot, relativePath);
|
||||
const actual = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
if (actual !== String(resource.sha256).toLowerCase()) {
|
||||
throw new Error(`manifest hash mismatch: ${resource.id} expected=${resource.sha256} actual=${actual}`);
|
||||
throw new Error(`manifest hash mismatch: ${resource.url} expected=${resource.sha256} actual=${actual}`);
|
||||
}
|
||||
}
|
||||
console.log(`manifest-assets-ok resources=${(manifest.wasm ?? []).length}`);
|
||||
console.log(`manifest-assets-ok resources=${uniqueResources.length}`);
|
||||
|
||||
58
tools/web/check-operations-diagnostics.mjs
Normal file
58
tools/web/check-operations-diagnostics.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const sourcePath = path.join(repoRoot, "docs/web/operations-diagnostics.json");
|
||||
const diagnostics = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
|
||||
const contract = JSON.parse(fs.readFileSync(path.join(repoRoot, "docs/web/deployment-contract.json"), "utf8"));
|
||||
const guide = fs.readFileSync(path.join(repoRoot, "docs/web/DEPLOYMENT.md"), "utf8");
|
||||
const requiredDomains = ["mime", "range", "isolation", "hash", "quota", "worker", "gpu"];
|
||||
|
||||
assert.equal(diagnostics.schemaVersion, 1);
|
||||
assert.equal(diagnostics.product, contract.product);
|
||||
assert.deepEqual(diagnostics.entries.map((entry) => entry.domain).sort(), [...requiredDomains].sort());
|
||||
assert.equal(new Set(diagnostics.entries.map((entry) => entry.id)).size, requiredDomains.length);
|
||||
const allSignals = diagnostics.entries.flatMap((entry) => entry.signals);
|
||||
assert.equal(new Set(allSignals).size, allSignals.length, "diagnostic signals must be unambiguous");
|
||||
|
||||
for (const entry of diagnostics.entries) {
|
||||
assert.match(entry.id, /^OPS-[A-Z]+$/);
|
||||
for (const name of ["signals", "checks", "expected", "recovery"]) {
|
||||
assert.ok(Array.isArray(entry[name]) && entry[name].length > 0, `${entry.id} omits ${name}`);
|
||||
assert.ok(entry[name].every((value) => typeof value === "string" && value.length > 0), `${entry.id} has an empty ${name}`);
|
||||
}
|
||||
assert.ok(typeof entry.dataSafety === "string" && entry.dataSafety.length > 0, `${entry.id} omits dataSafety`);
|
||||
assert.ok(guide.toLowerCase().includes(`| ${entry.domain}`), `${entry.id} is absent from DEPLOYMENT.md`);
|
||||
}
|
||||
|
||||
const byDomain = Object.fromEntries(diagnostics.entries.map((entry) => [entry.domain, entry]));
|
||||
for (const mime of [contract.mimeTypes[".wasm"], contract.mimeTypes[".js"], contract.mimeTypes[".json"]]) {
|
||||
assert.ok(byDomain.mime.expected.some((value) => value.includes(mime)), `MIME diagnostics omit ${mime}`);
|
||||
}
|
||||
for (const status of [contract.rangeRequests.satisfiedStatus, contract.rangeRequests.unsatisfiedStatus, 200]) {
|
||||
assert.ok(byDomain.range.expected.some((value) => value.includes(String(status))), `range diagnostics omit ${status}`);
|
||||
}
|
||||
for (const [name, value] of Object.entries(contract.responseHeaders.allResponses)) {
|
||||
assert.ok(byDomain.isolation.expected.some((item) => item.includes(name) && item.includes(value)), `isolation diagnostics omit ${name}`);
|
||||
}
|
||||
assert.ok(byDomain.hash.expected.some((value) => value.includes("no fallback") && value.includes("no project open")));
|
||||
assert.ok(byDomain.quota.dataSafety.includes("Never delete"));
|
||||
assert.ok(byDomain.worker.dataSafety.includes("late Worker output"));
|
||||
assert.ok(byDomain.gpu.dataSafety.includes("must not rewrite"));
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M6-17D",
|
||||
status: "READY",
|
||||
sourceSha256: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
|
||||
domains: requiredDomains,
|
||||
entries: diagnostics.entries.length,
|
||||
signals: allSignals.length,
|
||||
};
|
||||
const reportRoot = path.join(repoRoot, "release/operations-reports");
|
||||
fs.mkdirSync(reportRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(reportRoot, "diagnostics.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`operations-diagnostics-ok domains=${requiredDomains.join(",")} signals=${allSignals.length} source=${report.sourceSha256}\n`);
|
||||
50
tools/web/check-rc-known-limitations.mjs
Normal file
50
tools/web/check-rc-known-limitations.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const limitationsPath = path.join(repoRoot, "docs/web/KNOWN_LIMITATIONS.md");
|
||||
const limitations = fs.readFileSync(limitationsPath, "utf8");
|
||||
const notes = fs.readFileSync(path.join(repoRoot, "docs/web/RELEASE_NOTES.md"), "utf8");
|
||||
const engine = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
|
||||
|
||||
assert.ok(notes.includes("[Known limitations](KNOWN_LIMITATIONS.md)"), "release notes do not link known limitations");
|
||||
for (const variant of engine.variants) {
|
||||
assert.equal(variant.memory.initialPages, 256);
|
||||
assert.equal(variant.memory.maximumPages, 32_768);
|
||||
}
|
||||
for (const required of [
|
||||
"Chromium is the only V1 browser baseline",
|
||||
"crossOriginIsolated",
|
||||
"SharedArrayBuffer",
|
||||
"PTHREAD_REQUIRED",
|
||||
"SINGLE_REQUIRED",
|
||||
"ENGINE_VARIANT_INTEGRITY_FAILED",
|
||||
"REFRESH_REQUIRED",
|
||||
"256 initial WebAssembly pages (16 MiB)",
|
||||
"32,768-page ceiling (2 GiB)",
|
||||
"bound to the exact scheme, host and port",
|
||||
"reserves no minimum capacity",
|
||||
"64 MiB",
|
||||
"16,384 pixels per dimension",
|
||||
"256 requested",
|
||||
"VDB input is bounded to 512 MiB",
|
||||
"NanoVDB bundle to 1 GiB",
|
||||
"residency to at most 512 MiB",
|
||||
"Automatic viewport page-fault feedback",
|
||||
]) {
|
||||
assert.ok(limitations.includes(required), `known limitations omit: ${required}`);
|
||||
}
|
||||
const renderAssets = fs.readFileSync(path.join(repoRoot, "web/protocol/render-assets.ts"), "utf8");
|
||||
assert.match(renderAssets, /MAX_GPU_TEXTURE_BYTES = 64 \* 1024 \* 1024/);
|
||||
assert.match(renderAssets, /MAX_GPU_TEXTURE_DIMENSION = 16_384/);
|
||||
assert.match(renderAssets, /MAX_GPU_TEXTURE_ASSETS = 256/);
|
||||
const volume = fs.readFileSync(path.join(repoRoot, "web/protocol/volume-vdb.ts"), "utf8");
|
||||
assert.match(volume, /VDB_MAX_RESOURCE_BYTES = 512 \* 1024 \* 1024/);
|
||||
assert.match(volume, /NANOVDB_MAX_BUNDLE_BYTES = 1024 \* 1024 \* 1024/);
|
||||
assert.match(volume, /NANOVDB_MAX_GPU_RESIDENT_BYTES = 512 \* 1024 \* 1024/);
|
||||
for (const link of ["V1_SCOPE.md", "parity-ledger.json", "DEPLOYMENT.md", "operations-diagnostics.json"]) {
|
||||
assert.ok(limitations.includes(`](${link})`), `known limitations omit link: ${link}`);
|
||||
}
|
||||
process.stdout.write(`rc-known-limitations-ok variants=${engine.variants.length} wasmMaximumPages=${engine.variants[0].memory.maximumPages}\n`);
|
||||
75
tools/web/check-rc-manifest.mjs
Normal file
75
tools/web/check-rc-manifest.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const manifestPath = path.join(repoRoot, "release/RC_MANIFEST.json");
|
||||
const manifestBytes = fs.readFileSync(manifestPath);
|
||||
const manifest = JSON.parse(manifestBytes);
|
||||
const sha256Bytes = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const sha256File = (file) => sha256Bytes(fs.readFileSync(file));
|
||||
const archiveEntry = (archive, entry) => execFileSync("tar", ["-xOf", archive, entry], { maxBuffer: 32 * 1024 * 1024 });
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package.json"), "utf8"));
|
||||
const lockfile = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package-lock.json"), "utf8"));
|
||||
const engine = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
|
||||
const binaryArchive = path.join(repoRoot, manifest.artifacts.binaryArchive.path);
|
||||
const sourceArchive = path.join(repoRoot, manifest.artifacts.sourceArchive.path);
|
||||
const sbomPath = path.join(repoRoot, manifest.artifacts.sbom.path);
|
||||
const sumsPath = path.join(repoRoot, "release/SHA256SUMS.txt");
|
||||
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.match(manifest.semver, /^\d+\.\d+\.\d+-rc\.\d+$/);
|
||||
assert.equal(manifest.rcId, `web-blender-${manifest.semver}`);
|
||||
assert.equal(packageJson.version, manifest.semver);
|
||||
assert.equal(lockfile.version, manifest.semver);
|
||||
assert.equal(lockfile.packages[""].version, manifest.semver);
|
||||
assert.equal(engine.releaseId, manifest.engineReleaseId);
|
||||
assert.equal(engine.engineVersion, manifest.engineReleaseId);
|
||||
assert.equal(manifest.engineReleaseId, `blender-wasm-${manifest.semver}`);
|
||||
assert.equal(manifest.gitCommit, execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim());
|
||||
assert.equal(manifest.sourceBinding.mode, "corresponding-source-archive");
|
||||
assert.equal(typeof manifest.sourceBinding.worktreeDirty, "boolean");
|
||||
|
||||
for (const entry of Object.values(manifest.artifacts)) {
|
||||
const file = path.join(repoRoot, entry.path);
|
||||
assert.equal(fs.statSync(file).size, entry.bytes, `${entry.path} byte length drifted`);
|
||||
assert.equal(sha256File(file), entry.sha256, `${entry.path} SHA-256 drifted`);
|
||||
}
|
||||
const embeddedPackageJson = archiveEntry(sourceArchive, "web/package.json");
|
||||
const embeddedPackageLock = archiveEntry(sourceArchive, "web/package-lock.json");
|
||||
const embeddedEngineManifest = archiveEntry(binaryArchive, "blender-web-offline/app/engine-manifest.json");
|
||||
const embeddedReleaseMetadata = archiveEntry(binaryArchive, "blender-web-offline/release-metadata.json");
|
||||
const embeddedSbom = archiveEntry(binaryArchive, "blender-web-offline/sbom.spdx.json");
|
||||
assert.deepEqual(embeddedPackageJson, fs.readFileSync(path.join(repoRoot, "web/package.json")));
|
||||
assert.deepEqual(embeddedPackageLock, fs.readFileSync(path.join(repoRoot, "web/package-lock.json")));
|
||||
assert.deepEqual(embeddedEngineManifest, fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json")));
|
||||
assert.deepEqual(embeddedSbom, fs.readFileSync(sbomPath));
|
||||
const releaseMetadata = JSON.parse(embeddedReleaseMetadata.toString("utf8"));
|
||||
const sourceManifest = JSON.parse(archiveEntry(sourceArchive, "SOURCE_MANIFEST.json").toString("utf8"));
|
||||
assert.equal(releaseMetadata.productVersion, manifest.semver);
|
||||
assert.equal(releaseMetadata.engineReleaseId, manifest.engineReleaseId);
|
||||
assert.equal(sourceManifest.version, manifest.semver);
|
||||
assert.equal(sourceManifest.engineReleaseId, manifest.engineReleaseId);
|
||||
assert.equal(manifest.bindings.packageJsonSha256, sha256File(path.join(repoRoot, "web/package.json")));
|
||||
assert.equal(manifest.bindings.packageLockSha256, sha256File(path.join(repoRoot, "web/package-lock.json")));
|
||||
assert.equal(manifest.bindings.ledgerSha256, sha256File(path.join(repoRoot, "docs/status/parity-ledger.json")));
|
||||
assert.equal(manifest.bindings.engineManifestSha256, sha256File(path.join(repoRoot, "web/app/public/engine-manifest.json")));
|
||||
assert.equal(manifest.bindings.embeddedReleaseMetadataSha256, sha256Bytes(embeddedReleaseMetadata));
|
||||
assert.equal(manifest.bindings.sha256SumsSha256, sha256File(sumsPath));
|
||||
assert.deepEqual(Object.keys(manifest.operations), ["deploy", "upgrade", "rollback", "diagnostics", "rehearsal"]);
|
||||
for (const entry of Object.values(manifest.operations)) {
|
||||
const file = path.join(repoRoot, entry.path);
|
||||
assert.equal(fs.statSync(file).size, entry.bytes);
|
||||
assert.equal(sha256File(file), entry.sha256);
|
||||
}
|
||||
const manifestSha256 = sha256Bytes(manifestBytes);
|
||||
assert.equal(fs.readFileSync(`${manifestPath}.sha256`, "utf8"), `${manifestSha256} RC_MANIFEST.json\n`);
|
||||
const checksumLines = fs.readFileSync(sumsPath, "utf8").trim().split("\n");
|
||||
assert.deepEqual(checksumLines, [
|
||||
`${manifest.artifacts.binaryArchive.sha256} blender-web-offline.tar.gz`,
|
||||
`${manifest.artifacts.sourceArchive.sha256} blender-web-corresponding-source.tar.gz`,
|
||||
]);
|
||||
process.stdout.write(`rc-manifest-check-ok id=${manifest.rcId} commit=${manifest.gitCommit} artifacts=3 operations=${Object.keys(manifest.operations).length}\n`);
|
||||
39
tools/web/check-rc-release-notes.mjs
Normal file
39
tools/web/check-rc-release-notes.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const notesPath = path.join(repoRoot, "docs/web/RELEASE_NOTES.md");
|
||||
const notes = fs.readFileSync(notesPath, "utf8");
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package.json"), "utf8"));
|
||||
const engine = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
|
||||
const ledger = JSON.parse(fs.readFileSync(path.join(repoRoot, "docs/status/parity-ledger.json"), "utf8"));
|
||||
|
||||
assert.match(notes, new RegExp(`^# Web Blender Modeler V1 ${packageJson.version.replaceAll(".", "\\.")} Release Notes`, "m"));
|
||||
assert.match(notes, new RegExp(`engine\\s+\`${engine.releaseId.replaceAll(".", "\\.")}\``));
|
||||
for (const capability of ["\.blend", "undo", "redo", "OffscreenCanvas", "single-thread", "pthread", "OPFS", "10M geometry", "8K textures", "SBOM"]) {
|
||||
assert.match(notes, new RegExp(capability, "i"), `release notes omit verified capability: ${capability}`);
|
||||
}
|
||||
for (const boundary of ["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "EXCLUDED", "Chromium-only", "not a browser port"]) {
|
||||
assert.ok(notes.includes(boundary), `release notes omit capability boundary: ${boundary}`);
|
||||
}
|
||||
const releaseReady = ledger.families.filter((family) => family.releaseStatus === "READY").length;
|
||||
const parityBlocked = ledger.families.filter((family) => family.parityStatus === "BLOCKED").length;
|
||||
assert.equal(releaseReady, 12);
|
||||
assert.equal(parityBlocked, 12);
|
||||
assert.ok(notes.includes(`All ${releaseReady} V1 family release slices are \`READY\``));
|
||||
assert.match(notes, new RegExp(`all ${parityBlocked} complete Blender 5\\.2 family parity states are\\s+\`BLOCKED\``));
|
||||
|
||||
const links = [...notes.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)].map((match) => match[1]);
|
||||
assert.ok(links.length >= 5, "release notes do not link the capability and operations sources");
|
||||
for (const link of links) {
|
||||
assert.doesNotMatch(link, /^(?:https?:|\/)/, `release notes contain an external or absolute link: ${link}`);
|
||||
const source = link === "V1_SCOPE.md"
|
||||
? path.join(repoRoot, "docs/WEB_BLENDER_MODELER_V1_SCOPE.md")
|
||||
: link === "parity-ledger.json"
|
||||
? path.join(repoRoot, "docs/status/parity-ledger.json")
|
||||
: path.join(repoRoot, "docs/web", link);
|
||||
assert.ok(fs.statSync(source).isFile(), `release note link is unresolved: ${link}`);
|
||||
}
|
||||
process.stdout.write(`rc-release-notes-ok version=${packageJson.version} ready=${releaseReady} parityBlocked=${parityBlocked} links=${links.length}\n`);
|
||||
39
tools/web/check-rc-release-recovery.mjs
Normal file
39
tools/web/check-rc-release-recovery.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const recovery = fs.readFileSync(path.join(repoRoot, "docs/web/RELEASE_RECOVERY.md"), "utf8");
|
||||
const notes = fs.readFileSync(path.join(repoRoot, "docs/web/RELEASE_NOTES.md"), "utf8");
|
||||
const app = fs.readFileSync(path.join(repoRoot, "web/app/src/app/App.tsx"), "utf8");
|
||||
const deployment = fs.readFileSync(path.join(repoRoot, "docs/web/DEPLOYMENT.md"), "utf8");
|
||||
|
||||
assert.ok(notes.includes("[Release recovery](RELEASE_RECOVERY.md)"), "release notes do not link release recovery");
|
||||
for (const required of [
|
||||
"sha256sum --check SHA256SUMS.txt",
|
||||
"sha256sum --check RC_MANIFEST.json.sha256",
|
||||
"test:rc-manifest",
|
||||
"test:binary-archive",
|
||||
"test:source-archive",
|
||||
"check-ci-report.mjs",
|
||||
"Save Project",
|
||||
"blender-web.blend",
|
||||
"SceneIR revision",
|
||||
"readlink current",
|
||||
"failure before the switch",
|
||||
"failure after the switch",
|
||||
"INDEXEDDB_SCHEMA_DOWNGRADE_UNSUPPORTED",
|
||||
"OPFS_REVERSE_MIGRATION_UNDECLARED",
|
||||
"Recover Project",
|
||||
"Open .blend",
|
||||
"test:operations-rehearsal",
|
||||
]) {
|
||||
assert.ok(recovery.toLowerCase().includes(required.toLowerCase()), `release recovery omits: ${required}`);
|
||||
}
|
||||
assert.match(app, /anchor\.download = "blender-web\.blend"/);
|
||||
assert.match(app, /await storage\.saveProject\(projectIdRef\.current, revision, data\.slice\(0\)\)/);
|
||||
assert.match(deployment, /Rollback changes only the `current` app\/engine release pointer/);
|
||||
assert.match(deployment, /in-place rollback is\s+`BLOCKED`/);
|
||||
for (const link of ["DEPLOYMENT.md"]) assert.ok(recovery.includes(`](${link})`));
|
||||
process.stdout.write("rc-release-recovery-ok checksums=2 archives=2 schemaBlocks=2 projectBackup=downloaded-blend\n");
|
||||
136
tools/web/check-rollback-runbook.mjs
Normal file
136
tools/web/check-rollback-runbook.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const archive = path.join(repoRoot, "release/blender-web-offline.tar.gz");
|
||||
const reportRoot = path.join(repoRoot, "release/operations-reports");
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-rollback-runbook-"));
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function writeMetadata(release, metadata) {
|
||||
fs.writeFileSync(path.join(release, "release-metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function switchCurrent(installRoot, releaseName) {
|
||||
const pending = path.join(installRoot, ".current.next");
|
||||
fs.rmSync(pending, { force: true });
|
||||
fs.symlinkSync(path.join("releases", releaseName), pending);
|
||||
fs.renameSync(pending, path.join(installRoot, "current"));
|
||||
}
|
||||
|
||||
function rollbackCompatibility(openedStorage, target) {
|
||||
if (target.storage.indexedDbSchemaVersion < openedStorage.indexedDbSchemaVersion) {
|
||||
return { status: "BLOCKED", reason: "INDEXEDDB_SCHEMA_DOWNGRADE_UNSUPPORTED" };
|
||||
}
|
||||
if (target.storage.opfsProjectManifestSchemaVersion !== openedStorage.opfsProjectManifestSchemaVersion) {
|
||||
return { status: "BLOCKED", reason: "OPFS_REVERSE_MIGRATION_UNDECLARED" };
|
||||
}
|
||||
return { status: "READY", reason: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const installRoot = path.join(workspace, "install");
|
||||
const releases = path.join(installRoot, "releases");
|
||||
const originStorage = path.join(workspace, "origin-storage", "projects", "project-a");
|
||||
fs.mkdirSync(releases, { recursive: true });
|
||||
fs.mkdirSync(originStorage, { recursive: true });
|
||||
|
||||
const extracted = path.join(workspace, "extracted");
|
||||
fs.mkdirSync(extracted);
|
||||
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", archive, "-C", extracted]);
|
||||
const currentRelease = path.join(releases, "current-release");
|
||||
fs.renameSync(path.join(extracted, "blender-web-offline"), currentRelease);
|
||||
const currentMetadata = readJson(path.join(currentRelease, "release-metadata.json"));
|
||||
|
||||
const compatibleOld = path.join(releases, "compatible-old-release");
|
||||
fs.cpSync(currentRelease, compatibleOld, { recursive: true });
|
||||
const compatibleMetadata = structuredClone(currentMetadata);
|
||||
compatibleMetadata.productVersion = "0.0.9-compatible";
|
||||
compatibleMetadata.engineReleaseId = `${currentMetadata.engineReleaseId}-compatible-old`;
|
||||
writeMetadata(compatibleOld, compatibleMetadata);
|
||||
|
||||
const incompatibleOld = path.join(releases, "incompatible-old-release");
|
||||
fs.cpSync(currentRelease, incompatibleOld, { recursive: true });
|
||||
const incompatibleMetadata = structuredClone(currentMetadata);
|
||||
incompatibleMetadata.productVersion = "0.0.8-incompatible";
|
||||
incompatibleMetadata.engineReleaseId = `${currentMetadata.engineReleaseId}-incompatible-old`;
|
||||
incompatibleMetadata.storage.indexedDbSchemaVersion = Math.max(1, currentMetadata.storage.indexedDbSchemaVersion - 1);
|
||||
writeMetadata(incompatibleOld, incompatibleMetadata);
|
||||
|
||||
switchCurrent(installRoot, path.basename(currentRelease));
|
||||
const blend = Buffer.from("M6-17C persistent project content");
|
||||
const project = {
|
||||
schemaVersion: currentMetadata.storage.opfsProjectManifestSchemaVersion,
|
||||
revision: 17,
|
||||
bytes: blend.byteLength,
|
||||
sha256: sha256(blend),
|
||||
};
|
||||
fs.writeFileSync(path.join(originStorage, "scene.blend"), blend);
|
||||
fs.writeFileSync(path.join(originStorage, "scene.blend.meta.json"), `${JSON.stringify(project)}\n`);
|
||||
const projectBytesBefore = fs.readFileSync(path.join(originStorage, "scene.blend"));
|
||||
const manifestBefore = fs.readFileSync(path.join(originStorage, "scene.blend.meta.json"));
|
||||
const currentBefore = fs.realpathSync(path.join(installRoot, "current"));
|
||||
|
||||
const openedStorage = {
|
||||
indexedDbSchemaVersion: currentMetadata.storage.indexedDbSchemaVersion,
|
||||
opfsProjectManifestSchemaVersion: currentMetadata.storage.opfsProjectManifestSchemaVersion,
|
||||
};
|
||||
const blocked = rollbackCompatibility(openedStorage, incompatibleMetadata);
|
||||
assert.deepEqual(blocked, { status: "BLOCKED", reason: "INDEXEDDB_SCHEMA_DOWNGRADE_UNSUPPORTED" });
|
||||
assert.equal(fs.realpathSync(path.join(installRoot, "current")), currentBefore, "blocked rollback changed current");
|
||||
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend")), projectBytesBefore);
|
||||
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend.meta.json")), manifestBefore);
|
||||
|
||||
const compatible = rollbackCompatibility(openedStorage, compatibleMetadata);
|
||||
assert.deepEqual(compatible, { status: "READY", reason: null });
|
||||
switchCurrent(installRoot, path.basename(compatibleOld));
|
||||
assert.equal(fs.realpathSync(path.join(installRoot, "current")), compatibleOld);
|
||||
assert.equal(readJson(path.join(installRoot, "current", "release-metadata.json")).engineReleaseId, compatibleMetadata.engineReleaseId);
|
||||
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend")), projectBytesBefore);
|
||||
assert.deepEqual(fs.readFileSync(path.join(originStorage, "scene.blend.meta.json")), manifestBefore);
|
||||
const reopened = readJson(path.join(originStorage, "scene.blend.meta.json"));
|
||||
assert.equal(reopened.revision, project.revision);
|
||||
assert.equal(reopened.sha256, sha256(fs.readFileSync(path.join(originStorage, "scene.blend"))));
|
||||
assert.ok(fs.statSync(currentRelease).isDirectory(), "newer release was removed during rollback");
|
||||
|
||||
const opfsBlockedTarget = structuredClone(compatibleMetadata);
|
||||
opfsBlockedTarget.storage.opfsProjectManifestSchemaVersion += 1;
|
||||
assert.deepEqual(rollbackCompatibility(openedStorage, opfsBlockedTarget), {
|
||||
status: "BLOCKED",
|
||||
reason: "OPFS_REVERSE_MIGRATION_UNDECLARED",
|
||||
});
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M6-17C",
|
||||
status: "READY",
|
||||
activeBefore: currentMetadata.engineReleaseId,
|
||||
activeAfter: compatibleMetadata.engineReleaseId,
|
||||
compatibleRollback: compatible,
|
||||
blockedOldReader: blocked,
|
||||
blockedOpfsMismatch: "OPFS_REVERSE_MIGRATION_UNDECLARED",
|
||||
project: {
|
||||
revisionBefore: project.revision,
|
||||
revisionAfter: reopened.revision,
|
||||
sha256Before: project.sha256,
|
||||
sha256After: reopened.sha256,
|
||||
preserved: true,
|
||||
},
|
||||
releases: { newerRetained: true, targetSwitchedAtomically: true },
|
||||
};
|
||||
fs.mkdirSync(reportRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(reportRoot, "rollback.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`rollback-runbook-ok compatible=ready idb-downgrade=blocked opfs-mismatch=blocked revision=${project.revision} project=${project.sha256}\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
165
tools/web/check-source-archive.mjs
Normal file
165
tools/web/check-source-archive.mjs
Normal file
@@ -0,0 +1,165 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync, 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 sourceArchive = path.resolve(process.env.M6_SOURCE_ARCHIVE ?? path.join(releaseRoot, "blender-web-corresponding-source.tar.gz"));
|
||||
const binaryArchive = path.resolve(process.env.M6_BINARY_ARCHIVE ?? path.join(releaseRoot, "blender-web-offline.tar.gz"));
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-source-archive-"));
|
||||
const sourceRoot = path.join(workspace, "source");
|
||||
const binaryRoot = path.join(workspace, "binary");
|
||||
|
||||
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
|
||||
function archiveEntries(archive) {
|
||||
const entries = execFileSync("tar", ["-tzf", archive], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }).split("\n").filter(Boolean);
|
||||
assert.ok(entries.length > 0, `${path.basename(archive)} is empty`);
|
||||
for (const entry of entries) {
|
||||
assert.ok(!entry.startsWith("/") && !entry.split("/").includes(".."), `unsafe archive path: ${entry}`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function run(command, args, cwd) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
assert.equal(result.status, 0, `${command} ${args.join(" ")} failed\n${output.slice(-8000)}`);
|
||||
return output;
|
||||
}
|
||||
|
||||
function firstPartyTextFiles(root) {
|
||||
const files = [];
|
||||
const visit = (directory) => {
|
||||
if (!fs.existsSync(directory)) return;
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
const relative = path.relative(root, absolute).replaceAll(path.sep, "/");
|
||||
if (relative.startsWith("web/app/public/vendor/") || relative.startsWith("web/app/src/vendor/")) continue;
|
||||
if (entry.isDirectory()) visit(absolute);
|
||||
else if (entry.isFile() && fs.statSync(absolute).size <= 2 * 1024 * 1024 && /\.(?:c|cc|cpp|h|hh|hpp|js|mjs|json|md|sh|ts|tsx|txt|yml|yaml)$/.test(entry.name)) files.push(absolute);
|
||||
}
|
||||
};
|
||||
for (const relative of ["web", "tools/web", "docs/web"]) visit(path.join(root, relative));
|
||||
for (const relative of ["docs/PROJECT_STATUS_AND_NEXT_WORK.md", "WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md"]) {
|
||||
const file = path.join(root, relative);
|
||||
if (fs.existsSync(file)) files.push(file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function distManifest(root) {
|
||||
const files = [];
|
||||
const visit = (directory, base) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(absolute, base);
|
||||
else files.push({ path: path.relative(base, absolute).replaceAll(path.sep, "/"), bytes: fs.statSync(absolute).size, sha256: sha256(absolute) });
|
||||
}
|
||||
};
|
||||
visit(root, root);
|
||||
return files;
|
||||
}
|
||||
|
||||
try {
|
||||
assert.ok(fs.statSync(sourceArchive).isFile(), `source archive is missing: ${sourceArchive}`);
|
||||
assert.ok(fs.statSync(binaryArchive).isFile(), `binary archive is missing: ${binaryArchive}`);
|
||||
const entries = archiveEntries(sourceArchive);
|
||||
const forbidden = entries.filter((entry) =>
|
||||
/(^|\/)(?:node_modules|dist|test-results|playwright-report|__pycache__)(?:\/|$)/.test(entry) ||
|
||||
/\.pyc$/.test(entry) ||
|
||||
/^(?:release|build_[^/]*|\.emcache)(?:\/|$)/.test(entry),
|
||||
);
|
||||
assert.deepEqual(forbidden, [], `source archive contains generated output: ${forbidden.join(", ")}`);
|
||||
|
||||
fs.mkdirSync(sourceRoot);
|
||||
fs.mkdirSync(binaryRoot);
|
||||
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", sourceArchive, "-C", sourceRoot]);
|
||||
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", binaryArchive, "-C", binaryRoot]);
|
||||
assert.ok(!fs.realpathSync(sourceRoot).startsWith(`${repoRoot}${path.sep}`), "source archive was not extracted independently");
|
||||
|
||||
const required = [
|
||||
"SOURCE_MANIFEST.json",
|
||||
"blender-5.2.0/COPYING",
|
||||
"blender-5.2.0/CMakeLists.txt",
|
||||
"blender-5.2.0/source/blender/web_engine/CMakeLists.txt",
|
||||
"web/package.json",
|
||||
"web/package-lock.json",
|
||||
"web/app/vite.config.ts",
|
||||
"web/app/src/main.tsx",
|
||||
"web/protocol/manifest.ts",
|
||||
"tools/web/create-offline-release.mjs",
|
||||
];
|
||||
for (const relative of required) assert.ok(fs.lstatSync(path.join(sourceRoot, relative)), `source archive omits ${relative}`);
|
||||
const sourceOffer = fs.readFileSync(path.join(binaryRoot, "blender-web-offline/SOURCE_OFFER.txt"), "utf8");
|
||||
assert.match(sourceOffer, /blender-web-corresponding-source\.tar\.gz/);
|
||||
assert.match(sourceOffer, /Blender 5\.2/);
|
||||
|
||||
const manifestPath = path.join(sourceRoot, "SOURCE_MANIFEST.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.product, "Web Blender Modeler V1");
|
||||
assert.ok(Array.isArray(manifest.files) && manifest.files.length > 10_000, "source manifest is incomplete");
|
||||
const actualPaths = entries.filter((entry) => !entry.endsWith("/") && entry !== "SOURCE_MANIFEST.json").sort();
|
||||
assert.deepEqual(manifest.files.map((entry) => entry.path).sort(), actualPaths, "source manifest path list drifted");
|
||||
for (const entry of manifest.files) {
|
||||
const file = path.join(sourceRoot, entry.path);
|
||||
if (entry.type === "symlink") {
|
||||
assert.equal(fs.readlinkSync(file), entry.target, `source symlink target drifted: ${entry.path}`);
|
||||
}
|
||||
else {
|
||||
assert.equal(fs.statSync(file).size, entry.bytes, `source byte length drifted: ${entry.path}`);
|
||||
assert.equal(sha256(file), entry.sha256, `source SHA-256 drifted: ${entry.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
const absoluteDevelopmentPath = /(?:\/home\/[A-Za-z0-9._-]+\/|\/Users\/[A-Za-z0-9._-]+\/|[A-Za-z]:\\Users\\)/;
|
||||
const credential = /(?:-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})/;
|
||||
for (const file of firstPartyTextFiles(sourceRoot)) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
assert.doesNotMatch(text, absoluteDevelopmentPath, `source archive contains an absolute development path: ${path.relative(sourceRoot, file)}`);
|
||||
assert.doesNotMatch(text, credential, `source archive contains a credential signature: ${path.relative(sourceRoot, file)}`);
|
||||
}
|
||||
|
||||
const installOutput = run("npm", ["ci", "--ignore-scripts"], path.join(sourceRoot, "web"));
|
||||
const typecheckOutput = run("npm", ["run", "typecheck"], path.join(sourceRoot, "web"));
|
||||
const buildOutput = run("npm", ["run", "build"], path.join(sourceRoot, "web"));
|
||||
const rebuilt = distManifest(path.join(sourceRoot, "web/dist"));
|
||||
const packaged = distManifest(path.join(binaryRoot, "blender-web-offline/app"));
|
||||
assert.deepEqual(rebuilt, packaged, "source archive build does not reproduce the binary app manifest");
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M6-12",
|
||||
status: "READY",
|
||||
archive: { path: path.basename(sourceArchive), bytes: fs.statSync(sourceArchive).size, sha256: sha256(sourceArchive) },
|
||||
binaryArchiveSha256: sha256(binaryArchive),
|
||||
extractedRootOutsideWorkspace: true,
|
||||
filesVerified: manifest.files.length,
|
||||
buildFilesVerified: rebuilt.length,
|
||||
commands: [
|
||||
{ command: "npm ci --ignore-scripts", outputSha256: crypto.createHash("sha256").update(installOutput).digest("hex") },
|
||||
{ command: "npm run typecheck", outputSha256: crypto.createHash("sha256").update(typecheckOutput).digest("hex") },
|
||||
{ command: "npm run build", outputSha256: crypto.createHash("sha256").update(buildOutput).digest("hex") },
|
||||
],
|
||||
keyFiles: Object.fromEntries(required.map((relative) => [relative, sha256(path.join(sourceRoot, relative))])),
|
||||
rebuiltManifestSha256: crypto.createHash("sha256").update(JSON.stringify(rebuilt)).digest("hex"),
|
||||
};
|
||||
const reportRoot = path.join(releaseRoot, "archive-reports");
|
||||
fs.mkdirSync(reportRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(reportRoot, "source.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`source-archive-ok files=${report.filesVerified} buildFiles=${report.buildFilesVerified} sha256=${report.archive.sha256}\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
117
tools/web/check-upgrade-runbook.mjs
Normal file
117
tools/web/check-upgrade-runbook.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const archive = path.join(repoRoot, "release/blender-web-offline.tar.gz");
|
||||
const reportRoot = path.join(repoRoot, "release/operations-reports");
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-upgrade-runbook-"));
|
||||
const sha256Bytes = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const sha256File = (file) => sha256Bytes(fs.readFileSync(file));
|
||||
|
||||
function readMetadata(release) {
|
||||
return JSON.parse(fs.readFileSync(path.join(release, "release-metadata.json"), "utf8"));
|
||||
}
|
||||
|
||||
function switchCurrent(installRoot, relativeTarget) {
|
||||
const pending = path.join(installRoot, ".current.next");
|
||||
fs.rmSync(pending, { force: true });
|
||||
fs.symlinkSync(relativeTarget, pending);
|
||||
fs.renameSync(pending, path.join(installRoot, "current"));
|
||||
}
|
||||
|
||||
try {
|
||||
const installRoot = path.join(workspace, "install");
|
||||
const releases = path.join(installRoot, "releases");
|
||||
const originStorage = path.join(workspace, "origin-storage");
|
||||
fs.mkdirSync(releases, { recursive: true });
|
||||
fs.mkdirSync(originStorage);
|
||||
|
||||
const extracted = path.join(workspace, "extracted");
|
||||
fs.mkdirSync(extracted);
|
||||
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", archive, "-C", extracted]);
|
||||
const newRelease = path.join(releases, sha256File(archive));
|
||||
fs.renameSync(path.join(extracted, "blender-web-offline"), newRelease);
|
||||
const newMetadata = readMetadata(newRelease);
|
||||
|
||||
const oldRelease = path.join(releases, "synthetic-previous-release");
|
||||
fs.cpSync(newRelease, oldRelease, { recursive: true });
|
||||
const oldMetadata = structuredClone(newMetadata);
|
||||
oldMetadata.productVersion = "0.0.9";
|
||||
oldMetadata.engineReleaseId = `${newMetadata.engineReleaseId}-previous`;
|
||||
oldMetadata.storage.indexedDbSchemaVersion = Math.max(1, newMetadata.storage.indexedDbSchemaVersion - 1);
|
||||
const oldWorker = oldMetadata.app.workerAssets[0];
|
||||
const previousWorker = oldWorker.replace(/\.worker-([A-Za-z0-9_-]+)\.js$/, ".worker-previous.js");
|
||||
assert.notEqual(previousWorker, oldWorker, "Worker asset does not have a content-hashed name");
|
||||
fs.copyFileSync(path.join(oldRelease, "app", oldWorker), path.join(oldRelease, "app", previousWorker));
|
||||
oldMetadata.app.workerAssets = [previousWorker, ...oldMetadata.app.workerAssets.slice(1)];
|
||||
fs.writeFileSync(path.join(oldRelease, "release-metadata.json"), `${JSON.stringify(oldMetadata, null, 2)}\n`);
|
||||
switchCurrent(installRoot, path.join("releases", path.basename(oldRelease)));
|
||||
|
||||
const projectBytes = Buffer.from("M6-17B persistent OPFS project bytes");
|
||||
const projectFile = path.join(originStorage, "scene.blend");
|
||||
fs.writeFileSync(projectFile, projectBytes);
|
||||
const projectBefore = sha256File(projectFile);
|
||||
|
||||
assert.notEqual(oldMetadata.engineReleaseId, newMetadata.engineReleaseId, "upgrade does not change engine release identity");
|
||||
assert.ok(newMetadata.storage.indexedDbSchemaVersion >= oldMetadata.storage.indexedDbSchemaVersion, "upgrade would downgrade IndexedDB schema");
|
||||
assert.equal(newMetadata.storage.opfsProjectManifestSchemaVersion, oldMetadata.storage.opfsProjectManifestSchemaVersion, "upgrade changes OPFS schema without a migration");
|
||||
assert.notDeepEqual(newMetadata.app.workerAssets, oldMetadata.app.workerAssets, "upgrade does not exercise Worker identity switching");
|
||||
assert.equal(newMetadata.app.documentCache, "no-cache");
|
||||
assert.equal(newMetadata.app.manifestCache, "no-cache");
|
||||
assert.equal(newMetadata.app.hashedAssetCache, "public, max-age=31536000, immutable");
|
||||
|
||||
const oldTarget = fs.realpathSync(path.join(installRoot, "current"));
|
||||
const failedPending = path.join(installRoot, ".current.next");
|
||||
fs.symlinkSync(path.join("releases", "missing-release"), failedPending);
|
||||
assert.throws(() => fs.realpathSync(failedPending), /ENOENT/);
|
||||
fs.rmSync(failedPending);
|
||||
assert.equal(fs.realpathSync(path.join(installRoot, "current")), oldTarget, "failed preflight changed current");
|
||||
assert.equal(sha256File(projectFile), projectBefore, "failed preflight changed project data");
|
||||
|
||||
switchCurrent(installRoot, path.join("releases", path.basename(newRelease)));
|
||||
assert.equal(fs.realpathSync(path.join(installRoot, "current")), newRelease);
|
||||
assert.deepEqual(readMetadata(path.join(installRoot, "current")), newMetadata);
|
||||
assert.equal(sha256File(projectFile), projectBefore, "release switch changed origin storage");
|
||||
for (const worker of newMetadata.app.workerAssets) {
|
||||
assert.ok(fs.statSync(path.join(newRelease, "app", worker)).isFile(), `new Worker is missing: ${worker}`);
|
||||
}
|
||||
assert.ok(fs.statSync(path.join(oldRelease, "app", previousWorker)).isFile(), "old Worker was removed inside the rollback window");
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M6-17B",
|
||||
status: "READY",
|
||||
old: {
|
||||
productVersion: oldMetadata.productVersion,
|
||||
engineReleaseId: oldMetadata.engineReleaseId,
|
||||
indexedDbSchemaVersion: oldMetadata.storage.indexedDbSchemaVersion,
|
||||
opfsProjectManifestSchemaVersion: oldMetadata.storage.opfsProjectManifestSchemaVersion,
|
||||
workerAssets: oldMetadata.app.workerAssets,
|
||||
},
|
||||
next: {
|
||||
productVersion: newMetadata.productVersion,
|
||||
engineReleaseId: newMetadata.engineReleaseId,
|
||||
indexedDbSchemaVersion: newMetadata.storage.indexedDbSchemaVersion,
|
||||
opfsProjectManifestSchemaVersion: newMetadata.storage.opfsProjectManifestSchemaVersion,
|
||||
workerAssets: newMetadata.app.workerAssets,
|
||||
},
|
||||
cache: {
|
||||
document: newMetadata.app.documentCache,
|
||||
manifest: newMetadata.app.manifestCache,
|
||||
hashedAssets: newMetadata.app.hashedAssetCache,
|
||||
},
|
||||
switch: { failedPreflightKeptCurrent: true, atomicCurrentSwitch: true, oldReleaseRetained: true },
|
||||
project: { sha256Before: projectBefore, sha256After: sha256File(projectFile), preserved: true },
|
||||
};
|
||||
fs.mkdirSync(reportRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(reportRoot, "upgrade.json"), `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.stdout.write(`upgrade-runbook-ok old=${oldMetadata.engineReleaseId} next=${newMetadata.engineReleaseId} idb=${oldMetadata.storage.indexedDbSchemaVersion}->${newMetadata.storage.indexedDbSchemaVersion} opfs=${newMetadata.storage.opfsProjectManifestSchemaVersion} workers=switched project=preserved\n`);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(new URL("../..", import.meta.url).pathname);
|
||||
@@ -13,7 +14,11 @@ assert.match(protocol, /validateNanoVDBBundleManifest/, "NanoVDB bundle validati
|
||||
assert.match(protocol, /gateNanoVDBPipeline/, "NanoVDB stage capability gates are missing");
|
||||
assert.doesNotMatch(protocol, /export async function decodeVDBResource/, "Browser raw OpenVDB decode entry must not be restored");
|
||||
|
||||
const roots = [path.join(root, "resource-library"), path.join(root, "tests"), "/home/mes123456/resource-library"];
|
||||
const roots = [
|
||||
path.join(root, "resource-library"),
|
||||
path.join(root, "tests"),
|
||||
path.resolve(process.env.VDB_RESOURCE_ROOT ?? path.join(os.homedir(), "resource-library/blender-web-vdb")),
|
||||
];
|
||||
const vdbFiles = [];
|
||||
function scan(directory) {
|
||||
if (!fs.existsSync(directory)) return;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url";
|
||||
import ts from "../../web/node_modules/typescript/lib/typescript.js";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? "/home/mes123456/resource-library/blender-web-vdb");
|
||||
const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? path.join(os.homedir(), "resource-library/blender-web-vdb"));
|
||||
const converter = path.join(root, "build_vdb_tools", "vdb_to_nanovdb");
|
||||
const generator = path.join(root, "build_vdb_tools", "vdb_fixture_generator");
|
||||
const source = path.join(resourceRoot, "generated", "generated-smoke.vdb");
|
||||
|
||||
@@ -10,7 +10,8 @@ import { VDBJobService, createVDBJobHttpServer } from "../vdb/server/vdb-job-ser
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const converter = path.join(root, "build_vdb_tools/vdb_to_nanovdb");
|
||||
const source = "/home/mes123456/resource-library/blender-web-vdb/generated/generated-smoke.vdb";
|
||||
const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? path.join(os.homedir(), "resource-library/blender-web-vdb"));
|
||||
const source = path.join(resourceRoot, "generated/generated-smoke.vdb");
|
||||
const secret = "vdb-server-test-signing-key-000000000000000000000000";
|
||||
assert.ok(fs.existsSync(converter), "native converter is missing");
|
||||
assert.ok(fs.existsSync(source), "real OpenVDB fixture is missing");
|
||||
|
||||
@@ -17,6 +17,12 @@ fs.copyFileSync(path.join(root, "docs/web/third-party-notices.json"), path.join(
|
||||
fs.copyFileSync(path.join(root, "docs/web/sbom.spdx.json"), path.join(bundle, "sbom.spdx.json"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/deployment-contract.json"), path.join(bundle, "deployment-contract.json"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/DEPLOYMENT.md"), path.join(bundle, "DEPLOYMENT.md"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/operations-diagnostics.json"), path.join(bundle, "operations-diagnostics.json"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/RELEASE_NOTES.md"), path.join(bundle, "RELEASE_NOTES.md"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/KNOWN_LIMITATIONS.md"), path.join(bundle, "KNOWN_LIMITATIONS.md"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/RELEASE_RECOVERY.md"), path.join(bundle, "RELEASE_RECOVERY.md"));
|
||||
fs.copyFileSync(path.join(root, "docs/WEB_BLENDER_MODELER_V1_SCOPE.md"), path.join(bundle, "V1_SCOPE.md"));
|
||||
fs.copyFileSync(path.join(root, "docs/status/parity-ledger.json"), path.join(bundle, "parity-ledger.json"));
|
||||
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/opensubdiv-source/LICENSE.txt"), path.join(bundle, "LICENSE-OpenSubdiv.txt"));
|
||||
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/gmp-source/COPYING.LESSERv3"), path.join(bundle, "LICENSE-GMP-LGPLv3.txt"));
|
||||
fs.writeFileSync(path.join(bundle, "README.txt"), [
|
||||
@@ -24,6 +30,7 @@ fs.writeFileSync(path.join(bundle, "README.txt"), [
|
||||
"",
|
||||
"Serve app/ from a static HTTP server that implements deployment-contract.json.",
|
||||
"See DEPLOYMENT.md for the required isolation, cache, MIME, ETag and byte-range behavior.",
|
||||
"See RELEASE_NOTES.md, KNOWN_LIMITATIONS.md, RELEASE_RECOVERY.md and V1_SCOPE.md for release boundaries and recovery.",
|
||||
"The application has no runtime CDN dependency.",
|
||||
"Opening index.html directly is unsupported because browsers restrict module Workers and WASM under file://.",
|
||||
"See SOURCE_OFFER.txt and third-party-notices.json for licensing and corresponding source.",
|
||||
@@ -37,6 +44,38 @@ fs.writeFileSync(path.join(bundle, "SOURCE_OFFER.txt"), [
|
||||
"",
|
||||
].join("\n"));
|
||||
|
||||
function exportedInteger(file, name) {
|
||||
const source = fs.readFileSync(path.join(root, file), "utf8");
|
||||
const match = source.match(new RegExp(`export const ${name} = (\\d+);`));
|
||||
if (!match) throw new Error(`${file} does not export ${name} as an integer literal`);
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(root, "web/package.json"), "utf8"));
|
||||
const engineManifest = JSON.parse(fs.readFileSync(path.join(root, "web/app/public/engine-manifest.json"), "utf8"));
|
||||
const builtFiles = walk(path.join(root, "web/dist"));
|
||||
const workerAssets = builtFiles.filter((relative) => /^assets\/.+\.worker-[A-Za-z0-9_-]+\.js$/.test(relative));
|
||||
if (workerAssets.length === 0) throw new Error("production build has no content-hashed Worker assets");
|
||||
fs.writeFileSync(path.join(bundle, "release-metadata.json"), `${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
product: "Web Blender Modeler V1",
|
||||
productVersion: packageJson.version,
|
||||
engineReleaseId: engineManifest.releaseId,
|
||||
storage: {
|
||||
indexedDbSchemaVersion: exportedInteger("web/app/src/storage/migrations.ts", "STORAGE_SCHEMA_VERSION"),
|
||||
opfsProjectManifestSchemaVersion: exportedInteger("web/app/src/storage/opfs-files.ts", "OPFS_PROJECT_SCHEMA_VERSION"),
|
||||
migrationDirection: "forward-only",
|
||||
originBound: true,
|
||||
},
|
||||
app: {
|
||||
workerAssets,
|
||||
entryAssets: builtFiles.filter((relative) => /^assets\/index-[A-Za-z0-9_-]+\.(?:js|css)$/.test(relative)),
|
||||
documentCache: "no-cache",
|
||||
manifestCache: "no-cache",
|
||||
hashedAssetCache: "public, max-age=31536000, immutable",
|
||||
},
|
||||
}, null, 2)}\n`);
|
||||
|
||||
function sha256(file) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
}
|
||||
@@ -53,18 +92,18 @@ function walk(directory, base = directory) {
|
||||
const manifest = walk(bundle).map((relative) => ({ path: relative, bytes: fs.statSync(path.join(bundle, relative)).size, sha256: sha256(path.join(bundle, relative)) }));
|
||||
fs.writeFileSync(path.join(bundle, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, files: manifest }, null, 2)}\n`);
|
||||
|
||||
function deterministicArchive(output, cwd, entries) {
|
||||
function deterministicArchive(output, cwd, entries, options = []) {
|
||||
const tarPath = output.replace(/\.gz$/, "");
|
||||
fs.rmSync(output, { force: true });
|
||||
fs.rmSync(tarPath, { force: true });
|
||||
execFileSync("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-cf", tarPath, "-C", cwd, ...entries]);
|
||||
execFileSync("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", ...options, "-cf", tarPath, "-C", cwd, ...entries]);
|
||||
execFileSync("gzip", ["-n", "-f", tarPath]);
|
||||
}
|
||||
|
||||
const binaryArchive = path.join(releaseRoot, "blender-web-offline.tar.gz");
|
||||
deterministicArchive(binaryArchive, releaseRoot, ["blender-web-offline"]);
|
||||
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
|
||||
deterministicArchive(sourceArchive, root, [
|
||||
const sourceEntries = [
|
||||
"blender-5.2.0",
|
||||
"web/app",
|
||||
"web/protocol",
|
||||
@@ -74,12 +113,58 @@ deterministicArchive(sourceArchive, root, [
|
||||
"web/eslint.config.js",
|
||||
"web/playwright.config.ts",
|
||||
"web/playwright.release.config.ts",
|
||||
"web/playwright.archive.config.ts",
|
||||
"web/playwright.single-thread.config.ts",
|
||||
"tools/web",
|
||||
"docs/web",
|
||||
"docs/status",
|
||||
"docs/PROJECT_STATUS_AND_NEXT_WORK.md",
|
||||
"docs/WEB_BLENDER_MODELER_V1_SCOPE.md",
|
||||
"docs/status/parity-ledger.json",
|
||||
"WEB_BLENDER_MIGRATION_EXECUTION_PLAN.md",
|
||||
]);
|
||||
];
|
||||
|
||||
function sourceManifestFiles(entries) {
|
||||
const files = [];
|
||||
const visit = (absolute, relative) => {
|
||||
const stat = fs.lstatSync(absolute);
|
||||
if (stat.isDirectory()) {
|
||||
if (path.basename(absolute) === "__pycache__") return;
|
||||
for (const name of fs.readdirSync(absolute).sort((left, right) => left.localeCompare(right))) {
|
||||
visit(path.join(absolute, name), path.posix.join(relative, name));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (relative.endsWith(".pyc")) return;
|
||||
if (stat.isSymbolicLink()) {
|
||||
files.push({ path: relative, type: "symlink", target: fs.readlinkSync(absolute) });
|
||||
return;
|
||||
}
|
||||
files.push({ path: relative, type: "file", bytes: stat.size, sha256: sha256(absolute) });
|
||||
};
|
||||
for (const entry of entries) visit(path.join(root, entry), entry);
|
||||
return files;
|
||||
}
|
||||
|
||||
const sourceManifestPath = path.join(root, "SOURCE_MANIFEST.json");
|
||||
if (fs.existsSync(sourceManifestPath)) throw new Error(`refusing to replace existing ${sourceManifestPath}`);
|
||||
const sourceManifest = {
|
||||
schemaVersion: 1,
|
||||
product: "Web Blender Modeler V1",
|
||||
version: packageJson.version,
|
||||
engineReleaseId: engineManifest.releaseId,
|
||||
files: sourceManifestFiles(sourceEntries),
|
||||
};
|
||||
fs.writeFileSync(sourceManifestPath, `${JSON.stringify(sourceManifest, null, 2)}\n`);
|
||||
try {
|
||||
deterministicArchive(sourceArchive, root, [...sourceEntries, "SOURCE_MANIFEST.json"], [
|
||||
"--exclude=*/__pycache__",
|
||||
"--exclude=*/__pycache__/*",
|
||||
"--exclude=*.pyc",
|
||||
]);
|
||||
}
|
||||
finally {
|
||||
fs.rmSync(sourceManifestPath, { force: true });
|
||||
}
|
||||
const sums = [binaryArchive, sourceArchive].map((file) => `${sha256(file)} ${path.basename(file)}`).join("\n") + "\n";
|
||||
fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), sums);
|
||||
process.stdout.write(`offline-release-ok binary=${fs.statSync(binaryArchive).size} source=${fs.statSync(sourceArchive).size} sha256=${sha256(binaryArchive)}\n`);
|
||||
|
||||
98
tools/web/create-rc-manifest.mjs
Normal file
98
tools/web/create-rc-manifest.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } 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 sha256Bytes = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const sha256 = (file) => sha256Bytes(fs.readFileSync(file));
|
||||
const archiveEntry = (archive, entry) => execFileSync("tar", ["-xOf", archive, entry], { maxBuffer: 32 * 1024 * 1024 });
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/package.json"), "utf8"));
|
||||
const engineManifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "web/app/public/engine-manifest.json"), "utf8"));
|
||||
const binaryArchive = path.join(releaseRoot, "blender-web-offline.tar.gz");
|
||||
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
|
||||
const sbom = path.join(repoRoot, "docs/web/sbom.spdx.json");
|
||||
const sumsPath = path.join(releaseRoot, "SHA256SUMS.txt");
|
||||
const packageJsonPath = path.join(repoRoot, "web/package.json");
|
||||
const packageLockPath = path.join(repoRoot, "web/package-lock.json");
|
||||
const engineManifestPath = path.join(repoRoot, "web/app/public/engine-manifest.json");
|
||||
const embeddedPackageJsonBytes = archiveEntry(sourceArchive, "web/package.json");
|
||||
const embeddedPackageLockBytes = archiveEntry(sourceArchive, "web/package-lock.json");
|
||||
const embeddedEngineManifestBytes = archiveEntry(binaryArchive, "blender-web-offline/app/engine-manifest.json");
|
||||
const embeddedMetadataBytes = archiveEntry(binaryArchive, "blender-web-offline/release-metadata.json");
|
||||
const embeddedSbomBytes = archiveEntry(binaryArchive, "blender-web-offline/sbom.spdx.json");
|
||||
const sourceManifest = JSON.parse(archiveEntry(sourceArchive, "SOURCE_MANIFEST.json").toString("utf8"));
|
||||
const embeddedMetadata = JSON.parse(embeddedMetadataBytes.toString("utf8"));
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim();
|
||||
const worktreeDirty = execFileSync("git", ["status", "--porcelain", "--untracked-files=all"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
}).trim().length > 0;
|
||||
|
||||
if (!/^\d+\.\d+\.\d+-rc\.\d+$/.test(packageJson.version)) throw new Error("package version is not an RC semver");
|
||||
if (engineManifest.releaseId !== `blender-wasm-${packageJson.version}`) throw new Error("engine releaseId does not match RC semver");
|
||||
if (embeddedMetadata.productVersion !== packageJson.version || embeddedMetadata.engineReleaseId !== engineManifest.releaseId) {
|
||||
throw new Error("binary release metadata does not match the frozen RC identity");
|
||||
}
|
||||
if (sourceManifest.version !== packageJson.version || sourceManifest.engineReleaseId !== engineManifest.releaseId) {
|
||||
throw new Error("source manifest does not match the frozen RC identity");
|
||||
}
|
||||
for (const [label, actual, expected] of [
|
||||
["source package.json", embeddedPackageJsonBytes, fs.readFileSync(packageJsonPath)],
|
||||
["source package-lock.json", embeddedPackageLockBytes, fs.readFileSync(packageLockPath)],
|
||||
["binary engine manifest", embeddedEngineManifestBytes, fs.readFileSync(engineManifestPath)],
|
||||
["binary SBOM", embeddedSbomBytes, fs.readFileSync(sbom)],
|
||||
]) {
|
||||
if (!actual.equals(expected)) throw new Error(`${label} does not match the frozen workspace bytes`);
|
||||
}
|
||||
const checksumEntries = fs.readFileSync(sumsPath, "utf8").trim().split("\n").map((line) => line.match(/^([a-f0-9]{64}) ([^/]+)$/));
|
||||
if (checksumEntries.some((entry) => entry === null) || checksumEntries.length !== 2) {
|
||||
throw new Error("SHA256SUMS.txt must contain exactly two canonical archive entries");
|
||||
}
|
||||
const checksums = new Map(checksumEntries.map((entry) => [entry[2], entry[1]]));
|
||||
for (const file of [binaryArchive, sourceArchive]) {
|
||||
if (checksums.get(path.basename(file)) !== sha256(file)) throw new Error(`SHA256SUMS.txt does not match ${path.basename(file)}`);
|
||||
}
|
||||
|
||||
const artifact = (file, relative) => ({
|
||||
path: relative,
|
||||
bytes: fs.statSync(file).size,
|
||||
sha256: sha256(file),
|
||||
});
|
||||
const operations = ["deploy", "upgrade", "rollback", "diagnostics", "rehearsal"]
|
||||
.map((name) => [name, path.join(releaseRoot, "operations-reports", `${name}.json`)]);
|
||||
for (const [name, file] of operations) {
|
||||
if (!fs.existsSync(file)) throw new Error(`RC operations report is missing: ${name}`);
|
||||
}
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
rcId: `web-blender-${packageJson.version}`,
|
||||
semver: packageJson.version,
|
||||
gitCommit: commit,
|
||||
sourceBinding: {
|
||||
mode: "corresponding-source-archive",
|
||||
worktreeDirty,
|
||||
note: "The source archive SHA-256 binds the candidate bytes independently of the base commit.",
|
||||
},
|
||||
engineReleaseId: engineManifest.releaseId,
|
||||
artifacts: {
|
||||
binaryArchive: artifact(binaryArchive, "release/blender-web-offline.tar.gz"),
|
||||
sourceArchive: artifact(sourceArchive, "release/blender-web-corresponding-source.tar.gz"),
|
||||
sbom: artifact(sbom, "docs/web/sbom.spdx.json"),
|
||||
},
|
||||
bindings: {
|
||||
packageJsonSha256: sha256(packageJsonPath),
|
||||
packageLockSha256: sha256(packageLockPath),
|
||||
ledgerSha256: sha256(path.join(repoRoot, "docs/status/parity-ledger.json")),
|
||||
engineManifestSha256: sha256(engineManifestPath),
|
||||
embeddedReleaseMetadataSha256: sha256Bytes(embeddedMetadataBytes),
|
||||
sha256SumsSha256: sha256(sumsPath),
|
||||
},
|
||||
operations: Object.fromEntries(operations.map(([name, file]) => [name, artifact(file, path.relative(repoRoot, file).replaceAll(path.sep, "/"))])),
|
||||
};
|
||||
const output = path.join(releaseRoot, "RC_MANIFEST.json");
|
||||
fs.writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
fs.writeFileSync(`${output}.sha256`, `${sha256(output)} ${path.basename(output)}\n`);
|
||||
process.stdout.write(`rc-manifest-ok id=${manifest.rcId} commit=${commit} binary=${manifest.artifacts.binaryArchive.sha256} source=${manifest.artifacts.sourceArchive.sha256} sbom=${manifest.artifacts.sbom.sha256}\n`);
|
||||
166
tools/web/deployment-http-server.mjs
Normal file
166
tools/web/deployment-http-server.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
export function loadDeploymentContract(
|
||||
contractPath = path.join(repoRoot, "docs/web/deployment-contract.json"),
|
||||
) {
|
||||
return JSON.parse(fs.readFileSync(contractPath, "utf8"));
|
||||
}
|
||||
|
||||
function cacheControl(pathname, routes) {
|
||||
for (const route of routes) {
|
||||
for (const pattern of route.patterns) {
|
||||
if (pattern === "*" || pattern === pathname ||
|
||||
(pattern.endsWith("*") && pathname.startsWith(pattern.slice(0, -1)))) {
|
||||
return route.cacheControl;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "no-cache";
|
||||
}
|
||||
|
||||
function resolvedFile(root, pathname) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded.includes("\0")) return null;
|
||||
const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
||||
const filePath = path.resolve(root, relative);
|
||||
const rootPrefix = `${path.resolve(root)}${path.sep}`;
|
||||
if (!filePath.startsWith(rootPrefix)) return null;
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function strongEtag(filePath) {
|
||||
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
return `"sha256-${hash}"`;
|
||||
}
|
||||
|
||||
function matchesIfNoneMatch(value, etag) {
|
||||
if (typeof value !== "string") return false;
|
||||
return value === "*" || value.split(",").some((candidate) => candidate.trim() === etag);
|
||||
}
|
||||
|
||||
function parseRange(value, size) {
|
||||
if (typeof value !== "string" || !value.startsWith("bytes=") || value.includes(",")) return null;
|
||||
const match = value.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match || (!match[1] && !match[2])) return null;
|
||||
if (!match[1]) {
|
||||
const suffixLength = Number(match[2]);
|
||||
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0 || suffixLength > size) return null;
|
||||
return { start: size - suffixLength, end: size - 1 };
|
||||
}
|
||||
const start = Number(match[1]);
|
||||
const end = match[2] ? Number(match[2]) : size - 1;
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) ||
|
||||
start < 0 || start >= size || end < start || end >= size) return null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function responseHeaders(contract, pathname, filePath, stat) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const mime = contract.mimeTypes[extension];
|
||||
if (!mime) return null;
|
||||
return {
|
||||
...contract.responseHeaders.allResponses,
|
||||
"Cache-Control": cacheControl(pathname, contract.responseHeaders.routes),
|
||||
"Content-Type": mime,
|
||||
"Content-Length": String(stat.size),
|
||||
};
|
||||
}
|
||||
|
||||
export function createDeploymentHttpServer({ root, contract = loadDeploymentContract() }) {
|
||||
const staticRoot = path.resolve(root);
|
||||
return http.createServer((request, response) => {
|
||||
const requestUrl = new URL(request.url ?? "/", "http://deployment.local");
|
||||
const pathname = requestUrl.pathname;
|
||||
const commonHeaders = { ...contract.responseHeaders.allResponses, "Cache-Control": "no-cache" };
|
||||
if (!contract.methods.includes(request.method)) {
|
||||
response.writeHead(405, { ...commonHeaders, Allow: contract.methods.join(", "), "Content-Length": "0" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolvedFile(staticRoot, pathname);
|
||||
if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
response.writeHead(404, { ...commonHeaders, "Content-Length": "0" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const stat = fs.statSync(filePath);
|
||||
const headers = responseHeaders(contract, pathname, filePath, stat);
|
||||
if (!headers) {
|
||||
response.writeHead(415, { ...commonHeaders, "Content-Length": "0" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const rangeEnabled = contract.rangeRequests.extensions.includes(extension);
|
||||
const etag = strongEtag(filePath);
|
||||
headers.ETag = etag;
|
||||
if (rangeEnabled) headers["Accept-Ranges"] = contract.rangeRequests.unit;
|
||||
if (matchesIfNoneMatch(request.headers["if-none-match"], etag)) {
|
||||
response.writeHead(304, { ...headers, "Content-Length": "0" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const requestedRange = request.headers.range;
|
||||
const ifRangeMatches = !request.headers["if-range"] || request.headers["if-range"] === etag;
|
||||
if (requestedRange && rangeEnabled && ifRangeMatches) {
|
||||
const range = parseRange(requestedRange, stat.size);
|
||||
if (!range) {
|
||||
response.writeHead(contract.rangeRequests.unsatisfiedStatus, {
|
||||
...headers,
|
||||
"Content-Range": `bytes */${stat.size}`,
|
||||
"Content-Length": "0",
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const length = range.end - range.start + 1;
|
||||
response.writeHead(contract.rangeRequests.satisfiedStatus, {
|
||||
...headers,
|
||||
"Content-Range": `bytes ${range.start}-${range.end}/${stat.size}`,
|
||||
"Content-Length": String(length),
|
||||
});
|
||||
if (request.method === "HEAD") response.end();
|
||||
else fs.createReadStream(filePath, { start: range.start, end: range.end }).pipe(response);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, headers);
|
||||
if (request.method === "HEAD") response.end();
|
||||
else fs.createReadStream(filePath).pipe(response);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listenDeploymentHttpServer(server, { host = "127.0.0.1", port = 0 } = {}) {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("deployment server did not bind a TCP port");
|
||||
return `http://${host}:${address.port}`;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const root = path.resolve(process.argv[2] ?? path.join(repoRoot, "web/dist"));
|
||||
const port = Number.parseInt(process.env.BLENDER_WEB_HTTP_PORT ?? "8080", 10);
|
||||
const server = createDeploymentHttpServer({ root });
|
||||
const origin = await listenDeploymentHttpServer(server, { port });
|
||||
process.stdout.write(`deployment-http-server ${origin} root=${root}\n`);
|
||||
}
|
||||
@@ -2,18 +2,51 @@
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source_dir="${repo_root}/build_web_blender6/bin"
|
||||
target_dir="${repo_root}/web/app/public/vendor/blender"
|
||||
source_vendor_dir="${repo_root}/web/app/src/vendor/blender"
|
||||
mkdir -p "${target_dir}"
|
||||
mkdir -p "${source_vendor_dir}"
|
||||
test -s "${source_dir}/web_engine.js" && test -s "${source_dir}/web_engine.wasm"
|
||||
cp "${source_dir}/web_engine.js" "${target_dir}/web_engine.js"
|
||||
cp "${source_dir}/web_engine.wasm" "${target_dir}/web_engine.wasm"
|
||||
cp "${source_dir}/web_engine.js" "${source_vendor_dir}/web_engine.js"
|
||||
cp "${source_dir}/web_engine.wasm" "${source_vendor_dir}/web_engine.wasm"
|
||||
wasm_hash="$(sha256sum "${target_dir}/web_engine.wasm" | cut -d ' ' -f 1)"
|
||||
single_build_dir="${WEB_ENGINE_SINGLE_BUILD_DIR:-${repo_root}/build_web-single}"
|
||||
pthread_build_dir="${WEB_ENGINE_PTHREAD_BUILD_DIR:-${repo_root}/build_web-pthread}"
|
||||
public_root="${WEB_ENGINE_PUBLIC_ROOT:-${repo_root}/web/app/public}"
|
||||
manifest_path="${WEB_ENGINE_MANIFEST_PATH:-${public_root}/engine-manifest.json}"
|
||||
|
||||
if [[ "$(realpath -m "${single_build_dir}")" == "$(realpath -m "${pthread_build_dir}")" ]]; then
|
||||
echo "single and pthread variants must come from independent build directories" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for build_dir in "${single_build_dir}" "${pthread_build_dir}"; do
|
||||
test -s "${build_dir}/web_engine.js"
|
||||
test -s "${build_dir}/web_engine.wasm"
|
||||
done
|
||||
test -s "${manifest_path}"
|
||||
|
||||
staging_dir="$(mktemp -d "${TMPDIR:-/tmp}/blender-web-engine-install.XXXXXX")"
|
||||
trap 'rm -rf "${staging_dir}"' EXIT
|
||||
for variant in single pthread; do
|
||||
mkdir -p "${staging_dir}/vendor/blender/${variant}"
|
||||
done
|
||||
cp "${single_build_dir}/web_engine.js" "${staging_dir}/vendor/blender/single/web_engine.js"
|
||||
cp "${single_build_dir}/web_engine.wasm" "${staging_dir}/vendor/blender/single/web_engine.wasm"
|
||||
cp "${pthread_build_dir}/web_engine.js" "${staging_dir}/vendor/blender/pthread/web_engine.js"
|
||||
cp "${pthread_build_dir}/web_engine.wasm" "${staging_dir}/vendor/blender/pthread/web_engine.wasm"
|
||||
cp "${manifest_path}" "${staging_dir}/engine-manifest.json"
|
||||
|
||||
node "${repo_root}/tools/web/update-engine-manifest.mjs" \
|
||||
"${repo_root}/web/app/public/engine-manifest.json" \
|
||||
"${wasm_hash}"
|
||||
sha256sum "${target_dir}/web_engine.js" "${target_dir}/web_engine.wasm"
|
||||
"${staging_dir}/engine-manifest.json" \
|
||||
"${staging_dir}/vendor/blender/single" \
|
||||
"${staging_dir}/vendor/blender/pthread"
|
||||
node "${repo_root}/tools/web/check-manifest-assets.mjs" \
|
||||
"${staging_dir}/engine-manifest.json" "${staging_dir}"
|
||||
|
||||
for variant in single pthread; do
|
||||
mkdir -p "${public_root}/vendor/blender/${variant}"
|
||||
install -m 0644 "${staging_dir}/vendor/blender/${variant}/web_engine.js" \
|
||||
"${public_root}/vendor/blender/${variant}/web_engine.js"
|
||||
install -m 0644 "${staging_dir}/vendor/blender/${variant}/web_engine.wasm" \
|
||||
"${public_root}/vendor/blender/${variant}/web_engine.wasm"
|
||||
done
|
||||
install -m 0644 "${staging_dir}/engine-manifest.json" "${manifest_path}"
|
||||
|
||||
sha256sum \
|
||||
"${public_root}/vendor/blender/single/web_engine.js" \
|
||||
"${public_root}/vendor/blender/single/web_engine.wasm" \
|
||||
"${public_root}/vendor/blender/pthread/web_engine.js" \
|
||||
"${public_root}/vendor/blender/pthread/web_engine.wasm"
|
||||
|
||||
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;
|
||||
191
tools/web/run-operations-rehearsal.mjs
Normal file
191
tools/web/run-operations-rehearsal.mjs
Normal file
@@ -0,0 +1,191 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createDeploymentHttpServer,
|
||||
listenDeploymentHttpServer,
|
||||
} from "./deployment-http-server.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const releaseRoot = path.join(repoRoot, "release");
|
||||
const archive = path.join(releaseRoot, "blender-web-offline.tar.gz");
|
||||
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
|
||||
const sumsFile = path.join(releaseRoot, "SHA256SUMS.txt");
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "blender-operations-rehearsal-"));
|
||||
assert.equal(path.dirname(workspace), os.tmpdir());
|
||||
assert.ok(path.basename(workspace).startsWith("blender-operations-rehearsal-"));
|
||||
|
||||
const records = [];
|
||||
let failure = null;
|
||||
let server = null;
|
||||
let projectBefore = null;
|
||||
let projectAfter = null;
|
||||
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||||
const fileSha256 = (file) => sha256(fs.readFileSync(file));
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
async function run(id, command, action) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const output = await action();
|
||||
records.push({ id, command, exitCode: 0, durationMs: Date.now() - started, output });
|
||||
return output;
|
||||
}
|
||||
catch (error) {
|
||||
records.push({
|
||||
id,
|
||||
command,
|
||||
exitCode: 1,
|
||||
durationMs: Date.now() - started,
|
||||
output: { error: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function switchCurrent(installRoot, releaseName) {
|
||||
const pending = path.join(installRoot, ".current.next");
|
||||
fs.rmSync(pending, { force: true });
|
||||
fs.symlinkSync(path.join("releases", releaseName), pending);
|
||||
fs.renameSync(pending, path.join(installRoot, "current"));
|
||||
}
|
||||
|
||||
async function validateInstalledHttp(installRoot) {
|
||||
const bundle = path.join(installRoot, "current");
|
||||
const contract = readJson(path.join(bundle, "deployment-contract.json"));
|
||||
const engine = readJson(path.join(bundle, "app/engine-manifest.json"));
|
||||
server = createDeploymentHttpServer({ root: path.join(bundle, "app"), contract });
|
||||
const origin = await listenDeploymentHttpServer(server);
|
||||
try {
|
||||
const head = await fetch(`${origin}/`, { method: "HEAD" });
|
||||
assert.equal(head.status, 200);
|
||||
for (const [name, value] of Object.entries(contract.responseHeaders.allResponses)) {
|
||||
assert.equal(head.headers.get(name), value);
|
||||
}
|
||||
const single = engine.variants.find((variant) => variant.id === "single");
|
||||
const range = await fetch(new URL(single.resources.wasm.url, origin), { headers: { Range: "bytes=0-15" } });
|
||||
assert.equal(range.status, 206);
|
||||
assert.equal((await range.arrayBuffer()).byteLength, 16);
|
||||
return { originMode: "loopback-http", headStatus: head.status, rangeStatus: range.status, releaseId: engine.releaseId };
|
||||
}
|
||||
finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
server = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const delivery = path.join(workspace, "delivery");
|
||||
const installRoot = path.join(workspace, "install");
|
||||
const originStorage = path.join(workspace, "origin-storage", "projects", "rehearsal-project");
|
||||
fs.mkdirSync(delivery);
|
||||
fs.mkdirSync(installRoot);
|
||||
|
||||
await run("delivery-check", "sha256sum -c SHA256SUMS.txt && tar -tzf blender-web-offline.tar.gz", async () => {
|
||||
for (const file of [archive, sourceArchive, sumsFile]) fs.copyFileSync(file, path.join(delivery, path.basename(file)));
|
||||
const entries = Object.fromEntries(fs.readFileSync(path.join(delivery, "SHA256SUMS.txt"), "utf8").trim().split("\n").map((line) => {
|
||||
const match = line.match(/^([a-f0-9]{64}) ([^/]+)$/);
|
||||
assert.ok(match, `invalid checksum entry: ${line}`);
|
||||
return [match[2], match[1]];
|
||||
}));
|
||||
assert.equal(fileSha256(path.join(delivery, path.basename(archive))), entries[path.basename(archive)]);
|
||||
assert.equal(fileSha256(path.join(delivery, path.basename(sourceArchive))), entries[path.basename(sourceArchive)]);
|
||||
const archiveEntries = execFileSync("tar", ["-tzf", path.join(delivery, path.basename(archive))], { encoding: "utf8" }).split("\n").filter(Boolean);
|
||||
assert.ok(archiveEntries.length > 0);
|
||||
for (const entry of archiveEntries) {
|
||||
assert.ok(!entry.startsWith("/") && !entry.split("/").includes(".."));
|
||||
assert.ok(entry === "blender-web-offline/" || entry.startsWith("blender-web-offline/"));
|
||||
}
|
||||
return { binarySha256: entries[path.basename(archive)], sourceSha256: entries[path.basename(sourceArchive)], archiveEntries: archiveEntries.length };
|
||||
});
|
||||
|
||||
const installed = await run("deploy-install", "tar --no-same-owner --no-same-permissions -xzf ARCHIVE -C STAGE && mv STAGE RELEASE && mv -Tf .current.next current", async () => {
|
||||
const releases = path.join(installRoot, "releases");
|
||||
fs.mkdirSync(releases);
|
||||
const staging = fs.mkdtempSync(path.join(installRoot, ".install."));
|
||||
execFileSync("tar", ["--no-same-owner", "--no-same-permissions", "-xzf", path.join(delivery, path.basename(archive)), "-C", staging]);
|
||||
const releaseName = fileSha256(path.join(delivery, path.basename(archive)));
|
||||
fs.renameSync(path.join(staging, "blender-web-offline"), path.join(releases, releaseName));
|
||||
fs.rmdirSync(staging);
|
||||
switchCurrent(installRoot, releaseName);
|
||||
return { releaseName, currentTarget: fs.readlinkSync(path.join(installRoot, "current")), emptyInstallRoot: true };
|
||||
});
|
||||
|
||||
await run("deploy-http", "HEAD / && GET Range:bytes=0-15 single/web_engine.wasm", () => validateInstalledHttp(installRoot));
|
||||
|
||||
await run("project-seed", "persist project revision and SHA-256 outside release directories", async () => {
|
||||
fs.mkdirSync(originStorage, { recursive: true });
|
||||
const bytes = Buffer.from("M6-17E rehearsal project bytes");
|
||||
const manifest = { schemaVersion: 1, revision: 23, bytes: bytes.length, sha256: sha256(bytes) };
|
||||
fs.writeFileSync(path.join(originStorage, "scene.blend"), bytes);
|
||||
fs.writeFileSync(path.join(originStorage, "scene.blend.meta.json"), `${JSON.stringify(manifest)}\n`);
|
||||
projectBefore = manifest;
|
||||
return manifest;
|
||||
});
|
||||
|
||||
const upgraded = await run("upgrade-switch", "stage NEXT_RELEASE && mv -Tf .current.next current", async () => {
|
||||
const oldRelease = path.join(installRoot, "releases", installed.releaseName);
|
||||
const nextName = `${installed.releaseName}-next`;
|
||||
const nextRelease = path.join(installRoot, "releases", nextName);
|
||||
fs.cpSync(oldRelease, nextRelease, { recursive: true });
|
||||
const metadataPath = path.join(nextRelease, "release-metadata.json");
|
||||
const metadata = readJson(metadataPath);
|
||||
metadata.productVersion = `${metadata.productVersion}-rehearsal-next`;
|
||||
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
switchCurrent(installRoot, nextName);
|
||||
assert.equal(fileSha256(path.join(originStorage, "scene.blend")), projectBefore.sha256);
|
||||
return { from: installed.releaseName, to: nextName, oldReleaseRetained: fs.existsSync(oldRelease) };
|
||||
});
|
||||
|
||||
await run("upgrade-http", "HEAD / && GET Range:bytes=0-15 after upgrade", () => validateInstalledHttp(installRoot));
|
||||
|
||||
await run("rollback-switch", "preflight schemas && mv -Tf .current.next current", async () => {
|
||||
const activeMetadata = readJson(path.join(installRoot, "current", "release-metadata.json"));
|
||||
const targetMetadata = readJson(path.join(installRoot, "releases", installed.releaseName, "release-metadata.json"));
|
||||
assert.equal(activeMetadata.storage.indexedDbSchemaVersion, targetMetadata.storage.indexedDbSchemaVersion);
|
||||
assert.equal(activeMetadata.storage.opfsProjectManifestSchemaVersion, targetMetadata.storage.opfsProjectManifestSchemaVersion);
|
||||
switchCurrent(installRoot, installed.releaseName);
|
||||
projectAfter = readJson(path.join(originStorage, "scene.blend.meta.json"));
|
||||
assert.deepEqual(projectAfter, projectBefore);
|
||||
assert.equal(fileSha256(path.join(originStorage, "scene.blend")), projectBefore.sha256);
|
||||
return { from: upgraded.to, to: installed.releaseName, projectRevision: projectAfter.revision, projectSha256: projectAfter.sha256 };
|
||||
});
|
||||
|
||||
await run("rollback-http", "HEAD / && GET Range:bytes=0-15 after rollback", () => validateInstalledHttp(installRoot));
|
||||
}
|
||||
catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
finally {
|
||||
if (server) await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: "M6-17E",
|
||||
status: failure ? "FAILED" : "READY",
|
||||
generatedAt: new Date().toISOString(),
|
||||
freshTemporaryRoot: true,
|
||||
oneContinuousWorkspace: true,
|
||||
workspaceCleaned: !fs.existsSync(workspace),
|
||||
project: projectBefore && projectAfter ? {
|
||||
revisionBefore: projectBefore.revision,
|
||||
revisionAfter: projectAfter.revision,
|
||||
sha256Before: projectBefore.sha256,
|
||||
sha256After: projectAfter.sha256,
|
||||
preserved: projectBefore.revision === projectAfter.revision && projectBefore.sha256 === projectAfter.sha256,
|
||||
} : null,
|
||||
records,
|
||||
};
|
||||
const reportRoot = path.join(releaseRoot, "operations-reports");
|
||||
fs.mkdirSync(reportRoot, { recursive: true });
|
||||
const reportPath = path.join(reportRoot, "rehearsal.json");
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
fs.writeFileSync(`${reportPath}.sha256`, `${fileSha256(reportPath)} ${path.basename(reportPath)}\n`);
|
||||
process.stdout.write(`operations-rehearsal status=${report.status} records=${records.length} project=${report.project?.preserved === true ? "preserved" : "unverified"} cleanup=${report.workspaceCleaned}\n`);
|
||||
if (failure) throw failure;
|
||||
@@ -31,7 +31,7 @@ if (blenderArchiveSha256 && !/^[a-f0-9]{64}$/.test(blenderArchiveSha256)) {
|
||||
const environment = {
|
||||
node: process.version,
|
||||
npm: capture("npm", ["--version"]).split(/\r?\n/)[0],
|
||||
chromium: capture(process.env.CHROME_PATH ?? "/home/mes123456/.local/bin/google-chrome", ["--version"]).split(/\r?\n/)[0],
|
||||
chromium: capture(process.env.CHROME_PATH ?? "google-chrome", ["--version"]).split(/\r?\n/)[0],
|
||||
blenderBin: process.env.BLENDER_BIN ? "BLENDER_BIN" : blenderBin,
|
||||
blenderExecutableSha256: fs.existsSync(blenderBin) ? sha256(fs.readFileSync(blenderBin)) : "unavailable",
|
||||
blenderArchiveSha256: blenderArchiveSha256 || "unavailable",
|
||||
|
||||
@@ -1,12 +1,51 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [manifestPath, wasmHash] = process.argv.slice(2);
|
||||
if (!manifestPath || !/^[a-f0-9]{64}$/.test(wasmHash ?? "")) {
|
||||
throw new Error("usage: update-engine-manifest.mjs <manifest.json> <wasm-sha256>");
|
||||
const [manifestPath, singleDirectory, pthreadDirectory] = process.argv.slice(2);
|
||||
if (!manifestPath || !singleDirectory || !pthreadDirectory) {
|
||||
throw new Error(
|
||||
"usage: update-engine-manifest.mjs <manifest.json> <single-directory> <pthread-directory>",
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const resource = manifest.wasm?.find((entry) => entry.id === "web-engine-bootstrap");
|
||||
if (!resource) throw new Error("web-engine-bootstrap resource is missing from the engine manifest");
|
||||
resource.sha256 = wasmHash;
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
if (typeof manifest.engineVersion !== "string" || manifest.engineVersion.length === 0) {
|
||||
throw new Error("engineVersion is missing from the existing engine manifest");
|
||||
}
|
||||
|
||||
function resource(directory, variant, fileName) {
|
||||
const filePath = path.join(directory, fileName);
|
||||
const bytes = fs.readFileSync(filePath);
|
||||
if (bytes.byteLength === 0) throw new Error(`${variant} ${fileName} is empty`);
|
||||
return {
|
||||
fileName,
|
||||
url: `/vendor/blender/${variant}/${fileName}`,
|
||||
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
const singleJs = resource(singleDirectory, "single", "web_engine.js");
|
||||
const singleWasm = resource(singleDirectory, "single", "web_engine.wasm");
|
||||
const pthreadJs = resource(pthreadDirectory, "pthread", "web_engine.js");
|
||||
const pthreadWasm = resource(pthreadDirectory, "pthread", "web_engine.wasm");
|
||||
const updated = {
|
||||
schemaVersion: 2,
|
||||
protocolVersion: 1,
|
||||
releaseId: typeof manifest.releaseId === "string" ? manifest.releaseId : manifest.engineVersion,
|
||||
engineVersion: manifest.engineVersion,
|
||||
engine: "blender-wasm",
|
||||
variants: [
|
||||
{
|
||||
id: "single",
|
||||
memory: { initialPages: 256, maximumPages: 32768, shared: false },
|
||||
resources: { js: singleJs, wasm: singleWasm },
|
||||
},
|
||||
{
|
||||
id: "pthread",
|
||||
memory: { initialPages: 256, maximumPages: 32768, shared: true },
|
||||
resources: { js: pthreadJs, wasm: pthreadWasm, pthreadWorker: pthreadJs },
|
||||
},
|
||||
],
|
||||
};
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(updated, null, 2)}\n`);
|
||||
|
||||
Reference in New Issue
Block a user