@kungfu-tech/buildchain 2.12.2 → 2.12.3-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,6 +50,97 @@ function optionalString(value) {
50
50
  return value === undefined || value === null ? "" : String(value);
51
51
  }
52
52
 
53
+ function hasOwn(value, key) {
54
+ return Object.prototype.hasOwnProperty.call(value, key);
55
+ }
56
+
57
+ function optionalPositiveInteger(value, label) {
58
+ if (value === undefined || value === null || value === "") {
59
+ return undefined;
60
+ }
61
+ const normalized = Number(value);
62
+ if (!Number.isInteger(normalized) || normalized < 1) {
63
+ throw new Error(`${label} must be a positive integer`);
64
+ }
65
+ return normalized;
66
+ }
67
+
68
+ function normalizeArtifactCoordinate(value, label, { partial = false } = {}) {
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
70
+ throw new Error(`${label} must be an object`);
71
+ }
72
+ const field = (key) => {
73
+ if (partial && !hasOwn(value, key)) {
74
+ return undefined;
75
+ }
76
+ return assertNonEmptyString(value[key], `${label}.${key}`);
77
+ };
78
+ const normalized = {};
79
+ for (const key of ["version", "ref", "source_sha", "material_sha"]) {
80
+ const item = field(key);
81
+ if (item !== undefined) {
82
+ normalized[key] = item;
83
+ }
84
+ }
85
+ if (hasOwn(value, "target_ref")) {
86
+ normalized.target_ref = assertNonEmptyString(value.target_ref, `${label}.target_ref`);
87
+ }
88
+ return normalized;
89
+ }
90
+
91
+ function normalizeArtifactVerification(value, label, { partial = false } = {}) {
92
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
93
+ throw new Error(`${label} must be an object`);
94
+ }
95
+ const normalized = {};
96
+ const stringField = (key) => {
97
+ if (hasOwn(value, key)) {
98
+ normalized[key] = assertNonEmptyString(value[key], `${label}.${key}`);
99
+ }
100
+ };
101
+ for (const key of ["ref", "digest", "platform", "parent_digest", "evidence"]) {
102
+ stringField(key);
103
+ }
104
+ if (hasOwn(value, "public_manifest")) {
105
+ normalized.public_manifest = value.public_manifest === true;
106
+ }
107
+ const contractMajor = optionalPositiveInteger(value.contract_major, `${label}.contract_major`);
108
+ if (contractMajor !== undefined) {
109
+ normalized.contract_major = contractMajor;
110
+ }
111
+ if (hasOwn(value, "smoke")) {
112
+ if (!value.smoke || typeof value.smoke !== "object" || Array.isArray(value.smoke)) {
113
+ throw new Error(`${label}.smoke must be an object`);
114
+ }
115
+ normalized.smoke = {};
116
+ for (const key of ["policy", "evidence"]) {
117
+ if (hasOwn(value.smoke, key)) {
118
+ normalized.smoke[key] = assertNonEmptyString(value.smoke[key], `${label}.smoke.${key}`);
119
+ }
120
+ }
121
+ if (hasOwn(value.smoke, "passed")) {
122
+ normalized.smoke.passed = value.smoke.passed === true;
123
+ }
124
+ }
125
+ if (!partial) {
126
+ for (const key of ["ref", "digest", "platform", "evidence"]) {
127
+ assertNonEmptyString(normalized[key], `${label}.${key}`);
128
+ }
129
+ if (normalized.public_manifest !== true) {
130
+ throw new Error(`${label}.public_manifest must be true`);
131
+ }
132
+ if (normalized.contract_major === undefined) {
133
+ throw new Error(`${label}.contract_major must be a positive integer`);
134
+ }
135
+ if (!normalized.smoke || normalized.smoke.passed !== true) {
136
+ throw new Error(`${label}.smoke.passed must be true`);
137
+ }
138
+ assertNonEmptyString(normalized.smoke.policy, `${label}.smoke.policy`);
139
+ assertNonEmptyString(normalized.smoke.evidence, `${label}.smoke.evidence`);
140
+ }
141
+ return normalized;
142
+ }
143
+
53
144
  function posixPath(value) {
54
145
  return String(value || "").split(path.sep).join("/");
55
146
  }
@@ -215,7 +306,11 @@ function artifactMatchesRequirement(actual, required) {
215
306
  );
216
307
  }
217
308
 
218
- export function normalizePublishArtifact(artifact, label = "artifact") {
309
+ export function normalizePublishArtifact(
310
+ artifact,
311
+ label = "artifact",
312
+ { requireDigest = true, partialProvenance = !requireDigest } = {},
313
+ ) {
219
314
  if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
220
315
  throw new Error(`${label} must be an object`);
221
316
  }
@@ -223,15 +318,84 @@ export function normalizePublishArtifact(artifact, label = "artifact") {
223
318
  if (role && !["main", "platform"].includes(role)) {
224
319
  throw new Error(`${label}.role must be one of main or platform`);
225
320
  }
226
- return {
321
+ const action = optionalString(artifact.action);
322
+ if (action && !["built", "reused"].includes(action)) {
323
+ throw new Error(`${label}.action must be one of built or reused`);
324
+ }
325
+ const digest = requireDigest
326
+ ? assertNonEmptyString(artifact.digest, `${label}.digest`)
327
+ : optionalString(artifact.digest);
328
+ const contractMajor = optionalPositiveInteger(artifact.contract_major, `${label}.contract_major`);
329
+ const normalized = {
227
330
  group: optionalString(artifact.group),
228
331
  kind: assertNonEmptyString(artifact.kind, `${label}.kind`),
229
332
  name: assertNonEmptyString(artifact.name, `${label}.name`),
230
333
  ref: optionalString(artifact.ref),
231
- digest: assertNonEmptyString(artifact.digest, `${label}.digest`),
334
+ digest,
232
335
  role,
233
336
  required: artifact.required === undefined ? true : Boolean(artifact.required),
234
337
  };
338
+ if (action) {
339
+ normalized.action = action;
340
+ }
341
+ for (const key of ["platform", "parent_digest"]) {
342
+ if (hasOwn(artifact, key)) {
343
+ normalized[key] = assertNonEmptyString(artifact[key], `${label}.${key}`);
344
+ }
345
+ }
346
+ if (contractMajor !== undefined) {
347
+ normalized.contract_major = contractMajor;
348
+ }
349
+ for (const key of ["content", "release"]) {
350
+ if (hasOwn(artifact, key)) {
351
+ normalized[key] = normalizeArtifactCoordinate(
352
+ artifact[key],
353
+ `${label}.${key}`,
354
+ { partial: partialProvenance },
355
+ );
356
+ }
357
+ }
358
+ if (hasOwn(artifact, "verification")) {
359
+ normalized.verification = normalizeArtifactVerification(
360
+ artifact.verification,
361
+ `${label}.verification`,
362
+ { partial: partialProvenance },
363
+ );
364
+ }
365
+ if (requireDigest && action) {
366
+ for (const key of ["content", "release"]) {
367
+ if (!normalized[key]) {
368
+ throw new Error(`${label}.${key} is required when action is ${action}`);
369
+ }
370
+ }
371
+ if (normalized.kind === "oci") {
372
+ if (!normalized.platform) {
373
+ throw new Error(`${label}.platform is required for OCI provenance`);
374
+ }
375
+ if (!normalized.contract_major) {
376
+ throw new Error(`${label}.contract_major is required for OCI provenance`);
377
+ }
378
+ if (!normalized.verification) {
379
+ throw new Error(`${label}.verification is required for OCI provenance`);
380
+ }
381
+ const verification = normalized.verification;
382
+ const comparisons = [
383
+ [verification.ref, normalized.ref, "ref"],
384
+ [verification.digest, normalized.digest, "digest"],
385
+ [verification.platform, normalized.platform, "platform"],
386
+ [verification.contract_major, normalized.contract_major, "contract_major"],
387
+ [verification.parent_digest || "", normalized.parent_digest || "", "parent_digest"],
388
+ ];
389
+ for (const [actual, expected, field] of comparisons) {
390
+ if (actual !== expected) {
391
+ throw new Error(
392
+ `${label}.verification.${field} mismatch: expected ${expected || "<empty>"}, got ${actual || "<empty>"}`,
393
+ );
394
+ }
395
+ }
396
+ }
397
+ }
398
+ return normalized;
235
399
  }
236
400
 
237
401
  export function parsePublishArtifactsJson(value, label = "publish artifacts") {
@@ -244,7 +408,7 @@ export function parsePublishArtifactsJson(value, label = "publish artifacts") {
244
408
  throw new Error(`${label} must be a JSON array`);
245
409
  }
246
410
  return parsed.map((artifact, index) =>
247
- normalizePublishArtifact(artifact, `${label}[${index}]`),
411
+ normalizePublishArtifact(artifact, `${label}[${index}]`, { requireDigest: false }),
248
412
  );
249
413
  }
250
414
 
@@ -260,6 +424,14 @@ export function normalizePublishEvidence(evidence) {
260
424
  normalizePublishArtifact(artifact, `artifacts[${index}]`),
261
425
  )
262
426
  : [];
427
+ const identities = new Set();
428
+ for (const artifact of artifacts) {
429
+ const identity = artifactIdentity(artifact);
430
+ if (identities.has(identity)) {
431
+ throw new Error(`duplicate publish artifact: ${artifact.kind}:${artifact.name}:${artifact.ref}`);
432
+ }
433
+ identities.add(identity);
434
+ }
263
435
  return {
264
436
  schema: 1,
265
437
  version: assertNonEmptyString(evidence.version, "evidence.version"),
@@ -282,25 +454,49 @@ export function readPublishEvidence(filePath) {
282
454
 
283
455
  export function planArtifactPublish({ requiredArtifacts = [], existingArtifacts = [] } = {}) {
284
456
  const required = requiredArtifacts.map((artifact, index) =>
285
- normalizePublishArtifact(artifact, `requiredArtifacts[${index}]`),
457
+ normalizePublishArtifact(artifact, `requiredArtifacts[${index}]`, { requireDigest: false }),
286
458
  );
287
459
  const existing = existingArtifacts.map((artifact, index) =>
288
460
  normalizePublishArtifact(artifact, `existingArtifacts[${index}]`),
289
461
  );
290
- const existingByIdentity = new Map(existing.map((artifact) => [artifactIdentity(artifact), artifact]));
291
462
  const publish = [];
292
463
  const accepted = [];
293
464
  const conflicts = [];
294
465
  for (const artifact of required) {
295
- const current =
296
- existingByIdentity.get(artifactIdentity(artifact)) ||
297
- existing.find((candidate) => artifactMatchesRequirement(candidate, artifact));
466
+ const candidates = existing.filter((candidate) => (
467
+ candidate.kind === artifact.kind &&
468
+ candidate.name === artifact.name &&
469
+ (!artifact.group || candidate.group === artifact.group)
470
+ ));
471
+ const current = candidates.find((candidate) => artifactMatchesRequirement(candidate, artifact));
298
472
  if (!current) {
473
+ if (candidates.length > 0) {
474
+ conflicts.push({ expected: artifact, actual: candidates[0], fields: ["ref"] });
475
+ continue;
476
+ }
299
477
  publish.push(artifact);
300
478
  continue;
301
479
  }
302
- if (current.digest !== artifact.digest) {
303
- conflicts.push({ expected: artifact, actual: current });
480
+ const fields = [];
481
+ if (artifact.digest && current.digest !== artifact.digest) {
482
+ fields.push("digest");
483
+ }
484
+ const compareSubset = (actual, expected, prefix = "") => {
485
+ for (const [key, value] of Object.entries(expected || {})) {
486
+ if (["group", "kind", "name", "ref", "digest", "role", "required"].includes(key) && !prefix) {
487
+ continue;
488
+ }
489
+ const field = prefix ? `${prefix}.${key}` : key;
490
+ if (value && typeof value === "object" && !Array.isArray(value)) {
491
+ compareSubset(actual?.[key], value, field);
492
+ } else if (actual?.[key] !== value) {
493
+ fields.push(field);
494
+ }
495
+ }
496
+ };
497
+ compareSubset(current, artifact);
498
+ if (fields.length > 0) {
499
+ conflicts.push({ expected: artifact, actual: current, fields });
304
500
  continue;
305
501
  }
306
502
  accepted.push(current);
@@ -314,6 +510,40 @@ export function planArtifactPublish({ requiredArtifacts = [], existingArtifacts
314
510
  };
315
511
  }
316
512
 
513
+ export function resolvePublishArtifactRequirements(
514
+ requiredArtifacts,
515
+ { version, targetRef, sourceSha, releaseMaterialSha } = {},
516
+ ) {
517
+ return requiredArtifacts.map((artifact, index) => {
518
+ const normalized = normalizePublishArtifact(
519
+ artifact,
520
+ `requiredArtifacts[${index}]`,
521
+ { requireDigest: false },
522
+ );
523
+ normalized.ref ||= assertNonEmptyString(version, "version");
524
+ if (!normalized.action) {
525
+ return normalized;
526
+ }
527
+ const current = {
528
+ version: assertNonEmptyString(version, "version"),
529
+ ref: normalized.ref,
530
+ source_sha: assertNonEmptyString(sourceSha, "sourceSha"),
531
+ material_sha: assertNonEmptyString(releaseMaterialSha, "releaseMaterialSha"),
532
+ };
533
+ if (normalized.action === "built") {
534
+ normalized.content = { ...current, ...(normalized.content || {}) };
535
+ } else if (!normalized.content) {
536
+ throw new Error(`requiredArtifacts[${index}].content is required when action is reused`);
537
+ }
538
+ normalized.release = {
539
+ ...current,
540
+ target_ref: assertNonEmptyString(targetRef, "targetRef"),
541
+ ...(normalized.release || {}),
542
+ };
543
+ return normalized;
544
+ });
545
+ }
546
+
317
547
  export function validatePublishEvidence({
318
548
  evidence,
319
549
  version,
@@ -354,9 +584,30 @@ export function validatePublishEvidence({
354
584
  );
355
585
  }
356
586
  for (const conflict of artifactPlan.conflicts) {
357
- errors.push(
358
- `artifact digest mismatch: ${conflict.expected.kind}:${conflict.expected.name}`,
359
- );
587
+ const fields = conflict.fields || ["digest"];
588
+ const category = fields.length === 1 && fields[0] === "digest"
589
+ ? "artifact digest mismatch"
590
+ : "artifact coordinate or provenance mismatch";
591
+ errors.push(`${category}: ${conflict.expected.kind}:${conflict.expected.name}:${fields.join(",")}`);
592
+ }
593
+ for (const [index, artifact] of normalized.artifacts.entries()) {
594
+ if (!artifact.action) {
595
+ continue;
596
+ }
597
+ const checks = [
598
+ [artifact.release?.version, version, "release.version"],
599
+ [artifact.release?.ref, artifact.ref, "release.ref"],
600
+ [artifact.release?.target_ref, targetRef, "release.target_ref"],
601
+ [artifact.release?.source_sha, sourceSha, "release.source_sha"],
602
+ [artifact.release?.material_sha, releaseMaterialSha, "release.material_sha"],
603
+ ];
604
+ for (const [actual, expected, field] of checks) {
605
+ if (actual !== expected) {
606
+ errors.push(
607
+ `artifact provenance mismatch: artifacts[${index}].${field}: expected ${expected || "<empty>"}, got ${actual || "<empty>"}`,
608
+ );
609
+ }
610
+ }
360
611
  }
361
612
 
362
613
  return {
@@ -15,6 +15,7 @@ import {
15
15
  validateKfd3CollaborationInterfaceReleaseGateEvidence,
16
16
  } from "./kfd-gate.js";
17
17
  import { createSurfaceTimestampPolicy } from "./surface-manifest.js";
18
+ import { validatePublishEvidence as validateTransactionPublishEvidence } from "./publish-transaction.js";
18
19
 
19
20
  export const RELEASE_PASSPORT_CONTRACT = "kungfu-buildchain-release-passport";
20
21
  export const ARTIFACT_EVIDENCE_CONTRACT = "kungfu-buildchain-artifact-evidence";
@@ -521,7 +522,7 @@ function normalizePlatformArtifactManifest(meta, index = 0) {
521
522
 
522
523
  function normalizePublishArtifact(artifact = {}, index = 0) {
523
524
  const name = nonEmptyString(artifact.name, `publishEvidence.artifacts[${index}].name`);
524
- return {
525
+ const normalized = {
525
526
  group: optionalString(artifact.group),
526
527
  kind: optionalString(artifact.kind),
527
528
  name,
@@ -529,6 +530,20 @@ function normalizePublishArtifact(artifact = {}, index = 0) {
529
530
  digest: optionalString(artifact.digest || artifact.sha256 || artifact.integrity || artifact.shasum),
530
531
  evidence: optionalString(artifact.evidence),
531
532
  };
533
+ for (const key of [
534
+ "action",
535
+ "platform",
536
+ "contract_major",
537
+ "parent_digest",
538
+ "content",
539
+ "release",
540
+ "verification",
541
+ ]) {
542
+ if (Object.prototype.hasOwnProperty.call(artifact, key)) {
543
+ normalized[key] = structuredClone(artifact[key]);
544
+ }
545
+ }
546
+ return normalized;
532
547
  }
533
548
 
534
549
  function normalizePublishEvidence(value = undefined) {
@@ -1138,11 +1153,7 @@ export function createReleasePassport({
1138
1153
  url: asset.url,
1139
1154
  })),
1140
1155
  ...publishArtifacts.map((artifact) => ({
1141
- group: artifact.group,
1142
- kind: artifact.kind,
1143
- name: artifact.name,
1144
- ref: artifact.ref,
1145
- digest: artifact.digest,
1156
+ ...artifact,
1146
1157
  evidence: publishEvidencePath || artifact.evidence || artifactEvidencePath,
1147
1158
  })),
1148
1159
  ],
@@ -1652,6 +1663,29 @@ export function createReleaseCheckReport({
1652
1663
  if (!Array.isArray(normalizedPublishEvidence?.artifacts) || normalizedPublishEvidence.artifacts.length === 0) {
1653
1664
  issues.push(issue("error", "publishEvidence.artifacts", "publishEvidence.artifacts must include published artifacts"));
1654
1665
  }
1666
+ if ((normalizedPublishEvidence?.artifacts || []).some((artifact) => artifact.action)) {
1667
+ try {
1668
+ const validation = validateTransactionPublishEvidence({
1669
+ evidence: publishEvidence,
1670
+ version: normalizedPublishEvidence.version,
1671
+ channel: normalizedPublishEvidence.channel,
1672
+ sourceSha: normalizedPublishEvidence.sourceSha,
1673
+ releaseSha: normalizedPublishEvidence.releaseSha,
1674
+ targetRef: normalizedPublishEvidence.targetRef,
1675
+ releaseMaterialSha: normalizedPublishEvidence.releaseMaterialSha,
1676
+ publishToolingSha: normalizedPublishEvidence.publishToolingSha,
1677
+ });
1678
+ for (const error of validation.errors) {
1679
+ issues.push(issue("error", "publishEvidence.artifactProvenance", error));
1680
+ }
1681
+ } catch (error) {
1682
+ issues.push(issue(
1683
+ "error",
1684
+ "publishEvidence.artifactProvenance",
1685
+ `publish artifact provenance is invalid: ${error.message}`,
1686
+ ));
1687
+ }
1688
+ }
1655
1689
  if (passport?.release?.sourceSha && normalizedPublishEvidence?.sourceSha && passport.release.sourceSha !== normalizedPublishEvidence.sourceSha) {
1656
1690
  issues.push(issue("error", "publishEvidence.sourceSha.mismatch", "publishEvidence.sourceSha must match passport.release.sourceSha"));
1657
1691
  }
@@ -1686,6 +1720,26 @@ export function createReleaseCheckReport({
1686
1720
  if (artifact.digest && evidenceDigest && artifact.digest !== evidenceDigest && artifact.digest !== `sha256:${evidenceDigest}`) {
1687
1721
  issues.push(issue("error", "artifact.digest.mismatch", `artifact ${artifact.name} digest differs between passport and evidence`));
1688
1722
  }
1723
+ for (const field of [
1724
+ "action",
1725
+ "platform",
1726
+ "contract_major",
1727
+ "parent_digest",
1728
+ "content",
1729
+ "release",
1730
+ "verification",
1731
+ ]) {
1732
+ if (
1733
+ Object.prototype.hasOwnProperty.call(evidence, field) &&
1734
+ stableJson(artifact[field]) !== stableJson(evidence[field])
1735
+ ) {
1736
+ issues.push(issue(
1737
+ "error",
1738
+ `artifact.${field}.mismatch`,
1739
+ `artifact ${artifact.name} ${field} differs between passport and publish evidence`,
1740
+ ));
1741
+ }
1742
+ }
1689
1743
  }
1690
1744
  if (passport?.packageSet) {
1691
1745
  const main = passport.packageSet.main || {};