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 root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const releaseRoot = path.join(root, "release"); if (path.basename(releaseRoot) !== "release" || path.dirname(releaseRoot) !== root) throw new Error("unsafe release target"); const bundle = path.join(releaseRoot, "blender-web-offline"); execFileSync(process.execPath, [path.join(root, "tools/web/generate-sbom.mjs")], { stdio: "inherit" }); fs.rmSync(bundle, { recursive: true, force: true }); fs.mkdirSync(bundle, { recursive: true }); fs.cpSync(path.join(root, "web/dist"), path.join(bundle, "app"), { recursive: true }); fs.copyFileSync(path.join(root, "blender-5.2.0/COPYING"), path.join(bundle, "COPYING")); fs.copyFileSync(path.join(root, "docs/web/third-party-notices.json"), path.join(bundle, "third-party-notices.json")); 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"), [ "Web Blender Modeler V1 offline release", "", "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.", "", ].join("\n")); fs.writeFileSync(path.join(bundle, "SOURCE_OFFER.txt"), [ "Corresponding source for this GPL-2.0-or-later WebEngine distribution is provided in", "blender-web-corresponding-source.tar.gz alongside this archive. It contains the Blender 5.2", "source, Web application/protocol source, Web build scripts, dependency lockfile and build documentation.", "The source archive is covered by SHA256SUMS.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"); } function walk(directory, base = directory) { const files = []; for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.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; } 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, 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", ...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"); const sourceEntries = [ "blender-5.2.0", "web/app", "web/protocol", "web/package.json", "web/package-lock.json", "web/tsconfig.json", "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/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`);