From 172a26b8bd4a7d61f5c336d9690178ccc3a0451d Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 2 Aug 2026 03:54:58 +0900 Subject: [PATCH] fix: fail closed in release drill verification --- scripts/drill-runbook.ts | 131 ++++++++++++++++++--------- scripts/verify-release.ts | 64 ++++++++++--- tests/unit/release-coherence.test.ts | 86 ++++++++++++++++-- 3 files changed, 216 insertions(+), 65 deletions(-) diff --git a/scripts/drill-runbook.ts b/scripts/drill-runbook.ts index 059e2ca..2b0665b 100644 --- a/scripts/drill-runbook.ts +++ b/scripts/drill-runbook.ts @@ -7,6 +7,7 @@ import { verifyCompatibilityTuple } from "../src/application/policies/compatibil import type { StoragePort } from "../src/application/ports/storage-port.ts"; import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts"; import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts"; +import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts"; import { parseReleaseArtifact, parseRuntimeConfigArtifact, @@ -14,10 +15,7 @@ import { } from "../src/contracts/release-artifacts.ts"; import { projectTelemetryEvent } from "../src/contracts/telemetry.ts"; import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts"; -import { - verifyReleaseRuntimeCoherence, - type ReleaseRuntimeCoherenceInput, -} from "./lib/release-runtime-coherence.ts"; +import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts"; type RecoveryAssertion = Readonly<{ assertion: string; @@ -48,20 +46,42 @@ type RunbookDocument = Readonly<{ runbooks: Record; }>; -async function releaseManifest(): Promise { - for (const candidate of [ - "dist/release-manifest.json", - "public/release-manifest.json", - ]) { - try { - return parseReleaseArtifact( - JSON.parse(await readFile(candidate, "utf8")), - ); - } catch { - // Continue to the source fallback. - } - } - throw new Error("Release manifest is unavailable."); +export type JsonArtifactReader = (path: string) => Promise; + +export type RollbackArtifactPaths = Readonly<{ + primaryRelease: string; + fallbackRelease: string; + primaryRuntime: string; + fallbackRuntime: string; +}>; + +export type RollbackCoherenceOptions = Readonly<{ + readArtifact?: JsonArtifactReader; + contractPackages?: readonly InstalledContractPackageIdentity[]; + paths?: RollbackArtifactPaths; +}>; + +const DEFAULT_ROLLBACK_PATHS = Object.freeze({ + primaryRelease: "dist/release-manifest.json", + fallbackRelease: "public/release-manifest.json", + primaryRuntime: "dist/config.json", + fallbackRuntime: "public/config.json", +}); + +async function readJsonArtifact(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")); +} + +async function releaseManifest( + readArtifact: JsonArtifactReader = readJsonArtifact, + paths: RollbackArtifactPaths = DEFAULT_ROLLBACK_PATHS, +): Promise { + const value = await readPrimaryOrFallback( + readArtifact, + paths.primaryRelease, + paths.fallbackRelease, + ); + return parseReleaseArtifact(value); } const validConfig = { @@ -227,30 +247,8 @@ async function drillTelemetry(): Promise { } async function drillRollback(): Promise { - const release = await releaseManifest(); - const runtimeArtifact = parseRuntimeConfigArtifact( - JSON.parse( - await readFile( - (await access("dist/config.json").then(() => true).catch(() => false)) - ? "dist/config.json" - : "public/config.json", - "utf8", - ), - ), - ); - const runtime = { - ...runtimeArtifact, - BUILD_ID: requireIdentity(runtimeArtifact.BUILD_ID, "runtime BUILD_ID"), - RELEASE_ID: requireIdentity( - runtimeArtifact.RELEASE_ID, - "runtime RELEASE_ID", - ), - }; - const coherent = await verifyRollbackReleaseCoherence({ - release, - runtime, - contractPackages: EXPECTED_CONTRACT_SET_PACKAGES, - }); + const verified = await verifyRollbackReleaseCoherence(); + const { release, coherence: coherent } = verified; const mixed = verifyCompatibilityTuple({ frontend: { buildId: "build-a", @@ -290,9 +288,32 @@ const drillById: Record Promise> = { }; export async function verifyRollbackReleaseCoherence( - input: ReleaseRuntimeCoherenceInput, + options: RollbackCoherenceOptions = {}, ) { - return verifyReleaseRuntimeCoherence(input); + const readArtifact = options.readArtifact ?? readJsonArtifact; + const paths = options.paths ?? DEFAULT_ROLLBACK_PATHS; + const release = await releaseManifest(readArtifact, paths); + const runtimeValue = await readPrimaryOrFallback( + readArtifact, + paths.primaryRuntime, + paths.fallbackRuntime, + ); + const runtimeArtifact = parseRuntimeConfigArtifact(runtimeValue); + const runtime = { + ...runtimeArtifact, + BUILD_ID: requireIdentity(runtimeArtifact.BUILD_ID, "runtime BUILD_ID"), + RELEASE_ID: requireIdentity( + runtimeArtifact.RELEASE_ID, + "runtime RELEASE_ID", + ), + }; + const coherence = await verifyReleaseRuntimeCoherence({ + release, + runtime, + contractPackages: + options.contractPackages ?? EXPECTED_CONTRACT_SET_PACKAGES, + }); + return Object.freeze({ release, runtime, coherence }); } async function main(): Promise { @@ -356,6 +377,28 @@ function requireIdentity(value: string | undefined, label: string): string { return value; } +async function readPrimaryOrFallback( + readArtifact: JsonArtifactReader, + primary: string, + fallback: string, +): Promise { + try { + return await readArtifact(primary); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + return readArtifact(fallback); + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + error.code === code, + ); +} + const invokedPath = process.argv[1]; if ( invokedPath !== undefined && diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts index a16f913..261bbd0 100644 --- a/scripts/verify-release.ts +++ b/scripts/verify-release.ts @@ -1,10 +1,12 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; import { verifyCompatibilityTuple, type CompatibilityTuple, } from "../src/application/policies/compatibility.ts"; +import type { InstalledContractPackageIdentity } from "../src/contracts/external-contract-runtime.ts"; import { parseBuildManifestArtifact, parseReleaseArtifact, @@ -36,17 +38,53 @@ type ViteManifestEntry = Readonly<{ isDynamicEntry?: boolean; }>; +export type ReleaseArtifactReader = (path: string) => Promise; + +export type ReleaseArtifactsCoherenceOptions = Readonly<{ + readArtifact?: ReleaseArtifactReader; + contractPackages?: readonly InstalledContractPackageIdentity[]; + paths?: Readonly<{ release: string; runtime: string }>; +}>; + +const DEFAULT_RELEASE_COHERENCE_PATHS = Object.freeze({ + release: "dist/release-manifest.json", + runtime: "dist/config.json", +}); + +async function readJsonArtifact(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")); +} + +export async function verifyReleaseArtifactsCoherence( + options: ReleaseArtifactsCoherenceOptions = {}, +) { + const readArtifact = options.readArtifact ?? readJsonArtifact; + const paths = options.paths ?? DEFAULT_RELEASE_COHERENCE_PATHS; + const release = parseReleaseDocument(await readArtifact(paths.release)); + const runtime = parseRuntimeConfigDocument( + await readArtifact(paths.runtime), + ); + const coherence = await verifyReleaseRuntimeCoherence({ + release, + runtime, + contractPackages: + options.contractPackages ?? EXPECTED_CONTRACT_SET_PACKAGES, + }); + return Object.freeze({ release, runtime, coherence }); +} + +async function main(): Promise { const fixturesDocument = parseFixturesDocument( JSON.parse( await readFile("config/release/coherence-fixtures.json", "utf8"), ), ); -const release = parseReleaseDocument( - JSON.parse(await readFile("dist/release-manifest.json", "utf8")), -); -const runtimeConfig = parseRuntimeConfigDocument( - JSON.parse(await readFile("dist/config.json", "utf8")), -); +const verifiedRuntime = await verifyReleaseArtifactsCoherence(); +const { + release, + runtime: runtimeConfig, + coherence: artifactComparison, +} = verifiedRuntime; const buildManifestDocument: unknown = JSON.parse( await readFile("artifacts/release/build-manifest.json", "utf8"), ); @@ -68,11 +106,6 @@ const actualAssetManifestHash = createHash("sha256") .update(viteManifest) .digest("hex"); -const artifactComparison = await verifyReleaseRuntimeCoherence({ - release, - runtime: runtimeConfig, - contractPackages: EXPECTED_CONTRACT_SET_PACKAGES, -}); const artifactMismatches: string[] = [...artifactComparison.mismatches]; for (const [token, value] of Object.entries(projectReleaseTokens(release))) { if (token !== "schemaVersion" && (typeof value !== "string" || value.length === 0)) { @@ -193,6 +226,15 @@ if (!passed) { process.stdout.write( `Release coherence: PASS (${fixtures.length - 1} mixed fixtures rejected)\n`, ); +} + +const invokedPath = process.argv[1]; +if ( + invokedPath !== undefined && + import.meta.url === pathToFileURL(invokedPath).href +) { + await main(); +} function parseFixturesDocument(value: unknown): Readonly<{ fixtures: readonly CoherenceFixture[]; diff --git a/tests/unit/release-coherence.test.ts b/tests/unit/release-coherence.test.ts index 9cad675..e349473 100644 --- a/tests/unit/release-coherence.test.ts +++ b/tests/unit/release-coherence.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { verifyRollbackReleaseCoherence } from "../../scripts/drill-runbook.ts"; -import { verifyReleaseRuntimeCoherence } from "../../scripts/lib/release-runtime-coherence.ts"; +import { verifyReleaseArtifactsCoherence } from "../../scripts/verify-release.ts"; import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts"; import { computeContractSetDigest } from "../../src/contracts/contract-set-canonical.ts"; import type { @@ -98,6 +98,19 @@ async function releaseV2With( }; } +function artifactReader( + entries: Readonly>, +): (path: string) => Promise { + return async (path) => { + const value = entries[path]; + if (value === undefined) { + throw Object.assign(new Error(`missing ${path}`), { code: "ENOENT" }); + } + if (value instanceof Error) throw value; + return value; + }; +} + describe("release coherence", () => { it("owns all nine release tokens and keeps builtAt diagnostic-only", () => { // ยง5.2 adds contractSetDigest beside the legacy apiContractVersion scalar. @@ -234,17 +247,25 @@ describe("release coherence", () => { ]; for (const fixture of matrix) { - const input = { - release: fixture.release, - runtime: fixture.runtime, - contractPackages, - }; + const input = { release: fixture.release, runtime: fixture.runtime }; const verifierVerdict = ( - await verifyReleaseRuntimeCoherence(input) - ).compatible; + await verifyReleaseArtifactsCoherence({ + readArtifact: artifactReader({ + "dist/release-manifest.json": input.release, + "dist/config.json": input.runtime, + }), + contractPackages, + }) + ).coherence.compatible; const rollbackDrillVerdict = ( - await verifyRollbackReleaseCoherence(input) - ).compatible; + await verifyRollbackReleaseCoherence({ + readArtifact: artifactReader({ + "dist/release-manifest.json": fixture.release, + "dist/config.json": fixture.runtime, + }), + contractPackages, + }) + ).coherence.compatible; expect(verifierVerdict, `${fixture.name}: verifier`).toBe( fixture.expectedCompatible, ); @@ -256,4 +277,49 @@ describe("release coherence", () => { ); } }); + + it("falls back to public rollback artifacts only when primary dist is absent", async () => { + const exactV2 = await releaseV2With(contractPackages); + const tamperedPrimary = await releaseV2With(contractPackages.slice(0, 1)); + const validPublic = { + "public/release-manifest.json": exactV2, + "public/config.json": runtimeV2, + }; + const unavailablePrimary = [ + new SyntaxError("invalid primary JSON"), + Object.assign(new Error("unreadable primary"), { code: "EACCES" }), + { ...exactV2, unexpected: "tampered" }, + ]; + + for (const primaryFailure of unavailablePrimary) { + await expect( + verifyRollbackReleaseCoherence({ + readArtifact: artifactReader({ + "dist/release-manifest.json": primaryFailure, + "dist/config.json": runtimeV2, + ...validPublic, + }), + contractPackages, + }), + ).rejects.toBeDefined(); + } + + await expect( + verifyRollbackReleaseCoherence({ + readArtifact: artifactReader({ + "dist/release-manifest.json": tamperedPrimary, + "dist/config.json": runtimeV2, + ...validPublic, + }), + contractPackages, + }), + ).resolves.toMatchObject({ coherence: { compatible: false } }); + + await expect( + verifyRollbackReleaseCoherence({ + readArtifact: artifactReader(validPublic), + contractPackages, + }), + ).resolves.toMatchObject({ coherence: { compatible: true } }); + }); });