diff --git a/package.json b/package.json index 45e99ee..ee58db4 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,8 @@ "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration && corepack pnpm test:reference-feature && corepack pnpm test:recipes", "verify:lockfile": "corepack pnpm install --frozen-lockfile", "check:frozen-lockfile:fixture": "node scripts/check-frozen-lockfile-fixture.ts", + "generate:artifact-schemas": "node scripts/generate-artifact-schemas.ts", + "check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check", "generate:supply-chain": "node scripts/generate-supply-chain.ts", "verify:supply-chain": "node scripts/verify-supply-chain-artifacts.ts", "update:dependency-baseline": "node scripts/update-dependency-baseline.ts", @@ -101,7 +103,7 @@ "drill:runbook": "node scripts/drill-runbook.ts", "drill:runbooks": "corepack pnpm drill:runbook -- FE-RB-001 && corepack pnpm drill:runbook -- FE-RB-002 && corepack pnpm drill:runbook -- FE-RB-003 && corepack pnpm drill:runbook -- FE-RB-004 && corepack pnpm drill:runbook -- FE-RB-005", "ci:gate": "node scripts/run-ci-gate.ts", - "check:ci": "node scripts/check-ci-contract.ts", + "check:ci": "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts", "verify:documentation": "node scripts/verify-documentation-readiness.ts", "build:app-only": "vite build && node scripts/generate-build-manifest.ts", "generate:contract-set": "node scripts/generate-contract-set.ts", diff --git a/schemas/artifacts/build-manifest.schema.json b/schemas/artifacts/build-manifest.schema.json index a68089d..87956c8 100644 --- a/schemas/artifacts/build-manifest.schema.json +++ b/schemas/artifacts/build-manifest.schema.json @@ -1,51 +1,27 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", + "additionalProperties": false, "properties": { - "schemaVersion": { - "type": "number", - "const": 1 - }, - "buildId": { - "type": "string", - "minLength": 1 - }, - "commitSha": { - "type": "string", - "minLength": 1 - }, - "releaseId": { - "type": "string", - "minLength": 1 - }, - "moduleInventoryHash": { - "type": "string", - "minLength": 1 - }, - "generatedAt": { - "type": "string", - "minLength": 1 - }, "buildContext": { - "type": "object", + "additionalProperties": false, "properties": { "nodeVersion": { - "type": "string", - "minLength": 1 + "minLength": 1, + "type": "string" }, "packageManagerVersion": { - "type": "string", - "minLength": 1 + "minLength": 1, + "type": "string" }, "runnerImage": { - "type": "string", - "minLength": 1 + "minLength": 1, + "type": "string" }, "sourceDateEpoch": { "anyOf": [ { - "type": "string", - "minLength": 1 + "minLength": 1, + "type": "string" }, { "type": "null" @@ -59,37 +35,53 @@ "runnerImage", "sourceDateEpoch" ], - "additionalProperties": false + "type": "object" + }, + "buildId": { + "minLength": 1, + "type": "string" + }, + "commitSha": { + "minLength": 1, + "type": "string" + }, + "generatedAt": { + "minLength": 1, + "type": "string" + }, + "moduleInventoryHash": { + "minLength": 1, + "type": "string" }, "outputs": { - "type": "object", + "additionalProperties": false, "properties": { "directory": { - "type": "string", - "minLength": 1 - }, - "viteManifest": { - "type": "string", - "minLength": 1 + "minLength": 1, + "type": "string" }, "moduleInventory": { - "type": "string", - "minLength": 1 + "minLength": 1, + "type": "string" }, "routeChunks": { - "type": "object", - "propertyNames": { - "type": "string", - "minLength": 1 - }, "additionalProperties": { - "type": "string", - "minLength": 1 - } + "minLength": 1, + "type": "string" + }, + "propertyNames": { + "minLength": 1, + "type": "string" + }, + "type": "object" }, "runtimeConfigSchema": { - "type": "string", - "minLength": 1 + "minLength": 1, + "type": "string" + }, + "viteManifest": { + "minLength": 1, + "type": "string" } }, "required": [ @@ -99,7 +91,15 @@ "routeChunks", "runtimeConfigSchema" ], - "additionalProperties": false + "type": "object" + }, + "releaseId": { + "minLength": 1, + "type": "string" + }, + "schemaVersion": { + "const": 1, + "type": "number" } }, "required": [ @@ -112,5 +112,5 @@ "buildContext", "outputs" ], - "additionalProperties": false + "type": "object" } diff --git a/schemas/artifacts/dependency-inventory.schema.json b/schemas/artifacts/dependency-inventory.schema.json index a55d6ef..217c9d4 100644 --- a/schemas/artifacts/dependency-inventory.schema.json +++ b/schemas/artifacts/dependency-inventory.schema.json @@ -1,31 +1,49 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://clean-architecture-frontend.local/schemas/dependency-inventory.schema.json", - "type": "object", "additionalProperties": false, - "required": [ - "schemaVersion", - "packageManager", - "lockfileSha256", - "dependencyCount", - "directDependencyCount", - "dependencies" - ], "properties": { - "schemaVersion": { "const": 2 }, - "packageManager": { "type": "string", "minLength": 1 }, - "lockfileSha256": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "dependencyCount": { "type": "integer", "minimum": 1 }, - "directDependencyCount": { "type": "integer", "minimum": 1 }, "dependencies": { - "type": "array", - "minItems": 1, "items": { - "type": "object", "additionalProperties": false, + "properties": { + "dependencies": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "direct": { + "type": "boolean" + }, + "integrity": { + "pattern": "^sha512-", + "type": "string" + }, + "license": { + "minLength": 1, + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "scope": { + "enum": [ + "production", + "development" + ], + "type": "string" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, "required": [ "name", "version", @@ -36,25 +54,40 @@ "integrity", "dependencies" ], - "properties": { - "name": { "type": "string", "minLength": 1 }, - "version": { "type": "string", "minLength": 1 }, - "direct": { "type": "boolean" }, - "scope": { - "enum": ["production", "development"] - }, - "optional": { "type": "boolean" }, - "license": { "type": "string", "minLength": 1 }, - "integrity": { - "type": "string", - "pattern": "^sha512-" - }, - "dependencies": { - "type": "array", - "items": { "type": "string", "minLength": 1 } - } - } - } + "type": "object" + }, + "type": "array" + }, + "dependencyCount": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "directDependencyCount": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "lockfileSha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "packageManager": { + "minLength": 1, + "type": "string" + }, + "schemaVersion": { + "const": 2, + "type": "number" } - } + }, + "required": [ + "schemaVersion", + "packageManager", + "lockfileSha256", + "dependencyCount", + "directDependencyCount", + "dependencies" + ], + "type": "object" } diff --git a/schemas/artifacts/registry-snapshot.schema.json b/schemas/artifacts/registry-snapshot.schema.json index 2343765..599abeb 100644 --- a/schemas/artifacts/registry-snapshot.schema.json +++ b/schemas/artifacts/registry-snapshot.schema.json @@ -1,6 +1,257 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": { + "$ref": "#/$defs/__schema0" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/$defs/__schema0" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + } + ] + }, + "__schema1": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": { + "$ref": "#/$defs/__schema1" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/$defs/__schema1" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + } + ] + }, + "__schema2": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "items": { + "$ref": "#/$defs/__schema2" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/$defs/__schema2" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + } + ] + } + }, + "additionalProperties": false, + "properties": { + "baselineDigest": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "compatibility": { + "additionalProperties": false, + "properties": { + "changes": { + "items": { + "additionalProperties": false, + "properties": { + "after": { + "$ref": "#/$defs/__schema1" + }, + "before": { + "$ref": "#/$defs/__schema0" + }, + "changeId": { + "minLength": 1, + "type": "string" + }, + "field": { + "minLength": 1, + "type": "string" + }, + "impact": { + "enum": [ + "none", + "additive", + "behavior-change", + "breaking" + ], + "type": "string" + }, + "kind": { + "minLength": 1, + "type": "string" + }, + "registryId": { + "minLength": 1, + "type": "string" + }, + "rowName": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "changeId", + "registryId", + "rowName", + "field", + "kind", + "impact" + ], + "type": "object" + }, + "type": "array" + }, + "impact": { + "enum": [ + "not-evaluated", + "none", + "additive", + "behavior-change", + "breaking" + ], + "type": "string" + } + }, + "required": [ + "impact", + "changes" + ], + "type": "object" + }, + "currentDigest": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "failures": { + "items": { + "type": "string" + }, + "type": "array" + }, + "generatedAt": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "registries": { + "items": { + "additionalProperties": false, + "properties": { + "contract": { + "additionalProperties": { + "$ref": "#/$defs/__schema2" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "owner": { + "minLength": 1, + "type": "string" + }, + "registryId": { + "minLength": 1, + "type": "string" + }, + "rowCount": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "additionalProperties": { + "$ref": "#/$defs/__schema2" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "source": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "registryId", + "owner", + "source", + "rowCount", + "contract", + "rows" + ], + "type": "object" + }, + "type": "array" + }, + "schemaVersion": { + "const": 2, + "type": "number" + } + }, "required": [ "schemaVersion", "generatedAt", @@ -10,45 +261,5 @@ "failures", "registries" ], - "properties": { - "schemaVersion": { "const": 2 }, - "generatedAt": { "type": "string", "format": "date-time" }, - "baselineDigest": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "currentDigest": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "compatibility": { - "type": "object", - "required": ["impact", "changes"], - "properties": { - "impact": { - "enum": ["none", "additive", "behavior-change", "breaking"] - }, - "changes": { "type": "array" } - }, - "additionalProperties": false - }, - "failures": { "type": "array", "maxItems": 0 }, - "registries": { - "type": "array", - "minItems": 11, - "maxItems": 11, - "items": { - "type": "object", - "required": [ - "registryId", - "owner", - "source", - "rowCount", - "contract", - "rows" - ] - } - } - }, - "additionalProperties": false + "type": "object" } diff --git a/schemas/artifacts/supply-chain-verification.schema.json b/schemas/artifacts/supply-chain-verification.schema.json index 08e0402..803cd60 100644 --- a/schemas/artifacts/supply-chain-verification.schema.json +++ b/schemas/artifacts/supply-chain-verification.schema.json @@ -1,8 +1,130 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://clean-architecture-frontend.local/schemas/supply-chain-verification.schema.json", - "type": "object", "additionalProperties": false, + "properties": { + "dependencyDiff": { + "additionalProperties": false, + "properties": { + "added": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "changed": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "removed": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "upgrades": { + "items": { + "additionalProperties": false, + "properties": { + "from": { + "minLength": 1, + "type": "string" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "to": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name", + "from", + "to" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "added", + "removed", + "changed", + "upgrades" + ], + "type": "object" + }, + "distSha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "failures": { + "items": { + "type": "string" + }, + "type": "array" + }, + "highRiskReview": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "localStatus": { + "enum": [ + "PASS", + "FAIL" + ], + "type": "string" + }, + "lockfileSha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "promotionStatus": { + "enum": [ + "PASS", + "FAIL_UNVERIFIED" + ], + "type": "string" + }, + "provenanceAttestationStatus": { + "enum": [ + "PASS", + "FAIL_UNVERIFIED" + ], + "type": "string" + }, + "sbomSha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "schemaVersion": { + "const": 1, + "type": "number" + }, + "sourceSetSha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "vulnerabilityStatus": { + "enum": [ + "PASS", + "FAIL", + "FAIL_UNVERIFIED" + ], + "type": "string" + } + }, "required": [ "schemaVersion", "localStatus", @@ -17,39 +139,5 @@ "provenanceAttestationStatus", "failures" ], - "properties": { - "schemaVersion": { "const": 1 }, - "localStatus": { "enum": ["PASS", "FAIL"] }, - "promotionStatus": { - "enum": ["PASS", "FAIL_UNVERIFIED"] - }, - "lockfileSha256": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "sourceSetSha256": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "distSha256": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "sbomSha256": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "dependencyDiff": { "type": "object" }, - "highRiskReview": { "type": "array" }, - "vulnerabilityStatus": { - "enum": ["PASS", "FAIL", "FAIL_UNVERIFIED"] - }, - "provenanceAttestationStatus": { - "enum": ["PASS", "FAIL_UNVERIFIED"] - }, - "failures": { - "type": "array", - "items": { "type": "string" } - } - } + "type": "object" } diff --git a/scripts/check-registries.ts b/scripts/check-registries.ts index ac610f4..1610c4c 100644 --- a/scripts/check-registries.ts +++ b/scripts/check-registries.ts @@ -3,7 +3,6 @@ import { mkdir, readFile, readdir, - writeFile, } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -16,6 +15,8 @@ import { verifyRegistryBaselineApproval, } from "./lib/registry-compatibility.ts"; import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; +import { registrySnapshotArtifactSchema } from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; type RegistryRow = Record; type RegistryRows = Record; @@ -675,7 +676,11 @@ if (usesRepositoryBaseline && failures.length === 0) { } } await mkdir(path.dirname(artifactPath), { recursive: true }); -await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`); +await writeValidatedJsonArtifact({ + path: artifactPath, + schema: registrySnapshotArtifactSchema, + value: report, +}); if (failures.length > 0) { process.stderr.write(`Registry governance failed:\n${failures.join("\n")}\n`); diff --git a/scripts/collect-web-vitals-evidence.ts b/scripts/collect-web-vitals-evidence.ts index 2045774..1935e37 100644 --- a/scripts/collect-web-vitals-evidence.ts +++ b/scripts/collect-web-vitals-evidence.ts @@ -1,10 +1,12 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile } from "node:fs/promises"; import { evaluateFieldBudget, percentile75, } from "../src/application/policies/performance-budgets.ts"; +import { fieldWebVitalsArtifactSchema } from "./contracts/release-artifacts.ts"; import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; const inputPath = process.env.FIELD_WEB_VITALS_INPUT || @@ -94,10 +96,11 @@ const report = { }; await mkdir("artifacts/performance", { recursive: true }); -await writeFile( - "artifacts/performance/field-web-vitals.json", - `${JSON.stringify(report, null, 2)}\n`, -); +await writeValidatedJsonArtifact({ + path: "artifacts/performance/field-web-vitals.json", + schema: fieldWebVitalsArtifactSchema, + value: report, +}); if (!passed) { process.stderr.write( `Field Web Vitals: ${status} (approved threshold decision and valid 28-day production evidence are required)\n`, diff --git a/scripts/contracts/release-artifacts.ts b/scripts/contracts/release-artifacts.ts index 01893ca..1d71c60 100644 --- a/scripts/contracts/release-artifacts.ts +++ b/scripts/contracts/release-artifacts.ts @@ -1 +1,425 @@ +import { z } from "zod"; + export * from "../../src/contracts/release-artifacts.ts"; + +const nonEmptyString = z.string().min(1); +const timestamp = z.iso.datetime(); +const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); +const jsonObject = z.record(z.string(), z.json()); + +export const moduleInventoryArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + chunks: z.array( + z + .object({ + fileName: nonEmptyString, + modules: z.array(nonEmptyString), + }) + .strict(), + ), + }) + .strict(); + +export const jsonSchemaDocumentArtifactSchema = z + .object({ + $schema: z.literal("https://json-schema.org/draft/2020-12/schema"), + }) + .catchall(z.json()); + +const dependencyInventoryRowSchema = z + .object({ + name: nonEmptyString, + version: nonEmptyString, + direct: z.boolean(), + scope: z.enum(["production", "development"]), + optional: z.boolean(), + license: nonEmptyString, + integrity: z.string().regex(/^sha512-/u), + dependencies: z.array(nonEmptyString), + }) + .strict(); + +export const dependencyInventoryArtifactSchema = z + .object({ + schemaVersion: z.literal(2), + packageManager: nonEmptyString, + lockfileSha256: sha256, + dependencyCount: z.int().nonnegative(), + directDependencyCount: z.int().nonnegative(), + dependencies: z.array(dependencyInventoryRowSchema), + }) + .strict() + .superRefine((inventory, context) => { + if (inventory.dependencyCount !== inventory.dependencies.length) { + context.addIssue({ + code: "custom", + path: ["dependencyCount"], + message: "must equal dependencies.length", + }); + } + const actualDirect = inventory.dependencies.filter( + (dependency) => dependency.direct, + ).length; + if (inventory.directDependencyCount !== actualDirect) { + context.addIssue({ + code: "custom", + path: ["directDependencyCount"], + message: "must equal the number of direct dependencies", + }); + } + }); + +const dependencyUpgradeSchema = z + .object({ + name: nonEmptyString, + from: nonEmptyString, + to: nonEmptyString, + }) + .strict(); + +export const dependencyDiffSchema = z + .object({ + added: z.array(nonEmptyString), + removed: z.array(nonEmptyString), + changed: z.array(nonEmptyString), + upgrades: z.array(dependencyUpgradeSchema), + }) + .strict(); + +export const supplyChainVerificationArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + localStatus: z.enum(["PASS", "FAIL"]), + promotionStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), + lockfileSha256: sha256, + sourceSetSha256: sha256, + distSha256: sha256, + sbomSha256: sha256, + dependencyDiff: dependencyDiffSchema, + highRiskReview: z.array(nonEmptyString), + vulnerabilityStatus: z.enum(["PASS", "FAIL", "FAIL_UNVERIFIED"]), + provenanceAttestationStatus: z.enum(["PASS", "FAIL_UNVERIFIED"]), + failures: z.array(z.string()), + }) + .strict(); + +const registryChangeSchema = z + .object({ + changeId: nonEmptyString, + registryId: nonEmptyString, + rowName: nonEmptyString, + field: nonEmptyString, + kind: nonEmptyString, + impact: z.enum(["none", "additive", "behavior-change", "breaking"]), + before: z.json().optional(), + after: z.json().optional(), + }) + .strict(); + +const registryArtifactRowSchema = z + .object({ + registryId: nonEmptyString, + owner: nonEmptyString, + source: nonEmptyString, + rowCount: z.int().nonnegative(), + contract: jsonObject, + rows: jsonObject, + }) + .strict(); + +export const registrySnapshotArtifactSchema = z + .object({ + schemaVersion: z.literal(2), + generatedAt: timestamp, + baselineDigest: sha256.nullable(), + currentDigest: sha256, + compatibility: z + .object({ + impact: z.enum([ + "not-evaluated", + "none", + "additive", + "behavior-change", + "breaking", + ]), + changes: z.array(registryChangeSchema), + }) + .strict(), + failures: z.array(z.string()), + registries: z.array(registryArtifactRowSchema), + }) + .strict(); + +const outputDigestSchema = z + .object({ + path: nonEmptyString, + bytes: z.int().nonnegative(), + gzipBytes: z.int().nonnegative(), + sha256, + }) + .strict(); + +export const bundlePerformanceArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + context: z + .object({ + nodeVersion: nonEmptyString, + packageManager: nonEmptyString, + runnerImage: nonEmptyString, + }) + .strict(), + outputs: z.array(outputDigestSchema).min(1), + }) + .strict(); + +const cyclonedxComponentSchema = z + .object({ + type: z.literal("library"), + "bom-ref": nonEmptyString, + name: nonEmptyString, + version: nonEmptyString, + scope: z.enum(["optional", "required"]), + hashes: z.array( + z.object({ alg: z.literal("SHA-512"), content: nonEmptyString }).strict(), + ), + licenses: z.array( + z.object({ expression: nonEmptyString }).strict(), + ), + properties: z.array( + z.object({ name: nonEmptyString, value: nonEmptyString }).strict(), + ), + }) + .strict(); + +export const sbomArtifactSchema = z + .object({ + bomFormat: z.literal("CycloneDX"), + specVersion: z.literal("1.6"), + serialNumber: nonEmptyString, + version: z.literal(1), + metadata: z + .object({ + component: z + .object({ + type: z.literal("application"), + name: nonEmptyString, + version: nonEmptyString, + }) + .strict(), + properties: z.array( + z.object({ name: nonEmptyString, value: nonEmptyString }).strict(), + ), + }) + .strict(), + components: z.array(cyclonedxComponentSchema), + dependencies: z.array( + z + .object({ ref: nonEmptyString, dependsOn: z.array(nonEmptyString) }) + .strict(), + ), + }) + .strict(); + +export const provenanceArtifactSchema = z + .object({ + _type: z.literal("https://in-toto.io/Statement/v1"), + subject: z + .array( + z + .object({ + name: z.literal("dist"), + digest: z.object({ sha256 }).strict(), + }) + .strict(), + ) + .length(1), + predicateType: z.literal("https://slsa.dev/provenance/v1"), + predicate: z + .object({ + buildDefinition: z + .object({ + buildType: nonEmptyString, + externalParameters: jsonObject, + internalParameters: jsonObject, + resolvedDependencies: z.array( + z + .object({ uri: nonEmptyString, digest: z.object({ sha256 }).strict() }) + .strict(), + ), + }) + .strict(), + runDetails: z + .object({ + builder: z.object({ id: nonEmptyString }).strict(), + metadata: z.object({ invocationId: nonEmptyString }).strict(), + }) + .strict(), + materials: z + .object({ lockfileSha256: sha256, sourceSetSha256: sha256, sbomSha256: sha256 }) + .strict(), + }) + .strict(), + }) + .strict(); + +export const dependencyDiffArtifactSchema = z + .object({ + schemaVersion: z.literal(2), + baselineDigest: sha256.nullable(), + currentDigest: sha256, + ...dependencyDiffSchema.shape, + highRisk: z.array(nonEmptyString), + reviewFailures: z.array(z.string()), + }) + .strict(); + +export const licenseReportArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + status: z.enum(["PASS", "FAIL"]), + dependencyCount: z.int().nonnegative(), + results: z.array( + z + .object({ + package: nonEmptyString, + license: nonEmptyString, + passed: z.boolean(), + reason: z.string().nullable(), + }) + .strict(), + ), + failures: z.array(z.string()), + }) + .strict(); + +export const vulnerabilityReportArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + provider: nonEmptyString, + scannedLockfileSha256: sha256, + status: z.enum(["PASS", "FAIL", "FAIL_UNVERIFIED"]), + findings: z.array(jsonObject), + exceptionsApplied: z.array(jsonObject), + failures: z.array(z.string()), + blocking: z.array(z.string()), + }) + .strict(); + +export const fieldWebVitalsArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + window: z + .object({ days: z.literal(28), start: timestamp, end: timestamp }) + .strict(), + context: z + .object({ + source: nonEmptyString, + sourceSystem: z.string().nullable(), + exportId: z.string().nullable(), + network: z.literal("production-real-user"), + routeAggregation: z.literal("route-id-only"), + releaseId: z.string().nullable(), + privacyApprovalRef: z.string().nullable(), + thresholdDecisionRef: z.string().nullable(), + validationFailures: z.array(z.string()), + }) + .strict(), + metrics: z + .object({ + p75LcpMs: z.number().finite().nonnegative().nullable(), + p75Cls: z.number().finite().nonnegative().nullable(), + p75InpMs: z.number().finite().nonnegative().nullable(), + }) + .strict(), + thresholds: z + .object({ + p75LcpMs: z.number().finite().nonnegative(), + p75Cls: z.number().finite().nonnegative(), + p75InpMs: z.number().finite().nonnegative(), + minimumEligibleSamples: z.int().positive().nullable(), + }) + .strict(), + eligibility: z + .object({ + consentRequired: z.literal(true), + totalSamples: z.int().nonnegative(), + eligibleSamples: z.int().nonnegative(), + minimumEligibleSamples: z.int().positive().nullable(), + routeSamples: z.record(z.string(), z.int().nonnegative()), + }) + .strict(), + status: z.enum(["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"]), + passed: z.boolean(), + }) + .strict(); + +export const labPerformanceArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + context: jsonObject, + metrics: jsonObject, + thresholds: jsonObject, + fixtures: z.array( + z.object({ name: nonEmptyString, passed: z.boolean() }).strict(), + ), + passed: z.boolean(), + }) + .strict(); + +export const releaseVerificationArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + generatedAt: timestamp, + artifact: z + .object({ + checked: z.boolean(), + compatible: z.boolean(), + mismatches: z.array(z.string()), + releaseId: nonEmptyString, + }) + .strict(), + fixtures: z.array( + z + .object({ + name: nonEmptyString, + expectedCompatible: z.boolean(), + actualCompatible: z.boolean(), + mismatches: z.array(z.string()), + passed: z.boolean(), + }) + .strict(), + ), + passed: z.boolean(), + }) + .strict(); + +export const runbookRecordArtifactSchema = z + .object({ + schemaVersion: z.literal(1), + runbookId: z.string().regex(/^FE-RB-00[1-5]$/u), + releaseId: nonEmptyString, + drillTimestamp: timestamp, + triggerInjected: nonEmptyString, + triggerAsserted: z.boolean(), + containmentAsserted: z.boolean(), + escalationPathAsserted: z.boolean(), + recoveryAssertions: z.array( + z + .object({ + assertion: nonEmptyString, + evidence: nonEmptyString, + passed: z.boolean(), + }) + .strict(), + ), + negativeFixtureFailedAsExpected: z.boolean(), + windowObservedBucket: nonEmptyString, + providerVerificationRequired: z.boolean(), + passed: z.boolean(), + }) + .strict(); diff --git a/scripts/drill-runbook.ts b/scripts/drill-runbook.ts index f37fee1..4a967ef 100644 --- a/scripts/drill-runbook.ts +++ b/scripts/drill-runbook.ts @@ -1,4 +1,4 @@ -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { shouldRetry } from "../src/adapters/http/retry-policy.ts"; @@ -15,7 +15,9 @@ import { } from "../src/contracts/release-artifacts.ts"; import { projectTelemetryEvent } from "../src/contracts/telemetry.ts"; import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts"; +import { runbookRecordArtifactSchema } from "./contracts/release-artifacts.ts"; import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; type RecoveryAssertion = Readonly<{ assertion: string; @@ -354,10 +356,11 @@ async function main(): Promise { }; const artifactDirectory = `artifacts/runbooks/${runbookId}`; await mkdir(artifactDirectory, { recursive: true }); - await writeFile( - `${artifactDirectory}/record.json`, - `${JSON.stringify(record, null, 2)}\n`, - ); + await writeValidatedJsonArtifact({ + path: `${artifactDirectory}/record.json`, + schema: runbookRecordArtifactSchema, + value: record, + }); if (!passed) { process.stderr.write(`${runbookId} drill failed.\n`); process.exit(1); diff --git a/scripts/generate-artifact-schemas.ts b/scripts/generate-artifact-schemas.ts new file mode 100644 index 0000000..af9187e --- /dev/null +++ b/scripts/generate-artifact-schemas.ts @@ -0,0 +1,131 @@ +import { mkdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { z } from "zod"; + +import { + buildManifestArtifactSchema, + dependencyInventoryArtifactSchema, + jsonSchemaDocumentArtifactSchema, + registrySnapshotArtifactSchema, + supplyChainVerificationArtifactSchema, +} from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; + +const ARTIFACT_SCHEMA_DEFINITIONS = Object.freeze([ + { + relativePath: "schemas/artifacts/build-manifest.schema.json", + id: undefined, + schema: buildManifestArtifactSchema, + }, + { + relativePath: "schemas/artifacts/dependency-inventory.schema.json", + id: "https://clean-architecture-frontend.local/schemas/dependency-inventory.schema.json", + schema: dependencyInventoryArtifactSchema, + }, + { + relativePath: "schemas/artifacts/registry-snapshot.schema.json", + id: undefined, + schema: registrySnapshotArtifactSchema, + }, + { + relativePath: "schemas/artifacts/supply-chain-verification.schema.json", + id: "https://clean-architecture-frontend.local/schemas/supply-chain-verification.schema.json", + schema: supplyChainVerificationArtifactSchema, + }, +] as const); + +type GenerateArtifactSchemasOptions = Readonly<{ + root?: string; + check?: boolean; +}>; + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalize(item)]), + ); + } + return value; +} + +function generatedSchema( + definition: (typeof ARTIFACT_SCHEMA_DEFINITIONS)[number], +): Readonly> { + const schema = z.toJSONSchema(definition.schema, { + target: "draft-2020-12", + }); + return canonicalize({ + ...schema, + ...(definition.id === undefined ? {} : { $id: definition.id }), + }) as Readonly>; +} + +export async function generateArtifactSchemas( + options: GenerateArtifactSchemasOptions = {}, +): Promise { + const root = path.resolve(options.root ?? process.cwd()); + const documents = ARTIFACT_SCHEMA_DEFINITIONS.map((definition) => ({ + definition, + document: generatedSchema(definition), + })); + const drift: string[] = []; + + for (const { definition, document } of documents) { + const destination = path.join(root, definition.relativePath); + const validatedDocument = jsonSchemaDocumentArtifactSchema.parse(document); + const expected = `${JSON.stringify(validatedDocument, null, 2)}\n`; + if (options.check) { + let actual: string | null = null; + try { + actual = await readFile(destination, "utf8"); + } catch { + // A missing or unreadable checked-in schema is drift. + } + if (actual !== expected) drift.push(definition.relativePath); + continue; + } + await mkdir(path.dirname(destination), { recursive: true }); + await writeValidatedJsonArtifact({ + path: destination, + schema: jsonSchemaDocumentArtifactSchema, + value: document, + }); + } + + if (drift.length > 0) { + throw new Error(`Artifact JSON Schema drift:\n- ${drift.join("\n- ")}`); + } +} + +function argumentValue(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +const invokedPath = process.argv[1]; +if ( + invokedPath !== undefined && + import.meta.url === pathToFileURL(invokedPath).href +) { + try { + await generateArtifactSchemas({ + root: argumentValue("--root"), + check: process.argv.includes("--check"), + }); + process.stdout.write( + process.argv.includes("--check") + ? "Artifact JSON Schemas: PASS\n" + : "Artifact JSON Schemas: GENERATED\n", + ); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/generate-build-manifest.ts b/scripts/generate-build-manifest.ts index b2afb72..7e65c92 100644 --- a/scripts/generate-build-manifest.ts +++ b/scripts/generate-build-manifest.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm } from "node:fs/promises"; import process from "node:process"; import { z } from "zod"; @@ -9,14 +9,17 @@ import { } from "../src/features/installed-feature-contracts.ts"; import { buildManifestArtifactSchema, + jsonSchemaDocumentArtifactSchema, + moduleInventoryArtifactSchema, releaseManifestV2ArtifactSchema, runtimeConfigV2ArtifactSchema, -} from "../src/contracts/release-artifacts.ts"; +} 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"; assertCiBuildEnvironment(process.env); type ViteManifestEntry = Readonly<{ @@ -41,12 +44,15 @@ const moduleInventory = await readFile( "dist/.vite/module-inventory.json", "utf8", ); -parseModuleInventory(JSON.parse(moduleInventory)); +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(moduleInventory) + .update(moduleInventoryBytes) .digest("hex"); const runtimeConfig = runtimeConfigV2ArtifactSchema.parse( JSON.parse(await readFile("dist/config.json", "utf8")), @@ -108,28 +114,37 @@ const releaseManifest = releaseManifestV2ArtifactSchema.parse({ await mkdir("artifacts/release", { recursive: true }); await mkdir("artifacts/quality", { recursive: true }); -await writeFile( - "artifacts/quality/vite-module-inventory.json", - moduleInventory, -); +await writeValidatedJsonArtifact({ + path: "artifacts/quality/vite-module-inventory.json", + schema: moduleInventoryArtifactSchema, + value: moduleInventoryDocument, +}); await rm("dist/.vite/module-inventory.json"); -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( - "dist/runtime-config.schema.json", - `${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`, -); -await writeFile( - "artifacts/release/runtime-config.schema.json", - `${JSON.stringify(runtimeConfigJsonSchema, null, 2)}\n`, -); -await writeFile( - "artifacts/release/build-manifest.json", - `${JSON.stringify(manifest, null, 2)}\n`, -); +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; @@ -165,23 +180,6 @@ function parseViteManifest( return entries; } -function parseModuleInventory(value: unknown): void { - if ( - !isRecord(value) || - value.schemaVersion !== 1 || - !Array.isArray(value.chunks) || - value.chunks.some( - (chunk) => - !isRecord(chunk) || - typeof chunk.fileName !== "string" || - !Array.isArray(chunk.modules) || - chunk.modules.some((moduleId) => typeof moduleId !== "string"), - ) - ) { - throw new TypeError("Vite module inventory is invalid"); - } -} - function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } diff --git a/scripts/generate-supply-chain.ts b/scripts/generate-supply-chain.ts index 5c869ac..6f04663 100644 --- a/scripts/generate-supply-chain.ts +++ b/scripts/generate-supply-chain.ts @@ -10,6 +10,16 @@ import { } from "node:fs/promises"; import path from "node:path"; +import { + bundlePerformanceArtifactSchema, + dependencyDiffArtifactSchema, + dependencyInventoryArtifactSchema, + licenseReportArtifactSchema, + provenanceArtifactSchema, + sbomArtifactSchema, + supplyChainVerificationArtifactSchema, + vulnerabilityReportArtifactSchema, +} from "./contracts/release-artifacts.ts"; import { diffDependencyInventories, flattenPnpmDependencyTree, @@ -22,6 +32,7 @@ import { verifySupplyChainCoherence, type DependencyInventoryDiff, } from "./lib/supply-chain.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; type Document = Record; @@ -428,81 +439,80 @@ const verification = { : "FAIL_UNVERIFIED", failures: localFailures, }; +const bundleReport = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + context: { + nodeVersion: process.version, + packageManager: String(packageJson.packageManager ?? ""), + runnerImage: + process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`, + }, + outputs, +}; +const dependencyDiffReport = { + schemaVersion: 2, + baselineDigest: baseline ? supplyChainDigest(baseline) : null, + currentDigest: supplyChainDigest(inventory), + ...dependencyDiff, + highRisk: reviewResult.highRisk, + reviewFailures: reviewResult.failures, +}; +const licenseReport = { + schemaVersion: 1, + status: licenseResult.passed ? "PASS" : "FAIL", + dependencyCount: inventory.dependencyCount, + results: licenseResult.results, + failures: licenseResult.failures, +}; await mkdir("artifacts/performance", { recursive: true }); await mkdir("artifacts/release", { recursive: true }); await mkdir("artifacts/security", { recursive: true }); -await writeFile( - "artifacts/performance/bundle.json", - `${JSON.stringify( - { - schemaVersion: 1, - generatedAt: new Date().toISOString(), - context: { - nodeVersion: process.version, - packageManager: String(packageJson.packageManager ?? ""), - runnerImage: - process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`, - }, - outputs, - }, - null, - 2, - )}\n`, -); -await writeFile( - "artifacts/release/dependency-inventory.json", - `${JSON.stringify(inventory, null, 2)}\n`, -); -await writeFile( - "artifacts/release/sbom.cdx.json", - `${JSON.stringify(sbom, null, 2)}\n`, -); -await writeFile( - "artifacts/release/provenance.json", - `${JSON.stringify(provenance, null, 2)}\n`, -); +await writeValidatedJsonArtifact({ + path: "artifacts/performance/bundle.json", + schema: bundlePerformanceArtifactSchema, + value: bundleReport, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/release/dependency-inventory.json", + schema: dependencyInventoryArtifactSchema, + value: inventory, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/release/sbom.cdx.json", + schema: sbomArtifactSchema, + value: sbom, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/release/provenance.json", + schema: provenanceArtifactSchema, + value: provenance, +}); await writeFile( "artifacts/release/checksums.txt", `${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`, ); -await writeFile( - "artifacts/security/dependency-diff.json", - `${JSON.stringify( - { - schemaVersion: 2, - baselineDigest: baseline ? supplyChainDigest(baseline) : null, - currentDigest: supplyChainDigest(inventory), - ...dependencyDiff, - highRisk: reviewResult.highRisk, - reviewFailures: reviewResult.failures, - }, - null, - 2, - )}\n`, -); -await writeFile( - "artifacts/security/license-report.json", - `${JSON.stringify( - { - schemaVersion: 1, - status: licenseResult.passed ? "PASS" : "FAIL", - dependencyCount: inventory.dependencyCount, - results: licenseResult.results, - failures: licenseResult.failures, - }, - null, - 2, - )}\n`, -); -await writeFile( - "artifacts/security/vulnerability-report.json", - `${JSON.stringify(vulnerabilityReport, null, 2)}\n`, -); -await writeFile( - "artifacts/security/supply-chain-verification.json", - `${JSON.stringify(verification, null, 2)}\n`, -); +await writeValidatedJsonArtifact({ + path: "artifacts/security/dependency-diff.json", + schema: dependencyDiffArtifactSchema, + value: dependencyDiffReport, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/security/license-report.json", + schema: licenseReportArtifactSchema, + value: licenseReport, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/security/vulnerability-report.json", + schema: vulnerabilityReportArtifactSchema, + value: vulnerabilityReport, +}); +await writeValidatedJsonArtifact({ + path: "artifacts/security/supply-chain-verification.json", + schema: supplyChainVerificationArtifactSchema, + value: verification, +}); if (!localPassed) { process.stderr.write( diff --git a/scripts/lib/validated-json-artifact.ts b/scripts/lib/validated-json-artifact.ts new file mode 100644 index 0000000..75f3a26 --- /dev/null +++ b/scripts/lib/validated-json-artifact.ts @@ -0,0 +1,85 @@ +import { randomUUID } from "node:crypto"; +import { + open as openFile, + rename as renameFile, + rm as removeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import type { z } from "zod"; + +export type ValidatedJsonArtifactInput = Readonly<{ + path: string; + schema: z.ZodType; + value: unknown; +}>; + +export type ValidatedJsonArtifactFileSystem = Readonly<{ + open: (path: string, flags: "wx") => Promise<{ + writeFile(data: string, encoding: "utf8"): Promise; + close(): Promise; + }>; + rename: (source: string, destination: string) => Promise; + rm: (path: string, options: Readonly<{ force: true }>) => Promise; +}>; + +type ValidatedJsonArtifactWriterDependencies = Readonly<{ + createNonce?: () => string; + fileSystem?: ValidatedJsonArtifactFileSystem; +}>; + +const defaultFileSystem: ValidatedJsonArtifactFileSystem = Object.freeze({ + open: async (target, flags) => openFile(target, flags), + rename: async (source, destination) => renameFile(source, destination), + rm: async (target, options) => removeFile(target, options), +}); + +/** + * Builds a writer whose only publish operation is an atomic sibling rename. + * Dependency injection is limited to the file-system boundary so failure + * ownership can be verified without exposing a caller-selected cleanup path. + */ +export function createValidatedJsonArtifactWriter( + dependencies: ValidatedJsonArtifactWriterDependencies = {}, +) { + const createNonce = dependencies.createNonce ?? randomUUID; + const fileSystem = dependencies.fileSystem ?? defaultFileSystem; + + return async function writeArtifact( + input: ValidatedJsonArtifactInput, + ): Promise { + const parsed = input.schema.parse(input.value); + const serialized = JSON.stringify(parsed, null, 2); + if (serialized === undefined) { + throw new TypeError("Validated JSON artifact is not serializable"); + } + + const temporaryPath = path.join( + path.dirname(input.path), + `.${path.basename(input.path)}.${createNonce()}.tmp`, + ); + let ownsTemporaryFile = false; + try { + const handle = await fileSystem.open(temporaryPath, "wx"); + ownsTemporaryFile = true; + try { + await handle.writeFile(`${serialized}\n`, "utf8"); + } finally { + await handle.close(); + } + await fileSystem.rename(temporaryPath, input.path); + } catch (error) { + if (ownsTemporaryFile) { + try { + await fileSystem.rm(temporaryPath, { force: true }); + } catch { + // Preserve the publishing failure; cleanup is confined to our nonce. + } + } + throw error; + } + }; +} + +export const writeValidatedJsonArtifact = + createValidatedJsonArtifactWriter(); diff --git a/scripts/test-performance.ts b/scripts/test-performance.ts index 2e8c144..eac262f 100644 --- a/scripts/test-performance.ts +++ b/scripts/test-performance.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile } from "node:fs/promises"; import { performance } from "node:perf_hooks"; import process from "node:process"; @@ -7,6 +7,8 @@ import { chromium } from "@playwright/test"; import { evaluateLabBudget } from "../src/application/policies/performance-budgets.ts"; import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.ts"; +import { labPerformanceArtifactSchema } from "./contracts/release-artifacts.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; type ContractPerformanceEvidence = { lcpMs: number; @@ -142,22 +144,19 @@ try { ]; const passed = result.passed && fixtures.every((fixture) => fixture.passed); await mkdir("artifacts/performance", { recursive: true }); - await writeFile( - "artifacts/performance/lab.json", - `${JSON.stringify( - { - schemaVersion: 1, - generatedAt: new Date().toISOString(), - context: contextMetadata, - metrics, - thresholds, - fixtures, - passed, - }, - null, - 2, - )}\n`, - ); + await writeValidatedJsonArtifact({ + path: "artifacts/performance/lab.json", + schema: labPerformanceArtifactSchema, + value: { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + context: contextMetadata, + metrics, + thresholds, + fixtures, + passed, + }, + }); if (!passed) { throw new Error(`Lab performance failed: ${JSON.stringify(metrics)}`); } diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts index 261bbd0..311f8d3 100644 --- a/scripts/verify-release.ts +++ b/scripts/verify-release.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { @@ -23,6 +23,8 @@ import { } from "../src/features/installed-feature-contracts.ts"; import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts"; +import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; +import { releaseVerificationArtifactSchema } from "./contracts/release-artifacts.ts"; type CoherenceFixture = Readonly<{ name: string; @@ -212,10 +214,11 @@ const report = { }; await mkdir("artifacts/release", { recursive: true }); -await writeFile( - "artifacts/release/verification.json", - `${JSON.stringify(report, null, 2)}\n`, -); +await writeValidatedJsonArtifact({ + path: "artifacts/release/verification.json", + schema: releaseVerificationArtifactSchema, + value: report, +}); if (!passed) { process.stderr.write( diff --git a/tests/unit/json-schema.test.ts b/tests/unit/json-schema.test.ts index 8ed35c7..46a35b9 100644 --- a/tests/unit/json-schema.test.ts +++ b/tests/unit/json-schema.test.ts @@ -1,9 +1,28 @@ -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { generateArtifactSchemas } from "../../scripts/generate-artifact-schemas.ts"; import { assertMatchesJsonSchema } from "../../scripts/lib/json-schema.ts"; +const temporaryDirectories: string[] = []; + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "artifact-schemas-")); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { force: true, recursive: true }), + ), + ); +}); + async function json(path: string): Promise { return JSON.parse(await readFile(path, "utf8")) as unknown; } @@ -55,4 +74,130 @@ describe("checked-in JSON Schema execution", () => { assertMatchesJsonSchema(schema, catalog, "optional recipe catalog"), ).not.toThrow(); }); + + it("generates deterministic draft 2020-12 artifact schemas and detects drift", async () => { + const root = await temporaryDirectory(); + await generateArtifactSchemas({ root }); + const generatedPath = path.join( + root, + "schemas/artifacts/dependency-inventory.schema.json", + ); + const generated = await readFile(generatedPath, "utf8"); + expect(generated.endsWith("\n")).toBe(true); + expect(JSON.parse(generated)).toMatchObject({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + additionalProperties: false, + }); + + await generateArtifactSchemas({ root, check: true }); + await writeFile(generatedPath, `${generated.trimEnd()} \n`, "utf8"); + + await expect( + generateArtifactSchemas({ root, check: true }), + ).rejects.toThrow(/dependency-inventory\.schema\.json/u); + }); + + it.each([ + { + name: "dependency inventory", + path: "schemas/artifacts/dependency-inventory.schema.json", + value: { + schemaVersion: 2, + packageManager: "pnpm@11.17.0", + lockfileSha256: "a".repeat(64), + dependencyCount: 1, + directDependencyCount: 1, + dependencies: [ + { + name: "zod", + version: "4.4.3", + direct: true, + scope: "production", + optional: false, + license: "MIT", + integrity: `sha512-${"a".repeat(86)}`, + dependencies: [], + }, + ], + }, + }, + { + name: "registry snapshot", + path: "schemas/artifacts/registry-snapshot.schema.json", + value: { + schemaVersion: 2, + generatedAt: "2026-08-01T00:00:00.000Z", + baselineDigest: "b".repeat(64), + currentDigest: "c".repeat(64), + compatibility: { impact: "none", changes: [] }, + failures: [], + registries: Array.from({ length: 11 }, (_, index) => ({ + registryId: `registry-${index}`, + owner: "platform", + source: `src/registry-${index}.ts`, + rowCount: 0, + contract: {}, + rows: {}, + })), + }, + }, + { + name: "supply-chain verification", + path: "schemas/artifacts/supply-chain-verification.schema.json", + value: { + schemaVersion: 1, + localStatus: "PASS", + promotionStatus: "FAIL_UNVERIFIED", + lockfileSha256: "d".repeat(64), + sourceSetSha256: "e".repeat(64), + distSha256: "f".repeat(64), + sbomSha256: "0".repeat(64), + dependencyDiff: { + added: [], + removed: [], + changed: [], + upgrades: [], + }, + highRiskReview: [], + vulnerabilityStatus: "FAIL_UNVERIFIED", + provenanceAttestationStatus: "FAIL_UNVERIFIED", + failures: [], + }, + }, + ])("accepts $name and rejects undeclared top-level fields", async (fixture) => { + const schema = await json(fixture.path); + + expect(() => + assertMatchesJsonSchema(schema, fixture.value, fixture.name), + ).not.toThrow(); + expect(() => + assertMatchesJsonSchema( + schema, + { ...fixture.value, undocumented: true }, + fixture.name, + ), + ).toThrow(/checked-in JSON Schema/u); + if (fixture.name === "dependency inventory") { + const dependency = fixture.value.dependencies?.[0]; + if (dependency === undefined) { + throw new TypeError("dependency inventory fixture is incomplete"); + } + expect(() => + assertMatchesJsonSchema( + schema, + { + ...fixture.value, + dependencies: [ + { + ...dependency, + undocumented: true, + }, + ], + }, + fixture.name, + ), + ).toThrow(/checked-in JSON Schema/u); + } + }); }); diff --git a/tests/unit/release-artifacts.test.ts b/tests/unit/release-artifacts.test.ts index 99140cf..ce24c46 100644 --- a/tests/unit/release-artifacts.test.ts +++ b/tests/unit/release-artifacts.test.ts @@ -1,10 +1,18 @@ +import { readFile } from "node:fs/promises"; + import { describe, expect, it } from "vitest"; import { + dependencyInventoryArtifactSchema, + fieldWebVitalsArtifactSchema, + jsonSchemaDocumentArtifactSchema, + labPerformanceArtifactSchema, parseBuildManifestArtifact, parseReleaseArtifact, parseRuntimeConfigArtifact, projectReleaseTokens, + registrySnapshotArtifactSchema, + supplyChainVerificationArtifactSchema, } from "../../scripts/contracts/release-artifacts.ts"; const EMPTY_CONTRACT_SET_DIGEST = @@ -126,4 +134,241 @@ describe("release artifact contracts", () => { }), ).not.toHaveProperty("API_CONTRACT_VERSION"); }); + + it("enforces dependency inventory counts at the executable writer boundary", () => { + const inventory = { + schemaVersion: 2, + packageManager: "pnpm@11.17.0", + lockfileSha256: "a".repeat(64), + dependencyCount: 1, + directDependencyCount: 1, + dependencies: [ + { + name: "zod", + version: "4.4.3", + direct: true, + scope: "production", + optional: false, + license: "MIT", + integrity: `sha512-${"a".repeat(86)}`, + dependencies: [], + }, + ], + } as const; + + expect(dependencyInventoryArtifactSchema.parse(inventory)).toEqual( + inventory, + ); + expect(() => + dependencyInventoryArtifactSchema.parse({ + ...inventory, + dependencyCount: 2, + }), + ).toThrow(); + }); + + it("rejects undeclared supply-chain verification evidence", () => { + const verification = { + schemaVersion: 1, + localStatus: "PASS", + promotionStatus: "FAIL_UNVERIFIED", + lockfileSha256: "a".repeat(64), + sourceSetSha256: "b".repeat(64), + distSha256: "c".repeat(64), + sbomSha256: "d".repeat(64), + dependencyDiff: { + added: [], + removed: [], + changed: [], + upgrades: [], + }, + highRiskReview: [], + vulnerabilityStatus: "FAIL_UNVERIFIED", + provenanceAttestationStatus: "FAIL_UNVERIFIED", + failures: [], + } as const; + + expect(supplyChainVerificationArtifactSchema.parse(verification)).toEqual( + verification, + ); + expect(() => + supplyChainVerificationArtifactSchema.parse({ + ...verification, + undocumented: true, + }), + ).toThrow(); + }); + + it("preserves no-baseline and failure registry evidence", () => { + const failureEvidence = { + schemaVersion: 2, + generatedAt: "2026-08-01T00:00:00.000Z", + baselineDigest: null, + currentDigest: "a".repeat(64), + compatibility: { impact: "not-evaluated", changes: [] }, + failures: ["missing registry source"], + registries: [], + } as const; + + expect(registrySnapshotArtifactSchema.parse(failureEvidence)).toEqual( + failureEvidence, + ); + }); + + it("preserves unverified field evidence when no samples are eligible", () => { + const report = { + schemaVersion: 1, + generatedAt: "2026-08-01T00:00:00.000Z", + window: { + days: 28, + start: "2026-07-04T00:00:00.000Z", + end: "2026-08-01T00:00:00.000Z", + }, + context: { + source: "config/performance/field-input.example.json", + sourceSystem: null, + exportId: null, + network: "production-real-user", + routeAggregation: "route-id-only", + releaseId: null, + privacyApprovalRef: null, + thresholdDecisionRef: null, + validationFailures: ["input: invalid evidence"], + }, + metrics: { p75LcpMs: null, p75Cls: null, p75InpMs: null }, + thresholds: { + p75LcpMs: 2_500, + p75Cls: 0.1, + p75InpMs: 200, + minimumEligibleSamples: null, + }, + eligibility: { + consentRequired: true, + totalSamples: 0, + eligibleSamples: 0, + minimumEligibleSamples: null, + routeSamples: {}, + }, + status: "FAIL_UNVERIFIED", + passed: false, + } as const; + + expect(fieldWebVitalsArtifactSchema.parse(report)).toEqual(report); + }); + + it("preserves verified field evidence that fails an approved threshold", () => { + const report = { + schemaVersion: 1, + generatedAt: "2026-08-01T00:00:00.000Z", + window: { + days: 28, + start: "2026-07-04T00:00:00.000Z", + end: "2026-08-01T00:00:00.000Z", + }, + context: { + source: "provider.json", + sourceSystem: "provider", + exportId: "export-1", + network: "production-real-user", + routeAggregation: "route-id-only", + releaseId: "release-1", + privacyApprovalRef: "privacy-1", + thresholdDecisionRef: "decision-1", + validationFailures: [], + }, + metrics: { p75LcpMs: 2_501, p75Cls: 0.1, p75InpMs: 200 }, + thresholds: { + p75LcpMs: 2_500, + p75Cls: 0.1, + p75InpMs: 200, + minimumEligibleSamples: 1, + }, + eligibility: { + consentRequired: true, + totalSamples: 1, + eligibleSamples: 1, + minimumEligibleSamples: 1, + routeSamples: { APP_HOME: 1 }, + }, + status: "FAIL_THRESHOLD", + passed: false, + } as const; + + expect(fieldWebVitalsArtifactSchema.parse(report)).toEqual(report); + }); + + it("accepts the lab writer's nested browser context as JSON evidence", () => { + const report = { + schemaVersion: 1, + generatedAt: "2026-08-01T00:00:00.000Z", + context: { + runner: { platform: "linux", architecture: "x64", nodeVersion: "v24" }, + browser: { name: "chromium", version: "140" }, + viewport: { width: 1280, height: 720 }, + network: { + profile: "contract-fast-4g", + latencyMs: 40, + downloadBytesPerSecond: 200_000, + uploadBytesPerSecond: 93_750, + }, + cpu: { throttlingRate: 4 }, + cache: { state: "cold", isolation: "new-browser-context" }, + build: { buildId: "build-1", releaseId: "release-1" }, + }, + metrics: { lcpMs: 1_000, cls: 0.01, namedInteractionMs: 100 }, + thresholds: { lcpMs: 2_500, cls: 0.1, namedInteractionMs: 200 }, + fixtures: [ + { name: "missing-context", passed: true }, + { name: "lcp-over-threshold", passed: true }, + ], + passed: true, + } as const; + + expect(labPerformanceArtifactSchema.parse(report)).toEqual(report); + }); + + it("rejects non-JSON values in generated schema documents", () => { + expect(() => + jsonSchemaDocumentArtifactSchema.parse({ + $schema: "https://json-schema.org/draft/2020-12/schema", + invalid: () => undefined, + }), + ).toThrow(); + }); + + it("keeps machine-readable evidence publishers on the validated writer", async () => { + const writerFiles = [ + "scripts/generate-build-manifest.ts", + "scripts/generate-supply-chain.ts", + "scripts/collect-web-vitals-evidence.ts", + "scripts/test-performance.ts", + "scripts/verify-release.ts", + "scripts/drill-runbook.ts", + "scripts/check-registries.ts", + ] as const; + const sources = await Promise.all( + writerFiles.map(async (file) => ({ + file, + source: await readFile(file, "utf8"), + })), + ); + + for (const { file, source } of sources) { + const directWrites = source.match(/\bwriteFile\s*\(/gu) ?? []; + if (file === "scripts/generate-supply-chain.ts") { + expect(directWrites, file).toHaveLength(1); + expect(source, file).toMatch( + /writeFile\(\s*["']artifacts\/release\/checksums\.txt["']/u, + ); + } else { + expect(directWrites, file).toHaveLength(0); + } + } + + expect( + sources.flatMap(({ source }) => + source.match(/\bwriteValidatedJsonArtifact\s*\(/gu) ?? [], + ), + ).toHaveLength(19); + }); }); diff --git a/tests/unit/validated-json-artifact.test.ts b/tests/unit/validated-json-artifact.test.ts new file mode 100644 index 0000000..3cfb50d --- /dev/null +++ b/tests/unit/validated-json-artifact.test.ts @@ -0,0 +1,169 @@ +import { + mkdtemp, + open, + readFile, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createValidatedJsonArtifactWriter, + writeValidatedJsonArtifact, +} from "../../scripts/lib/validated-json-artifact.ts"; + +const artifactSchema = z + .object({ schemaVersion: z.literal(1), name: z.string().min(1) }) + .strict(); + +const temporaryDirectories: string[] = []; + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp( + path.join(tmpdir(), "validated-json-artifact-"), + ); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { force: true, recursive: true }), + ), + ); +}); + +describe("validated JSON artifact writer", () => { + it.each(["existing", "missing"] as const)( + "rejects invalid %s artifacts before changing destination state", + async (destinationState) => { + const directory = await temporaryDirectory(); + const destination = path.join(directory, "artifact.json"); + if (destinationState === "existing") { + await writeFile(destination, "previous-bytes\n", "utf8"); + } + const entriesBefore = await readdir(directory); + + await expect( + writeValidatedJsonArtifact({ + path: destination, + schema: artifactSchema, + value: { schemaVersion: 1, name: "" }, + }), + ).rejects.toThrow(); + + expect(await readdir(directory)).toEqual(entriesBefore); + if (destinationState === "existing") { + await expect(readFile(destination, "utf8")).resolves.toBe( + "previous-bytes\n", + ); + } else { + await expect(readFile(destination, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + } + }, + ); + + it("publishes complete formatted bytes through a sibling rename", async () => { + const directory = await temporaryDirectory(); + const destination = path.join(directory, "artifact.json"); + + await writeValidatedJsonArtifact({ + path: destination, + schema: artifactSchema, + value: { schemaVersion: 1, name: "valid" }, + }); + + await expect(readFile(destination, "utf8")).resolves.toBe( + '{\n "schemaVersion": 1,\n "name": "valid"\n}\n', + ); + expect(await readdir(directory)).toEqual(["artifact.json"]); + }); + + it.each(["write", "rename"] as const)( + "cleans only its owned sibling temp when %s fails", + async (failurePoint) => { + const directory = await temporaryDirectory(); + const destination = path.join(directory, "artifact.json"); + const unrelatedTemp = path.join(directory, ".artifact.json.unrelated.tmp"); + const ownedTemp = path.join(directory, ".artifact.json.owned.tmp"); + await writeFile(destination, "previous-bytes\n", "utf8"); + await writeFile(unrelatedTemp, "unrelated\n", "utf8"); + const writer = createValidatedJsonArtifactWriter({ + createNonce: () => "owned", + fileSystem: { + open: async (target, flags) => { + const handle = await open(target, flags); + return { + writeFile: async (data, encoding) => { + await handle.writeFile(data, encoding); + if (failurePoint === "write") { + throw new Error("injected write failure"); + } + }, + close: async () => handle.close(), + }; + }, + rename: async (source, target) => { + if (failurePoint === "rename") { + throw new Error("injected rename failure"); + } + await rename(source, target); + }, + rm, + }, + }); + + await expect( + writer({ + path: destination, + schema: artifactSchema, + value: { schemaVersion: 1, name: "valid" }, + }), + ).rejects.toThrow(`injected ${failurePoint} failure`); + + await expect(readFile(destination, "utf8")).resolves.toBe( + "previous-bytes\n", + ); + await expect(readFile(unrelatedTemp, "utf8")).resolves.toBe( + "unrelated\n", + ); + await expect(readFile(ownedTemp, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, + ); + + it("does not delete a pre-existing colliding sibling temp", async () => { + const directory = await temporaryDirectory(); + const destination = path.join(directory, "artifact.json"); + const collidingTemp = path.join(directory, ".artifact.json.collision.tmp"); + await writeFile(collidingTemp, "another-writer\n", "utf8"); + const writer = createValidatedJsonArtifactWriter({ + createNonce: () => "collision", + }); + + await expect( + writer({ + path: destination, + schema: artifactSchema, + value: { schemaVersion: 1, name: "valid" }, + }), + ).rejects.toMatchObject({ code: "EEXIST" }); + + await expect(readFile(collidingTemp, "utf8")).resolves.toBe( + "another-writer\n", + ); + await expect(readFile(destination, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +});