321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
|
import {
|
|
verifyCompatibilityTuple,
|
|
type CompatibilityTuple,
|
|
} from "../src/application/policies/compatibility.ts";
|
|
import {
|
|
compareReleaseToRuntime,
|
|
RELEASE_TOKEN_REGISTRY,
|
|
} from "../src/contracts/release-tokens.ts";
|
|
import {
|
|
ROUTE_REGISTRY,
|
|
ROUTE_RUNTIME_CONTRACT,
|
|
} from "../src/features/installed-feature-contracts.ts";
|
|
|
|
type CoherenceFixture = Readonly<{
|
|
name: string;
|
|
expectedCompatible: boolean;
|
|
frontend: CompatibilityTuple;
|
|
runtime: CompatibilityTuple;
|
|
}>;
|
|
type ReleaseDocument = CompatibilityTuple &
|
|
Record<string, unknown> &
|
|
Readonly<{ releaseId: string; routeChunks: Readonly<Record<string, unknown>> }>;
|
|
type RuntimeConfigDocument = Readonly<{
|
|
BUILD_ID: string;
|
|
CONFIG_SCHEMA_VERSION: string;
|
|
API_CONTRACT_VERSION: string;
|
|
RELEASE_ID: string;
|
|
}>;
|
|
type BuildManifestDocument = Readonly<
|
|
Record<string, unknown> & {
|
|
outputs?: Readonly<{
|
|
runtimeConfigSchema?: unknown;
|
|
routeChunks?: Readonly<Record<string, unknown>>;
|
|
}>;
|
|
}
|
|
>;
|
|
type ViteManifestEntry = Readonly<{
|
|
file: string;
|
|
name?: string;
|
|
isDynamicEntry?: boolean;
|
|
}>;
|
|
|
|
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 buildManifest = parseBuildManifestDocument(
|
|
JSON.parse(await readFile("artifacts/release/build-manifest.json", "utf8")),
|
|
);
|
|
const runtimeConfigJsonSchema = requireRecord(
|
|
JSON.parse(await readFile("dist/runtime-config.schema.json", "utf8")),
|
|
"runtime config JSON schema",
|
|
);
|
|
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
|
|
const viteManifestObject = parseViteManifest(JSON.parse(viteManifest));
|
|
const actualAssetManifestHash = createHash("sha256")
|
|
.update(viteManifest)
|
|
.digest("hex");
|
|
|
|
const artifactComparison = compareReleaseToRuntime(release, runtimeConfig);
|
|
const artifactMismatches: string[] = [...artifactComparison.mismatches];
|
|
for (const token of Object.keys(RELEASE_TOKEN_REGISTRY)) {
|
|
if (typeof release[token] !== "string" || release[token].length === 0) {
|
|
artifactMismatches.push(`releaseToken:${token}`);
|
|
}
|
|
}
|
|
if (
|
|
typeof release.builtAt !== "string" ||
|
|
!Number.isFinite(Date.parse(release.builtAt))
|
|
) {
|
|
artifactMismatches.push("releaseToken:builtAtFormat");
|
|
}
|
|
if (release.assetManifestHash !== actualAssetManifestHash) {
|
|
artifactMismatches.push("assetManifestContent");
|
|
}
|
|
if (
|
|
runtimeConfigJsonSchema.$schema !== "https://json-schema.org/draft/2020-12/schema" ||
|
|
runtimeConfigJsonSchema.type !== "object" ||
|
|
!runtimeConfigJsonSchema.properties
|
|
) {
|
|
artifactMismatches.push("runtimeConfigSchema");
|
|
}
|
|
if (
|
|
buildManifest.outputs?.runtimeConfigSchema !==
|
|
"dist/runtime-config.schema.json"
|
|
) {
|
|
artifactMismatches.push("buildManifest:runtimeConfigSchema");
|
|
}
|
|
for (const [buildToken, releaseToken] of [
|
|
["buildId", "buildId"],
|
|
["commitSha", "commitSha"],
|
|
["releaseId", "releaseId"],
|
|
["generatedAt", "builtAt"],
|
|
]) {
|
|
if (buildManifest[buildToken] !== release[releaseToken]) {
|
|
artifactMismatches.push(`buildManifest:${buildToken}`);
|
|
}
|
|
}
|
|
|
|
const expectedChunkIds = new Set(
|
|
Object.values(ROUTE_REGISTRY).map((definition) => definition.chunkId),
|
|
);
|
|
const actualChunkIds = new Set(Object.keys(release.routeChunks));
|
|
for (const chunkId of expectedChunkIds) {
|
|
if (!actualChunkIds.has(chunkId)) {
|
|
artifactMismatches.push(`routeChunk:missing:${chunkId}`);
|
|
}
|
|
}
|
|
for (const chunkId of actualChunkIds) {
|
|
if (!expectedChunkIds.has(chunkId)) {
|
|
artifactMismatches.push(`routeChunk:orphan:${chunkId}`);
|
|
}
|
|
}
|
|
const runtimeContracts: Readonly<Record<string, { moduleId: string }>> =
|
|
ROUTE_RUNTIME_CONTRACT;
|
|
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
|
const runtime = runtimeContracts[definition.routeId];
|
|
const viteEntry = Object.values(viteManifestObject).find(
|
|
(entry) => entry.name === runtime?.moduleId && entry.isDynamicEntry,
|
|
);
|
|
const routeAsset = release.routeChunks[definition.chunkId];
|
|
if (!runtime || !viteEntry || routeAsset !== viteEntry.file) {
|
|
artifactMismatches.push(`routeChunk:mismatch:${definition.chunkId}`);
|
|
continue;
|
|
}
|
|
if (
|
|
buildManifest.outputs?.routeChunks?.[definition.chunkId] !== routeAsset
|
|
) {
|
|
artifactMismatches.push(`buildManifest:routeChunk:${definition.chunkId}`);
|
|
}
|
|
try {
|
|
await readFile(`dist/${routeAsset}`);
|
|
} catch {
|
|
artifactMismatches.push(`routeChunk:file:${definition.chunkId}`);
|
|
}
|
|
}
|
|
|
|
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`,
|
|
);
|
|
|
|
function parseFixturesDocument(value: unknown): Readonly<{
|
|
fixtures: readonly CoherenceFixture[];
|
|
}> {
|
|
const document = requireRecord(value, "release coherence fixtures");
|
|
if (!Array.isArray(document.fixtures)) {
|
|
throw new TypeError("release coherence fixtures must be an array");
|
|
}
|
|
return {
|
|
fixtures: document.fixtures.map((candidate, index) => {
|
|
const fixture = requireRecord(candidate, `release fixture ${index}`);
|
|
if (
|
|
typeof fixture.name !== "string" ||
|
|
typeof fixture.expectedCompatible !== "boolean"
|
|
) {
|
|
throw new TypeError(`Invalid release fixture metadata: ${index}`);
|
|
}
|
|
return {
|
|
name: fixture.name,
|
|
expectedCompatible: fixture.expectedCompatible,
|
|
frontend: parseCompatibilityTuple(
|
|
fixture.frontend,
|
|
`release fixture ${index}.frontend`,
|
|
),
|
|
runtime: parseCompatibilityTuple(
|
|
fixture.runtime,
|
|
`release fixture ${index}.runtime`,
|
|
),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
function parseReleaseDocument(value: unknown): ReleaseDocument {
|
|
const document = requireRecord(value, "release manifest");
|
|
const tuple = parseCompatibilityTuple(document, "release manifest");
|
|
const routeChunks = isRecord(document.routeChunks)
|
|
? document.routeChunks
|
|
: {};
|
|
return { ...document, ...tuple, routeChunks };
|
|
}
|
|
|
|
function parseRuntimeConfigDocument(value: unknown): RuntimeConfigDocument {
|
|
const document = requireRecord(value, "runtime config");
|
|
return {
|
|
BUILD_ID: requireString(document.BUILD_ID, "runtime config BUILD_ID"),
|
|
CONFIG_SCHEMA_VERSION: requireString(
|
|
document.CONFIG_SCHEMA_VERSION,
|
|
"runtime config CONFIG_SCHEMA_VERSION",
|
|
),
|
|
API_CONTRACT_VERSION: requireString(
|
|
document.API_CONTRACT_VERSION,
|
|
"runtime config API_CONTRACT_VERSION",
|
|
),
|
|
RELEASE_ID: requireString(
|
|
document.RELEASE_ID,
|
|
"runtime config RELEASE_ID",
|
|
),
|
|
};
|
|
}
|
|
|
|
function parseBuildManifestDocument(value: unknown): BuildManifestDocument {
|
|
const document = requireRecord(value, "build manifest");
|
|
const outputs = isRecord(document.outputs)
|
|
? {
|
|
runtimeConfigSchema: document.outputs.runtimeConfigSchema,
|
|
routeChunks: isRecord(document.outputs.routeChunks)
|
|
? document.outputs.routeChunks
|
|
: undefined,
|
|
}
|
|
: undefined;
|
|
return { ...document, ...(outputs ? { outputs } : {}) };
|
|
}
|
|
|
|
function parseCompatibilityTuple(value: unknown, label: string): CompatibilityTuple {
|
|
const document = requireRecord(value, label);
|
|
return {
|
|
buildId: requireString(document.buildId, `${label}.buildId`),
|
|
configSchemaVersion: requireString(
|
|
document.configSchemaVersion,
|
|
`${label}.configSchemaVersion`,
|
|
),
|
|
apiContractVersion: requireString(
|
|
document.apiContractVersion,
|
|
`${label}.apiContractVersion`,
|
|
),
|
|
assetManifestHash: requireString(
|
|
document.assetManifestHash,
|
|
`${label}.assetManifestHash`,
|
|
),
|
|
releaseId: requireString(document.releaseId, `${label}.releaseId`),
|
|
};
|
|
}
|
|
|
|
function parseViteManifest(
|
|
value: unknown,
|
|
): Readonly<Record<string, ViteManifestEntry>> {
|
|
const document = requireRecord(value, "Vite manifest");
|
|
const entries: Record<string, ViteManifestEntry> = {};
|
|
for (const [key, candidate] of Object.entries(document)) {
|
|
const entry = requireRecord(candidate, `Vite manifest entry ${key}`);
|
|
entries[key] = {
|
|
file: requireString(entry.file, `Vite manifest entry ${key}.file`),
|
|
...(typeof entry.name === "string" ? { name: entry.name } : {}),
|
|
...(typeof entry.isDynamicEntry === "boolean"
|
|
? { isDynamicEntry: entry.isDynamicEntry }
|
|
: {}),
|
|
};
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function requireString(value: unknown, label: string): string {
|
|
if (typeof value !== "string" || value.length === 0) {
|
|
throw new TypeError(`${label} must be a non-empty string`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function requireRecord(
|
|
value: unknown,
|
|
label: string,
|
|
): Record<string, unknown> {
|
|
if (!isRecord(value)) throw new TypeError(`${label} must be an object`);
|
|
return value;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
}
|