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