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

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,100 @@ 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 ref = optionalString(artifact.ref);
330
+ const refTemplate = optionalString(artifact.ref_template);
331
+ if (/[{}]/.test(ref)) {
332
+ throw new Error(`${label}.ref must be exact and cannot contain template braces; use ref_template`);
333
+ }
334
+ if (ref && refTemplate) {
335
+ throw new Error(`${label} cannot declare both ref and ref_template`);
336
+ }
337
+ if (refTemplate && !/^[^{}]*\{version\}[^{}]*$/.test(refTemplate)) {
338
+ throw new Error(
339
+ `${label}.ref_template must contain exactly one {version} placeholder and no other braces`,
340
+ );
341
+ }
342
+ const normalized = {
227
343
  group: optionalString(artifact.group),
228
344
  kind: assertNonEmptyString(artifact.kind, `${label}.kind`),
229
345
  name: assertNonEmptyString(artifact.name, `${label}.name`),
230
- ref: optionalString(artifact.ref),
231
- digest: assertNonEmptyString(artifact.digest, `${label}.digest`),
346
+ ref,
347
+ digest,
232
348
  role,
233
349
  required: artifact.required === undefined ? true : Boolean(artifact.required),
234
350
  };
351
+ if (refTemplate) {
352
+ normalized.ref_template = refTemplate;
353
+ }
354
+ if (action) {
355
+ normalized.action = action;
356
+ }
357
+ for (const key of ["platform", "parent_digest"]) {
358
+ if (hasOwn(artifact, key)) {
359
+ normalized[key] = assertNonEmptyString(artifact[key], `${label}.${key}`);
360
+ }
361
+ }
362
+ if (contractMajor !== undefined) {
363
+ normalized.contract_major = contractMajor;
364
+ }
365
+ for (const key of ["content", "release"]) {
366
+ if (hasOwn(artifact, key)) {
367
+ normalized[key] = normalizeArtifactCoordinate(
368
+ artifact[key],
369
+ `${label}.${key}`,
370
+ { partial: partialProvenance },
371
+ );
372
+ }
373
+ }
374
+ if (hasOwn(artifact, "verification")) {
375
+ normalized.verification = normalizeArtifactVerification(
376
+ artifact.verification,
377
+ `${label}.verification`,
378
+ { partial: partialProvenance },
379
+ );
380
+ }
381
+ if (requireDigest && action) {
382
+ for (const key of ["content", "release"]) {
383
+ if (!normalized[key]) {
384
+ throw new Error(`${label}.${key} is required when action is ${action}`);
385
+ }
386
+ }
387
+ if (normalized.kind === "oci") {
388
+ if (!normalized.platform) {
389
+ throw new Error(`${label}.platform is required for OCI provenance`);
390
+ }
391
+ if (!normalized.contract_major) {
392
+ throw new Error(`${label}.contract_major is required for OCI provenance`);
393
+ }
394
+ if (!normalized.verification) {
395
+ throw new Error(`${label}.verification is required for OCI provenance`);
396
+ }
397
+ const verification = normalized.verification;
398
+ const comparisons = [
399
+ [verification.ref, normalized.ref, "ref"],
400
+ [verification.digest, normalized.digest, "digest"],
401
+ [verification.platform, normalized.platform, "platform"],
402
+ [verification.contract_major, normalized.contract_major, "contract_major"],
403
+ [verification.parent_digest || "", normalized.parent_digest || "", "parent_digest"],
404
+ ];
405
+ for (const [actual, expected, field] of comparisons) {
406
+ if (actual !== expected) {
407
+ throw new Error(
408
+ `${label}.verification.${field} mismatch: expected ${expected || "<empty>"}, got ${actual || "<empty>"}`,
409
+ );
410
+ }
411
+ }
412
+ }
413
+ }
414
+ return normalized;
235
415
  }
236
416
 
237
417
  export function parsePublishArtifactsJson(value, label = "publish artifacts") {
@@ -244,7 +424,7 @@ export function parsePublishArtifactsJson(value, label = "publish artifacts") {
244
424
  throw new Error(`${label} must be a JSON array`);
245
425
  }
246
426
  return parsed.map((artifact, index) =>
247
- normalizePublishArtifact(artifact, `${label}[${index}]`),
427
+ normalizePublishArtifact(artifact, `${label}[${index}]`, { requireDigest: false }),
248
428
  );
249
429
  }
250
430
 
@@ -260,6 +440,14 @@ export function normalizePublishEvidence(evidence) {
260
440
  normalizePublishArtifact(artifact, `artifacts[${index}]`),
261
441
  )
262
442
  : [];
443
+ const identities = new Set();
444
+ for (const artifact of artifacts) {
445
+ const identity = artifactIdentity(artifact);
446
+ if (identities.has(identity)) {
447
+ throw new Error(`duplicate publish artifact: ${artifact.kind}:${artifact.name}:${artifact.ref}`);
448
+ }
449
+ identities.add(identity);
450
+ }
263
451
  return {
264
452
  schema: 1,
265
453
  version: assertNonEmptyString(evidence.version, "evidence.version"),
@@ -282,25 +470,49 @@ export function readPublishEvidence(filePath) {
282
470
 
283
471
  export function planArtifactPublish({ requiredArtifacts = [], existingArtifacts = [] } = {}) {
284
472
  const required = requiredArtifacts.map((artifact, index) =>
285
- normalizePublishArtifact(artifact, `requiredArtifacts[${index}]`),
473
+ normalizePublishArtifact(artifact, `requiredArtifacts[${index}]`, { requireDigest: false }),
286
474
  );
287
475
  const existing = existingArtifacts.map((artifact, index) =>
288
476
  normalizePublishArtifact(artifact, `existingArtifacts[${index}]`),
289
477
  );
290
- const existingByIdentity = new Map(existing.map((artifact) => [artifactIdentity(artifact), artifact]));
291
478
  const publish = [];
292
479
  const accepted = [];
293
480
  const conflicts = [];
294
481
  for (const artifact of required) {
295
- const current =
296
- existingByIdentity.get(artifactIdentity(artifact)) ||
297
- existing.find((candidate) => artifactMatchesRequirement(candidate, artifact));
482
+ const candidates = existing.filter((candidate) => (
483
+ candidate.kind === artifact.kind &&
484
+ candidate.name === artifact.name &&
485
+ (!artifact.group || candidate.group === artifact.group)
486
+ ));
487
+ const current = candidates.find((candidate) => artifactMatchesRequirement(candidate, artifact));
298
488
  if (!current) {
489
+ if (candidates.length > 0) {
490
+ conflicts.push({ expected: artifact, actual: candidates[0], fields: ["ref"] });
491
+ continue;
492
+ }
299
493
  publish.push(artifact);
300
494
  continue;
301
495
  }
302
- if (current.digest !== artifact.digest) {
303
- conflicts.push({ expected: artifact, actual: current });
496
+ const fields = [];
497
+ if (artifact.digest && current.digest !== artifact.digest) {
498
+ fields.push("digest");
499
+ }
500
+ const compareSubset = (actual, expected, prefix = "") => {
501
+ for (const [key, value] of Object.entries(expected || {})) {
502
+ if (["group", "kind", "name", "ref", "digest", "role", "required"].includes(key) && !prefix) {
503
+ continue;
504
+ }
505
+ const field = prefix ? `${prefix}.${key}` : key;
506
+ if (value && typeof value === "object" && !Array.isArray(value)) {
507
+ compareSubset(actual?.[key], value, field);
508
+ } else if (actual?.[key] !== value) {
509
+ fields.push(field);
510
+ }
511
+ }
512
+ };
513
+ compareSubset(current, artifact);
514
+ if (fields.length > 0) {
515
+ conflicts.push({ expected: artifact, actual: current, fields });
304
516
  continue;
305
517
  }
306
518
  accepted.push(current);
@@ -314,6 +526,45 @@ export function planArtifactPublish({ requiredArtifacts = [], existingArtifacts
314
526
  };
315
527
  }
316
528
 
529
+ export function resolvePublishArtifactRequirements(
530
+ requiredArtifacts,
531
+ { version, targetRef, sourceSha, releaseMaterialSha } = {},
532
+ ) {
533
+ return requiredArtifacts.map((artifact, index) => {
534
+ const normalized = normalizePublishArtifact(
535
+ artifact,
536
+ `requiredArtifacts[${index}]`,
537
+ { requireDigest: false },
538
+ );
539
+ const exactVersion = assertNonEmptyString(version, "version");
540
+ if (normalized.ref_template) {
541
+ normalized.ref = normalized.ref_template.replace("{version}", exactVersion);
542
+ delete normalized.ref_template;
543
+ }
544
+ normalized.ref ||= exactVersion;
545
+ if (!normalized.action) {
546
+ return normalized;
547
+ }
548
+ const current = {
549
+ version: exactVersion,
550
+ ref: normalized.ref,
551
+ source_sha: assertNonEmptyString(sourceSha, "sourceSha"),
552
+ material_sha: assertNonEmptyString(releaseMaterialSha, "releaseMaterialSha"),
553
+ };
554
+ if (normalized.action === "built") {
555
+ normalized.content = { ...current, ...(normalized.content || {}) };
556
+ } else if (!normalized.content) {
557
+ throw new Error(`requiredArtifacts[${index}].content is required when action is reused`);
558
+ }
559
+ normalized.release = {
560
+ ...current,
561
+ target_ref: assertNonEmptyString(targetRef, "targetRef"),
562
+ ...(normalized.release || {}),
563
+ };
564
+ return normalized;
565
+ });
566
+ }
567
+
317
568
  export function validatePublishEvidence({
318
569
  evidence,
319
570
  version,
@@ -354,9 +605,30 @@ export function validatePublishEvidence({
354
605
  );
355
606
  }
356
607
  for (const conflict of artifactPlan.conflicts) {
357
- errors.push(
358
- `artifact digest mismatch: ${conflict.expected.kind}:${conflict.expected.name}`,
359
- );
608
+ const fields = conflict.fields || ["digest"];
609
+ const category = fields.length === 1 && fields[0] === "digest"
610
+ ? "artifact digest mismatch"
611
+ : "artifact coordinate or provenance mismatch";
612
+ errors.push(`${category}: ${conflict.expected.kind}:${conflict.expected.name}:${fields.join(",")}`);
613
+ }
614
+ for (const [index, artifact] of normalized.artifacts.entries()) {
615
+ if (!artifact.action) {
616
+ continue;
617
+ }
618
+ const checks = [
619
+ [artifact.release?.version, version, "release.version"],
620
+ [artifact.release?.ref, artifact.ref, "release.ref"],
621
+ [artifact.release?.target_ref, targetRef, "release.target_ref"],
622
+ [artifact.release?.source_sha, sourceSha, "release.source_sha"],
623
+ [artifact.release?.material_sha, releaseMaterialSha, "release.material_sha"],
624
+ ];
625
+ for (const [actual, expected, field] of checks) {
626
+ if (actual !== expected) {
627
+ errors.push(
628
+ `artifact provenance mismatch: artifacts[${index}].${field}: expected ${expected || "<empty>"}, got ${actual || "<empty>"}`,
629
+ );
630
+ }
631
+ }
360
632
  }
361
633
 
362
634
  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 || {};