fix: preserve artifact writer failures

This commit is contained in:
DongHyeonka
2026-08-02 04:46:33 +09:00
parent c9f5887cac
commit 381d5549e2
8 changed files with 575 additions and 149 deletions
@@ -56,16 +56,17 @@
], ],
"type": "object" "type": "object"
}, },
"minItems": 1,
"type": "array" "type": "array"
}, },
"dependencyCount": { "dependencyCount": {
"exclusiveMinimum": 0,
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": 0,
"type": "integer" "type": "integer"
}, },
"directDependencyCount": { "directDependencyCount": {
"exclusiveMinimum": 0,
"maximum": 9007199254740991, "maximum": 9007199254740991,
"minimum": 0,
"type": "integer" "type": "integer"
}, },
"lockfileSha256": { "lockfileSha256": {
+313 -136
View File
@@ -95,50 +95,123 @@
] ]
} }
}, },
"additionalProperties": false, "anyOf": [
"properties": { {
"baselineDigest": { "additionalProperties": false,
"anyOf": [ "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}$", "pattern": "^[a-f0-9]{64}$",
"type": "string" "type": "string"
}, },
{ "failures": {
"type": "null" "items": {
} "type": "string"
] },
}, "maxItems": 0,
"compatibility": { "type": "array"
"additionalProperties": false, },
"properties": { "generatedAt": {
"changes": { "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": { "items": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"after": { "contract": {
"$ref": "#/$defs/__schema1" "additionalProperties": {
"$ref": "#/$defs/__schema2"
},
"propertyNames": {
"type": "string"
},
"type": "object"
}, },
"before": { "owner": {
"$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, "minLength": 1,
"type": "string" "type": "string"
}, },
@@ -146,120 +219,224 @@
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
"rowName": { "rowCount": {
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
},
"rows": {
"additionalProperties": {
"$ref": "#/$defs/__schema2"
},
"propertyNames": {
"type": "string"
},
"type": "object"
},
"source": {
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
} }
}, },
"required": [ "required": [
"changeId",
"registryId", "registryId",
"rowName", "owner",
"field", "source",
"kind", "rowCount",
"impact" "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": "object"
}, },
"type": "array" "type": "array"
}, },
"impact": { "schemaVersion": {
"enum": [ "const": 2,
"not-evaluated", "type": "number"
"none",
"additive",
"behavior-change",
"breaking"
],
"type": "string"
} }
}, },
"required": [ "required": [
"impact", "schemaVersion",
"changes" "generatedAt",
"baselineDigest",
"currentDigest",
"compatibility",
"failures",
"registries"
], ],
"type": "object" "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"
} }
+9 -3
View File
@@ -15,7 +15,10 @@ import {
verifyRegistryBaselineApproval, verifyRegistryBaselineApproval,
} from "./lib/registry-compatibility.ts"; } from "./lib/registry-compatibility.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.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"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type RegistryRow = Record<string, unknown>; type RegistryRow = Record<string, unknown>;
@@ -79,8 +82,9 @@ const governancePath =
const artifactPath = const artifactPath =
argumentValue("--artifact", "artifacts/quality/registries.json") ?? argumentValue("--artifact", "artifacts/quality/registries.json") ??
"artifacts/quality/registries.json"; "artifacts/quality/registries.json";
const usesRepositoryGovernance = governancePath === defaultGovernancePath;
const usesRepositoryBaseline = const usesRepositoryBaseline =
governancePath === defaultGovernancePath && usesRepositoryGovernance &&
!process.argv.includes("--no-baseline"); !process.argv.includes("--no-baseline");
const baselinePath = argumentValue( const baselinePath = argumentValue(
"--baseline", "--baseline",
@@ -678,7 +682,9 @@ if (usesRepositoryBaseline && failures.length === 0) {
await mkdir(path.dirname(artifactPath), { recursive: true }); await mkdir(path.dirname(artifactPath), { recursive: true });
await writeValidatedJsonArtifact({ await writeValidatedJsonArtifact({
path: artifactPath, path: artifactPath,
schema: registrySnapshotArtifactSchema, schema: usesRepositoryGovernance
? registrySnapshotArtifactSchema
: registryGovernanceRunArtifactSchema,
value: report, value: report,
}); });
+29 -6
View File
@@ -45,9 +45,9 @@ export const dependencyInventoryArtifactSchema = z
schemaVersion: z.literal(2), schemaVersion: z.literal(2),
packageManager: nonEmptyString, packageManager: nonEmptyString,
lockfileSha256: sha256, lockfileSha256: sha256,
dependencyCount: z.int().nonnegative(), dependencyCount: z.int().positive(),
directDependencyCount: z.int().nonnegative(), directDependencyCount: z.int().positive(),
dependencies: z.array(dependencyInventoryRowSchema), dependencies: z.array(dependencyInventoryRowSchema).min(1),
}) })
.strict() .strict()
.superRefine((inventory, context) => { .superRefine((inventory, context) => {
@@ -128,7 +128,7 @@ const registryArtifactRowSchema = z
}) })
.strict(); .strict();
export const registrySnapshotArtifactSchema = z const registrySnapshotBaseArtifactSchema = z
.object({ .object({
schemaVersion: z.literal(2), schemaVersion: z.literal(2),
generatedAt: timestamp, generatedAt: timestamp,
@@ -146,11 +146,34 @@ export const registrySnapshotArtifactSchema = z
changes: z.array(registryChangeSchema), changes: z.array(registryChangeSchema),
}) })
.strict(), .strict(),
failures: z.array(z.string()),
registries: z.array(registryArtifactRowSchema),
}) })
.strict(); .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 const outputDigestSchema = z
.object({ .object({
path: nonEmptyString, path: nonEmptyString,
+15 -2
View File
@@ -62,11 +62,24 @@ export function createValidatedJsonArtifactWriter(
try { try {
const handle = await fileSystem.open(temporaryPath, "wx"); const handle = await fileSystem.open(temporaryPath, "wx");
ownsTemporaryFile = true; ownsTemporaryFile = true;
let writeFailed = false;
let writeFailure: unknown;
try { try {
await handle.writeFile(`${serialized}\n`, "utf8"); await handle.writeFile(`${serialized}\n`, "utf8");
} finally { } catch (error) {
await handle.close(); 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); await fileSystem.rename(temporaryPath, input.path);
} catch (error) { } catch (error) {
if (ownsTemporaryFile) { if (ownsTemporaryFile) {
+34
View File
@@ -198,6 +198,40 @@ describe("checked-in JSON Schema execution", () => {
fixture.name, fixture.name,
), ),
).toThrow(/checked-in JSON Schema/u); ).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();
} }
}); });
}); });
+37
View File
@@ -165,6 +165,14 @@ describe("release artifact contracts", () => {
dependencyCount: 2, dependencyCount: 2,
}), }),
).toThrow(); ).toThrow();
expect(() =>
dependencyInventoryArtifactSchema.parse({
...inventory,
dependencyCount: 0,
directDependencyCount: 0,
dependencies: [],
}),
).toThrow();
}); });
it("rejects undeclared supply-chain verification evidence", () => { 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", () => { it("preserves unverified field evidence when no samples are eligible", () => {
const report = { const report = {
schemaVersion: 1, schemaVersion: 1,
+135
View File
@@ -88,6 +88,141 @@ describe("validated JSON artifact writer", () => {
expect(await readdir(directory)).toEqual(["artifact.json"]); 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)( it.each(["write", "rename"] as const)(
"cleans only its owned sibling temp when %s fails", "cleans only its owned sibling temp when %s fails",
async (failurePoint) => { async (failurePoint) => {