@kungfu-tech/buildchain 2.8.0 → 2.8.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.
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import http from "node:http";
4
5
  import https from "node:https";
@@ -6,8 +7,11 @@ import os from "node:os";
6
7
  import path from "node:path";
7
8
  import {
8
9
  createKfd1ReleaseGateEvidence,
10
+ createKfd3CollaborationInterfaceReleaseGateEvidence,
9
11
  resolveKfd1Metadata,
12
+ resolveKfd3Metadata,
10
13
  validateKfd1ReleaseGateEvidence,
14
+ validateKfd3CollaborationInterfaceReleaseGateEvidence,
11
15
  } from "./kfd-gate.js";
12
16
 
13
17
  export const RELEASE_PASSPORT_CONTRACT = "kungfu-buildchain-release-passport";
@@ -16,6 +20,7 @@ export const IMPACT_LEDGER_CONTRACT = "kungfu-buildchain-impact";
16
20
  export const AGENT_INDEX_CONTRACT = "kungfu-buildchain-agent-index";
17
21
  export const PRODUCT_MECHANISM_CONTRACT = "kungfu-buildchain-product-mechanism";
18
22
  export const RELEASE_CHECK_REPORT_CONTRACT = "kungfu-buildchain-release-check-report";
23
+ export const KFD2_RELEASE_TRUST_PASSPORT_CONTRACT = "kungfu-buildchain-kfd-2-release-trust-passport-audit";
19
24
 
20
25
  const CONTRACTS = new Set([
21
26
  RELEASE_PASSPORT_CONTRACT,
@@ -275,6 +280,36 @@ function parseJsonInputWithMeta(value, fallback = undefined) {
275
280
  };
276
281
  }
277
282
 
283
+ function parseJsonCommandOutput({ command = "", cwd = process.cwd(), label = "command" } = {}) {
284
+ const normalized = String(command || "").trim();
285
+ if (!normalized) {
286
+ return { value: undefined, path: "", sha256: "" };
287
+ }
288
+ const result = spawnSync(normalized, [], {
289
+ cwd,
290
+ shell: true,
291
+ encoding: "utf8",
292
+ maxBuffer: 16 * 1024 * 1024,
293
+ });
294
+ if (result.error) {
295
+ throw new Error(`${label} failed to start: ${result.error.message}`);
296
+ }
297
+ if (result.status !== 0) {
298
+ const stderr = String(result.stderr || "").trim();
299
+ throw new Error(`${label} exited with ${result.status}${stderr ? `: ${stderr.slice(-1000)}` : ""}`);
300
+ }
301
+ const stdout = String(result.stdout || "").trim();
302
+ if (!stdout) {
303
+ throw new Error(`${label} produced no JSON on stdout`);
304
+ }
305
+ const parsed = JSON.parse(stdout);
306
+ return {
307
+ value: parsed,
308
+ path: "",
309
+ sha256: sha256Text(stableJson(parsed)),
310
+ };
311
+ }
312
+
278
313
  function discoverAssetsFromDir(dir) {
279
314
  if (!dir || !fs.existsSync(dir)) {
280
315
  return [];
@@ -518,6 +553,166 @@ function normalizeTrustedPublishing(value = undefined, { workflow = {}, publish
518
553
  };
519
554
  }
520
555
 
556
+ function arrayOrEmpty(value) {
557
+ return Array.isArray(value) ? value : [];
558
+ }
559
+
560
+ function normalizeKfd2Claim(raw = {}, index = 0) {
561
+ const claim = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
562
+ const sourceBindings = arrayOrEmpty(claim.sourceBindings || claim.source_bindings || claim.sources || claim.declaredSources);
563
+ const machineEvidence = arrayOrEmpty(claim.machineEvidence || claim.machine_evidence || claim.evidence);
564
+ const hashes = claim.hashes && typeof claim.hashes === "object" && !Array.isArray(claim.hashes) ? claim.hashes : {};
565
+ const artifacts = arrayOrEmpty(claim.artifacts || claim.artifactCoordinates || claim.artifact_coordinates);
566
+ const verification = claim.verification && typeof claim.verification === "object" && !Array.isArray(claim.verification) ? claim.verification : {};
567
+ const auditBoundary = claim.auditBoundary && typeof claim.auditBoundary === "object" && !Array.isArray(claim.auditBoundary)
568
+ ? claim.auditBoundary
569
+ : claim.audit_boundary && typeof claim.audit_boundary === "object" && !Array.isArray(claim.audit_boundary)
570
+ ? claim.audit_boundary
571
+ : {};
572
+ const responsibility = claim.responsibility && typeof claim.responsibility === "object" && !Array.isArray(claim.responsibility) ? claim.responsibility : {};
573
+ const residualRisk = arrayOrEmpty(claim.residualRisk || claim.residual_risk);
574
+ const missingBindings = [];
575
+ if (sourceBindings.length === 0) missingBindings.push("declared-sources");
576
+ if (machineEvidence.length === 0) missingBindings.push("machine-readable-evidence");
577
+ if (Object.keys(hashes).length === 0) missingBindings.push("hashes");
578
+ if (artifacts.length === 0) missingBindings.push("artifact-coordinates");
579
+ if (!verification.result && !verification.status) missingBindings.push("verification-result");
580
+ if (Object.keys(auditBoundary).length === 0) missingBindings.push("audit-boundary");
581
+ if (!responsibility.owner && !responsibility.sourceOwner && !responsibility.sourceContractOwner && !responsibility.releasePassportProofOwner) {
582
+ missingBindings.push("responsibility-state");
583
+ }
584
+ if (!Array.isArray(claim.residualRisk || claim.residual_risk)) missingBindings.push("residual-risk");
585
+ const proseOnly = Boolean(claim.proseOnly || claim.prose_only || claim.support === "prose" || claim.supportLevel === "prose");
586
+ const explicitStatus = optionalString(claim.status || claim.result);
587
+ const status = explicitStatus || (missingBindings.length > 0 ? "failed" : (proseOnly || residualRisk.length > 0) ? "downgraded" : "passed");
588
+ return {
589
+ id: optionalString(claim.id || `claim-${index + 1}`),
590
+ public: claim.public === undefined ? true : Boolean(claim.public),
591
+ claim: optionalString(claim.claim || claim.statement || claim.summary),
592
+ sourceBindings,
593
+ machineEvidence,
594
+ hashes,
595
+ artifacts,
596
+ verification,
597
+ auditBoundary,
598
+ responsibility,
599
+ residualRisk,
600
+ proseOnly,
601
+ missingBindings,
602
+ status,
603
+ };
604
+ }
605
+
606
+ function kfd2ClaimFromKfd1World(world = {}, index = 0) {
607
+ const sourceSurfaces = arrayOrEmpty(world.sourceVerification?.surfaces);
608
+ const artifactSurfaces = arrayOrEmpty(world.artifactVerification?.surfaces);
609
+ return normalizeKfd2Claim({
610
+ id: `kfd-1:${world.id || index + 1}`,
611
+ public: true,
612
+ claim: `KFD-1 contract world ${world.id || index + 1} is verified from declared source surfaces to packaged artifact bytes.`,
613
+ sourceBindings: sourceSurfaces.map((surface) => ({
614
+ id: surface.name,
615
+ path: surface.sourcePath,
616
+ sha256: surface.actualSha256 || surface.expectedSha256,
617
+ })),
618
+ machineEvidence: [
619
+ { id: "kfd-1-witness", sha256: world.preBuildWitnessSha256 },
620
+ { id: "source-verification", status: world.sourceVerification?.status || "" },
621
+ { id: "artifact-verification", status: world.artifactVerification?.status || "" },
622
+ ],
623
+ hashes: {
624
+ witnessSha256: world.preBuildWitnessSha256,
625
+ sourceSha256: world.sourceHashes?.sha256 || "",
626
+ artifactSha256: world.artifactHashes?.sha256 || "",
627
+ },
628
+ artifacts: artifactSurfaces.map((surface) => ({
629
+ id: surface.name,
630
+ path: surface.artifactPath,
631
+ sha256: surface.actualSha256,
632
+ })),
633
+ verification: {
634
+ result: world.result === "passed" ? "passed" : "failed",
635
+ source: world.sourceVerification?.status || "",
636
+ artifact: world.artifactVerification?.status || "",
637
+ },
638
+ auditBoundary: world.selfHostingBoundary,
639
+ responsibility: world.responsibility,
640
+ residualRisk: world.selfHostingBoundary?.residualRisk || [],
641
+ }, index);
642
+ }
643
+
644
+ function kfd2ClaimFromKfd3Interface(entry = {}, index = 0) {
645
+ return normalizeKfd2Claim({
646
+ id: `kfd-3:${entry.id || index + 1}`,
647
+ public: true,
648
+ claim: `KFD-3 collaboration interface ${entry.id || index + 1} exposes only declared public participant-facing shipped surfaces within its audit boundary.`,
649
+ sourceBindings: arrayOrEmpty(entry.declaredSurfaces).map((surface) => ({
650
+ id: surface.id,
651
+ kind: surface.kind,
652
+ sourcePath: surface.sourcePath,
653
+ })),
654
+ machineEvidence: [
655
+ { id: "prebuild-witness", ...entry.witnessEvidence?.prebuild },
656
+ { id: "artifact-witness", ...entry.witnessEvidence?.artifact },
657
+ { id: "declared-capability-verification", result: entry.declaredCapabilityVerification?.result || "" },
658
+ { id: "reverse-audit", result: entry.reverseAudit?.status || "" },
659
+ ],
660
+ hashes: {
661
+ prebuildWitnessSha256: entry.preBuildWitnessSha256,
662
+ artifactWitnessSha256: entry.artifactWitnessSha256,
663
+ prebuildCanonicalSha256: entry.witnessEvidence?.prebuild?.canonicalSha256 || "",
664
+ artifactCanonicalSha256: entry.witnessEvidence?.artifact?.canonicalSha256 || "",
665
+ },
666
+ artifacts: entry.artifactWitness?.artifact?.name || entry.artifactWitness?.artifact?.path
667
+ ? [entry.artifactWitness.artifact]
668
+ : arrayOrEmpty(entry.exposedSurfaces).map((surface) => ({ id: surface.id, kind: surface.kind })),
669
+ verification: {
670
+ result: entry.comparison?.status === "passed" ? "passed" : "failed",
671
+ declaredCapabilityVerification: entry.declaredCapabilityVerification?.result || "",
672
+ reverseAudit: entry.reverseAudit?.status || "",
673
+ },
674
+ auditBoundary: entry.auditBoundary,
675
+ responsibility: entry.responsibility,
676
+ residualRisk: entry.residualRisk,
677
+ status: entry.trustProof?.result === "pass"
678
+ ? (arrayOrEmpty(entry.residualRisk).length > 0 ? "downgraded" : "passed")
679
+ : "failed",
680
+ }, index);
681
+ }
682
+
683
+ function createKfd2ReleaseTrustPassportAudit({ explicitClaims = [], kfd1Section = undefined, kfd3Section = undefined, verifiedAt = nowIso() } = {}) {
684
+ const generatedClaims = [
685
+ ...arrayOrEmpty(kfd1Section?.contractWorlds).map((world, index) => kfd2ClaimFromKfd1World(world, index)),
686
+ ...arrayOrEmpty(kfd3Section?.collaborationInterfaces).map((entry, index) => kfd2ClaimFromKfd3Interface(entry, index)),
687
+ ];
688
+ const claims = [
689
+ ...generatedClaims,
690
+ ...explicitClaims.map((claim, index) => normalizeKfd2Claim(claim, generatedClaims.length + index)),
691
+ ];
692
+ if (claims.length === 0) {
693
+ return undefined;
694
+ }
695
+ const failed = claims.filter((claim) => claim.public && claim.status === "failed");
696
+ const downgraded = claims.filter((claim) => claim.public && (claim.status === "downgraded" || claim.proseOnly));
697
+ return {
698
+ schemaVersion: 1,
699
+ contract: KFD2_RELEASE_TRUST_PASSPORT_CONTRACT,
700
+ status: failed.length > 0 ? "failed" : downgraded.length > 0 ? "downgraded" : "passed",
701
+ verifiedAt,
702
+ auditBoundary: {
703
+ scope: "public release claims visible to humans or agents",
704
+ policy: "public claims must bind declared sources, machine-readable evidence, hashes, artifact coordinates, verification results, audit boundaries, responsibility state, and residual risk",
705
+ },
706
+ claims,
707
+ summary: {
708
+ claimCount: claims.length,
709
+ failed: failed.length,
710
+ downgraded: downgraded.length,
711
+ proseOnly: claims.filter((claim) => claim.proseOnly).length,
712
+ },
713
+ };
714
+ }
715
+
521
716
  function normalizeTransactionResult(value = {}) {
522
717
  const result = {};
523
718
  if (value.command) {
@@ -627,6 +822,8 @@ export function createReleasePassport({
627
822
  impact = undefined,
628
823
  workflow = {},
629
824
  kfd1 = undefined,
825
+ kfd2Claims = [],
826
+ kfd3 = undefined,
630
827
  } = {}) {
631
828
  const normalizedTag = nonEmptyString(tag, "tag");
632
829
  const artifactEvidence = createArtifactEvidence({ assets, repository, tag: normalizedTag, sourceSha, workflow });
@@ -644,6 +841,12 @@ export function createReleasePassport({
644
841
  const normalizedImpact = normalizeImpactLedger(impact, { tag: normalizedTag, line });
645
842
  const kfd1Metadata = resolveKfd1Metadata();
646
843
  const normalizedKfd1 = kfd1?.passportSection ? kfd1 : undefined;
844
+ const normalizedKfd3 = kfd3?.passportSection ? kfd3 : undefined;
845
+ const normalizedKfd2 = createKfd2ReleaseTrustPassportAudit({
846
+ explicitClaims: kfd2Claims,
847
+ kfd1Section: normalizedKfd1?.passportSection,
848
+ kfd3Section: normalizedKfd3?.passportSection,
849
+ });
647
850
  const publishArtifacts = normalizedPublishEvidence?.artifacts || [];
648
851
  const normalizedPublishSummary = normalizePublishSummary({
649
852
  packageSet: normalizedPackageSet,
@@ -745,6 +948,8 @@ export function createReleasePassport({
745
948
  ...(normalizedPlatformArtifactManifests.length > 0 ? { platformArtifactManifests: normalizedPlatformArtifactManifests } : {}),
746
949
  ...(normalizedDistTagPromotionEvidence ? { distTagPromotion: normalizedDistTagPromotionEvidence } : {}),
747
950
  ...(normalizedKfd1 ? { [normalizedKfd1.key || kfd1Metadata.key]: normalizedKfd1.passportSection } : {}),
951
+ ...(normalizedKfd2 ? { "kfd-2": normalizedKfd2 } : {}),
952
+ ...(normalizedKfd3 ? { [normalizedKfd3.key || "kfd-3"]: normalizedKfd3.passportSection } : {}),
748
953
  versionImpact: normalizedImpact.versionImpact,
749
954
  surfaceImpacts: normalizedImpact.surfaceImpacts,
750
955
  artifacts: [
@@ -781,6 +986,8 @@ export function createReleasePassport({
781
986
  })),
782
987
  distTagPromotionEvidence: normalizedDistTagPromotionEvidence?.path || "",
783
988
  kfd1: normalizedKfd1 ? `${normalizedKfd1.key || kfd1Metadata.key}` : "",
989
+ kfd2: normalizedKfd2 ? "kfd-2" : "",
990
+ kfd3: normalizedKfd3 ? `${normalizedKfd3.key || "kfd-3"}` : "",
784
991
  impact: impactPath,
785
992
  checkReport: checkReportPath,
786
993
  agentIndex: agentIndexPath,
@@ -815,6 +1022,10 @@ export function collectGitHubReleasePassport({
815
1022
  platformManifestJsons = [],
816
1023
  distTagEvidenceJson = "",
817
1024
  kfd1WitnessJsons = [],
1025
+ kfd2ClaimJsons = [],
1026
+ kfd3PrebuildWitnessJsons = [],
1027
+ kfd3ArtifactWitnessJsons = [],
1028
+ kfd3ArtifactVerifyCommand = "",
818
1029
  releaseJsonExtra = "",
819
1030
  publishJson = "",
820
1031
  workflow = {},
@@ -837,6 +1048,27 @@ export function collectGitHubReleasePassport({
837
1048
  .filter(Boolean)
838
1049
  .map((witnessJson) => parseJsonInputWithMeta(witnessJson, undefined))
839
1050
  .filter((meta) => meta.value);
1051
+ const kfd2ClaimMetas = (kfd2ClaimJsons || [])
1052
+ .filter(Boolean)
1053
+ .map((claimJson) => parseJsonInputWithMeta(claimJson, undefined))
1054
+ .filter((meta) => meta.value);
1055
+ const kfd3PrebuildWitnessMetas = (kfd3PrebuildWitnessJsons || [])
1056
+ .filter(Boolean)
1057
+ .map((witnessJson) => parseJsonInputWithMeta(witnessJson, undefined))
1058
+ .filter((meta) => meta.value);
1059
+ const kfd3ArtifactWitnessMetas = (kfd3ArtifactWitnessJsons || [])
1060
+ .filter(Boolean)
1061
+ .map((witnessJson) => parseJsonInputWithMeta(witnessJson, undefined))
1062
+ .filter((meta) => meta.value);
1063
+ const kfd3ArtifactCommandMeta = parseJsonCommandOutput({
1064
+ command: kfd3ArtifactVerifyCommand,
1065
+ cwd,
1066
+ label: "KFD-3 artifact verify command",
1067
+ });
1068
+ const kfd3ArtifactWitnesses = [
1069
+ ...kfd3ArtifactWitnessMetas.map((meta) => meta.value),
1070
+ ...(kfd3ArtifactCommandMeta.value ? [kfd3ArtifactCommandMeta.value] : []),
1071
+ ];
840
1072
  const publish = parseJsonInput(publishJson, {});
841
1073
  const assets = [
842
1074
  ...(Array.isArray(release.assets) ? release.assets : []),
@@ -855,6 +1087,13 @@ export function collectGitHubReleasePassport({
855
1087
  artifacts: assets,
856
1088
  witnesses: kfd1WitnessMetas.map((meta) => meta.value),
857
1089
  });
1090
+ const kfd3 = createKfd3CollaborationInterfaceReleaseGateEvidence({
1091
+ prebuildWitnesses: kfd3PrebuildWitnessMetas.map((meta) => meta.value),
1092
+ artifactWitnesses: kfd3ArtifactWitnesses,
1093
+ prebuildWitnessMetas: kfd3PrebuildWitnessMetas,
1094
+ artifactWitnessMetas: kfd3ArtifactWitnessMetas,
1095
+ artifactCommandMeta: kfd3ArtifactCommandMeta.value ? kfd3ArtifactCommandMeta : undefined,
1096
+ });
858
1097
  const passport = createReleasePassport({
859
1098
  cwd,
860
1099
  repository,
@@ -892,6 +1131,8 @@ export function collectGitHubReleasePassport({
892
1131
  publish,
893
1132
  impact,
894
1133
  kfd1,
1134
+ kfd2Claims: kfd2ClaimMetas.map((meta) => meta.value),
1135
+ kfd3,
895
1136
  publishEvidencePath: publishEvidenceMeta.path ? path.relative(resolvedOutputDir, publishEvidenceMeta.path).split(path.sep).join("/") : "",
896
1137
  transactionStatePath: transactionMeta.path ? path.relative(resolvedOutputDir, transactionMeta.path).split(path.sep).join("/") : "",
897
1138
  workflow,
@@ -945,6 +1186,50 @@ function validateContract(value, expectedContract, label, issues) {
945
1186
  }
946
1187
  }
947
1188
 
1189
+ function validateKfd2ReleaseTrustPassportAudit(section, issues) {
1190
+ if (!section) {
1191
+ return;
1192
+ }
1193
+ if (typeof section !== "object" || Array.isArray(section)) {
1194
+ issues.push(issue("error", "kfd-2.object", "kfd-2 release trust passport audit must be a JSON object"));
1195
+ return;
1196
+ }
1197
+ if (section.contract !== KFD2_RELEASE_TRUST_PASSPORT_CONTRACT) {
1198
+ issues.push(issue("error", "kfd-2.contract", `kfd-2 contract must be ${KFD2_RELEASE_TRUST_PASSPORT_CONTRACT}`));
1199
+ }
1200
+ const claims = Array.isArray(section.claims) ? section.claims : [];
1201
+ if (claims.length === 0) {
1202
+ issues.push(issue("error", "kfd-2.claims.empty", "kfd-2 audit must enumerate at least one public release claim"));
1203
+ }
1204
+ for (const [index, claim] of claims.entries()) {
1205
+ if (!claim.id || !claim.claim) {
1206
+ issues.push(issue("error", `kfd-2.claims[${index}].identity`, "public release claim must include id and statement"));
1207
+ }
1208
+ if (claim.public === false) {
1209
+ continue;
1210
+ }
1211
+ const missing = Array.isArray(claim.missingBindings) ? claim.missingBindings : [];
1212
+ if (missing.length > 0 || claim.status === "failed") {
1213
+ issues.push(issue("error", `kfd-2.claims[${index}].bindings`, "public release claim is missing machine-verifiable trust bindings", {
1214
+ id: claim.id || "",
1215
+ missingBindings: missing,
1216
+ }));
1217
+ } else if (claim.status === "downgraded" || claim.proseOnly) {
1218
+ issues.push(issue("warning", `kfd-2.claims[${index}].downgraded`, "public release claim is downgraded and needs human review", {
1219
+ id: claim.id || "",
1220
+ proseOnly: Boolean(claim.proseOnly),
1221
+ }));
1222
+ }
1223
+ }
1224
+ if (section.status === "failed") {
1225
+ issues.push(issue("error", "kfd-2.status", "kfd-2 release trust passport audit must not contain failed public claims"));
1226
+ } else if (section.status === "downgraded") {
1227
+ issues.push(issue("warning", "kfd-2.status", "kfd-2 release trust passport audit is downgraded by prose-only or residual-risk claims"));
1228
+ } else if (section.status !== "passed") {
1229
+ issues.push(issue("error", "kfd-2.status", "kfd-2 status must be passed, downgraded, or failed"));
1230
+ }
1231
+ }
1232
+
948
1233
  function resolveSiblingJson(basePath, relativePath) {
949
1234
  if (!basePath || !relativePath || /^https?:\/\//.test(relativePath)) {
950
1235
  return undefined;
@@ -1194,6 +1479,19 @@ export function createReleaseCheckReport({
1194
1479
  const requiredSurfaceImpacts = surfaceImpactRequirement({ passport, impact });
1195
1480
  const kfd1Metadata = resolveKfd1Metadata();
1196
1481
  issues.push(...validateKfd1ReleaseGateEvidence(passport?.[kfd1Metadata.key], { metadata: kfd1Metadata }));
1482
+ validateKfd2ReleaseTrustPassportAudit(passport?.["kfd-2"], issues);
1483
+ const fallbackKfd3Section = passport?.["kfd-3"];
1484
+ try {
1485
+ const kfd3Metadata = resolveKfd3Metadata();
1486
+ const kfd3Section = passport?.[kfd3Metadata.key] || fallbackKfd3Section;
1487
+ if (kfd3Section) {
1488
+ issues.push(...validateKfd3CollaborationInterfaceReleaseGateEvidence(kfd3Section, { metadata: kfd3Metadata }));
1489
+ }
1490
+ } catch (error) {
1491
+ if (fallbackKfd3Section) {
1492
+ issues.push(issue("error", "kfd-3.metadata", error.message));
1493
+ }
1494
+ }
1197
1495
  if (requiredSurfaceImpacts.required && surfaceImpacts.length === 0) {
1198
1496
  issues.push(issue("error", "impact.surfaceImpacts.required", "surfaceImpacts[] is required for this release passport type", requiredSurfaceImpacts));
1199
1497
  }
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import {
6
+ createBuildchainContractLock,
7
+ createBuildchainContractWorld,
8
+ evaluateBuildchainContractLock,
9
+ readBuildchainContractLock,
10
+ readBuildchainContractWorld,
11
+ renderBuildchainContractDriftIssueBody,
12
+ } from "../packages/core/buildchain-contract.js";
13
+ import { writeGitHubOutputs } from "./build-contract-core.mjs";
14
+
15
+ function env(name, fallback = "") {
16
+ return process.env[name] || fallback;
17
+ }
18
+
19
+ function boolEnv(name, fallback = false) {
20
+ const value = String(process.env[name] ?? "").trim().toLowerCase();
21
+ if (!value) {
22
+ return fallback;
23
+ }
24
+ return ["1", "true", "yes", "on"].includes(value);
25
+ }
26
+
27
+ function readCurrentContract(contractPath, runtimeRoot) {
28
+ if (contractPath && fs.existsSync(contractPath)) {
29
+ return readBuildchainContractWorld(contractPath);
30
+ }
31
+ return createBuildchainContractWorld({ root: runtimeRoot || process.cwd() });
32
+ }
33
+
34
+ function writeJson(filePath, value) {
35
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
36
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
37
+ }
38
+
39
+ function appendSummary(markdown) {
40
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
41
+ if (!summaryPath) {
42
+ return;
43
+ }
44
+ fs.appendFileSync(summaryPath, `${markdown.trim()}\n\n`);
45
+ }
46
+
47
+ function issueModeAllows(mode, evaluation) {
48
+ if (!evaluation.issueRecommended) {
49
+ return false;
50
+ }
51
+ if (mode === "off") {
52
+ return false;
53
+ }
54
+ if (mode === "breaking-only") {
55
+ return evaluation.status === "breaking-drift";
56
+ }
57
+ return mode === "compatible-and-breaking";
58
+ }
59
+
60
+ export function checkBuildchainContractLock({
61
+ lockPath = env("BUILDCHAIN_CONTRACT_LOCK_PATH", "buildchain.contract-lock.json"),
62
+ currentContractPath = env("BUILDCHAIN_CONTRACT_CURRENT_PATH", ".buildchain/runtime/dist/site/buildchain-contract.json"),
63
+ runtimeRoot = env("BUILDCHAIN_RUNTIME_ROOT", ".buildchain/runtime"),
64
+ runtimeRef = env("BUILDCHAIN_RUNTIME_REF"),
65
+ runtimeSha = env("BUILDCHAIN_RUNTIME_SHA"),
66
+ runtimeClass = env("BUILDCHAIN_RUNTIME_CLASS"),
67
+ compatibilityPolicy = env("BUILDCHAIN_CONTRACT_COMPATIBILITY_POLICY"),
68
+ issueMode = env("BUILDCHAIN_CONTRACT_DRIFT_ISSUE_MODE", "compatible-and-breaking"),
69
+ issueBodyPath = env("BUILDCHAIN_CONTRACT_DRIFT_ISSUE_BODY", ".buildchain/contract-drift/issue-body.md"),
70
+ repository = env("GITHUB_REPOSITORY"),
71
+ workflow = env("GITHUB_WORKFLOW"),
72
+ runUrl = env("BUILDCHAIN_WORKFLOW_RUN_URL"),
73
+ } = {}) {
74
+ const current = readCurrentContract(currentContractPath, runtimeRoot);
75
+ const lock = readBuildchainContractLock(lockPath);
76
+ const evaluation = evaluateBuildchainContractLock({
77
+ lock,
78
+ current,
79
+ runtimeRef,
80
+ runtimeSha,
81
+ runtimeClass,
82
+ compatibilityPolicy,
83
+ });
84
+ const shouldIssue = issueModeAllows(issueMode, evaluation);
85
+ if (shouldIssue) {
86
+ const body = renderBuildchainContractDriftIssueBody({
87
+ repository,
88
+ workflow,
89
+ runUrl,
90
+ lockPath,
91
+ evaluation,
92
+ });
93
+ fs.mkdirSync(path.dirname(issueBodyPath), { recursive: true });
94
+ fs.writeFileSync(issueBodyPath, `${body}\n`);
95
+ }
96
+ appendSummary([
97
+ "## Buildchain contract lock",
98
+ "",
99
+ `- Status: \`${evaluation.status}\``,
100
+ `- Compatible: \`${evaluation.compatible ? "true" : "false"}\``,
101
+ `- Runtime ref: \`${runtimeRef || "(unknown)"}\``,
102
+ `- Runtime SHA: \`${runtimeSha || "(unknown)"}\``,
103
+ `- Contract digest: \`${current.contractDigest}\``,
104
+ `- Compatibility digest: \`${current.compatibilityDigest}\``,
105
+ evaluation.reasons?.length ? `- Reasons: ${evaluation.reasons.join("; ")}` : "",
106
+ shouldIssue ? `- Drift issue body: \`${issueBodyPath}\`` : "",
107
+ ].filter(Boolean).join("\n"));
108
+ writeGitHubOutputs({
109
+ "contract-lock-status": evaluation.status,
110
+ "contract-lock-compatible": String(evaluation.compatible === true),
111
+ "contract-lock-drift": String(evaluation.drift === true),
112
+ "contract-lock-issue-needed": String(shouldIssue),
113
+ "contract-lock-issue-body-file": shouldIssue ? issueBodyPath : "",
114
+ "contract-digest": current.contractDigest,
115
+ "contract-compatibility-digest": current.compatibilityDigest,
116
+ "accepted-contract-digest": evaluation.accepted?.contractDigest || "",
117
+ "accepted-buildchain-sha": evaluation.accepted?.resolvedSha || "",
118
+ "current-buildchain-sha": runtimeSha || "",
119
+ });
120
+ if (!evaluation.ok) {
121
+ throw new Error(`Buildchain contract drift is not compatible: ${(evaluation.reasons || []).join("; ")}`);
122
+ }
123
+ return { evaluation, current, shouldIssue };
124
+ }
125
+
126
+ export function writeBuildchainContractLock({
127
+ output = env("BUILDCHAIN_CONTRACT_LOCK_PATH", "buildchain.contract-lock.json"),
128
+ currentContractPath = env("BUILDCHAIN_CONTRACT_CURRENT_PATH", "dist/site/buildchain-contract.json"),
129
+ runtimeRoot = env("BUILDCHAIN_RUNTIME_ROOT", process.cwd()),
130
+ buildchainRef = env("BUILDCHAIN_RUNTIME_REF", "v2"),
131
+ resolvedSha = env("BUILDCHAIN_RUNTIME_SHA"),
132
+ compatibilityPolicy = env("BUILDCHAIN_CONTRACT_COMPATIBILITY_POLICY", "major-compatible"),
133
+ acceptedAt = env("BUILDCHAIN_CONTRACT_ACCEPTED_AT") || new Date().toISOString(),
134
+ } = {}) {
135
+ const contractWorld = readCurrentContract(currentContractPath, runtimeRoot);
136
+ const lock = createBuildchainContractLock({
137
+ buildchainRef,
138
+ resolvedSha,
139
+ contractWorld,
140
+ compatibilityPolicy,
141
+ acceptedAt,
142
+ });
143
+ writeJson(output, lock);
144
+ return lock;
145
+ }
146
+
147
+ function main(argv = process.argv.slice(2)) {
148
+ const command = argv[0] || "check";
149
+ if (command === "check") {
150
+ checkBuildchainContractLock();
151
+ return;
152
+ }
153
+ if (command === "write-lock") {
154
+ const outputFlag = argv.indexOf("--output");
155
+ const output = outputFlag >= 0 ? argv[outputFlag + 1] : undefined;
156
+ const lock = writeBuildchainContractLock({ output });
157
+ process.stdout.write(`${JSON.stringify(lock, null, 2)}\n`);
158
+ return;
159
+ }
160
+ throw new Error(`unknown buildchain contract lock command: ${command}`);
161
+ }
162
+
163
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
164
+ try {
165
+ main();
166
+ } catch (error) {
167
+ console.error(`buildchain contract lock: ${error.message}`);
168
+ process.exitCode = 1;
169
+ }
170
+ }