Files
tech-log-frontend/scripts/generate-build-manifest.ts
T

190 lines
6.3 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdir, readFile, rm } from "node:fs/promises";
import process from "node:process";
import { z } from "zod";
import {
ROUTE_REGISTRY,
ROUTE_RUNTIME_CONTRACT,
} from "../src/features/installed-feature-contracts.ts";
import {
buildManifestArtifactSchema,
jsonSchemaDocumentArtifactSchema,
moduleInventoryArtifactSchema,
releaseManifestV2ArtifactSchema,
runtimeConfigV2ArtifactSchema,
} from "./contracts/release-artifacts.ts";
import { buildContractSet } from "./generate-contract-set.ts";
import {
assertCiBuildEnvironment,
buildDate,
} from "./lib/build-environment.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { CANONICAL_VITE_MANIFEST_PATH } from "./lib/build-manifest-outputs.ts";
import {
findViteDynamicRouteChunk,
type ViteManifestRouteEntry,
} from "./lib/vite-route-chunks.ts";
assertCiBuildEnvironment(process.env);
const packageJson = parsePackageMetadata(
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 buildTime = buildDate(process.env);
const builtAt = buildTime.toISOString();
const viteManifest = await readFile(CANONICAL_VITE_MANIFEST_PATH, "utf8");
const viteManifestObject = parseViteManifest(JSON.parse(viteManifest));
const moduleInventory = await readFile(
"dist/.vite/module-inventory.json",
"utf8",
);
const moduleInventoryDocument = moduleInventoryArtifactSchema.parse(
JSON.parse(moduleInventory),
);
const moduleInventoryBytes = `${JSON.stringify(moduleInventoryDocument, null, 2)}\n`;
const assetManifestHash = createHash("sha256")
.update(viteManifest)
.digest("hex");
const moduleInventoryHash = createHash("sha256")
.update(moduleInventoryBytes)
.digest("hex");
const runtimeConfig = runtimeConfigV2ArtifactSchema.parse(
JSON.parse(await readFile("dist/config.json", "utf8")),
);
const routeChunks: Record<string, string> = {};
const runtimeContracts: Readonly<Record<string, { moduleId: string }>> =
ROUTE_RUNTIME_CONTRACT;
for (const definition of Object.values(ROUTE_REGISTRY)) {
const runtime = runtimeContracts[definition.routeId];
const asset = runtime
? findViteDynamicRouteChunk(viteManifestObject, runtime.moduleId)
: undefined;
if (!runtime || !asset?.file) {
throw new Error(`Missing built route chunk: ${definition.routeId}`);
}
routeChunks[definition.chunkId] = asset.file;
}
const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigV2ArtifactSchema);
runtimeConfig.BUILD_ID = buildId;
runtimeConfig.RELEASE_ID = releaseId;
const manifest = buildManifestArtifactSchema.parse({
schemaVersion: 1,
buildId,
commitSha,
releaseId,
moduleInventoryHash,
generatedAt: builtAt,
buildContext: {
nodeVersion: process.version,
packageManagerVersion,
runnerImage,
sourceDateEpoch: process.env.SOURCE_DATE_EPOCH ?? null,
},
outputs: {
directory: "dist",
viteManifest: CANONICAL_VITE_MANIFEST_PATH,
moduleInventory: "artifacts/quality/vite-module-inventory.json",
routeChunks,
runtimeConfigSchema: "dist/runtime-config.schema.json",
},
});
// §5.2 / §24.6 RC-3. The writer emits V2 only; the scalar API contract
// version has no writer source left.
const releaseManifest = releaseManifestV2ArtifactSchema.parse({
schemaVersion: 2,
appVersion: packageJson.version,
buildId,
commitSha,
configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION,
assetManifestHash,
releaseId,
builtAt,
routeChunks,
contractSet: buildContractSet(),
});
await mkdir("artifacts/release", { recursive: true });
await mkdir("artifacts/quality", { recursive: true });
await writeValidatedJsonArtifact({
path: "artifacts/quality/vite-module-inventory.json",
schema: moduleInventoryArtifactSchema,
value: moduleInventoryDocument,
});
await rm("dist/.vite/module-inventory.json");
await writeValidatedJsonArtifact({
path: "dist/config.json",
schema: runtimeConfigV2ArtifactSchema,
value: runtimeConfig,
});
await writeValidatedJsonArtifact({
path: "dist/release-manifest.json",
schema: releaseManifestV2ArtifactSchema,
value: releaseManifest,
});
await writeValidatedJsonArtifact({
path: "dist/runtime-config.schema.json",
schema: jsonSchemaDocumentArtifactSchema,
value: runtimeConfigJsonSchema,
});
await writeValidatedJsonArtifact({
path: "artifacts/release/runtime-config.schema.json",
schema: jsonSchemaDocumentArtifactSchema,
value: runtimeConfigJsonSchema,
});
await writeValidatedJsonArtifact({
path: "artifacts/release/build-manifest.json",
schema: buildManifestArtifactSchema,
value: manifest,
});
function parsePackageMetadata(value: unknown): Readonly<{
version: string;
packageManager: string;
}> {
if (
!isRecord(value) ||
typeof value.version !== "string" ||
typeof value.packageManager !== "string"
) {
throw new TypeError("package.json release metadata is invalid");
}
return { version: value.version, packageManager: value.packageManager };
}
function parseViteManifest(
value: unknown,
): Readonly<Record<string, ViteManifestRouteEntry>> {
if (!isRecord(value)) throw new TypeError("Vite manifest must be an object");
const entries: Record<string, ViteManifestRouteEntry> = {};
for (const [key, candidate] of Object.entries(value)) {
if (!isRecord(candidate) || typeof candidate.file !== "string") {
throw new TypeError(`Invalid Vite manifest entry: ${key}`);
}
entries[key] = {
file: candidate.file,
...(typeof candidate.name === "string" ? { name: candidate.name } : {}),
...(typeof candidate.isDynamicEntry === "boolean"
? { isDynamicEntry: candidate.isDynamicEntry }
: {}),
...(Array.isArray(candidate.dynamicImports) &&
candidate.dynamicImports.every((item) => typeof item === "string")
? { dynamicImports: candidate.dynamicImports as string[] }
: {}),
};
}
return entries;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}