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