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