@kungfu-tech/buildchain 2.13.0-alpha.0 → 2.13.0-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.
@@ -16,6 +16,10 @@ export const PUBLICATION_CONTROL_PLANE_AUDIT_CONTRACT =
16
16
  "kungfu-buildchain-publication-control-plane-audit";
17
17
  export const PUBLICATION_GATE_DECISION_CONTRACT =
18
18
  "kungfu-buildchain-publication-gate-decision";
19
+ export const CONSUMER_PUBLICATION_DECISION_CONTRACT =
20
+ "kungfu-buildchain-consumer-publication-decision";
21
+ export const PUBLICATION_QUALIFICATION_RECEIPT_CONTRACT =
22
+ "kungfu-buildchain-publication-qualification-receipt";
19
23
  export const PUBLICATION_ARTIFACT_MANIFEST_SET_CONTRACT =
20
24
  "kungfu-buildchain-publication-artifact-manifest-set";
21
25
 
@@ -225,6 +229,16 @@ export function createPublicationControlPlaneAudit({
225
229
  }
226
230
 
227
231
  export function createPublicationAdmission(input = {}) {
232
+ const qualificationRequired = input.qualification?.required === true;
233
+ const qualification = {
234
+ required: qualificationRequired,
235
+ predicateId: qualificationRequired
236
+ ? requiredString(input.qualification?.predicateId, "qualification.predicateId")
237
+ : "",
238
+ predicateDigest: qualificationRequired
239
+ ? normalizeDigest(input.qualification?.predicateDigest, "qualification.predicateDigest")
240
+ : "",
241
+ };
228
242
  const payload = {
229
243
  schemaVersion: 1,
230
244
  contract: PUBLICATION_ADMISSION_CONTRACT,
@@ -236,6 +250,10 @@ export function createPublicationAdmission(input = {}) {
236
250
  runtimeSha: normalizeGitSha(input.runtimeSha, "runtimeSha"),
237
251
  contractDigest: normalizeDigest(input.contractDigest, "contractDigest"),
238
252
  policyDigest: normalizeDigest(input.policyDigest, "policyDigest"),
253
+ gateRegistryDigest: normalizeDigest(
254
+ input.gateRegistryDigest || input.policyDigest,
255
+ "gateRegistryDigest",
256
+ ),
239
257
  controllerReceiptDigest: normalizeDigest(input.controllerReceiptDigest, "controllerReceiptDigest"),
240
258
  runnerProvenanceDigest: normalizeDigest(input.runnerProvenanceDigest, "runnerProvenanceDigest"),
241
259
  controlPlaneAuditDigest: normalizeDigest(input.controlPlaneAuditDigest, "controlPlaneAuditDigest"),
@@ -249,6 +267,7 @@ export function createPublicationAdmission(input = {}) {
249
267
  nonce: requiredString(input.nonce, "nonce"),
250
268
  issuedAt: new Date(parseTime(input.issuedAt, "issuedAt")).toISOString(),
251
269
  expiresAt: new Date(parseTime(input.expiresAt, "expiresAt")).toISOString(),
270
+ qualification,
252
271
  };
253
272
  return { ...payload, admissionDigest: publicationAuthorityDigest(payload) };
254
273
  }
@@ -381,8 +400,7 @@ export function createPublicationArtifactManifestSet({
381
400
  return { ...payload, manifestSetDigest: publicationAuthorityDigest(payload) };
382
401
  }
383
402
 
384
- function validateGateAggregate(gateAggregate, { admission, passport }) {
385
- const passportSourceSha = passport.source?.headSha || passport.source?.sha;
403
+ export function publicationGateAggregateBindings(gateAggregate) {
386
404
  if (gateAggregate?.contract === "buildchain.shifu-gate-aggregate/v1") {
387
405
  const { digest, ...payload } = gateAggregate;
388
406
  const actualDigest = sha256Json(payload);
@@ -392,12 +410,17 @@ function validateGateAggregate(gateAggregate, { admission, passport }) {
392
410
  if (gateAggregate.status !== "pass" || gateAggregate.qualifying !== true) {
393
411
  throw new Error("gate aggregate is not qualifying");
394
412
  }
395
- if (![admission.sourceSha, passportSourceSha].includes(gateAggregate.sourceSha)) {
396
- throw new Error("gate aggregate source SHA mismatch");
397
- }
398
- normalizeDigest(gateAggregate.registry?.digest, "gateAggregate.registry.digest");
413
+ const registryDigest = normalizeDigest(
414
+ gateAggregate.registry?.digest,
415
+ "gateAggregate.registry.digest",
416
+ );
399
417
  const policyDigest = normalizeDigest(gateAggregate.matrixDigest, "gateAggregate.matrixDigest");
400
- return { gateAggregateDigest: actualDigest, policyDigest };
418
+ return {
419
+ gateAggregateDigest: actualDigest,
420
+ policyDigest,
421
+ registryDigest,
422
+ sourceSha: normalizeGitSha(gateAggregate.sourceSha, "gateAggregate.sourceSha"),
423
+ };
401
424
  }
402
425
  if (gateAggregate?.contract !== PUBLICATION_GATE_DECISION_CONTRACT) {
403
426
  throw new Error("gate evidence must be a Shifu Gate aggregate or explicit publication Gate decision");
@@ -410,9 +433,6 @@ function validateGateAggregate(gateAggregate, { admission, passport }) {
410
433
  if (gateAggregate.required !== false) {
411
434
  throw new Error("a required Gate policy must supply a qualifying Shifu Gate aggregate");
412
435
  }
413
- if (![admission.sourceSha, passportSourceSha].includes(gateAggregate.sourceSha)) {
414
- throw new Error("publication Gate decision source SHA mismatch");
415
- }
416
436
  const normalizedPolicy = {
417
437
  profile: requiredString(gateAggregate.profile, "gateDecision.profile"),
418
438
  required: false,
@@ -423,7 +443,21 @@ function validateGateAggregate(gateAggregate, { admission, passport }) {
423
443
  if (normalizeDigest(gateAggregate.policyDigest, "gateDecision.policyDigest") !== policyDigest) {
424
444
  throw new Error("publication Gate policy digest mismatch");
425
445
  }
426
- return { gateAggregateDigest: actualDigest, policyDigest };
446
+ return {
447
+ gateAggregateDigest: actualDigest,
448
+ policyDigest,
449
+ registryDigest: policyDigest,
450
+ sourceSha: normalizeGitSha(gateAggregate.sourceSha, "gateDecision.sourceSha"),
451
+ };
452
+ }
453
+
454
+ function validateGateAggregate(gateAggregate, { admission, passport }) {
455
+ const bindings = publicationGateAggregateBindings(gateAggregate);
456
+ const passportSourceSha = passport.source?.headSha || passport.source?.sha;
457
+ if (![admission.sourceSha, passportSourceSha].includes(bindings.sourceSha)) {
458
+ throw new Error("gate aggregate source SHA mismatch");
459
+ }
460
+ return bindings;
427
461
  }
428
462
 
429
463
  function validatePublicationEvidence(publicationEvidence, admission) {
@@ -458,6 +492,7 @@ function validatePublicationEvidence(publicationEvidence, admission) {
458
492
  contractDigest,
459
493
  gateAggregateDigest: gate.gateAggregateDigest,
460
494
  policyDigest: gate.policyDigest,
495
+ gateRegistryDigest: gate.registryDigest,
461
496
  artifactDigest: candidate.candidateDigest,
462
497
  evidenceDigest: publicationAuthorityDigest({
463
498
  sourceTreeSha: candidate.sourceTreeSha,
@@ -551,6 +586,7 @@ function validatePublicationEvidence(publicationEvidence, admission) {
551
586
  contractDigest,
552
587
  gateAggregateDigest: gate.gateAggregateDigest,
553
588
  policyDigest: gate.policyDigest,
589
+ gateRegistryDigest: gate.registryDigest,
554
590
  artifactDigest: manifestSet.manifestSetDigest,
555
591
  evidenceDigest: publicationAuthorityDigest({
556
592
  passportCandidateHash: passport.candidateHash,
@@ -712,7 +748,7 @@ export function verifyPublicationAdmission({
712
748
  for (const key of ["sourceSha", "runtimeSha"]) {
713
749
  normalizeGitSha(admission[key], `admission.${key}`);
714
750
  }
715
- for (const key of ["contractDigest", "policyDigest", "controllerReceiptDigest", "gateAggregateDigest", "artifactDigest"]) {
751
+ for (const key of ["contractDigest", "policyDigest", "controllerReceiptDigest", "gateAggregateDigest", "artifactDigest", ...(admission.gateRegistryDigest ? ["gateRegistryDigest"] : [])]) {
716
752
  normalizeDigest(admission[key], `admission.${key}`);
717
753
  }
718
754
 
@@ -726,6 +762,13 @@ export function verifyPublicationAdmission({
726
762
  expectedDigest: admission.controlPlaneAuditDigest,
727
763
  });
728
764
  const evidence = validatePublicationEvidence(publicationEvidence, admission);
765
+ if (
766
+ admission.gateRegistryDigest &&
767
+ normalizeDigest(admission.gateRegistryDigest, "admission.gateRegistryDigest") !==
768
+ evidence.gateRegistryDigest
769
+ ) {
770
+ throw new Error("Gate registry evidence binding mismatch");
771
+ }
729
772
  const { admissionDigest: suppliedAdmissionDigest, producerDecision: _ignored, ...admissionPayload } = admission;
730
773
  const actualAdmissionDigest = publicationAuthorityDigest(admissionPayload);
731
774
  if (normalizeDigest(suppliedAdmissionDigest, "admission.admissionDigest") !== actualAdmissionDigest) {
@@ -744,6 +787,8 @@ export function verifyPublicationAdmission({
744
787
  runtimeSha: normalizeGitSha(admission.runtimeSha, "admission.runtimeSha"),
745
788
  contractDigest: normalizeDigest(admission.contractDigest, "admission.contractDigest"),
746
789
  policyDigest: normalizeDigest(admission.policyDigest, "admission.policyDigest"),
790
+ authorityRegistryDigest: actualRegistryDigest,
791
+ gateRegistryDigest: evidence.gateRegistryDigest,
747
792
  controllerReceiptDigest: normalizeDigest(admission.controllerReceiptDigest, "admission.controllerReceiptDigest"),
748
793
  product: admission.product,
749
794
  target: admission.target,
@@ -758,7 +803,231 @@ export function verifyPublicationAdmission({
758
803
  gateAggregateDigest: normalizeDigest(admission.gateAggregateDigest, "admission.gateAggregateDigest"),
759
804
  environment,
760
805
  nonce,
806
+ issuedAt: admission.issuedAt,
761
807
  expiresAt: admission.expiresAt,
808
+ qualification: admission.qualification?.required === true
809
+ ? {
810
+ required: true,
811
+ predicateId: requiredString(
812
+ admission.qualification.predicateId,
813
+ "admission.qualification.predicateId",
814
+ ),
815
+ predicateDigest: normalizeDigest(
816
+ admission.qualification.predicateDigest,
817
+ "admission.qualification.predicateDigest",
818
+ ),
819
+ }
820
+ : { required: false, predicateId: "", predicateDigest: "" },
762
821
  };
763
822
  return { ...capability, capabilityDigest: publicationAuthorityDigest(capability) };
764
823
  }
824
+
825
+ function validatePublicationCapability(capability, now = new Date()) {
826
+ if (capability?.contract !== PUBLICATION_CAPABILITY_CONTRACT) {
827
+ throw new Error("publication capability contract mismatch");
828
+ }
829
+ const { capabilityDigest: supplied, ...payload } = capability;
830
+ const actual = publicationAuthorityDigest(payload);
831
+ if (normalizeDigest(supplied, "capability.capabilityDigest") !== actual) {
832
+ throw new Error("publication capability digest mismatch");
833
+ }
834
+ if (capability.decision !== "allow") {
835
+ throw new Error("publication capability did not allow provider mutation");
836
+ }
837
+ const nowMs = now instanceof Date ? now.getTime() : parseTime(now, "now");
838
+ if (parseTime(capability.expiresAt, "capability.expiresAt") <= nowMs) {
839
+ throw new Error("publication capability is stale");
840
+ }
841
+ return actual;
842
+ }
843
+
844
+ function validateCapabilityGateAggregate(capability, gateAggregate) {
845
+ const gate = publicationGateAggregateBindings(gateAggregate);
846
+ if (gate.gateAggregateDigest !== normalizeDigest(capability.gateAggregateDigest, "capability.gateAggregateDigest")) {
847
+ throw new Error("publication qualification Gate aggregate binding mismatch");
848
+ }
849
+ if (gate.policyDigest !== normalizeDigest(capability.policyDigest, "capability.policyDigest")) {
850
+ throw new Error("publication qualification Gate policy binding mismatch");
851
+ }
852
+ if (gate.registryDigest !== normalizeDigest(capability.gateRegistryDigest, "capability.gateRegistryDigest")) {
853
+ throw new Error("publication qualification Gate registry binding mismatch");
854
+ }
855
+ if (gate.sourceSha !== normalizeGitSha(capability.sourceSha, "capability.sourceSha")) {
856
+ throw new Error("publication qualification Gate source binding mismatch");
857
+ }
858
+ return gate;
859
+ }
860
+
861
+ export function createConsumerPublicationDecision({
862
+ capability,
863
+ gateAggregate,
864
+ decision,
865
+ predicateId,
866
+ predicateDigest,
867
+ evidence = {},
868
+ now = new Date(),
869
+ } = {}) {
870
+ const capabilityDigest = validatePublicationCapability(capability, now);
871
+ const gate = validateCapabilityGateAggregate(capability, gateAggregate);
872
+ if (capability.qualification?.required !== true) {
873
+ throw new Error("publication capability did not require consumer qualification");
874
+ }
875
+ const normalizedPredicateId = requiredString(predicateId, "predicateId");
876
+ const normalizedPredicateDigest = normalizeDigest(predicateDigest, "predicateDigest");
877
+ if (normalizedPredicateId !== capability.qualification.predicateId) {
878
+ throw new Error("consumer publication predicate id mismatch");
879
+ }
880
+ if (normalizedPredicateDigest !== capability.qualification.predicateDigest) {
881
+ throw new Error("consumer publication predicate digest mismatch");
882
+ }
883
+ const normalizedDecision = requiredString(decision, "decision");
884
+ if (!["allow", "deny"].includes(normalizedDecision)) {
885
+ throw new Error("consumer publication decision must be allow or deny");
886
+ }
887
+ const payload = {
888
+ schemaVersion: 1,
889
+ contract: CONSUMER_PUBLICATION_DECISION_CONTRACT,
890
+ decision: normalizedDecision,
891
+ predicateId: normalizedPredicateId,
892
+ predicateDigest: normalizedPredicateDigest,
893
+ capabilityDigest,
894
+ gateAggregateDigest: gate.gateAggregateDigest,
895
+ sourceSha: capability.sourceSha,
896
+ artifactDigest: capability.artifactDigest,
897
+ version: capability.version,
898
+ channel: capability.channel,
899
+ nonce: capability.nonce,
900
+ evidence,
901
+ };
902
+ return { ...payload, decisionDigest: publicationAuthorityDigest(payload) };
903
+ }
904
+
905
+ export function createPublicationQualificationReceipt({
906
+ capability,
907
+ gateAggregate,
908
+ consumerDecision,
909
+ now = new Date(),
910
+ } = {}) {
911
+ const capabilityDigest = validatePublicationCapability(capability, now);
912
+ const gate = validateCapabilityGateAggregate(capability, gateAggregate);
913
+ if (capability.qualification?.required !== true) {
914
+ throw new Error("publication capability did not require consumer qualification");
915
+ }
916
+ if (consumerDecision?.contract !== CONSUMER_PUBLICATION_DECISION_CONTRACT) {
917
+ throw new Error("consumer publication decision contract mismatch");
918
+ }
919
+ const { decisionDigest: suppliedDecisionDigest, ...decisionPayload } = consumerDecision;
920
+ const decisionDigest = publicationAuthorityDigest(decisionPayload);
921
+ if (normalizeDigest(suppliedDecisionDigest, "consumerDecision.decisionDigest") !== decisionDigest) {
922
+ throw new Error("consumer publication decision digest mismatch");
923
+ }
924
+ if (consumerDecision.decision !== "allow") {
925
+ throw new Error("consumer publication predicate did not qualify");
926
+ }
927
+ const exactBindings = {
928
+ predicateId: capability.qualification.predicateId,
929
+ predicateDigest: capability.qualification.predicateDigest,
930
+ capabilityDigest,
931
+ gateAggregateDigest: gate.gateAggregateDigest,
932
+ sourceSha: capability.sourceSha,
933
+ artifactDigest: capability.artifactDigest,
934
+ version: capability.version,
935
+ channel: capability.channel,
936
+ nonce: capability.nonce,
937
+ };
938
+ for (const [key, expected] of Object.entries(exactBindings)) {
939
+ if (String(consumerDecision[key] || "") !== String(expected)) {
940
+ throw new Error(`consumer publication decision ${key} binding mismatch`);
941
+ }
942
+ }
943
+ const payload = {
944
+ schemaVersion: 1,
945
+ contract: PUBLICATION_QUALIFICATION_RECEIPT_CONTRACT,
946
+ decision: "allow",
947
+ ...exactBindings,
948
+ authorityRegistryDigest: capability.authorityRegistryDigest,
949
+ gateRegistryDigest: gate.registryDigest,
950
+ policyDigest: gate.policyDigest,
951
+ runtimeSha: capability.runtimeSha,
952
+ contractDigest: capability.contractDigest,
953
+ controllerReceiptDigest: capability.controllerReceiptDigest,
954
+ runnerProvenanceDigest: capability.runnerProvenanceDigest,
955
+ controlPlaneAuditDigest: capability.controlPlaneAuditDigest,
956
+ publicationEvidenceDigest: capability.publicationEvidenceDigest,
957
+ product: capability.product,
958
+ target: capability.target,
959
+ issuedAt: capability.issuedAt,
960
+ expiresAt: capability.expiresAt,
961
+ consumerDecisionDigest: decisionDigest,
962
+ };
963
+ return { ...payload, receiptDigest: publicationAuthorityDigest(payload) };
964
+ }
965
+
966
+ export function verifyPublicationQualificationReceipt({
967
+ receipt,
968
+ capability,
969
+ gateAggregate,
970
+ expected = {},
971
+ usedNonces = [],
972
+ now = new Date(),
973
+ } = {}) {
974
+ const capabilityDigest = validatePublicationCapability(capability, now);
975
+ const gate = validateCapabilityGateAggregate(capability, gateAggregate);
976
+ if (capability.qualification?.required !== true) {
977
+ throw new Error("publication capability did not require consumer qualification");
978
+ }
979
+ if (receipt?.contract !== PUBLICATION_QUALIFICATION_RECEIPT_CONTRACT) {
980
+ throw new Error("publication qualification receipt contract mismatch");
981
+ }
982
+ if (receipt.decision !== "allow") {
983
+ throw new Error("publication qualification receipt did not allow provider mutation");
984
+ }
985
+ const exactBindings = {
986
+ predicateId: capability.qualification.predicateId,
987
+ predicateDigest: capability.qualification.predicateDigest,
988
+ capabilityDigest,
989
+ gateAggregateDigest: gate.gateAggregateDigest,
990
+ authorityRegistryDigest: capability.authorityRegistryDigest,
991
+ gateRegistryDigest: gate.registryDigest,
992
+ policyDigest: gate.policyDigest,
993
+ sourceSha: capability.sourceSha,
994
+ runtimeSha: capability.runtimeSha,
995
+ contractDigest: capability.contractDigest,
996
+ controllerReceiptDigest: capability.controllerReceiptDigest,
997
+ runnerProvenanceDigest: capability.runnerProvenanceDigest,
998
+ controlPlaneAuditDigest: capability.controlPlaneAuditDigest,
999
+ publicationEvidenceDigest: capability.publicationEvidenceDigest,
1000
+ artifactDigest: capability.artifactDigest,
1001
+ product: capability.product,
1002
+ target: capability.target,
1003
+ version: capability.version,
1004
+ channel: capability.channel,
1005
+ nonce: capability.nonce,
1006
+ issuedAt: capability.issuedAt,
1007
+ expiresAt: capability.expiresAt,
1008
+ };
1009
+ for (const [key, value] of Object.entries(exactBindings)) {
1010
+ if (String(receipt[key] || "") !== String(value)) {
1011
+ throw new Error(`publication qualification receipt ${key} binding mismatch`);
1012
+ }
1013
+ }
1014
+ normalizeDigest(
1015
+ receipt.consumerDecisionDigest,
1016
+ "receipt.consumerDecisionDigest",
1017
+ );
1018
+ const { receiptDigest: suppliedReceiptDigest, ...receiptPayload } = receipt;
1019
+ const receiptDigest = publicationAuthorityDigest(receiptPayload);
1020
+ if (normalizeDigest(suppliedReceiptDigest, "receipt.receiptDigest") !== receiptDigest) {
1021
+ throw new Error("publication qualification receipt digest mismatch");
1022
+ }
1023
+ const nonce = requiredString(receipt.nonce, "receipt.nonce");
1024
+ if (new Set(usedNonces.map(String)).has(nonce)) {
1025
+ throw new Error("publication qualification receipt nonce was replayed");
1026
+ }
1027
+ for (const [key, value] of Object.entries(expected)) {
1028
+ if (value !== undefined && String(receipt[key] || "") !== String(value)) {
1029
+ throw new Error(`publication qualification receipt ${key} binding mismatch`);
1030
+ }
1031
+ }
1032
+ return { ok: true, receiptDigest, nonce };
1033
+ }
@@ -8,6 +8,7 @@ import {
8
8
  createPublicationAdmission,
9
9
  createPublicationGateDecision,
10
10
  createRunnerProvenance,
11
+ publicationGateAggregateBindings,
11
12
  } from "../packages/core/publication-authority.js";
12
13
  import { buildPublicationArtifactCandidate } from "./publication-artifact-candidate.mjs";
13
14
 
@@ -126,6 +127,8 @@ async function main() {
126
127
  isolation: "github-hosted-single-job",
127
128
  });
128
129
  const issuedAt = new Date();
130
+ const gateBindings = publicationGateAggregateBindings(gateAggregate);
131
+ const qualificationRequired = process.env.BUILDCHAIN_CONSUMER_QUALIFICATION_REQUIRED === "true";
129
132
  const admission = createPublicationAdmission({
130
133
  registryDigest: registry.registryDigest,
131
134
  workflowPath:
@@ -137,6 +140,7 @@ async function main() {
137
140
  runtimeSha,
138
141
  contractDigest: controllerReceipt.runtime?.contractDigest,
139
142
  policyDigest: gateAggregate.policyDigest,
143
+ gateRegistryDigest: gateBindings.registryDigest,
140
144
  controllerReceiptDigest: controllerReceipt.digest,
141
145
  runnerProvenanceDigest: runnerProvenance.receiptDigest,
142
146
  controlPlaneAuditDigest: controlPlaneAudit.receiptDigest,
@@ -150,6 +154,11 @@ async function main() {
150
154
  nonce: `${required("GITHUB_RUN_ID")}:${required("GITHUB_RUN_ATTEMPT")}:${sourceSha}:paper`,
151
155
  issuedAt: issuedAt.toISOString(),
152
156
  expiresAt: new Date(issuedAt.getTime() + 10 * 60 * 1000).toISOString(),
157
+ qualification: {
158
+ required: qualificationRequired,
159
+ predicateId: process.env.BUILDCHAIN_CONSUMER_PREDICATE_ID || "",
160
+ predicateDigest: process.env.BUILDCHAIN_CONSUMER_PREDICATE_DIGEST || "",
161
+ },
153
162
  });
154
163
  const bindingNames = [
155
164
  "repository",
@@ -158,6 +167,7 @@ async function main() {
158
167
  "runtimeSha",
159
168
  "contractDigest",
160
169
  "policyDigest",
170
+ "gateRegistryDigest",
161
171
  "controllerReceiptDigest",
162
172
  "gateAggregateDigest",
163
173
  "environment",
@@ -9,6 +9,7 @@ import {
9
9
  createPublicationArtifactManifestSet,
10
10
  createPublicationGateDecision,
11
11
  createRunnerProvenance,
12
+ publicationGateAggregateBindings,
12
13
  } from "../packages/core/publication-authority.js";
13
14
 
14
15
  function required(name) {
@@ -148,6 +149,8 @@ async function main() {
148
149
  const packageJson = JSON.parse(fs.readFileSync(path.join(runtimeRoot, "package.json"), "utf8"));
149
150
  const publicationVersion = required("BUILDCHAIN_PUBLICATION_VERSION");
150
151
  const issuedAt = new Date();
152
+ const gateBindings = publicationGateAggregateBindings(gateAggregate);
153
+ const qualificationRequired = process.env.BUILDCHAIN_CONSUMER_QUALIFICATION_REQUIRED === "true";
151
154
  const admission = createPublicationAdmission({
152
155
  registryDigest: registry.registryDigest,
153
156
  workflowPath: required("BUILDCHAIN_AUTHORITY_WORKFLOW_PATH"),
@@ -157,6 +160,7 @@ async function main() {
157
160
  runtimeSha,
158
161
  contractDigest: controllerReceipt.runtime?.contractDigest,
159
162
  policyDigest: gateAggregate.policyDigest,
163
+ gateRegistryDigest: gateBindings.registryDigest,
160
164
  controllerReceiptDigest: controllerReceipt.digest,
161
165
  runnerProvenanceDigest: runnerProvenance.receiptDigest,
162
166
  controlPlaneAuditDigest: controlPlaneAudit.receiptDigest,
@@ -170,9 +174,14 @@ async function main() {
170
174
  nonce: `${required("GITHUB_RUN_ID")}:${required("GITHUB_RUN_ATTEMPT")}:${sourceSha}`,
171
175
  issuedAt: issuedAt.toISOString(),
172
176
  expiresAt: new Date(issuedAt.getTime() + 10 * 60 * 1000).toISOString(),
177
+ qualification: {
178
+ required: qualificationRequired,
179
+ predicateId: process.env.BUILDCHAIN_CONSUMER_PREDICATE_ID || "",
180
+ predicateDigest: process.env.BUILDCHAIN_CONSUMER_PREDICATE_DIGEST || "",
181
+ },
173
182
  });
174
183
  const bindingNames = [
175
- "repository", "publisherWorkflowPath", "sourceSha", "runtimeSha", "contractDigest", "policyDigest",
184
+ "repository", "publisherWorkflowPath", "sourceSha", "runtimeSha", "contractDigest", "policyDigest", "gateRegistryDigest",
176
185
  "controllerReceiptDigest", "gateAggregateDigest", "environment", "product", "target", "version", "channel",
177
186
  "artifactDigest",
178
187
  ];
@@ -33,6 +33,14 @@ function repository(value) {
33
33
  return normalized;
34
34
  }
35
35
 
36
+ function optionalSha(value, label) {
37
+ const normalized = text(value);
38
+ if (normalized && !/^[0-9a-f]{40}$/i.test(normalized)) {
39
+ throw new Error(`${label} must be an exact 40-character commit SHA, got ${normalized}`);
40
+ }
41
+ return normalized;
42
+ }
43
+
36
44
  export function normalizeStableCandidateQualificationOptions(options = {}) {
37
45
  const candidateSha = text(options.candidateSha ?? process.env.BUILDCHAIN_QUALIFICATION_CANDIDATE_SHA);
38
46
  if (!/^[0-9a-f]{40}$/i.test(candidateSha)) {
@@ -47,6 +55,7 @@ export function normalizeStableCandidateQualificationOptions(options = {}) {
47
55
  canaryWorkflowFile: text(options.canaryWorkflowFile ?? process.env.BUILDCHAIN_QUALIFICATION_CANARY_WORKFLOW_FILE) || DEFAULTS.canaryWorkflowFile,
48
56
  canaryWorkflowName: text(options.canaryWorkflowName ?? process.env.BUILDCHAIN_QUALIFICATION_CANARY_WORKFLOW_NAME) || DEFAULTS.canaryWorkflowName,
49
57
  canaryStatusContext: text(options.canaryStatusContext ?? process.env.BUILDCHAIN_QUALIFICATION_CANARY_STATUS_CONTEXT) || DEFAULTS.canaryStatusContext,
58
+ canaryRef: optionalSha(options.canaryRef ?? process.env.BUILDCHAIN_QUALIFICATION_CANARY_REF, "canary ref"),
50
59
  pollAttempts: integer(options.pollAttempts ?? process.env.BUILDCHAIN_QUALIFICATION_POLL_ATTEMPTS, DEFAULTS.pollAttempts),
51
60
  pollIntervalMs: integer(options.pollIntervalMs ?? process.env.BUILDCHAIN_QUALIFICATION_POLL_INTERVAL_MS, DEFAULTS.pollIntervalMs),
52
61
  dryRun: bool(options.dryRun ?? process.env.BUILDCHAIN_QUALIFICATION_DRY_RUN, false),
@@ -108,13 +117,13 @@ export async function runStableCandidateQualification(optionsInput = {}, clientI
108
117
  let status = await client.findCommitStatus(options.repository, options.candidateSha, options.canaryStatusContext);
109
118
  let canary = { state: status?.state === "success" ? "existing" : "pending", run: undefined };
110
119
  if (status?.state !== "success") {
111
- const canaryDefaultBranch = await client.defaultBranch(options.canaryRepository);
120
+ const canaryRef = options.canaryRef || await client.defaultBranch(options.canaryRepository);
112
121
  canary = await ensureWorkflowEvidence({
113
122
  client,
114
123
  repository: options.canaryRepository,
115
124
  workflowFile: options.canaryWorkflowFile,
116
125
  workflowName: options.canaryWorkflowName,
117
- ref: canaryDefaultBranch,
126
+ ref: canaryRef,
118
127
  headSha: options.candidateSha,
119
128
  runName: `${options.canaryWorkflowName} / ${options.candidateSha}`,
120
129
  options,
@@ -137,7 +146,7 @@ export async function runStableCandidateQualification(optionsInput = {}, clientI
137
146
  dryRun: options.dryRun,
138
147
  candidate,
139
148
  build: { state: build.state, url: build.run?.html_url || "" },
140
- canary: { state: canary.state, url: canary.run?.html_url || status?.target_url || "" },
149
+ canary: { state: canary.state, ref: options.canaryRef || "default-branch", url: canary.run?.html_url || status?.target_url || "" },
141
150
  attestation: { context: options.canaryStatusContext, state: options.dryRun ? "planned" : status?.state || "" },
142
151
  };
143
152
  }