@kungfu-tech/buildchain 2.12.4 → 2.12.5-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.
Files changed (33) hide show
  1. package/bin/buildchain.mjs +3 -0
  2. package/dist/site/agent-index.json +1 -0
  3. package/dist/site/artifact-schemas.json +1 -0
  4. package/dist/site/buildchain-contract.json +2008 -12
  5. package/dist/site/buildchain-site.json +74 -11
  6. package/dist/site/capability-registry.json +4 -3
  7. package/dist/site/controller-registry.json +1626 -0
  8. package/dist/site/kfd-claims.json +88 -74
  9. package/dist/site/kfd-upstream-aggregate.json +1 -1
  10. package/dist/site/manual-registry.json +18 -2
  11. package/dist/site/node-api-registry.json +20 -7
  12. package/dist/site/page-registry.json +58 -5
  13. package/dist/site/public-surface-audit.json +572 -102
  14. package/dist/site/publication-registry.json +4 -4
  15. package/dist/site/release-provenance.json +2 -0
  16. package/dist/site/site-manifest.json +15 -6
  17. package/dist/site/workflow-registry.json +534 -68
  18. package/docs/MAP.md +2 -1
  19. package/docs/controller-evidence.md +113 -0
  20. package/docs/release-passport.md +6 -0
  21. package/package.json +3 -1
  22. package/packages/core/buildchain-contract.js +62 -1
  23. package/packages/core/buildchain-kfd-claims.js +3 -0
  24. package/packages/core/controller-evidence.js +505 -0
  25. package/packages/core/index.js +17 -0
  26. package/packages/core/public-surface-audit.js +40 -3
  27. package/packages/core/release-candidate.js +26 -0
  28. package/packages/core/release-passport.js +19 -0
  29. package/scripts/check-inventory.mjs +4 -1
  30. package/scripts/controller-evidence.mjs +170 -0
  31. package/scripts/generate-channel-build-workflow.mjs +141 -2
  32. package/scripts/generate-release-candidate-passport.mjs +4 -0
  33. package/scripts/generate-site-bundle.mjs +11 -1
@@ -0,0 +1,505 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export const BUILDCHAIN_CONTROLLER_EVIDENCE_CONTRACT = "buildchain.controller-evidence/v1";
4
+ export const BUILDCHAIN_CONTROLLER_DESCRIPTOR_CONTRACT = "buildchain.controller-descriptor/v1";
5
+ export const BUILDCHAIN_CONTROLLER_REGISTRY_CONTRACT = "buildchain.controller-registry/v1";
6
+ export const BUILDCHAIN_CONTROLLER_AGGREGATE_CONTRACT = "buildchain.controller-evidence-aggregate/v1";
7
+
8
+ const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
9
+ const GIT_SHA_PATTERN = /^[0-9a-f]{40}$/;
10
+ const STAGE_STATUSES = new Set(["passed", "failed", "skipped", "cancelled", "missing"]);
11
+
12
+ const CONTROLLER_SPECS = [
13
+ {
14
+ id: "source-check",
15
+ workflowId: "check",
16
+ version: 1,
17
+ capabilities: ["source-acceptance", "lifecycle-check"],
18
+ stages: ["resolve-runtime", "checkout-source", "install", "check", "aggregate"],
19
+ evidence: ["lifecycle-manifest", "lifecycle-summary", "controller-receipt"],
20
+ },
21
+ {
22
+ id: "build-lifecycle",
23
+ workflowId: ".build",
24
+ version: 1,
25
+ capabilities: ["source-lock", "lifecycle-build", "lifecycle-verify", "artifact-admission"],
26
+ stages: ["resolve-runtime", "resolve-source", "build", "verify", "aggregate"],
27
+ evidence: ["platform-manifests", "build-summary", "controller-receipt"],
28
+ },
29
+ {
30
+ id: "build-channel-router",
31
+ workflowId: "build",
32
+ version: 1,
33
+ capabilities: ["channel-selection", "runtime-selection", "build-delegation"],
34
+ stages: ["resolve-channel", "build", "aggregate"],
35
+ evidence: ["nested-controller-receipt", "controller-receipt"],
36
+ },
37
+ {
38
+ id: "shifu-gate-profile-envelope",
39
+ workflowId: ".gate-profile",
40
+ version: 1,
41
+ capabilities: ["shifu-gate-profile-delegation"],
42
+ stages: ["plan", "execute", "aggregate"],
43
+ evidence: ["shifu-gate-aggregate", "controller-receipt"],
44
+ semanticBoundary: "References the Shifu aggregate digest and status without copying project Gate identifiers or Gate policy.",
45
+ },
46
+ {
47
+ id: "web-surface",
48
+ workflowId: ".web-surface",
49
+ version: 1,
50
+ capabilities: ["web-build", "web-verify", "deployment-plan", "deployment-apply"],
51
+ stages: ["resolve-runtime", "plan", "build", "verify", "apply", "aggregate"],
52
+ optionalStages: ["build", "verify", "apply"],
53
+ evidence: ["web-surface-plan", "controller-receipt"],
54
+ },
55
+ {
56
+ id: "publication-artifact",
57
+ workflowId: "publication-artifact",
58
+ version: 1,
59
+ capabilities: ["publication-build", "publication-verify", "artifact-admission"],
60
+ stages: ["resolve-runtime", "build", "verify", "collect", "aggregate"],
61
+ optionalStages: ["verify"],
62
+ evidence: ["publication-manifest", "publication-passport", "controller-receipt"],
63
+ },
64
+ {
65
+ id: "paper-release",
66
+ workflowId: "paper-release",
67
+ version: 1,
68
+ capabilities: ["publication-build", "artifact-admission", "release-publish", "release-passport"],
69
+ stages: ["resolve-runtime", "build", "verify", "collect", "publish", "passport", "aggregate"],
70
+ optionalStages: ["verify"],
71
+ evidence: ["publication-manifest", "release-passport", "controller-receipt"],
72
+ },
73
+ {
74
+ id: "release-candidate-promotion",
75
+ workflowId: "release-candidate-promote",
76
+ version: 1,
77
+ capabilities: ["promotion-preflight", "artifact-admission", "publish-transaction", "release-passport"],
78
+ stages: ["preflight", "admit-release-candidate", "publish", "passport", "aggregate"],
79
+ evidence: ["release-candidate-passport", "publish-evidence", "release-passport", "controller-receipt"],
80
+ },
81
+ {
82
+ id: "release-propagation",
83
+ workflowId: "release-propagation",
84
+ version: 1,
85
+ capabilities: ["release-propagation-plan", "downstream-lock", "pull-request-handoff"],
86
+ stages: ["resolve-runtime", "plan", "write-lock", "open-pr", "aggregate"],
87
+ optionalStages: ["open-pr"],
88
+ evidence: ["propagation-plan", "propagation-lock", "controller-receipt"],
89
+ },
90
+ ];
91
+
92
+ function stableJson(value) {
93
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
94
+ if (value && typeof value === "object") {
95
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
96
+ }
97
+ return JSON.stringify(value);
98
+ }
99
+
100
+ export function controllerEvidenceDigest(value) {
101
+ return `sha256:${crypto.createHash("sha256").update(stableJson(value)).digest("hex")}`;
102
+ }
103
+
104
+ function digestDocument(value) {
105
+ const { digest: _digest, ...content } = value;
106
+ return controllerEvidenceDigest(content);
107
+ }
108
+
109
+ function nonEmptyString(value, label) {
110
+ const normalized = String(value || "").trim();
111
+ if (!normalized) throw new Error(`${label} must be a non-empty string`);
112
+ return normalized;
113
+ }
114
+
115
+ function exactSha(value, label) {
116
+ const normalized = nonEmptyString(value, label).toLowerCase();
117
+ if (!GIT_SHA_PATTERN.test(normalized)) throw new Error(`${label} must be a 40-character Git SHA`);
118
+ return normalized;
119
+ }
120
+
121
+ function sha256Digest(value, label) {
122
+ const normalized = nonEmptyString(value, label).toLowerCase();
123
+ if (!SHA256_PATTERN.test(normalized)) throw new Error(`${label} must be a sha256 digest`);
124
+ return normalized;
125
+ }
126
+
127
+ function unique(values = []) {
128
+ return [...new Set(values.map(String).filter(Boolean))];
129
+ }
130
+
131
+ function classifyInput(name, secretNames = new Set()) {
132
+ if (secretNames.has(name) || /(?:^|[-_])(token|secret|private[-_]?key)(?:$|[-_])/i.test(name)) {
133
+ return "redacted";
134
+ }
135
+ if (/(?:command|json|body|notes|role-arn|runner|mirror-url|repository-template|path|directory|environment|registry-index|s3-(?:bucket|region|prefix)|container-image|checkout-cache|hostname|endpoint|address)/i.test(name)) {
136
+ return "digest-only";
137
+ }
138
+ return "included";
139
+ }
140
+
141
+ function descriptorDigest(descriptor) {
142
+ const { digest: _digest, ...content } = descriptor;
143
+ return controllerEvidenceDigest(content);
144
+ }
145
+
146
+ export function createControllerRegistry({ workflows = [] } = {}) {
147
+ const workflowById = new Map(workflows.map((entry) => [entry.id, entry]));
148
+ const controllers = CONTROLLER_SPECS.map((spec) => {
149
+ const workflow = workflowById.get(spec.workflowId);
150
+ if (!workflow) throw new Error(`controller ${spec.id} requires workflow descriptor ${spec.workflowId}`);
151
+ const secrets = new Set(unique(workflow.secrets));
152
+ const names = unique([...(workflow.inputs || []), ...secrets]).sort();
153
+ const inputs = Object.fromEntries(names.map((name) => [name, {
154
+ classification: classifyInput(name, secrets),
155
+ source: secrets.has(name) ? "workflow-call-secret" : "workflow-call-input",
156
+ }]));
157
+ for (const [name, policy] of Object.entries(workflow.inputPolicies || {})) {
158
+ if (!inputs[name]) throw new Error(`controller ${spec.id} input policy references undeclared input ${name}`);
159
+ inputs[name] = { ...inputs[name], ...policy };
160
+ }
161
+ const optionalStages = new Set(spec.optionalStages || []);
162
+ const descriptor = {
163
+ schemaVersion: 1,
164
+ contract: BUILDCHAIN_CONTROLLER_DESCRIPTOR_CONTRACT,
165
+ id: spec.id,
166
+ version: spec.version,
167
+ workflow: { id: workflow.id, path: workflow.path },
168
+ inputs,
169
+ expected: {
170
+ stages: spec.stages.map((id) => ({ id, required: !optionalStages.has(id) })),
171
+ capabilities: [...spec.capabilities],
172
+ evidence: [...spec.evidence],
173
+ },
174
+ ...(spec.semanticBoundary ? { semanticBoundary: spec.semanticBoundary } : {}),
175
+ };
176
+ descriptor.digest = descriptorDigest(descriptor);
177
+ return descriptor;
178
+ });
179
+ const registry = {
180
+ schemaVersion: 1,
181
+ contract: BUILDCHAIN_CONTROLLER_REGISTRY_CONTRACT,
182
+ controllers,
183
+ };
184
+ registry.digest = digestDocument(registry);
185
+ return registry;
186
+ }
187
+
188
+ function normalizeIncludedValue(value, label, name) {
189
+ if (value === undefined) return undefined;
190
+ if (value === null || ["number", "boolean"].includes(typeof value)) return value;
191
+ if (typeof value === "string") {
192
+ if (/(?:path|directory)/i.test(name) && (/^(?:\/|~[\\/]|[A-Za-z]:[\\/])/.test(value))) {
193
+ throw new Error(`${label} must not expose an absolute runner path`);
194
+ }
195
+ return value;
196
+ }
197
+ throw new Error(`${label} must use digest-only classification for structured values`);
198
+ }
199
+
200
+ function normalizeInputs(descriptor, supplied = {}) {
201
+ if (!supplied || typeof supplied !== "object" || Array.isArray(supplied)) {
202
+ throw new Error("controller inputs must be an object");
203
+ }
204
+ for (const name of Object.keys(supplied)) {
205
+ if (!descriptor.inputs?.[name]) throw new Error(`undeclared controller input: ${name}`);
206
+ }
207
+ const normalized = {};
208
+ const issues = [];
209
+ for (const name of Object.keys(descriptor.inputs || {}).sort()) {
210
+ const policy = descriptor.inputs[name];
211
+ const provided = Object.hasOwn(supplied, name) && supplied[name] !== undefined;
212
+ if (policy.classification === "included") {
213
+ normalized[name] = {
214
+ classification: "included",
215
+ provided,
216
+ ...(provided ? { value: normalizeIncludedValue(supplied[name], `controller input ${name}`, name) } : {}),
217
+ };
218
+ } else if (policy.classification === "digest-only") {
219
+ normalized[name] = {
220
+ classification: "digest-only",
221
+ provided,
222
+ ...(provided ? { digest: controllerEvidenceDigest(supplied[name]) } : {}),
223
+ };
224
+ } else if (policy.classification === "redacted") {
225
+ normalized[name] = { classification: "redacted" };
226
+ } else if (policy.classification === "unsupported") {
227
+ normalized[name] = { classification: "unsupported", provided };
228
+ if (provided) issues.push(`unsupported controller input was provided: ${name}`);
229
+ } else {
230
+ throw new Error(`unsupported controller input classification for ${name}: ${policy.classification}`);
231
+ }
232
+ }
233
+ return { normalized, issues };
234
+ }
235
+
236
+ export function createControllerPlan({ descriptor, source = {}, runtime = {}, inputs = {} } = {}) {
237
+ if (!descriptor || descriptor.contract !== BUILDCHAIN_CONTROLLER_DESCRIPTOR_CONTRACT) {
238
+ throw new Error("descriptor must use buildchain.controller-descriptor/v1");
239
+ }
240
+ if (descriptor.digest !== descriptorDigest(descriptor)) throw new Error("controller descriptor digest mismatch");
241
+ const normalizedInputs = normalizeInputs(descriptor, inputs);
242
+ const plan = {
243
+ schemaVersion: 1,
244
+ contract: BUILDCHAIN_CONTROLLER_EVIDENCE_CONTRACT,
245
+ kind: "plan",
246
+ controller: {
247
+ id: descriptor.id,
248
+ version: descriptor.version,
249
+ descriptorDigest: descriptor.digest,
250
+ workflow: { ...descriptor.workflow },
251
+ },
252
+ source: {
253
+ repository: nonEmptyString(source.repository, "source.repository"),
254
+ sha: exactSha(source.sha, "source.sha"),
255
+ },
256
+ runtime: {
257
+ ref: nonEmptyString(runtime.ref, "runtime.ref"),
258
+ sha: exactSha(runtime.sha, "runtime.sha"),
259
+ contractDigest: sha256Digest(runtime.contractDigest, "runtime.contractDigest"),
260
+ },
261
+ inputs: normalizedInputs.normalized,
262
+ expected: {
263
+ stages: descriptor.expected.stages.map((stage) => ({ ...stage })),
264
+ capabilities: [...descriptor.expected.capabilities],
265
+ evidence: [...descriptor.expected.evidence],
266
+ },
267
+ qualifying: normalizedInputs.issues.length === 0,
268
+ issues: normalizedInputs.issues,
269
+ };
270
+ plan.digest = digestDocument(plan);
271
+ return plan;
272
+ }
273
+
274
+ export function validateControllerPlan(plan) {
275
+ const issues = [];
276
+ if (!plan || typeof plan !== "object" || Array.isArray(plan)) return { ok: false, qualifying: false, issues: ["plan must be an object"] };
277
+ if (plan.contract !== BUILDCHAIN_CONTROLLER_EVIDENCE_CONTRACT) issues.push(`plan contract must be ${BUILDCHAIN_CONTROLLER_EVIDENCE_CONTRACT}`);
278
+ if (plan.kind !== "plan") issues.push("plan kind must be plan");
279
+ if (!GIT_SHA_PATTERN.test(String(plan.source?.sha || ""))) issues.push("plan source SHA must be exact");
280
+ if (!GIT_SHA_PATTERN.test(String(plan.runtime?.sha || ""))) issues.push("plan runtime SHA must be exact");
281
+ if (!SHA256_PATTERN.test(String(plan.runtime?.contractDigest || ""))) issues.push("plan runtime contract digest is invalid");
282
+ if (plan.digest !== digestDocument(plan)) issues.push("plan digest mismatch");
283
+ for (const [name, input] of Object.entries(plan.inputs || {})) {
284
+ if (input.classification === "redacted" && ("value" in input || "digest" in input || "provided" in input)) {
285
+ issues.push(`redacted input leaked metadata: ${name}`);
286
+ }
287
+ if (input.classification === "digest-only" && input.provided && !SHA256_PATTERN.test(String(input.digest || ""))) {
288
+ issues.push(`digest-only input is missing a digest: ${name}`);
289
+ }
290
+ }
291
+ const qualifying = issues.length === 0 && plan.qualifying === true;
292
+ return { ok: issues.length === 0, qualifying, issues: [...issues, ...(plan.issues || [])] };
293
+ }
294
+
295
+ function normalizeEvidence(entries = []) {
296
+ return entries.map((entry, index) => ({
297
+ kind: nonEmptyString(entry.kind, `evidence[${index}].kind`),
298
+ digest: sha256Digest(entry.digest, `evidence[${index}].digest`),
299
+ ...(entry.artifact ? { artifact: String(entry.artifact) } : {}),
300
+ }));
301
+ }
302
+
303
+ function receiptStatus(plan, stages) {
304
+ if (stages.some((stage) => stage.status === "failed")) return "failed";
305
+ if (stages.length > 0 && stages.every((stage) => stage.status === "skipped")) return "skipped";
306
+ if (stages.some((stage) => stage.status === "cancelled" || stage.status === "missing")) return "partial";
307
+ const required = new Set(plan.expected.stages.filter((stage) => stage.required).map((stage) => stage.id));
308
+ if (stages.filter((stage) => required.has(stage.id)).every((stage) => stage.status === "passed")) return "passed";
309
+ return "partial";
310
+ }
311
+
312
+ export function createControllerReceipt({ plan, stages = [], evidence = [], reason = undefined, artifact = "" } = {}) {
313
+ const planValidation = validateControllerPlan(plan);
314
+ if (!planValidation.ok) throw new Error(`controller plan is invalid: ${planValidation.issues.join("; ")}`);
315
+ const expected = new Map(plan.expected.stages.map((stage) => [stage.id, stage]));
316
+ const supplied = new Map();
317
+ for (const [index, stage] of stages.entries()) {
318
+ const id = nonEmptyString(stage.id, `stages[${index}].id`);
319
+ if (!expected.has(id)) throw new Error(`undeclared controller stage: ${id}`);
320
+ if (supplied.has(id)) throw new Error(`duplicate controller stage: ${id}`);
321
+ const status = nonEmptyString(stage.status, `stages[${index}].status`);
322
+ if (!STAGE_STATUSES.has(status)) throw new Error(`unsupported controller stage status: ${status}`);
323
+ supplied.set(id, {
324
+ id,
325
+ required: expected.get(id).required,
326
+ status,
327
+ evidence: normalizeEvidence(stage.evidence || []),
328
+ });
329
+ }
330
+ const normalizedStages = plan.expected.stages.map((stage) => supplied.get(stage.id) || {
331
+ id: stage.id,
332
+ required: stage.required,
333
+ status: "missing",
334
+ evidence: [],
335
+ });
336
+ const status = receiptStatus(plan, normalizedStages);
337
+ const normalizedEvidence = normalizeEvidence(evidence);
338
+ const availableEvidence = new Set([
339
+ ...normalizedEvidence.map((entry) => entry.kind),
340
+ ...normalizedStages.flatMap((stage) => stage.evidence.map((entry) => entry.kind)),
341
+ ]);
342
+ const missingEvidence = plan.expected.evidence
343
+ .filter((kind) => kind !== "controller-receipt" && !availableEvidence.has(kind));
344
+ const issues = status === "passed"
345
+ ? missingEvidence.map((kind) => `required controller evidence is missing: ${kind}`)
346
+ : [];
347
+ const receipt = {
348
+ schemaVersion: 1,
349
+ contract: BUILDCHAIN_CONTROLLER_EVIDENCE_CONTRACT,
350
+ kind: "receipt",
351
+ controller: { ...plan.controller },
352
+ source: { ...plan.source },
353
+ runtime: { ...plan.runtime },
354
+ planDigest: plan.digest,
355
+ status,
356
+ qualifying: status === "passed" && plan.qualifying === true && issues.length === 0,
357
+ stages: normalizedStages,
358
+ evidence: normalizedEvidence,
359
+ issues,
360
+ ...(reason ? {
361
+ reason: {
362
+ code: nonEmptyString(reason.code, "reason.code"),
363
+ summary: nonEmptyString(reason.summary, "reason.summary"),
364
+ },
365
+ } : {}),
366
+ ...(artifact ? { artifact: String(artifact) } : {}),
367
+ };
368
+ receipt.digest = digestDocument(receipt);
369
+ return receipt;
370
+ }
371
+
372
+ export function validateControllerReceipt(receipt, {
373
+ plan = undefined,
374
+ expectedSourceSha = "",
375
+ expectedRuntimeSha = "",
376
+ expectedPlanDigest = "",
377
+ } = {}) {
378
+ const issues = [];
379
+ if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return { ok: false, qualifying: false, issues: ["receipt must be an object"] };
380
+ if (receipt.contract !== BUILDCHAIN_CONTROLLER_EVIDENCE_CONTRACT) issues.push(`receipt contract must be ${BUILDCHAIN_CONTROLLER_EVIDENCE_CONTRACT}`);
381
+ if (receipt.kind !== "receipt") issues.push("receipt kind must be receipt");
382
+ if (receipt.digest !== digestDocument(receipt)) issues.push("receipt digest mismatch");
383
+ issues.push(...(receipt.issues || []));
384
+ if (plan) {
385
+ if (receipt.planDigest !== plan.digest) issues.push("receipt plan digest mismatch");
386
+ if (receipt.controller?.id !== plan.controller?.id) issues.push("receipt controller mismatch");
387
+ if (receipt.source?.sha !== plan.source?.sha) issues.push("receipt source SHA mismatch");
388
+ if (receipt.runtime?.sha !== plan.runtime?.sha) issues.push("receipt runtime SHA mismatch");
389
+ }
390
+ if (expectedSourceSha && receipt.source?.sha !== expectedSourceSha) issues.push("receipt source SHA mismatch");
391
+ if (expectedRuntimeSha && receipt.runtime?.sha !== expectedRuntimeSha) issues.push("receipt runtime SHA mismatch");
392
+ if (expectedPlanDigest && receipt.planDigest !== expectedPlanDigest) issues.push("receipt plan digest mismatch");
393
+ const qualifying = issues.length === 0 && receipt.qualifying === true && receipt.status === "passed";
394
+ return { ok: issues.length === 0, qualifying, issues };
395
+ }
396
+
397
+ export function aggregateControllerReceipts({ plans = [], receipts = [] } = {}) {
398
+ const byPlan = new Map(receipts.map((receipt) => [receipt.planDigest, receipt]));
399
+ const rows = [];
400
+ const issues = [];
401
+ for (const plan of plans) {
402
+ const receipt = byPlan.get(plan.digest);
403
+ if (!receipt) {
404
+ rows.push({ controllerId: plan.controller.id, planDigest: plan.digest, status: "receipt-missing", qualifying: false });
405
+ issues.push(`${plan.controller.id}: receipt is missing`);
406
+ continue;
407
+ }
408
+ const validation = validateControllerReceipt(receipt, { plan });
409
+ rows.push({
410
+ controllerId: plan.controller.id,
411
+ planDigest: plan.digest,
412
+ receiptDigest: receipt.digest,
413
+ status: validation.ok ? receipt.status : "invalid",
414
+ qualifying: validation.qualifying,
415
+ });
416
+ issues.push(...validation.issues.map((issue) => `${plan.controller.id}: ${issue}`));
417
+ }
418
+ const status = rows.some((row) => row.status === "receipt-missing")
419
+ ? "receipt-missing"
420
+ : rows.some((row) => row.status === "failed" || row.status === "invalid")
421
+ ? "failed"
422
+ : rows.some((row) => row.status === "partial")
423
+ ? "partial"
424
+ : rows.length > 0 && rows.every((row) => row.status === "skipped")
425
+ ? "skipped"
426
+ : "passed";
427
+ const aggregate = {
428
+ schemaVersion: 1,
429
+ contract: BUILDCHAIN_CONTROLLER_AGGREGATE_CONTRACT,
430
+ status,
431
+ qualifying: status === "passed" && rows.every((row) => row.qualifying),
432
+ controllers: rows,
433
+ issues,
434
+ };
435
+ aggregate.digest = digestDocument(aggregate);
436
+ return aggregate;
437
+ }
438
+
439
+ export function createControllerReceiptReference(receipt) {
440
+ const validation = validateControllerReceipt(receipt);
441
+ if (!validation.ok) throw new Error(`controller receipt is invalid: ${validation.issues.join("; ")}`);
442
+ return {
443
+ controllerId: receipt.controller.id,
444
+ planDigest: receipt.planDigest,
445
+ receiptDigest: receipt.digest,
446
+ sourceSha: receipt.source.sha,
447
+ runtimeSha: receipt.runtime.sha,
448
+ status: receipt.status,
449
+ artifact: receipt.artifact || "",
450
+ };
451
+ }
452
+
453
+ export function validateControllerReceiptReference(reference, {
454
+ expectedSourceSha = "",
455
+ acceptedSourceShas = [],
456
+ expectedRuntimeSha = "",
457
+ requirePassed = false,
458
+ } = {}) {
459
+ const issues = [];
460
+ if (!reference || typeof reference !== "object" || Array.isArray(reference)) {
461
+ return { ok: false, issues: ["controller receipt reference must be an object"] };
462
+ }
463
+ if (!String(reference.controllerId || "").trim()) issues.push("controller receipt reference controllerId is required");
464
+ if (!SHA256_PATTERN.test(String(reference.planDigest || ""))) issues.push("controller receipt reference plan digest is invalid");
465
+ if (!SHA256_PATTERN.test(String(reference.receiptDigest || ""))) issues.push("controller receipt reference receipt digest is invalid");
466
+ if (!GIT_SHA_PATTERN.test(String(reference.sourceSha || ""))) issues.push("controller receipt reference source SHA is invalid");
467
+ if (!GIT_SHA_PATTERN.test(String(reference.runtimeSha || ""))) issues.push("controller receipt reference runtime SHA is invalid");
468
+ if (!["passed", "failed", "skipped", "partial"].includes(String(reference.status || ""))) {
469
+ issues.push("controller receipt reference status is invalid");
470
+ }
471
+ if (requirePassed && reference.status !== "passed") issues.push("controller receipt reference status must be passed");
472
+ const allowedSourceShas = new Set([expectedSourceSha, ...(acceptedSourceShas || [])].filter(Boolean));
473
+ if (allowedSourceShas.size > 0 && !allowedSourceShas.has(reference.sourceSha)) {
474
+ issues.push("controller receipt reference source SHA mismatch");
475
+ }
476
+ if (expectedRuntimeSha && reference.runtimeSha !== expectedRuntimeSha) issues.push("controller receipt reference runtime SHA mismatch");
477
+ return { ok: issues.length === 0, issues };
478
+ }
479
+
480
+ export function normalizeControllerReceiptReferences({
481
+ receipts = [],
482
+ references = [],
483
+ expectedSourceSha = "",
484
+ acceptedSourceShas = [],
485
+ expectedRuntimeSha = "",
486
+ requirePassed = false,
487
+ } = {}) {
488
+ const normalized = [
489
+ ...(receipts || []).map(createControllerReceiptReference),
490
+ ...(references || []).map((reference) => ({ ...reference, artifact: String(reference.artifact || "") })),
491
+ ];
492
+ const seen = new Set();
493
+ for (const reference of normalized) {
494
+ const validation = validateControllerReceiptReference(reference, {
495
+ expectedSourceSha,
496
+ acceptedSourceShas,
497
+ expectedRuntimeSha,
498
+ requirePassed,
499
+ });
500
+ if (!validation.ok) throw new Error(`controller receipt reference is invalid: ${validation.issues.join("; ")}`);
501
+ if (seen.has(reference.controllerId)) throw new Error(`duplicate controller receipt reference: ${reference.controllerId}`);
502
+ seen.add(reference.controllerId);
503
+ }
504
+ return normalized.sort((left, right) => left.controllerId.localeCompare(right.controllerId));
505
+ }
@@ -158,6 +158,23 @@ export {
158
158
  sha256Json as sha256BuildchainContractJson,
159
159
  } from "./buildchain-contract.js";
160
160
 
161
+ export {
162
+ BUILDCHAIN_CONTROLLER_AGGREGATE_CONTRACT,
163
+ BUILDCHAIN_CONTROLLER_DESCRIPTOR_CONTRACT,
164
+ BUILDCHAIN_CONTROLLER_EVIDENCE_CONTRACT,
165
+ BUILDCHAIN_CONTROLLER_REGISTRY_CONTRACT,
166
+ aggregateControllerReceipts,
167
+ controllerEvidenceDigest,
168
+ createControllerPlan,
169
+ createControllerReceipt,
170
+ createControllerReceiptReference,
171
+ createControllerRegistry,
172
+ normalizeControllerReceiptReferences,
173
+ validateControllerPlan,
174
+ validateControllerReceipt,
175
+ validateControllerReceiptReference,
176
+ } from "./controller-evidence.js";
177
+
161
178
  export {
162
179
  planReleaseLineBootstrap,
163
180
  writeReleaseLineBootstrapVersionState,
@@ -108,6 +108,38 @@ export function enumerateCliCommandsFromBin({ root = process.cwd(), binPath = "b
108
108
  }));
109
109
  }
110
110
 
111
+ function parseYamlWorkflowCall(text) {
112
+ const lines = text.split(/\r?\n/);
113
+ const result = { reusable: false, inputs: [], secrets: [], outputs: [] };
114
+ const workflowCallIndex = lines.findIndex((line) => /^(\s*)workflow_call:\s*$/.test(line));
115
+ if (workflowCallIndex === -1) return result;
116
+ result.reusable = true;
117
+ const workflowCallIndent = lines[workflowCallIndex].match(/^(\s*)/)?.[1].length || 0;
118
+ let section = "";
119
+ for (const line of lines.slice(workflowCallIndex + 1)) {
120
+ const currentIndent = line.match(/^(\s*)/)?.[1].length || 0;
121
+ if (line.trim() && currentIndent <= workflowCallIndent) break;
122
+ const sectionMatch = line.match(new RegExp(`^\\s{${workflowCallIndent + 2}}(inputs|secrets|outputs):\\s*$`));
123
+ if (sectionMatch) {
124
+ section = sectionMatch[1];
125
+ continue;
126
+ }
127
+ if (line.trim() && currentIndent <= workflowCallIndent + 2) {
128
+ section = "";
129
+ continue;
130
+ }
131
+ if (!section) continue;
132
+ const fieldMatch = line.match(new RegExp(`^\\s{${workflowCallIndent + 4}}([A-Za-z0-9_-]+):\\s*$`));
133
+ if (fieldMatch) result[section].push(fieldMatch[1]);
134
+ }
135
+ return {
136
+ reusable: result.reusable,
137
+ inputs: uniqueSorted(result.inputs),
138
+ secrets: uniqueSorted(result.secrets),
139
+ outputs: uniqueSorted(result.outputs),
140
+ };
141
+ }
142
+
111
143
  function parseYamlTopLevelInputs(text) {
112
144
  const lines = text.split(/\r?\n/);
113
145
  const inputs = [];
@@ -134,12 +166,17 @@ function parseYamlTopLevelInputs(text) {
134
166
 
135
167
  export function enumerateWorkflowInputs({ root = process.cwd() } = {}) {
136
168
  return listFiles(root, ".github/workflows", (name) => /\.ya?ml$/.test(name)).map((relPath) => {
137
- const inputs = parseYamlTopLevelInputs(readText(root, relPath));
169
+ const contract = parseYamlWorkflowCall(readText(root, relPath));
138
170
  return {
139
171
  id: relPath.replace(/^\.github\/workflows\//, "").replace(/\.ya?ml$/, ""),
140
172
  path: relPath,
141
- inputs,
142
- inputCount: inputs.length,
173
+ reusable: contract.reusable,
174
+ inputs: contract.inputs,
175
+ inputCount: contract.inputs.length,
176
+ secrets: contract.secrets,
177
+ secretCount: contract.secrets.length,
178
+ outputs: contract.outputs,
179
+ outputCount: contract.outputs.length,
143
180
  };
144
181
  });
145
182
  }
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { normalizeControllerReceiptReferences, validateControllerReceiptReference } from "./controller-evidence.js";
2
3
 
3
4
  export const RELEASE_CANDIDATE_PASSPORT_CONTRACT = "kungfu-buildchain-release-candidate-passport";
4
5
 
@@ -142,6 +143,8 @@ export function createReleaseCandidatePassport({
142
143
  buildSummary = {},
143
144
  buildchain = {},
144
145
  gateAggregate = undefined,
146
+ controllerReceipts = [],
147
+ controllerReceiptReferences = [],
145
148
  workflow = {},
146
149
  createdAt = nowIso(),
147
150
  } = {}) {
@@ -154,6 +157,13 @@ export function createReleaseCandidatePassport({
154
157
  || normalizeTargetChannel(pullRequest.baseRef);
155
158
  const resolvedVersion = version || normalizedSummary.publishSource?.consumerVersion || inferVersionFromReleaseManifest(normalizedSummary);
156
159
  const gateProfileEvidence = normalizeGateProfileEvidence(gateAggregate);
160
+ const controllerReceiptEvidence = normalizeControllerReceiptReferences({
161
+ receipts: controllerReceipts,
162
+ references: controllerReceiptReferences,
163
+ expectedSourceSha: sourceSha,
164
+ expectedRuntimeSha: optionalString(buildchain.sha || normalizedSummary.runtime?.sha),
165
+ requirePassed: true,
166
+ });
157
167
  const candidate = {
158
168
  schemaVersion: 1,
159
169
  contract: RELEASE_CANDIDATE_PASSPORT_CONTRACT,
@@ -197,6 +207,7 @@ export function createReleaseCandidatePassport({
197
207
  buildSummaryHash: sha256Json(normalizedSummary),
198
208
  },
199
209
  ...(gateProfileEvidence ? { gateProfileEvidence } : {}),
210
+ ...(controllerReceiptEvidence.length > 0 ? { controllerReceipts: controllerReceiptEvidence } : {}),
200
211
  };
201
212
  candidate.candidateHash = sha256Json({
202
213
  repository: candidate.repository,
@@ -205,6 +216,7 @@ export function createReleaseCandidatePassport({
205
216
  platformMatrix: candidate.platformMatrix,
206
217
  buildchain: candidate.buildchain,
207
218
  ...(candidate.gateProfileEvidence ? { gateProfileEvidence: candidate.gateProfileEvidence } : {}),
219
+ ...(candidate.controllerReceipts ? { controllerReceipts: candidate.controllerReceipts } : {}),
208
220
  });
209
221
  return candidate;
210
222
  }
@@ -267,5 +279,19 @@ export function validateReleaseCandidatePassport({
267
279
  check(Boolean(passport.gateProfileEvidence.digest), "gate profile evidence digest is required");
268
280
  check(Boolean(passport.gateProfileEvidence.matrixDigest), "gate profile matrix digest is required");
269
281
  }
282
+ if (passport.controllerReceipts !== undefined) {
283
+ check(Array.isArray(passport.controllerReceipts), "controllerReceipts must be an array");
284
+ const controllerIds = new Set();
285
+ for (const [index, reference] of (passport.controllerReceipts || []).entries()) {
286
+ const validation = validateControllerReceiptReference(reference, {
287
+ expectedSourceSha: passport.source?.headSha || "",
288
+ expectedRuntimeSha: passport.buildchain?.sha || "",
289
+ requirePassed: true,
290
+ });
291
+ for (const issue of validation.issues) check(false, `controllerReceipts[${index}]: ${issue}`);
292
+ check(!controllerIds.has(reference.controllerId), `controllerReceipts[${index}]: duplicate controller id ${reference.controllerId}`);
293
+ controllerIds.add(reference.controllerId);
294
+ }
295
+ }
270
296
  return { ok: errors.length === 0, errors };
271
297
  }