feat: enforce coherent release and rollback contract
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Release, cache, and rollback contract
|
||||
|
||||
Each deployment is an immutable `releases/<releaseId>/` 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"`.
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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<string, Record<string, string>>} */
|
||||
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`,
|
||||
);
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user