From eb37cbe8be7f7b0c89ad5b45577df902c8260477 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 21:22:35 +0900 Subject: [PATCH] feat: enforce coherent release and rollback contract --- config/contracts/registry-governance.json | 44 +------ config/hosting/cache-policy.json | 31 +++++ config/hosting/response-headers.fixture.json | 35 ++++++ config/release/coherence-fixtures.json | 77 ++++++++++++ .../schemas/release-verification.schema.json | 17 +++ docs/operations/release-cache-rollback.md | 25 ++++ package.json | 4 +- public/release-manifest.json | 11 ++ scripts/generate-build-manifest.mjs | 30 ++++- scripts/verify-hosting-headers.mjs | 110 ++++++++++++++++++ scripts/verify-release.mjs | 87 ++++++++++++++ src/contracts/release-tokens.js | 65 +++++++++++ tests/unit/release-coherence.test.js | 54 +++++++++ 13 files changed, 545 insertions(+), 45 deletions(-) create mode 100644 config/hosting/cache-policy.json create mode 100644 config/hosting/response-headers.fixture.json create mode 100644 config/release/coherence-fixtures.json create mode 100644 config/schemas/release-verification.schema.json create mode 100644 docs/operations/release-cache-rollback.md create mode 100644 public/release-manifest.json create mode 100644 scripts/verify-hosting-headers.mjs create mode 100644 scripts/verify-release.mjs create mode 100644 src/contracts/release-tokens.js create mode 100644 tests/unit/release-coherence.test.js diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index a7a0c8c..d0f24be 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -106,49 +106,7 @@ "path": "src/contracts/release-tokens.js", "exportName": "RELEASE_TOKEN_REGISTRY", "owner": "feature-frontend-release-cache-rollback-contract", - "requiredFields": ["token", "source", "compatibilityRole"], - "declaredRows": { - "appVersion": { - "token": "appVersion", - "source": "manifest", - "compatibilityRole": "human release label" - }, - "buildId": { - "token": "buildId", - "source": "CI build", - "compatibilityRole": "asset and HTML coherence" - }, - "commitSha": { - "token": "commitSha", - "source": "VCS", - "compatibilityRole": "source traceability" - }, - "configSchemaVersion": { - "token": "configSchemaVersion", - "source": "runtime config schema", - "compatibilityRole": "boot compatibility" - }, - "apiContractVersion": { - "token": "apiContractVersion", - "source": "frontend/backend agreement", - "compatibilityRole": "schema compatibility" - }, - "assetManifestHash": { - "token": "assetManifestHash", - "source": "build output", - "compatibilityRole": "chunk integrity" - }, - "releaseId": { - "token": "releaseId", - "source": "deploy system", - "compatibilityRole": "rollback target" - }, - "builtAt": { - "token": "builtAt", - "source": "CI", - "compatibilityRole": "diagnostics only" - } - } + "requiredFields": ["token", "source", "compatibilityRole"] } ], "compatibilityImpact": { diff --git a/config/hosting/cache-policy.json b/config/hosting/cache-policy.json new file mode 100644 index 0000000..4730f86 --- /dev/null +++ b/config/hosting/cache-policy.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "surfaces": { + "index": { + "path": "/", + "cacheControl": "no-cache", + "securityHeaders": true + }, + "runtimeConfig": { + "path": "/config.json", + "cacheControl": "no-store", + "securityHeaders": true + }, + "releaseManifest": { + "path": "/release-manifest.json", + "cacheControl": "no-store", + "securityHeaders": true + }, + "hashedAsset": { + "pathPattern": "/assets/*", + "cacheControl": "public, max-age=31536000, immutable", + "securityHeaders": false + }, + "sourceMap": { + "public": false + }, + "serviceWorker": { + "enabled": false + } + } +} diff --git a/config/hosting/response-headers.fixture.json b/config/hosting/response-headers.fixture.json new file mode 100644 index 0000000..a7add6e --- /dev/null +++ b/config/hosting/response-headers.fixture.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "responses": { + "index": { + "cache-control": "no-cache", + "content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests", + "strict-transport-security": "max-age=31536000; includeSubDomains", + "x-frame-options": "DENY", + "referrer-policy": "strict-origin-when-cross-origin", + "x-content-type-options": "nosniff", + "permissions-policy": "camera=(), microphone=(), geolocation=()" + }, + "runtimeConfig": { + "cache-control": "no-store", + "content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests", + "strict-transport-security": "max-age=31536000; includeSubDomains", + "x-frame-options": "DENY", + "referrer-policy": "strict-origin-when-cross-origin", + "x-content-type-options": "nosniff", + "permissions-policy": "camera=(), microphone=(), geolocation=()" + }, + "releaseManifest": { + "cache-control": "no-store", + "content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests", + "strict-transport-security": "max-age=31536000; includeSubDomains", + "x-frame-options": "DENY", + "referrer-policy": "strict-origin-when-cross-origin", + "x-content-type-options": "nosniff", + "permissions-policy": "camera=(), microphone=(), geolocation=()" + }, + "hashedAsset": { + "cache-control": "public, max-age=31536000, immutable" + } + } +} diff --git a/config/release/coherence-fixtures.json b/config/release/coherence-fixtures.json new file mode 100644 index 0000000..c479d22 --- /dev/null +++ b/config/release/coherence-fixtures.json @@ -0,0 +1,77 @@ +{ + "schemaVersion": 1, + "fixtures": [ + { + "name": "coherent-release", + "expectedCompatible": true, + "frontend": { + "buildId": "build-a", + "configSchemaVersion": "1.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + }, + "runtime": { + "buildId": "build-a", + "configSchemaVersion": "1.1", + "apiContractVersion": "1.2", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + } + }, + { + "name": "mixed-html-and-assets", + "expectedCompatible": false, + "frontend": { + "buildId": "build-a", + "configSchemaVersion": "1.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + }, + "runtime": { + "buildId": "build-b", + "configSchemaVersion": "1.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-b", + "releaseId": "release-b" + } + }, + { + "name": "incompatible-runtime-config", + "expectedCompatible": false, + "frontend": { + "buildId": "build-a", + "configSchemaVersion": "1.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + }, + "runtime": { + "buildId": "build-a", + "configSchemaVersion": "2.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + } + }, + { + "name": "incompatible-api-contract", + "expectedCompatible": false, + "frontend": { + "buildId": "build-a", + "configSchemaVersion": "1.0", + "apiContractVersion": "1.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + }, + "runtime": { + "buildId": "build-a", + "configSchemaVersion": "1.0", + "apiContractVersion": "2.0", + "assetManifestHash": "assets-a", + "releaseId": "release-a" + } + } + ] +} diff --git a/config/schemas/release-verification.schema.json b/config/schemas/release-verification.schema.json new file mode 100644 index 0000000..29e5ff7 --- /dev/null +++ b/config/schemas/release-verification.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "ART-FE-003@1", + "type": "object", + "required": ["schemaVersion", "generatedAt", "artifact", "fixtures", "passed"], + "properties": { + "schemaVersion": { "const": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "artifact": { + "type": "object", + "required": ["checked", "compatible", "mismatches"] + }, + "fixtures": { "type": "array", "minItems": 2 }, + "passed": { "type": "boolean" } + }, + "additionalProperties": false +} diff --git a/docs/operations/release-cache-rollback.md b/docs/operations/release-cache-rollback.md new file mode 100644 index 0000000..15ca32a --- /dev/null +++ b/docs/operations/release-cache-rollback.md @@ -0,0 +1,25 @@ +# Release, cache, and rollback contract + +Each deployment is an immutable `releases//` artifact set. The +provider adapter must upload assets, release manifest, runtime config, and +verify asset reachability before atomically switching the active HTML pointer. +The post-switch boot, route, API, telemetry, and reload-loop smoke checks close +the deployment. + +Rollback selects a prior release tuple, confirms its assets and runtime/API +compatibility, atomically switches the complete set, performs the provider +cache action, and repeats the smoke checks. Rebuilding an old commit, replacing +HTML alone, or declaring recovery from cache-purge completion is prohibited. +Recovery is established by old/new reachability probes. + +The provider-independent cache defaults are: + +- hashed assets: `public, max-age=31536000, immutable` +- HTML: `no-cache` +- runtime config and release manifest: `no-store` +- public source maps: disabled +- service worker/offline cache: disabled + +`corepack pnpm verify:hosting-headers` uses a deterministic fixture locally. +Set `HOSTING_BASE_URL` to probe deployed responses; production promotion +requires the artifact to report `mode: "live"`. diff --git a/package.json b/package.json index f02955a..f5d6b94 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "scan:security": "node scripts/security-scan.mjs", "check:browser-security": "node scripts/check-browser-security.mjs", "check:registries": "node scripts/check-registries.mjs", - "verify:compatibility": "node scripts/check-compatibility.mjs" + "verify:compatibility": "node scripts/check-compatibility.mjs", + "verify:release": "node scripts/verify-release.mjs", + "verify:hosting-headers": "node scripts/verify-hosting-headers.mjs" }, "dependencies": { "@tanstack/react-query": "5.101.4", diff --git a/public/release-manifest.json b/public/release-manifest.json new file mode 100644 index 0000000..f316fcd --- /dev/null +++ b/public/release-manifest.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "appVersion": "0.1.0", + "buildId": "local-build", + "commitSha": "local", + "configSchemaVersion": "1", + "apiContractVersion": "1", + "assetManifestHash": "generated-during-build", + "releaseId": "local-release", + "builtAt": "1970-01-01T00:00:00.000Z" +} diff --git a/scripts/generate-build-manifest.mjs b/scripts/generate-build-manifest.mjs index 7f9a710..1f3df8f 100644 --- a/scripts/generate-build-manifest.mjs +++ b/scripts/generate-build-manifest.mjs @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import process from "node:process"; @@ -5,13 +6,23 @@ const packageJson = JSON.parse(await readFile("package.json", "utf8")); const packageManagerVersion = packageJson.packageManager.split("@").at(-1); const buildId = process.env.VITE_BUILD_ID ?? "local-build"; const commitSha = process.env.VITE_COMMIT_SHA ?? "local"; +const releaseId = process.env.RELEASE_ID ?? "local-release"; const runnerImage = process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`; +const builtAt = new Date().toISOString(); +const viteManifest = await readFile("dist/.vite/manifest.json"); +const assetManifestHash = createHash("sha256") + .update(viteManifest) + .digest("hex"); +const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8")); + +runtimeConfig.BUILD_ID = buildId; +runtimeConfig.RELEASE_ID = releaseId; const manifest = { schemaVersion: 1, buildId, commitSha, - generatedAt: new Date().toISOString(), + generatedAt: builtAt, buildContext: { nodeVersion: process.version, packageManagerVersion, @@ -23,7 +34,24 @@ const manifest = { }, }; +const releaseManifest = { + schemaVersion: 1, + appVersion: packageJson.version, + buildId, + commitSha, + configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION, + apiContractVersion: runtimeConfig.API_CONTRACT_VERSION, + assetManifestHash, + releaseId, + builtAt, +}; + await mkdir("artifacts/release", { recursive: true }); +await writeFile("dist/config.json", `${JSON.stringify(runtimeConfig, null, 2)}\n`); +await writeFile( + "dist/release-manifest.json", + `${JSON.stringify(releaseManifest, null, 2)}\n`, +); await writeFile( "artifacts/release/build-manifest.json", `${JSON.stringify(manifest, null, 2)}\n`, diff --git a/scripts/verify-hosting-headers.mjs b/scripts/verify-hosting-headers.mjs new file mode 100644 index 0000000..ee1fc15 --- /dev/null +++ b/scripts/verify-hosting-headers.mjs @@ -0,0 +1,110 @@ +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; + +const cachePolicy = JSON.parse( + await readFile("config/hosting/cache-policy.json", "utf8"), +); +const securityPolicy = JSON.parse( + await readFile("config/hosting/security-headers.json", "utf8"), +); +const baseUrl = process.env.HOSTING_BASE_URL; + +/** @type {Record>} */ +let responses; +let mode; + +if (baseUrl) { + mode = "live"; + const assets = await readdir("dist/assets"); + const hashedAsset = assets.find((file) => !file.endsWith(".map")); + if (!hashedAsset) throw new Error("No built hashed asset found."); + const paths = { + index: "/", + runtimeConfig: "/config.json", + releaseManifest: "/release-manifest.json", + hashedAsset: `/assets/${hashedAsset}`, + }; + responses = {}; + for (const [surface, pathname] of Object.entries(paths)) { + const response = await fetch(new URL(pathname, baseUrl)); + responses[surface] = Object.fromEntries( + [...response.headers.entries()].map(([name, value]) => [ + name.toLowerCase(), + value, + ]), + ); + } +} else { + mode = "fixture"; + responses = JSON.parse( + await readFile("config/hosting/response-headers.fixture.json", "utf8"), + ).responses; +} + +const results = []; +for (const [surface, policy] of Object.entries(cachePolicy.surfaces)) { + if (!("cacheControl" in policy)) continue; + const observed = responses[surface]?.["cache-control"]; + results.push({ + surface, + header: "cache-control", + expected: policy.cacheControl, + observed, + passed: observed === policy.cacheControl, + }); + if (policy.securityHeaders) { + for (const [header, expected] of Object.entries(securityPolicy.headers)) { + const observedSecurity = responses[surface]?.[header.toLowerCase()]; + results.push({ + surface, + header: header.toLowerCase(), + expected, + observed: observedSecurity, + passed: observedSecurity === expected, + }); + } + } +} + +results.push({ + surface: "sourceMap", + header: "public", + expected: false, + observed: cachePolicy.surfaces.sourceMap.public, + passed: cachePolicy.surfaces.sourceMap.public === false, +}); +results.push({ + surface: "serviceWorker", + header: "enabled", + expected: false, + observed: cachePolicy.surfaces.serviceWorker.enabled, + passed: cachePolicy.surfaces.serviceWorker.enabled === false, +}); + +const passed = results.every((result) => result.passed); +await mkdir("artifacts/release", { recursive: true }); +await writeFile( + "artifacts/release/hosting-headers.json", + `${JSON.stringify( + { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + mode, + baseUrl: baseUrl ?? null, + providerVerificationRequired: mode !== "live", + results, + passed, + }, + null, + 2, + )}\n`, +); + +if (!passed) { + process.stderr.write("Hosting cache/security header verification failed.\n"); + process.exit(1); +} +process.stdout.write( + `Hosting header contract: PASS (${mode}; live verification ${ + mode === "live" ? "complete" : "required before promotion" + })\n`, +); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..6ee9cf1 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,87 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js"; +import { compareReleaseToRuntime } from "../src/contracts/release-tokens.js"; + +const fixturesDocument = + /** @type {{ + * fixtures: Array<{ + * name: string, + * expectedCompatible: boolean, + * frontend: { + * buildId: string, + * configSchemaVersion: string, + * apiContractVersion: string, + * assetManifestHash: string, + * releaseId: string + * }, + * runtime: { + * buildId: string, + * configSchemaVersion: string, + * apiContractVersion: string, + * assetManifestHash: string, + * releaseId: string + * } + * }> + * }} */ ( + JSON.parse( + await readFile("config/release/coherence-fixtures.json", "utf8"), + ) + ); +const release = JSON.parse(await readFile("dist/release-manifest.json", "utf8")); +const runtimeConfig = JSON.parse(await readFile("dist/config.json", "utf8")); +const viteManifest = await readFile("dist/.vite/manifest.json"); +const actualAssetManifestHash = createHash("sha256") + .update(viteManifest) + .digest("hex"); + +const artifactComparison = compareReleaseToRuntime(release, runtimeConfig); +const artifactMismatches = [...artifactComparison.mismatches]; +if (release.assetManifestHash !== actualAssetManifestHash) { + artifactMismatches.push("assetManifestContent"); +} + +const fixtures = fixturesDocument.fixtures.map((fixture) => { + const result = verifyCompatibilityTuple({ + frontend: fixture.frontend, + runtime: fixture.runtime, + }); + return { + name: fixture.name, + expectedCompatible: fixture.expectedCompatible, + actualCompatible: result.compatible, + mismatches: result.mismatches, + passed: result.compatible === fixture.expectedCompatible, + }; +}); +const artifact = { + checked: true, + compatible: artifactComparison.compatible && artifactMismatches.length === 0, + mismatches: artifactMismatches, + releaseId: release.releaseId, +}; +const passed = artifact.compatible && fixtures.every((fixture) => fixture.passed); +const report = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + artifact, + fixtures, + passed, +}; + +await mkdir("artifacts/release", { recursive: true }); +await writeFile( + "artifacts/release/verification.json", + `${JSON.stringify(report, null, 2)}\n`, +); + +if (!passed) { + process.stderr.write( + `Release coherence failed: ${artifactMismatches.join(", ") || "fixture"}\n`, + ); + process.exit(1); +} +process.stdout.write( + `Release coherence: PASS (${fixtures.length - 1} mixed fixtures rejected)\n`, +); diff --git a/src/contracts/release-tokens.js b/src/contracts/release-tokens.js new file mode 100644 index 0000000..8a20817 --- /dev/null +++ b/src/contracts/release-tokens.js @@ -0,0 +1,65 @@ +import { verifyCompatibilityTuple } from "../application/policies/compatibility.js"; + +export const RELEASE_TOKEN_REGISTRY = Object.freeze({ + appVersion: token("appVersion", "manifest", "human release label"), + buildId: token("buildId", "CI build", "asset and HTML coherence"), + commitSha: token("commitSha", "VCS", "source traceability"), + configSchemaVersion: token( + "configSchemaVersion", + "runtime config schema", + "boot compatibility", + ), + apiContractVersion: token( + "apiContractVersion", + "frontend/backend agreement", + "schema compatibility", + ), + assetManifestHash: token( + "assetManifestHash", + "build output", + "chunk integrity and mismatch detection", + ), + releaseId: token("releaseId", "deploy system", "rollback target"), + builtAt: token("builtAt", "CI", "diagnostics only; never cache identity"), +}); + +/** + * @param {string} name + * @param {string} source + * @param {string} compatibilityRole + */ +function token(name, source, compatibilityRole) { + return Object.freeze({ token: name, source, compatibilityRole }); +} + +/** + * Compare a release manifest and runtime configuration structurally. Version + * fields are delegated to the numeric compatibility policy, never compared + * lexically. + * + * @param {{ + * buildId: string, + * configSchemaVersion: string, + * apiContractVersion: string, + * assetManifestHash: string, + * releaseId: string + * }} release + * @param {{ + * BUILD_ID: string, + * CONFIG_SCHEMA_VERSION: string, + * API_CONTRACT_VERSION: string, + * RELEASE_ID: string + * }} runtimeConfig + */ +export function compareReleaseToRuntime(release, runtimeConfig) { + return verifyCompatibilityTuple({ + frontend: release, + runtime: { + buildId: runtimeConfig.BUILD_ID, + configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION, + apiContractVersion: runtimeConfig.API_CONTRACT_VERSION, + assetManifestHash: release.assetManifestHash, + releaseId: runtimeConfig.RELEASE_ID, + }, + }); +} diff --git a/tests/unit/release-coherence.test.js b/tests/unit/release-coherence.test.js new file mode 100644 index 0000000..c8ccd83 --- /dev/null +++ b/tests/unit/release-coherence.test.js @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + compareReleaseToRuntime, + RELEASE_TOKEN_REGISTRY, +} from "../../src/contracts/release-tokens.js"; + +describe("release coherence", () => { + it("owns all eight release tokens and keeps builtAt diagnostic-only", () => { + expect(Object.keys(RELEASE_TOKEN_REGISTRY)).toHaveLength(8); + expect(RELEASE_TOKEN_REGISTRY.builtAt.compatibilityRole).toContain( + "never cache identity", + ); + }); + + it("compares the runtime config to the release structurally", () => { + const release = { + buildId: "build-a", + configSchemaVersion: "1.0", + apiContractVersion: "1.0", + assetManifestHash: "assets-a", + releaseId: "release-a", + }; + expect( + compareReleaseToRuntime(release, { + BUILD_ID: "build-a", + CONFIG_SCHEMA_VERSION: "1.2", + API_CONTRACT_VERSION: "1.1", + RELEASE_ID: "release-a", + }), + ).toMatchObject({ compatible: true, mismatches: [] }); + }); + + it("rejects HTML-only rollback against a newer runtime config", () => { + const oldRelease = { + buildId: "build-old", + configSchemaVersion: "1.0", + apiContractVersion: "1.0", + assetManifestHash: "assets-old", + releaseId: "release-old", + }; + expect( + compareReleaseToRuntime(oldRelease, { + BUILD_ID: "build-new", + CONFIG_SCHEMA_VERSION: "2.0", + API_CONTRACT_VERSION: "2.0", + RELEASE_ID: "release-new", + }), + ).toMatchObject({ + compatible: false, + mismatches: ["buildId", "configSchemaVersion", "apiContractVersion"], + }); + }); +});