refactor: 리펙토링
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { rm } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import { INSTALLED_RUNTIME_CAPABILITIES } from "../src/features/installed-runtime-capabilities.ts";
|
||||
|
||||
/**
|
||||
* §17.2.2. Deterministic two-pass build.
|
||||
*
|
||||
* The Service Worker needs the exact hashed asset list at compile time, so the
|
||||
* order is fixed:
|
||||
*
|
||||
* 1. clean dist and .generated/frontend-runtime
|
||||
* 2. generate contractSet and build-info source
|
||||
* 3. Vite app build (emptyOutDir = true)
|
||||
* 4. scan app dist and generate the static asset source
|
||||
* 5. ACTIVE only: Vite Service Worker build (emptyOutDir = false)
|
||||
* 6. generate Release Manifest V2 and the build manifest
|
||||
*
|
||||
* Steps 4 and 5 are skipped for `REMOVE_REGISTRATION`, `PURGE_OWNED_RESOURCES`
|
||||
* and `null`: those modes never run an active worker build.
|
||||
*/
|
||||
|
||||
const selection = INSTALLED_RUNTIME_CAPABILITIES.serviceWorker;
|
||||
const buildsActiveWorker = selection?.mode === "ACTIVE";
|
||||
|
||||
function run(command: string, args: readonly string[]): void {
|
||||
const result = spawnSync(command, [...args], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(`build step failed: ${command} ${args.join(" ")}\n`);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. clean
|
||||
await rm("dist", { recursive: true, force: true });
|
||||
await rm(".generated/frontend-runtime", { recursive: true, force: true });
|
||||
|
||||
// 2. contract set + build info
|
||||
run("node", ["scripts/generate-contract-set.ts"]);
|
||||
|
||||
// 3. app build
|
||||
run("npx", ["vite", "build"]);
|
||||
|
||||
if (buildsActiveWorker) {
|
||||
// 4. hashed asset inventory
|
||||
run("node", ["scripts/generate-service-worker-assets.ts", "dist"]);
|
||||
// 5. service worker build
|
||||
run("npx", ["vite", "build", "--config", "vite.service-worker.config.ts"]);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`service worker mode ${selection?.mode ?? "null"}: skipping worker build\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. release + build manifest
|
||||
run("node", ["scripts/generate-build-manifest.ts"]);
|
||||
@@ -71,6 +71,45 @@ for (const [gateId, gate] of Object.entries(document.gates)) {
|
||||
if (!gate.steps?.length || !gate.evidence?.length || !gate.retentionClass) {
|
||||
failures.push(`${gateId} lacks command, evidence, or retention wiring`);
|
||||
}
|
||||
for (const [index, step] of (gate.steps ?? []).entries()) {
|
||||
if (!isRecord(step) || (step.expect !== "pass" && step.expect !== "fail")) {
|
||||
failures.push(`${gateId}[${index}] has an invalid step expectation`);
|
||||
continue;
|
||||
}
|
||||
if (step.expect === "pass") {
|
||||
if (
|
||||
step.expectedExitCode !== undefined ||
|
||||
step.expectedDiagnosticId !== undefined
|
||||
) {
|
||||
failures.push(
|
||||
`${gateId}[${index}] passing step declares a negative fixture identity`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
typeof step.expectedExitCode !== "number" ||
|
||||
!Number.isSafeInteger(step.expectedExitCode) ||
|
||||
step.expectedExitCode < 1 ||
|
||||
step.expectedExitCode > 255
|
||||
) {
|
||||
failures.push(`${gateId}[${index}] lacks an exact expected exit code`);
|
||||
}
|
||||
const diagnosticId = step.expectedDiagnosticId;
|
||||
if (
|
||||
typeof diagnosticId !== "string" ||
|
||||
diagnosticId.trim().length === 0 ||
|
||||
diagnosticId.length > 256 ||
|
||||
["\r", "\n", "\0"].some(
|
||||
(character) =>
|
||||
typeof diagnosticId === "string" && diagnosticId.includes(character),
|
||||
)
|
||||
) {
|
||||
failures.push(
|
||||
`${gateId}[${index}] lacks a bounded expected diagnostic identity`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runbookGateEvidence = Object.freeze({
|
||||
@@ -107,7 +146,6 @@ if (
|
||||
const forbiddenWorkflowPatterns = [
|
||||
/continue-on-error\s*:/,
|
||||
/retention-days\s*:/,
|
||||
/timeout-minutes\s*:/,
|
||||
/allow_failure\s*:/,
|
||||
];
|
||||
for (const pattern of forbiddenWorkflowPatterns) {
|
||||
@@ -115,6 +153,13 @@ for (const pattern of forbiddenWorkflowPatterns) {
|
||||
failures.push(`workflow contains forbidden downgrade/unsupported setting ${pattern}`);
|
||||
}
|
||||
}
|
||||
const jobTimeoutCount = workflow.match(/timeout-minutes:\s*45/g)?.length ?? 0;
|
||||
if (jobTimeoutCount !== 5) {
|
||||
failures.push("every CI gate job must declare timeout-minutes: 45");
|
||||
}
|
||||
if (/if-no-files-found:\s*warn/.test(workflow)) {
|
||||
failures.push("CI evidence upload must fail when artifacts are absent");
|
||||
}
|
||||
for (const requiredToken of [
|
||||
"merge_gate:",
|
||||
"release_gate:",
|
||||
@@ -144,6 +189,8 @@ for (const requiredToken of [
|
||||
"SOURCE_DATE_EPOCH",
|
||||
'"--format=%H%n%ct"',
|
||||
"env: gateEnvironment",
|
||||
"classifyGateStepResult",
|
||||
"timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS",
|
||||
]) {
|
||||
if (!gateRunner.includes(requiredToken)) {
|
||||
failures.push(`CI gate runner missing ${requiredToken}`);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
scanProductionBundle,
|
||||
validateRecipeCatalog,
|
||||
} from "./lib/optional-recipes.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
|
||||
type GateViolation = Readonly<{
|
||||
ruleId: string;
|
||||
@@ -46,7 +47,25 @@ const recipes = Array.isArray(catalog.recipes) ? catalog.recipes : [];
|
||||
const packageDocument = JSON.parse(
|
||||
await readFile("package.json", "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const catalogViolations = validateRecipeCatalog(catalog, packageDocument);
|
||||
const catalogSchemaViolations: string[] = [];
|
||||
try {
|
||||
assertMatchesJsonSchema(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
"schemas/config/frontend-capability-recipes.schema.json",
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
catalog,
|
||||
"optional recipe catalog",
|
||||
);
|
||||
} catch {
|
||||
catalogSchemaViolations.push("CATALOG_JSON_SCHEMA_INVALID");
|
||||
}
|
||||
const catalogViolations = [
|
||||
...catalogSchemaViolations,
|
||||
...validateRecipeCatalog(catalog, packageDocument),
|
||||
];
|
||||
const runtimeSourceViolations = (
|
||||
await Promise.all(
|
||||
recipes.flatMap((recipe: unknown) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "./lib/registry-compatibility.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
|
||||
type RegistryRow = Record<string, unknown>;
|
||||
type RegistryRows = Record<string, RegistryRow>;
|
||||
@@ -436,6 +437,22 @@ const report = {
|
||||
failures,
|
||||
registries: snapshots,
|
||||
};
|
||||
if (usesRepositoryBaseline && failures.length === 0) {
|
||||
try {
|
||||
assertMatchesJsonSchema(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
"schemas/artifacts/registry-snapshot.schema.json",
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
report,
|
||||
"registry snapshot",
|
||||
);
|
||||
} catch {
|
||||
failures.push("registry snapshot JSON Schema mismatch");
|
||||
}
|
||||
}
|
||||
await mkdir(path.dirname(artifactPath), { recursive: true });
|
||||
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../src/contracts/release-artifacts.ts";
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
ROUTE_REGISTRY,
|
||||
ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../src/features/installed-feature-contracts.ts";
|
||||
import { runtimeConfigSchema } from "../src/bootstrap/runtime-config-schema.ts";
|
||||
import {
|
||||
buildManifestArtifactSchema,
|
||||
releaseManifestV2ArtifactSchema,
|
||||
runtimeConfigV2ArtifactSchema,
|
||||
} from "../src/contracts/release-artifacts.ts";
|
||||
import { buildContractSet } from "./generate-contract-set.ts";
|
||||
import {
|
||||
assertCiBuildEnvironment,
|
||||
buildDate,
|
||||
@@ -43,7 +48,7 @@ const assetManifestHash = createHash("sha256")
|
||||
const moduleInventoryHash = createHash("sha256")
|
||||
.update(moduleInventory)
|
||||
.digest("hex");
|
||||
const runtimeConfig = runtimeConfigSchema.parse(
|
||||
const runtimeConfig = runtimeConfigV2ArtifactSchema.parse(
|
||||
JSON.parse(await readFile("dist/config.json", "utf8")),
|
||||
);
|
||||
const routeChunks: Record<string, string> = {};
|
||||
@@ -59,12 +64,12 @@ for (const definition of Object.values(ROUTE_REGISTRY)) {
|
||||
}
|
||||
routeChunks[definition.chunkId] = asset.file;
|
||||
}
|
||||
const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigSchema);
|
||||
const runtimeConfigJsonSchema = z.toJSONSchema(runtimeConfigV2ArtifactSchema);
|
||||
|
||||
runtimeConfig.BUILD_ID = buildId;
|
||||
runtimeConfig.RELEASE_ID = releaseId;
|
||||
|
||||
const manifest = {
|
||||
const manifest = buildManifestArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
buildId,
|
||||
commitSha,
|
||||
@@ -84,20 +89,22 @@ const manifest = {
|
||||
routeChunks,
|
||||
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const releaseManifest = {
|
||||
schemaVersion: 1,
|
||||
// §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,
|
||||
apiContractVersion: runtimeConfig.API_CONTRACT_VERSION,
|
||||
assetManifestHash,
|
||||
releaseId,
|
||||
builtAt,
|
||||
routeChunks,
|
||||
};
|
||||
contractSet: buildContractSet(),
|
||||
});
|
||||
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import {
|
||||
CONTRACT_SET_ALGORITHM,
|
||||
computeContractSetDigestWith,
|
||||
type ContractSetPackage,
|
||||
} from "../src/contracts/contract-set-canonical.ts";
|
||||
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts";
|
||||
|
||||
/**
|
||||
* §5.4 / §17.2.2 step 2. Derives the build-time expected contract set from the
|
||||
* installed contributions and emits it as generated source for the manifest
|
||||
* writer. `TEMPLATE_FIXTURE` provenance never reaches this list, so a fixture
|
||||
* cannot influence the release digest.
|
||||
*/
|
||||
|
||||
const OUTPUT = ".generated/frontend-runtime/contract-set.ts";
|
||||
|
||||
function sha256(bytes: Uint8Array): Uint8Array {
|
||||
return new Uint8Array(createHash("sha256").update(bytes).digest());
|
||||
}
|
||||
|
||||
export function buildContractSet(
|
||||
packages: readonly ContractSetPackage[] = EXPECTED_CONTRACT_SET_PACKAGES as readonly ContractSetPackage[],
|
||||
) {
|
||||
return Object.freeze({
|
||||
setAlgorithm: CONTRACT_SET_ALGORITHM,
|
||||
setDigest: computeContractSetDigestWith(packages, sha256),
|
||||
packages: Object.freeze(
|
||||
[...packages]
|
||||
.map((entry) => Object.freeze({ ...entry }))
|
||||
.sort((left, right) => (left.packageId < right.packageId ? -1 : 1)),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const contractSet = buildContractSet();
|
||||
const source = [
|
||||
"// Generated by scripts/generate-contract-set.ts. Do not edit.",
|
||||
"// Regenerated from scratch on every build; never committed.",
|
||||
"",
|
||||
"import type { ContractSet } from \"../../src/contracts/contract-set.ts\";",
|
||||
"",
|
||||
`export const BUILD_CONTRACT_SET: ContractSet = ${JSON.stringify(
|
||||
contractSet,
|
||||
null,
|
||||
2,
|
||||
)} as const;`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
await mkdir(dirname(OUTPUT), { recursive: true });
|
||||
await writeFile(OUTPUT, source, "utf8");
|
||||
process.stdout.write(
|
||||
`contract set: ${contractSet.packages.length} package(s) ${contractSet.setDigest}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1]?.endsWith("generate-contract-set.ts")) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
SERVICE_WORKER_BOUNDS,
|
||||
SERVICE_WORKER_SCRIPT_PATH,
|
||||
type StaticAssetManifestV1,
|
||||
} from "../src/contracts/service-worker.ts";
|
||||
|
||||
/**
|
||||
* §17.2.2 step 4. Scans the completed app `dist` and emits the exact hashed
|
||||
* asset list the Service Worker will verify at install time.
|
||||
*
|
||||
* `service-worker.js` itself and `index.html` are excluded (§17.2.2), as are
|
||||
* the runtime config and release manifest, which are network-only (§18.4).
|
||||
*/
|
||||
|
||||
const OUTPUT = ".generated/frontend-runtime/service-worker-assets.ts";
|
||||
|
||||
const CACHEABLE_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".woff2": "font/woff2",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
});
|
||||
|
||||
const EXCLUDED_FILES: ReadonlySet<string> = new Set([
|
||||
"index.html",
|
||||
SERVICE_WORKER_SCRIPT_PATH,
|
||||
"config.json",
|
||||
"release-manifest.json",
|
||||
"runtime-config.schema.json",
|
||||
]);
|
||||
|
||||
/** Vite emits content-hashed names; only those may be treated as immutable. */
|
||||
const HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/;
|
||||
|
||||
export async function collectStaticAssets(
|
||||
distDirectory: string,
|
||||
buildId: string,
|
||||
releaseId: string,
|
||||
): Promise<StaticAssetManifestV1> {
|
||||
const files = await walk(distDirectory, distDirectory);
|
||||
const assets: StaticAssetManifestV1["assets"][number][] = [];
|
||||
|
||||
for (const relative of files.sort()) {
|
||||
const base = path.basename(relative);
|
||||
if (EXCLUDED_FILES.has(base) || relative.startsWith(".vite/")) continue;
|
||||
const contentType = CACHEABLE_EXTENSIONS[path.extname(base).toLowerCase()];
|
||||
if (!contentType || !HASHED_NAME.test(base)) continue;
|
||||
|
||||
const absolute = path.join(distDirectory, relative);
|
||||
const bytes = await readFile(absolute);
|
||||
if (bytes.byteLength > SERVICE_WORKER_BOUNDS.singleAssetBytes) {
|
||||
throw new Error(`Static asset exceeds its byte bound: ${relative}`);
|
||||
}
|
||||
assets.push({
|
||||
url: `/${relative.split(path.sep).join("/")}`,
|
||||
sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
||||
bytes: bytes.byteLength,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
if (assets.length > SERVICE_WORKER_BOUNDS.assets) {
|
||||
throw new Error("Static asset count exceeds its bound.");
|
||||
}
|
||||
const totalBytes = assets.reduce((sum, asset) => sum + asset.bytes, 0);
|
||||
if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) {
|
||||
throw new Error("Static asset set exceeds its byte bound.");
|
||||
}
|
||||
|
||||
// The set digest is a length-prefixed hash over the sorted asset identities,
|
||||
// so a reordered directory listing cannot change it.
|
||||
const hash = createHash("sha256");
|
||||
hash.update("CA_STATIC_ASSET_SET_V1\0");
|
||||
for (const asset of assets) {
|
||||
hash.update(lengthPrefixed(asset.url));
|
||||
hash.update(lengthPrefixed(asset.sha256));
|
||||
hash.update(lengthPrefixed(String(asset.bytes)));
|
||||
hash.update(lengthPrefixed(asset.contentType));
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
buildId,
|
||||
releaseId,
|
||||
setDigest: `sha256:${hash.digest("hex")}`,
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
function lengthPrefixed(value: string): Buffer {
|
||||
const bytes = Buffer.from(value, "utf8");
|
||||
const prefix = Buffer.alloc(4);
|
||||
prefix.writeUInt32BE(bytes.byteLength, 0);
|
||||
return Buffer.concat([prefix, bytes]);
|
||||
}
|
||||
|
||||
async function walk(root: string, current: string): Promise<string[]> {
|
||||
const entries = await readdir(current, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const absolute = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(root, absolute)));
|
||||
} else if ((await stat(absolute)).isFile()) {
|
||||
files.push(path.relative(root, absolute));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const distDirectory = process.argv[2] ?? "dist";
|
||||
const buildId = process.env.VITE_BUILD_ID ?? "local-build";
|
||||
const releaseId = process.env.RELEASE_ID ?? "local-release";
|
||||
const manifest = await collectStaticAssets(distDirectory, buildId, releaseId);
|
||||
const source = [
|
||||
"// Generated by scripts/generate-service-worker-assets.ts. Do not edit.",
|
||||
"",
|
||||
'import type { StaticAssetManifestV1 } from "../../src/contracts/service-worker.ts";',
|
||||
"",
|
||||
`export const SERVICE_WORKER_ASSETS: StaticAssetManifestV1 = ${JSON.stringify(
|
||||
manifest,
|
||||
null,
|
||||
2,
|
||||
)} as const;`,
|
||||
"",
|
||||
].join("\n");
|
||||
await mkdir(path.dirname(OUTPUT), { recursive: true });
|
||||
await writeFile(OUTPUT, source, "utf8");
|
||||
process.stdout.write(
|
||||
`service worker assets: ${manifest.assets.length} file(s) ${manifest.setDigest}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1]?.endsWith("generate-service-worker-assets.ts")) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export type GateStepExpectation =
|
||||
| Readonly<{ kind: "pass" }>
|
||||
| Readonly<{
|
||||
kind: "fail";
|
||||
expectedExitCode: number;
|
||||
expectedDiagnosticId: string;
|
||||
}>;
|
||||
|
||||
export type GateProcessResult = Readonly<{
|
||||
status: number | null;
|
||||
signal: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error?: Readonly<{ code?: string }>;
|
||||
}>;
|
||||
|
||||
export type GateStepClassification =
|
||||
| Readonly<{
|
||||
kind: "EXPECTED_PASS" | "EXPECTED_FAILURE";
|
||||
expectationMet: true;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "UNEXPECTED_EXIT";
|
||||
expectationMet: false;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "UNEXPECTED_DIAGNOSTIC";
|
||||
expectationMet: false;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "INFRASTRUCTURE_FAILURE";
|
||||
expectationMet: false;
|
||||
detail: string;
|
||||
}>;
|
||||
|
||||
/** A negative fixture passes only with its registered exit and diagnostic. */
|
||||
export function classifyGateStepResult(
|
||||
expectation: GateStepExpectation,
|
||||
result: GateProcessResult,
|
||||
): GateStepClassification {
|
||||
const errorCode = result.error?.code;
|
||||
const spawnFailed = result.error !== undefined;
|
||||
if (spawnFailed || result.signal || result.status === null) {
|
||||
return Object.freeze({
|
||||
kind: "INFRASTRUCTURE_FAILURE" as const,
|
||||
expectationMet: false as const,
|
||||
detail:
|
||||
errorCode ??
|
||||
result.signal ??
|
||||
(spawnFailed ? "SPAWN_ERROR" : "NO_EXIT_STATUS"),
|
||||
});
|
||||
}
|
||||
if (expectation.kind === "pass" && result.status === 0) {
|
||||
return Object.freeze({
|
||||
kind: "EXPECTED_PASS" as const,
|
||||
expectationMet: true as const,
|
||||
});
|
||||
}
|
||||
if (expectation.kind === "fail") {
|
||||
if (result.status !== expectation.expectedExitCode) {
|
||||
return Object.freeze({
|
||||
kind: "UNEXPECTED_EXIT" as const,
|
||||
expectationMet: false as const,
|
||||
});
|
||||
}
|
||||
const diagnosticOutput = `${result.stdout}\n${result.stderr}`;
|
||||
if (!diagnosticOutput.includes(expectation.expectedDiagnosticId)) {
|
||||
return Object.freeze({
|
||||
kind: "UNEXPECTED_DIAGNOSTIC" as const,
|
||||
expectationMet: false as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "EXPECTED_FAILURE" as const,
|
||||
expectationMet: true as const,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "UNEXPECTED_EXIT" as const,
|
||||
expectationMet: false as const,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Compile a checked-in JSON Schema and assert a concrete artifact against it.
|
||||
* Conversion failures are fatal too: an unsupported or malformed schema must
|
||||
* not silently turn a release schema into documentation-only metadata.
|
||||
*/
|
||||
export function assertMatchesJsonSchema(
|
||||
schemaDocument: unknown,
|
||||
value: unknown,
|
||||
label: string,
|
||||
): void {
|
||||
try {
|
||||
const schema = z.fromJSONSchema(schemaDocument as never);
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new TypeError(z.prettifyError(result.error));
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TypeError(`${label} does not satisfy its checked-in JSON Schema.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
InstalledServiceWorkerSelection,
|
||||
ServiceWorkerHandlerId,
|
||||
StaticAssetManifestV1,
|
||||
} from "../../src/contracts/service-worker.ts";
|
||||
|
||||
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
||||
|
||||
export type ServiceWorkerBuildInput = Readonly<{
|
||||
assets: StaticAssetManifestV1;
|
||||
handlers: readonly ServiceWorkerHandlerId[];
|
||||
contractSetDigest: string;
|
||||
releaseManifestUrl: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* ACTIVE worker compilation is a release-integrity boundary. Missing generated
|
||||
* modules, stale identities and placeholder digests are fatal build defects;
|
||||
* they must never be converted into a worker that merely degrades at runtime.
|
||||
*/
|
||||
export function resolveServiceWorkerBuildInput(input: Readonly<{
|
||||
selection: InstalledServiceWorkerSelection | null;
|
||||
assets: unknown;
|
||||
contractSet: unknown;
|
||||
runtimeConfig: unknown;
|
||||
buildId: string;
|
||||
releaseId: string;
|
||||
}>): ServiceWorkerBuildInput {
|
||||
if (input.selection?.mode !== "ACTIVE") {
|
||||
throw new TypeError(
|
||||
"Service Worker build requires an ACTIVE static selection.",
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(input.selection.handlers)) {
|
||||
throw new TypeError("Service Worker handlers must be an array.");
|
||||
}
|
||||
const handlers = new Set<ServiceWorkerHandlerId>();
|
||||
for (const handler of input.selection.handlers) {
|
||||
if (
|
||||
handler !== "PWA_STATIC_ASSETS" &&
|
||||
handler !== "OFFLINE_SYNC_WAKEUP" &&
|
||||
handler !== "WEB_PUSH"
|
||||
) {
|
||||
throw new TypeError(`Unknown Service Worker handler: ${String(handler)}.`);
|
||||
}
|
||||
if (handlers.has(handler)) {
|
||||
throw new TypeError(`Duplicate Service Worker handler: ${handler}.`);
|
||||
}
|
||||
handlers.add(handler);
|
||||
}
|
||||
if (handlers.has("WEB_PUSH")) {
|
||||
throw new TypeError(
|
||||
"WEB_PUSH requires an installed product-owned worker contribution.",
|
||||
);
|
||||
}
|
||||
const assets = parseAssets(input.assets);
|
||||
if (assets.buildId !== input.buildId || assets.releaseId !== input.releaseId) {
|
||||
throw new TypeError("Generated Service Worker asset identity is stale.");
|
||||
}
|
||||
const contractSet = record(input.contractSet);
|
||||
const contractSetDigest = contractSet?.setDigest;
|
||||
if (typeof contractSetDigest !== "string" || !DIGEST.test(contractSetDigest)) {
|
||||
throw new TypeError("Generated contract set digest is invalid.");
|
||||
}
|
||||
const runtimeConfig = record(input.runtimeConfig);
|
||||
const releaseManifestUrl = runtimeConfig?.RELEASE_MANIFEST_URL;
|
||||
if (
|
||||
typeof releaseManifestUrl !== "string" ||
|
||||
releaseManifestUrl.length === 0 ||
|
||||
releaseManifestUrl.length > 2_048
|
||||
) {
|
||||
throw new TypeError("Runtime release manifest URL is invalid.");
|
||||
}
|
||||
return Object.freeze({
|
||||
assets,
|
||||
handlers: Object.freeze([...handlers]),
|
||||
contractSetDigest,
|
||||
releaseManifestUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function parseAssets(value: unknown): StaticAssetManifestV1 {
|
||||
const candidate = record(value);
|
||||
if (
|
||||
candidate?.schemaVersion !== 1 ||
|
||||
typeof candidate.buildId !== "string" ||
|
||||
typeof candidate.releaseId !== "string" ||
|
||||
typeof candidate.setDigest !== "string" ||
|
||||
!DIGEST.test(candidate.setDigest) ||
|
||||
!Array.isArray(candidate.assets)
|
||||
) {
|
||||
throw new TypeError("Generated Service Worker asset manifest is invalid.");
|
||||
}
|
||||
return candidate as unknown as StaticAssetManifestV1;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
+104
-13
@@ -8,12 +8,21 @@ import {
|
||||
isValidCommitSha,
|
||||
isValidSourceDateEpoch,
|
||||
} from "./lib/build-environment.ts";
|
||||
import { classifyGateStepResult } from "./lib/ci-step-result.ts";
|
||||
|
||||
type GateStep = Readonly<{
|
||||
type GateStepBase = Readonly<{
|
||||
script: string;
|
||||
args?: readonly string[];
|
||||
expect: "pass" | "fail";
|
||||
timeoutMs?: number;
|
||||
}>;
|
||||
type GateStep =
|
||||
| (GateStepBase & Readonly<{ expect: "pass" }>)
|
||||
| (GateStepBase &
|
||||
Readonly<{
|
||||
expect: "fail";
|
||||
expectedExitCode: number;
|
||||
expectedDiagnosticId: string;
|
||||
}>);
|
||||
type GateDefinition = Readonly<{
|
||||
name: string;
|
||||
steps: readonly GateStep[];
|
||||
@@ -40,6 +49,8 @@ if (!gateId || !gate) {
|
||||
|
||||
const output: string[] = [];
|
||||
let passed = true;
|
||||
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||
const MAX_STEP_OUTPUT_BYTES = 16 * 1024 * 1_024;
|
||||
|
||||
const gateEnvironment = { ...process.env };
|
||||
if (gateEnvironment.CI === "true") {
|
||||
@@ -92,19 +103,49 @@ if (passed) {
|
||||
const result = spawnSync(
|
||||
"corepack",
|
||||
["pnpm", step.script, ...(step.args ?? [])],
|
||||
{ encoding: "utf8", env: gateEnvironment },
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: gateEnvironment,
|
||||
timeout: step.timeoutMs ?? DEFAULT_STEP_TIMEOUT_MS,
|
||||
maxBuffer: MAX_STEP_OUTPUT_BYTES,
|
||||
},
|
||||
);
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
output.push(
|
||||
`$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(),
|
||||
result.stdout,
|
||||
result.stderr,
|
||||
stdout,
|
||||
stderr,
|
||||
);
|
||||
const exitedSuccessfully = result.status === 0;
|
||||
const expectationMet =
|
||||
step.expect === "pass" ? exitedSuccessfully : !exitedSuccessfully;
|
||||
if (!expectationMet) {
|
||||
const expectation =
|
||||
step.expect === "pass"
|
||||
? ({ kind: "pass" } as const)
|
||||
: ({
|
||||
kind: "fail",
|
||||
expectedExitCode: step.expectedExitCode,
|
||||
expectedDiagnosticId: step.expectedDiagnosticId,
|
||||
} as const);
|
||||
const classification = classifyGateStepResult(expectation, {
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdout,
|
||||
stderr,
|
||||
...(result.error
|
||||
? { error: { code: (result.error as NodeJS.ErrnoException).code } }
|
||||
: {}),
|
||||
});
|
||||
output.push(`classification: ${classification.kind}`);
|
||||
if (!classification.expectationMet) {
|
||||
output.push(
|
||||
`expectation failed: expected ${step.expect}, exit=${result.status}`,
|
||||
`expectation failed: expected ${step.expect}, exit=${result.status}, signal=${result.signal ?? "none"}`,
|
||||
...(step.expect === "fail"
|
||||
? [
|
||||
`expected negative fixture identity: exit=${step.expectedExitCode}, diagnostic=${JSON.stringify(step.expectedDiagnosticId)}`,
|
||||
]
|
||||
: []),
|
||||
...(classification.kind === "INFRASTRUCTURE_FAILURE"
|
||||
? [`infrastructure failure: ${classification.detail}`]
|
||||
: []),
|
||||
);
|
||||
passed = false;
|
||||
break;
|
||||
@@ -187,11 +228,61 @@ function parseGateSteps(value: unknown, gateId: string): GateStep[] {
|
||||
const args =
|
||||
candidate.args === undefined
|
||||
? undefined
|
||||
: parseStringArray(candidate.args, `${gateId}[${index}].args`);
|
||||
return {
|
||||
: parseStringArray(candidate.args, `${gateId}[${index}].args`);
|
||||
const timeoutMs = candidate.timeoutMs;
|
||||
if (
|
||||
timeoutMs !== undefined &&
|
||||
(typeof timeoutMs !== "number" ||
|
||||
!Number.isSafeInteger(timeoutMs) ||
|
||||
timeoutMs < 1_000 ||
|
||||
timeoutMs > 3_600_000)
|
||||
) {
|
||||
throw new TypeError(`Invalid CI gate step timeout: ${gateId}[${index}]`);
|
||||
}
|
||||
const base = {
|
||||
script: candidate.script,
|
||||
expect: candidate.expect,
|
||||
...(args ? { args } : {}),
|
||||
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
|
||||
};
|
||||
if (candidate.expect === "pass") {
|
||||
if (
|
||||
candidate.expectedExitCode !== undefined ||
|
||||
candidate.expectedDiagnosticId !== undefined
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Passing CI gate step cannot declare failure identity: ${gateId}[${index}]`,
|
||||
);
|
||||
}
|
||||
return { ...base, expect: "pass" as const };
|
||||
}
|
||||
if (
|
||||
typeof candidate.expectedExitCode !== "number" ||
|
||||
!Number.isSafeInteger(candidate.expectedExitCode) ||
|
||||
candidate.expectedExitCode < 1 ||
|
||||
candidate.expectedExitCode > 255
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Invalid expected failure exit code: ${gateId}[${index}]`,
|
||||
);
|
||||
}
|
||||
const expectedDiagnosticId = candidate.expectedDiagnosticId;
|
||||
if (
|
||||
typeof expectedDiagnosticId !== "string" ||
|
||||
expectedDiagnosticId.trim().length === 0 ||
|
||||
expectedDiagnosticId.length > 256 ||
|
||||
["\r", "\n", "\0"].some((character) =>
|
||||
expectedDiagnosticId.includes(character),
|
||||
)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Invalid expected failure diagnostic: ${gateId}[${index}]`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
expect: "fail" as const,
|
||||
expectedExitCode: candidate.expectedExitCode,
|
||||
expectedDiagnosticId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ const copyTargets = [
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.recipes.json",
|
||||
"tsconfig.web-worker.json",
|
||||
"tsconfig.service-worker.json",
|
||||
"vite.service-worker.config.ts",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
|
||||
@@ -28,6 +28,9 @@ const copyTargets = [
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.recipes.json",
|
||||
"tsconfig.web-worker.json",
|
||||
"tsconfig.service-worker.json",
|
||||
"vite.service-worker.config.ts",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
|
||||
@@ -82,13 +82,7 @@ try {
|
||||
}).observe({ type: "layout-shift", buffered: true });
|
||||
});
|
||||
await page.goto(baseUrl, { waitUntil: "networkidle" });
|
||||
const performanceRoutes: ReadonlyArray<{
|
||||
access: string;
|
||||
navigationLabel: string | null;
|
||||
}> = Object.values(ROUTE_REGISTRY);
|
||||
const targetLabel = performanceRoutes.find(
|
||||
(definition) => definition.access === "integration-defined",
|
||||
)?.navigationLabel;
|
||||
const targetLabel = ROUTE_REGISTRY.REFERENCE_RESOURCE_LIST.navigationLabel;
|
||||
if (!targetLabel) {
|
||||
throw new Error("Performance route must be present in navigation.");
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ const copyTargets = [
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.recipes.json",
|
||||
"tsconfig.web-worker.json",
|
||||
"tsconfig.service-worker.json",
|
||||
"vite.service-worker.config.ts",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
|
||||
@@ -39,6 +39,9 @@ const copyTargets = [
|
||||
"tsconfig.node.json",
|
||||
"tsconfig.test.json",
|
||||
"tsconfig.recipes.json",
|
||||
"tsconfig.web-worker.json",
|
||||
"tsconfig.service-worker.json",
|
||||
"vite.service-worker.config.ts",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"playwright.config.ts",
|
||||
@@ -94,6 +97,23 @@ export function createInstalledFeatureInputs(_context: FeatureContext) {
|
||||
}
|
||||
`;
|
||||
|
||||
const emptyContractContributions = `import {
|
||||
composeContractContributions,
|
||||
type InstalledContractContribution,
|
||||
type InstalledContractPackageIdentity,
|
||||
} from "../contracts/external-contract-runtime.ts";
|
||||
|
||||
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||||
Object.freeze([]);
|
||||
|
||||
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
||||
INSTALLED_CONTRACT_CONTRIBUTIONS,
|
||||
);
|
||||
|
||||
export const EXPECTED_CONTRACT_SET_PACKAGES: readonly InstalledContractPackageIdentity[] =
|
||||
COMPOSED_CONTRACT_CONTRIBUTIONS.externalPackages;
|
||||
`;
|
||||
|
||||
const emptyMessages = `export const INSTALLED_MESSAGE_CATALOGS = Object.freeze({
|
||||
"ko-KR": Object.freeze({}),
|
||||
"en-US": Object.freeze({}),
|
||||
@@ -170,6 +190,10 @@ await writeFile(
|
||||
path.join(fixtureRoot, "src/features/installed-feature-messages.ts"),
|
||||
emptyMessages,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(fixtureRoot, "src/features/installed-contract-contributions.ts"),
|
||||
emptyContractContributions,
|
||||
);
|
||||
|
||||
const vitestConfigFile = path.join(fixtureRoot, "vitest.config.ts");
|
||||
const vitestConfig = await readFile(vitestConfigFile, "utf8");
|
||||
|
||||
+52
-63
@@ -7,12 +7,23 @@ import {
|
||||
} from "../src/application/policies/compatibility.ts";
|
||||
import {
|
||||
compareReleaseToRuntime,
|
||||
RELEASE_TOKEN_REGISTRY,
|
||||
} from "../src/contracts/release-tokens.ts";
|
||||
import {
|
||||
parseBuildManifestArtifact,
|
||||
parseReleaseArtifact,
|
||||
parseRuntimeConfigArtifact,
|
||||
projectReleaseTokens,
|
||||
type BuildManifestArtifact,
|
||||
type ReleaseArtifact,
|
||||
type RuntimeConfigArtifact,
|
||||
} from "../src/contracts/release-artifacts.ts";
|
||||
import { verifyContractSet } from "../src/contracts/contract-set.ts";
|
||||
import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts";
|
||||
import {
|
||||
ROUTE_REGISTRY,
|
||||
ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../src/features/installed-feature-contracts.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
|
||||
type CoherenceFixture = Readonly<{
|
||||
name: string;
|
||||
@@ -20,23 +31,8 @@ type CoherenceFixture = Readonly<{
|
||||
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 RuntimeConfigDocument = RuntimeConfigArtifact &
|
||||
Readonly<{ BUILD_ID: string; RELEASE_ID: string }>;
|
||||
type ViteManifestEntry = Readonly<{
|
||||
file: string;
|
||||
name?: string;
|
||||
@@ -54,9 +50,17 @@ const release = parseReleaseDocument(
|
||||
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 buildManifestDocument: unknown = JSON.parse(
|
||||
await readFile("artifacts/release/build-manifest.json", "utf8"),
|
||||
);
|
||||
assertMatchesJsonSchema(
|
||||
JSON.parse(
|
||||
await readFile("schemas/artifacts/build-manifest.schema.json", "utf8"),
|
||||
),
|
||||
buildManifestDocument,
|
||||
"build manifest",
|
||||
);
|
||||
const buildManifest = parseBuildManifestDocument(buildManifestDocument);
|
||||
const runtimeConfigJsonSchema = requireRecord(
|
||||
JSON.parse(await readFile("dist/runtime-config.schema.json", "utf8")),
|
||||
"runtime config JSON schema",
|
||||
@@ -69,11 +73,20 @@ const actualAssetManifestHash = createHash("sha256")
|
||||
|
||||
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) {
|
||||
for (const [token, value] of Object.entries(projectReleaseTokens(release))) {
|
||||
if (token !== "schemaVersion" && (typeof value !== "string" || value.length === 0)) {
|
||||
artifactMismatches.push(`releaseToken:${token}`);
|
||||
}
|
||||
}
|
||||
if (release.schemaVersion === 2) {
|
||||
const contractSetVerification = await verifyContractSet({
|
||||
expected: EXPECTED_CONTRACT_SET_PACKAGES,
|
||||
manifest: release.contractSet,
|
||||
});
|
||||
if (!contractSetVerification.ok) {
|
||||
artifactMismatches.push(contractSetVerification.code);
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof release.builtAt !== "string" ||
|
||||
!Number.isFinite(Date.parse(release.builtAt))
|
||||
@@ -91,19 +104,19 @@ if (
|
||||
artifactMismatches.push("runtimeConfigSchema");
|
||||
}
|
||||
if (
|
||||
buildManifest.outputs?.runtimeConfigSchema !==
|
||||
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}`);
|
||||
for (const [token, buildValue, releaseValue] of [
|
||||
["buildId", buildManifest.buildId, release.buildId],
|
||||
["commitSha", buildManifest.commitSha, release.commitSha],
|
||||
["releaseId", buildManifest.releaseId, release.releaseId],
|
||||
["generatedAt", buildManifest.generatedAt, release.builtAt],
|
||||
] as const) {
|
||||
if (buildValue !== releaseValue) {
|
||||
artifactMismatches.push(`buildManifest:${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +147,7 @@ for (const definition of Object.values(ROUTE_REGISTRY)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
buildManifest.outputs?.routeChunks?.[definition.chunkId] !== routeAsset
|
||||
buildManifest.outputs.routeChunks[definition.chunkId] !== routeAsset
|
||||
) {
|
||||
artifactMismatches.push(`buildManifest:routeChunk:${definition.chunkId}`);
|
||||
}
|
||||
@@ -221,45 +234,21 @@ function parseFixturesDocument(value: unknown): Readonly<{
|
||||
};
|
||||
}
|
||||
|
||||
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 parseReleaseDocument(value: unknown): ReleaseArtifact {
|
||||
return parseReleaseArtifact(value);
|
||||
}
|
||||
|
||||
function parseRuntimeConfigDocument(value: unknown): RuntimeConfigDocument {
|
||||
const document = requireRecord(value, "runtime config");
|
||||
const document = parseRuntimeConfigArtifact(value);
|
||||
return {
|
||||
...document,
|
||||
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",
|
||||
),
|
||||
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 parseBuildManifestDocument(value: unknown): BuildManifestArtifact {
|
||||
return parseBuildManifestArtifact(value);
|
||||
}
|
||||
|
||||
function parseCompatibilityTuple(value: unknown, label: string): CompatibilityTuple {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
supplyChainDigest,
|
||||
verifySupplyChainCoherence,
|
||||
} from "./lib/supply-chain.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
|
||||
@@ -48,6 +49,25 @@ const provenance = await readDocument("artifacts/release/provenance.json");
|
||||
const verification = await readDocument(
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
);
|
||||
const artifactSchemaFailures: string[] = [];
|
||||
for (const [schemaPath, artifact, label] of [
|
||||
[
|
||||
"schemas/artifacts/dependency-inventory.schema.json",
|
||||
inventory,
|
||||
"dependency inventory",
|
||||
],
|
||||
[
|
||||
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||
verification,
|
||||
"supply-chain verification",
|
||||
],
|
||||
] as const) {
|
||||
try {
|
||||
assertMatchesJsonSchema(await readDocument(schemaPath), artifact, label);
|
||||
} catch {
|
||||
artifactSchemaFailures.push(`${label} JSON Schema mismatch`);
|
||||
}
|
||||
}
|
||||
const lockfileText = await readFile("pnpm-lock.yaml", "utf8");
|
||||
const lockfileSha256 = createHash("sha256")
|
||||
.update(lockfileText)
|
||||
@@ -69,7 +89,7 @@ const coherence = verifySupplyChainCoherence(
|
||||
provenance,
|
||||
distDigest,
|
||||
);
|
||||
const failures: string[] = [...coherence.failures];
|
||||
const failures: string[] = [...artifactSchemaFailures, ...coherence.failures];
|
||||
if (
|
||||
inventory.lockfileSha256 !== lockfileSha256 ||
|
||||
verification.lockfileSha256 !== lockfileSha256
|
||||
|
||||
Reference in New Issue
Block a user