Advance M7 workflows and release operations
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-15 17:43:53 -04:00
parent 17ab961485
commit 7c16b279ae
103 changed files with 8064 additions and 429 deletions

View 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`);

View 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 });
}

View 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 });
}

View 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`);

View 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 });
}

View File

@@ -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/);

View 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 });
}

View 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 });
}

View 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 });
}

View File

@@ -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"

View File

@@ -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}`);

View 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`);

View 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`);

View 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`);

View 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`);

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

View 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 });
}

View 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 });
}

View 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 });
}

View File

@@ -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;

View File

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

View File

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

View File

@@ -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`);

View 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`);

View 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`);
}

View File

@@ -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
View 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;

View 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;

View File

@@ -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",

View File

@@ -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`);