163 lines
5.2 KiB
JavaScript
163 lines
5.2 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
|
import { verifyCompatibilityTuple } from "../src/application/policies/compatibility.js";
|
|
import {
|
|
compareReleaseToRuntime,
|
|
RELEASE_TOKEN_REGISTRY,
|
|
} from "../src/contracts/release-tokens.js";
|
|
import { ROUTE_RUNTIME_CONTRACT } from "../src/contracts/route-runtime-contract.js";
|
|
import { ROUTE_REGISTRY } from "../src/contracts/routes.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 buildManifest = JSON.parse(
|
|
await readFile("artifacts/release/build-manifest.json", "utf8"),
|
|
);
|
|
const runtimeConfigJsonSchema = JSON.parse(
|
|
await readFile("dist/runtime-config.schema.json", "utf8"),
|
|
);
|
|
const viteManifest = await readFile("dist/.vite/manifest.json", "utf8");
|
|
const viteManifestObject =
|
|
/** @type {Record<string, {file: string, name?: string, isDynamicEntry?: boolean}>} */ (
|
|
JSON.parse(viteManifest)
|
|
);
|
|
const actualAssetManifestHash = createHash("sha256")
|
|
.update(viteManifest)
|
|
.digest("hex");
|
|
|
|
const artifactComparison = compareReleaseToRuntime(release, runtimeConfig);
|
|
const artifactMismatches = [...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 (!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");
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
for (const definition of Object.values(ROUTE_REGISTRY)) {
|
|
const runtime =
|
|
/** @type {Record<string, {moduleId: string}>} */ (
|
|
ROUTE_RUNTIME_CONTRACT
|
|
)[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`,
|
|
);
|