From 381d5549e2e1efceed0bd5ce7d63cc82b98ccfaf Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 2 Aug 2026 04:46:33 +0900 Subject: [PATCH] fix: preserve artifact writer failures --- .../dependency-inventory.schema.json | 5 +- .../artifacts/registry-snapshot.schema.json | 449 ++++++++++++------ scripts/check-registries.ts | 12 +- scripts/contracts/release-artifacts.ts | 35 +- scripts/lib/validated-json-artifact.ts | 17 +- tests/unit/json-schema.test.ts | 34 ++ tests/unit/release-artifacts.test.ts | 37 ++ tests/unit/validated-json-artifact.test.ts | 135 ++++++ 8 files changed, 575 insertions(+), 149 deletions(-) diff --git a/schemas/artifacts/dependency-inventory.schema.json b/schemas/artifacts/dependency-inventory.schema.json index 217c9d4..3cf36c6 100644 --- a/schemas/artifacts/dependency-inventory.schema.json +++ b/schemas/artifacts/dependency-inventory.schema.json @@ -56,16 +56,17 @@ ], "type": "object" }, + "minItems": 1, "type": "array" }, "dependencyCount": { + "exclusiveMinimum": 0, "maximum": 9007199254740991, - "minimum": 0, "type": "integer" }, "directDependencyCount": { + "exclusiveMinimum": 0, "maximum": 9007199254740991, - "minimum": 0, "type": "integer" }, "lockfileSha256": { diff --git a/schemas/artifacts/registry-snapshot.schema.json b/schemas/artifacts/registry-snapshot.schema.json index 599abeb..199e344 100644 --- a/schemas/artifacts/registry-snapshot.schema.json +++ b/schemas/artifacts/registry-snapshot.schema.json @@ -95,50 +95,123 @@ ] } }, - "additionalProperties": false, - "properties": { - "baselineDigest": { - "anyOf": [ - { + "anyOf": [ + { + "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" }, - { - "type": "null" - } - ] - }, - "compatibility": { - "additionalProperties": false, - "properties": { - "changes": { + "failures": { + "items": { + "type": "string" + }, + "maxItems": 0, + "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": { - "after": { - "$ref": "#/$defs/__schema1" + "contract": { + "additionalProperties": { + "$ref": "#/$defs/__schema2" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" }, - "before": { - "$ref": "#/$defs/__schema0" - }, - "changeId": { - "minLength": 1, - "type": "string" - }, - "field": { - "minLength": 1, - "type": "string" - }, - "impact": { - "enum": [ - "none", - "additive", - "behavior-change", - "breaking" - ], - "type": "string" - }, - "kind": { + "owner": { "minLength": 1, "type": "string" }, @@ -146,120 +219,224 @@ "minLength": 1, "type": "string" }, - "rowName": { + "rowCount": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "additionalProperties": { + "$ref": "#/$defs/__schema2" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "source": { "minLength": 1, "type": "string" } }, "required": [ - "changeId", "registryId", - "rowName", - "field", - "kind", - "impact" + "owner", + "source", + "rowCount", + "contract", + "rows" + ], + "type": "object" + }, + "maxItems": 11, + "minItems": 11, + "type": "array" + }, + "schemaVersion": { + "const": 2, + "type": "number" + } + }, + "required": [ + "schemaVersion", + "generatedAt", + "baselineDigest", + "currentDigest", + "compatibility", + "failures", + "registries" + ], + "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" + }, + "minItems": 1, + "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" }, - "impact": { - "enum": [ - "not-evaluated", - "none", - "additive", - "behavior-change", - "breaking" - ], - "type": "string" + "schemaVersion": { + "const": 2, + "type": "number" } }, "required": [ - "impact", - "changes" + "schemaVersion", + "generatedAt", + "baselineDigest", + "currentDigest", + "compatibility", + "failures", + "registries" ], "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", - "baselineDigest", - "currentDigest", - "compatibility", - "failures", - "registries" - ], - "type": "object" + ] } diff --git a/scripts/check-registries.ts b/scripts/check-registries.ts index 1610c4c..9c45864 100644 --- a/scripts/check-registries.ts +++ b/scripts/check-registries.ts @@ -15,7 +15,10 @@ import { verifyRegistryBaselineApproval, } from "./lib/registry-compatibility.ts"; import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; -import { registrySnapshotArtifactSchema } from "./contracts/release-artifacts.ts"; +import { + registryGovernanceRunArtifactSchema, + registrySnapshotArtifactSchema, +} from "./contracts/release-artifacts.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; type RegistryRow = Record; @@ -79,8 +82,9 @@ const governancePath = const artifactPath = argumentValue("--artifact", "artifacts/quality/registries.json") ?? "artifacts/quality/registries.json"; +const usesRepositoryGovernance = governancePath === defaultGovernancePath; const usesRepositoryBaseline = - governancePath === defaultGovernancePath && + usesRepositoryGovernance && !process.argv.includes("--no-baseline"); const baselinePath = argumentValue( "--baseline", @@ -678,7 +682,9 @@ if (usesRepositoryBaseline && failures.length === 0) { await mkdir(path.dirname(artifactPath), { recursive: true }); await writeValidatedJsonArtifact({ path: artifactPath, - schema: registrySnapshotArtifactSchema, + schema: usesRepositoryGovernance + ? registrySnapshotArtifactSchema + : registryGovernanceRunArtifactSchema, value: report, }); diff --git a/scripts/contracts/release-artifacts.ts b/scripts/contracts/release-artifacts.ts index 1d71c60..05add5b 100644 --- a/scripts/contracts/release-artifacts.ts +++ b/scripts/contracts/release-artifacts.ts @@ -45,9 +45,9 @@ export const dependencyInventoryArtifactSchema = z schemaVersion: z.literal(2), packageManager: nonEmptyString, lockfileSha256: sha256, - dependencyCount: z.int().nonnegative(), - directDependencyCount: z.int().nonnegative(), - dependencies: z.array(dependencyInventoryRowSchema), + dependencyCount: z.int().positive(), + directDependencyCount: z.int().positive(), + dependencies: z.array(dependencyInventoryRowSchema).min(1), }) .strict() .superRefine((inventory, context) => { @@ -128,7 +128,7 @@ const registryArtifactRowSchema = z }) .strict(); -export const registrySnapshotArtifactSchema = z +const registrySnapshotBaseArtifactSchema = z .object({ schemaVersion: z.literal(2), generatedAt: timestamp, @@ -146,11 +146,34 @@ export const registrySnapshotArtifactSchema = z changes: z.array(registryChangeSchema), }) .strict(), - failures: z.array(z.string()), - registries: z.array(registryArtifactRowSchema), }) .strict(); +const successfulRegistrySnapshotArtifactSchema = + registrySnapshotBaseArtifactSchema.extend({ + failures: z.array(z.string()).max(0), + registries: z.array(registryArtifactRowSchema).length(11), + }); + +const failedRegistrySnapshotArtifactSchema = + registrySnapshotBaseArtifactSchema.extend({ + failures: z.array(z.string()).min(1), + registries: z.array(registryArtifactRowSchema), + }); + +export const registrySnapshotArtifactSchema = z.union([ + successfulRegistrySnapshotArtifactSchema, + failedRegistrySnapshotArtifactSchema, +]); + +export const registryGovernanceRunArtifactSchema = z.union([ + registrySnapshotBaseArtifactSchema.extend({ + failures: z.array(z.string()).max(0), + registries: z.array(registryArtifactRowSchema).min(1), + }), + failedRegistrySnapshotArtifactSchema, +]); + const outputDigestSchema = z .object({ path: nonEmptyString, diff --git a/scripts/lib/validated-json-artifact.ts b/scripts/lib/validated-json-artifact.ts index 75f3a26..68bd0e2 100644 --- a/scripts/lib/validated-json-artifact.ts +++ b/scripts/lib/validated-json-artifact.ts @@ -62,11 +62,24 @@ export function createValidatedJsonArtifactWriter( try { const handle = await fileSystem.open(temporaryPath, "wx"); ownsTemporaryFile = true; + let writeFailed = false; + let writeFailure: unknown; try { await handle.writeFile(`${serialized}\n`, "utf8"); - } finally { - await handle.close(); + } catch (error) { + writeFailed = true; + writeFailure = error; } + let closeFailed = false; + let closeFailure: unknown; + try { + await handle.close(); + } catch (error) { + closeFailed = true; + closeFailure = error; + } + if (writeFailed) throw writeFailure; + if (closeFailed) throw closeFailure; await fileSystem.rename(temporaryPath, input.path); } catch (error) { if (ownsTemporaryFile) { diff --git a/tests/unit/json-schema.test.ts b/tests/unit/json-schema.test.ts index 46a35b9..ad6c043 100644 --- a/tests/unit/json-schema.test.ts +++ b/tests/unit/json-schema.test.ts @@ -198,6 +198,40 @@ describe("checked-in JSON Schema execution", () => { fixture.name, ), ).toThrow(/checked-in JSON Schema/u); + expect(() => + assertMatchesJsonSchema( + schema, + { + ...fixture.value, + dependencyCount: 0, + directDependencyCount: 0, + dependencies: [], + }, + fixture.name, + ), + ).toThrow(/checked-in JSON Schema/u); + } + if (fixture.name === "registry snapshot") { + expect(() => + assertMatchesJsonSchema( + schema, + { ...fixture.value, failures: [], registries: [] }, + fixture.name, + ), + ).toThrow(/checked-in JSON Schema/u); + expect(() => + assertMatchesJsonSchema( + schema, + { + ...fixture.value, + baselineDigest: null, + compatibility: { impact: "not-evaluated", changes: [] }, + failures: ["missing registry source"], + registries: [], + }, + fixture.name, + ), + ).not.toThrow(); } }); }); diff --git a/tests/unit/release-artifacts.test.ts b/tests/unit/release-artifacts.test.ts index ce24c46..7c01d88 100644 --- a/tests/unit/release-artifacts.test.ts +++ b/tests/unit/release-artifacts.test.ts @@ -165,6 +165,14 @@ describe("release artifact contracts", () => { dependencyCount: 2, }), ).toThrow(); + expect(() => + dependencyInventoryArtifactSchema.parse({ + ...inventory, + dependencyCount: 0, + directDependencyCount: 0, + dependencies: [], + }), + ).toThrow(); }); it("rejects undeclared supply-chain verification evidence", () => { @@ -215,6 +223,35 @@ describe("release artifact contracts", () => { ); }); + it("requires the complete repository registry set for success evidence", () => { + const successfulEvidence = { + schemaVersion: 2, + generatedAt: "2026-08-01T00:00:00.000Z", + baselineDigest: null, + currentDigest: "a".repeat(64), + compatibility: { impact: "not-evaluated", changes: [] }, + failures: [], + registries: Array.from({ length: 11 }, (_, index) => ({ + registryId: `registry-${index}`, + owner: "platform", + source: `src/registry-${index}.ts`, + rowCount: 0, + contract: {}, + rows: {}, + })), + } as const; + + expect(registrySnapshotArtifactSchema.parse(successfulEvidence)).toEqual( + successfulEvidence, + ); + expect(() => + registrySnapshotArtifactSchema.parse({ + ...successfulEvidence, + registries: [], + }), + ).toThrow(); + }); + it("preserves unverified field evidence when no samples are eligible", () => { const report = { schemaVersion: 1, diff --git a/tests/unit/validated-json-artifact.test.ts b/tests/unit/validated-json-artifact.test.ts index 3cfb50d..f17a2ad 100644 --- a/tests/unit/validated-json-artifact.test.ts +++ b/tests/unit/validated-json-artifact.test.ts @@ -88,6 +88,141 @@ describe("validated JSON artifact writer", () => { expect(await readdir(directory)).toEqual(["artifact.json"]); }); + it("writes the schema's defaulted and transformed parsed output", async () => { + const directory = await temporaryDirectory(); + const destination = path.join(directory, "artifact.json"); + const parsedOutputSchema = z + .object({ + name: z.string().default("generated"), + count: z.string().transform((value) => Number(value)), + }) + .strict(); + + await writeValidatedJsonArtifact({ + path: destination, + schema: parsedOutputSchema, + value: { count: "3" }, + }); + + expect(JSON.parse(await readFile(destination, "utf8"))).toEqual({ + name: "generated", + count: 3, + }); + }); + + it("reports a close-only failure and cleans its owned temp", async () => { + const directory = await temporaryDirectory(); + const destination = path.join(directory, "artifact.json"); + const ownedTemp = path.join(directory, ".artifact.json.close-only.tmp"); + const closeError = new Error("injected close-only failure"); + const writer = createValidatedJsonArtifactWriter({ + createNonce: () => "close-only", + fileSystem: { + open: async (target, flags) => { + const handle = await open(target, flags); + return { + writeFile: async (data, encoding) => + handle.writeFile(data, encoding), + close: async () => { + await handle.close(); + throw closeError; + }, + }; + }, + rename, + rm, + }, + }); + + await expect( + writer({ + path: destination, + schema: artifactSchema, + value: { schemaVersion: 1, name: "valid" }, + }), + ).rejects.toBe(closeError); + await expect(readFile(destination, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(readFile(ownedTemp, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("preserves the write error when write and close both fail", async () => { + const directory = await temporaryDirectory(); + const destination = path.join(directory, "artifact.json"); + const ownedTemp = path.join(directory, ".artifact.json.double-failure.tmp"); + const writeError = new Error("injected primary write failure"); + const closeError = new Error("injected secondary close failure"); + const writer = createValidatedJsonArtifactWriter({ + createNonce: () => "double-failure", + fileSystem: { + open: async (target, flags) => { + const handle = await open(target, flags); + return { + writeFile: async (data, encoding) => { + await handle.writeFile(data, encoding); + throw writeError; + }, + close: async () => { + await handle.close(); + throw closeError; + }, + }; + }, + rename, + rm, + }, + }); + + await expect( + writer({ + path: destination, + schema: artifactSchema, + value: { schemaVersion: 1, name: "valid" }, + }), + ).rejects.toBe(writeError); + await expect(readFile(destination, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(readFile(ownedTemp, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("publishes one complete document when two valid writers race", async () => { + const directory = await temporaryDirectory(); + const destination = path.join(directory, "artifact.json"); + const nonces = ["first", "second"]; + const writer = createValidatedJsonArtifactWriter({ + createNonce: () => { + const nonce = nonces.shift(); + if (nonce === undefined) throw new Error("nonce fixture exhausted"); + return nonce; + }, + }); + + await Promise.all([ + writer({ + path: destination, + schema: artifactSchema, + value: { schemaVersion: 1, name: "first" }, + }), + writer({ + path: destination, + schema: artifactSchema, + value: { schemaVersion: 1, name: "second" }, + }), + ]); + + expect([ + { schemaVersion: 1, name: "first" }, + { schemaVersion: 1, name: "second" }, + ]).toContainEqual(JSON.parse(await readFile(destination, "utf8"))); + expect(await readdir(directory)).toEqual(["artifact.json"]); + }); + it.each(["write", "rename"] as const)( "cleans only its owned sibling temp when %s fails", async (failurePoint) => {