@kungfu-tech/buildchain 3.0.2-alpha.4 → 3.0.2-alpha.6

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 (49) hide show
  1. package/README.md +1 -0
  2. package/actions/github-artifact-attestation/README.md +10 -0
  3. package/actions/promote-buildchain-ref/README.md +7 -0
  4. package/bin/buildchain.mjs +5 -0
  5. package/bin/internal/trust-release-cli.mjs +74 -3
  6. package/dist/site/artifact-schemas.json +5 -1
  7. package/dist/site/buildchain-contract.json +79 -28
  8. package/dist/site/buildchain-site.json +110 -27
  9. package/dist/site/capability-registry.json +8 -7
  10. package/dist/site/cli-registry.json +12 -0
  11. package/dist/site/controller-registry.json +40 -4
  12. package/dist/site/kfd-claims.json +205 -19
  13. package/dist/site/kfd-upstream-aggregate.json +1 -1
  14. package/dist/site/manual-registry.json +19 -5
  15. package/dist/site/node-api-registry.json +22 -9
  16. package/dist/site/page-registry.json +91 -17
  17. package/dist/site/public-surface-audit.json +132 -17
  18. package/dist/site/publication-authority-registry.json +27 -2
  19. package/dist/site/publication-registry.json +4 -4
  20. package/dist/site/release-model.json +2 -1
  21. package/dist/site/release-passport-check-manifest.json +1 -0
  22. package/dist/site/release-provenance.json +1 -0
  23. package/dist/site/schemas/release-passport-v1.schema.json +6 -0
  24. package/dist/site/site-manifest.json +17 -9
  25. package/dist/site/workflow-registry.json +91 -9
  26. package/docs/MAP.md +3 -1
  27. package/docs/binary-distribution.md +7 -0
  28. package/docs/cli.md +9 -0
  29. package/docs/dev-alpha-candidate-patrol.md +51 -13
  30. package/docs/github-artifact-attestation.md +219 -0
  31. package/docs/release-passport.md +13 -0
  32. package/docs/reusable-build-surface.md +6 -0
  33. package/package.json +2 -1
  34. package/packages/core/buildchain-contract.js +6 -0
  35. package/packages/core/buildchain-kfd-claims.js +5 -0
  36. package/packages/core/buildchain-publication-authority.js +1 -0
  37. package/packages/core/github-artifact-attestation.js +642 -0
  38. package/packages/core/index.js +19 -0
  39. package/packages/core/publication-authority.js +1 -1
  40. package/packages/core/release-passport-contract.js +2 -0
  41. package/packages/core/release-passport.js +60 -3
  42. package/scripts/check-inventory.mjs +4 -0
  43. package/scripts/create-github-artifact-attestation-policy.mjs +62 -0
  44. package/scripts/dev-alpha-candidate-patrol.mjs +471 -71
  45. package/scripts/generate-channel-promotion-workflow.mjs +6 -0
  46. package/scripts/generate-site-bundle.mjs +12 -0
  47. package/scripts/publish-github-artifact-attestation-evidence.mjs +201 -0
  48. package/scripts/release-candidate-resolver.mjs +12 -0
  49. package/scripts/stage-github-artifact-attestation-inputs.mjs +65 -0
@@ -3,8 +3,16 @@
3
3
 
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
+ import crypto from "node:crypto";
6
7
  import { decideChannelCandidate } from "../packages/core/channel-candidate.js";
7
8
 
9
+ export const DEV_ALPHA_CANDIDATE_STATE_SCHEMA =
10
+ "kungfu-buildchain-dev-alpha-candidate-state/v1";
11
+ const STATE_MARKER_START = "<!-- buildchain-dev-alpha-candidate-state";
12
+ const STATE_MARKER_END = "-->";
13
+ const LEGACY_BODY_MARKER = "Buildchain exact-source channel candidate.";
14
+ const EXACT_SHA = /^[0-9a-f]{40}$/u;
15
+
8
16
  function text(value = "") {
9
17
  return String(value ?? "").trim();
10
18
  }
@@ -46,7 +54,127 @@ function integer(value, fallback) {
46
54
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
47
55
  }
48
56
 
57
+ function canonical(value) {
58
+ if (Array.isArray(value)) return value.map(canonical);
59
+ if (value && typeof value === "object") {
60
+ return Object.fromEntries(
61
+ Object.entries(value)
62
+ .sort(([left], [right]) => left.localeCompare(right))
63
+ .map(([key, item]) => [key, canonical(item)]),
64
+ );
65
+ }
66
+ return value;
67
+ }
68
+
69
+ function evidenceRoot(value) {
70
+ return `sha256:${crypto
71
+ .createHash("sha256")
72
+ .update(JSON.stringify(canonical(value)))
73
+ .digest("hex")}`;
74
+ }
75
+
76
+ function candidateStateMarker(state) {
77
+ return `${STATE_MARKER_START}\n${JSON.stringify(state)}\n${STATE_MARKER_END}`;
78
+ }
79
+
80
+ export function parseCandidateStateMarker(bodyInput) {
81
+ const body = String(bodyInput || "");
82
+ const start = body.indexOf(STATE_MARKER_START);
83
+ if (start < 0) return undefined;
84
+ const jsonStart = body.indexOf("\n", start);
85
+ const end = body.indexOf(STATE_MARKER_END, jsonStart + 1);
86
+ if (jsonStart < 0 || end < 0)
87
+ throw new Error("malformed Buildchain candidate state marker");
88
+ const state = JSON.parse(body.slice(jsonStart + 1, end).trim());
89
+ if (state.schema !== DEV_ALPHA_CANDIDATE_STATE_SCHEMA)
90
+ throw new Error(
91
+ `unsupported candidate state schema ${state.schema || "<empty>"}`,
92
+ );
93
+ return state;
94
+ }
95
+
96
+ function replaceCandidateStateMarker(bodyInput, state) {
97
+ const body = String(bodyInput || "").trimEnd();
98
+ const start = body.indexOf(STATE_MARKER_START);
99
+ if (start < 0) return `${body}\n\n${candidateStateMarker(state)}\n`;
100
+ const end = body.indexOf(STATE_MARKER_END, start);
101
+ if (end < 0) throw new Error("malformed Buildchain candidate state marker");
102
+ return `${body.slice(0, start).trimEnd()}\n\n${candidateStateMarker(state)}\n`;
103
+ }
104
+
105
+ function sourceShaFromLegacyBody(bodyInput) {
106
+ return String(bodyInput || "").match(/- Source SHA: `([0-9a-f]{40})`/u)?.[1];
107
+ }
108
+
109
+ function targetSlug(targetBranch) {
110
+ return targetBranch.replace(/[^A-Za-z0-9._-]+/g, "-");
111
+ }
112
+
113
+ export function managedCandidateFromPullRequest(pullRequest, targetBranch) {
114
+ const body = String(pullRequest.body || "");
115
+ const marker = parseCandidateStateMarker(body);
116
+ const expectedPrefix = `buildchain/candidate/${targetSlug(targetBranch)}/`;
117
+ const headRef = text(pullRequest.head?.ref);
118
+ const baseRef = text(pullRequest.base?.ref || targetBranch);
119
+ if (baseRef !== targetBranch) return undefined;
120
+ if (marker) {
121
+ if (marker.targetBranch !== targetBranch)
122
+ throw new Error(
123
+ `candidate PR #${pullRequest.number} state targets ${marker.targetBranch}, not ${targetBranch}`,
124
+ );
125
+ if (!headRef.startsWith(expectedPrefix))
126
+ throw new Error(
127
+ `candidate PR #${pullRequest.number} head ${headRef} is outside ${expectedPrefix}`,
128
+ );
129
+ const sourceSha = text(marker.activeCandidate?.sourceSha);
130
+ if (
131
+ !EXACT_SHA.test(sourceSha) ||
132
+ headRef !== `${expectedPrefix}${sourceSha.slice(0, 12)}`
133
+ ) {
134
+ throw new Error(
135
+ `candidate PR #${pullRequest.number} state does not bind its exact source-lock head`,
136
+ );
137
+ }
138
+ return {
139
+ number: Number(pullRequest.number),
140
+ url: text(pullRequest.html_url),
141
+ body,
142
+ sourceSha,
143
+ sourceLockRef: headRef,
144
+ decisionRoot: text(marker.activeCandidate?.decisionRoot),
145
+ nextCandidate: marker.nextCandidate || null,
146
+ state: marker,
147
+ };
148
+ }
149
+ if (!body.includes(LEGACY_BODY_MARKER) || !headRef.startsWith(expectedPrefix))
150
+ return undefined;
151
+ const sourceSha = sourceShaFromLegacyBody(body);
152
+ if (!sourceSha)
153
+ throw new Error(
154
+ `legacy candidate PR #${pullRequest.number} has no exact source SHA`,
155
+ );
156
+ if (headRef !== `${expectedPrefix}${sourceSha.slice(0, 12)}`)
157
+ throw new Error(
158
+ `legacy candidate PR #${pullRequest.number} does not bind its exact source-lock head`,
159
+ );
160
+ return {
161
+ number: Number(pullRequest.number),
162
+ url: text(pullRequest.html_url),
163
+ body,
164
+ sourceSha,
165
+ sourceLockRef: headRef,
166
+ decisionRoot: "",
167
+ nextCandidate: null,
168
+ state: null,
169
+ };
170
+ }
171
+
49
172
  export function normalizeDevAlphaPatrolOptions(options = {}) {
173
+ const createPullRequest = bool(
174
+ options.createPullRequest ??
175
+ process.env.BUILDCHAIN_CHANNEL_PATROL_CREATE_PR,
176
+ false,
177
+ );
50
178
  return {
51
179
  repository: repository(
52
180
  options.repository ??
@@ -82,10 +210,11 @@ export function normalizeDevAlphaPatrolOptions(options = {}) {
82
210
  process.env.BUILDCHAIN_CHANNEL_PATROL_MAX_AGE_SECONDS,
83
211
  7 * 24 * 60 * 60,
84
212
  ),
85
- createPullRequest: bool(
86
- options.createPullRequest ??
87
- process.env.BUILDCHAIN_CHANNEL_PATROL_CREATE_PR,
88
- false,
213
+ createPullRequest,
214
+ settlementAuthorized: bool(
215
+ options.settlementAuthorized ??
216
+ process.env.BUILDCHAIN_CHANNEL_PATROL_SETTLEMENT_AUTHORIZED,
217
+ createPullRequest,
89
218
  ),
90
219
  dryRun: bool(
91
220
  options.dryRun ?? process.env.BUILDCHAIN_CHANNEL_PATROL_DRY_RUN,
@@ -183,11 +312,118 @@ export function selectLatestQualifiedSource({
183
312
  };
184
313
  }
185
314
  }
315
+ const staleSuccessfulPair = sourceHistory.some((sourceSha) => {
316
+ const rows = requiredWorkflowPaths.map((workflow) =>
317
+ latestByPath.get(workflow).get(sourceSha),
318
+ );
319
+ return (
320
+ rows.every(
321
+ (run) =>
322
+ run?.status === "completed" &&
323
+ run?.conclusion === "success" &&
324
+ run?.head_sha === sourceSha,
325
+ ) &&
326
+ rows.some(
327
+ (run) =>
328
+ !workflowEvidenceIsFreshAndSuccessful(run, { now, maxAgeSeconds }),
329
+ )
330
+ );
331
+ });
332
+ if (staleSuccessfulPair)
333
+ throw new Error(
334
+ "same-SHA workflow evidence is stale for every qualified source commit",
335
+ );
186
336
  throw new Error(
187
337
  "no source commit ahead of target has fresh completed successful same-SHA workflow evidence",
188
338
  );
189
339
  }
190
340
 
341
+ function blockedCandidateDecision({
342
+ options,
343
+ sourceSha,
344
+ targetSha,
345
+ comparison,
346
+ reason,
347
+ }) {
348
+ const body = {
349
+ schema: "kungfu-buildchain-channel-candidate-decision/v1",
350
+ eligible: false,
351
+ reason: "qualification-evidence-blocked",
352
+ repository: options.repository,
353
+ source: { branch: options.sourceBranch, sha: sourceSha },
354
+ target: { branch: options.targetBranch, sha: targetSha },
355
+ comparison: {
356
+ status: text(comparison.status || "unknown"),
357
+ aheadBy: Number(comparison.ahead_by || 0),
358
+ },
359
+ blockReason: text(reason),
360
+ decidedAt: options.now,
361
+ };
362
+ return { ...body, decisionRoot: evidenceRoot(body) };
363
+ }
364
+
365
+ function candidateFromDecision(decision) {
366
+ if (!decision.eligible) return null;
367
+ return {
368
+ sourceSha: decision.source.sha,
369
+ sourceLockRef: decision.sourceLockRef,
370
+ decisionRoot: decision.decisionRoot,
371
+ workflowEvidence: decision.workflowEvidence,
372
+ };
373
+ }
374
+
375
+ function candidateStateBody({
376
+ options,
377
+ targetSha,
378
+ decision,
379
+ activeCandidate,
380
+ nextCandidate,
381
+ supersededCandidate,
382
+ }) {
383
+ const body = {
384
+ schema: DEV_ALPHA_CANDIDATE_STATE_SCHEMA,
385
+ repository: options.repository,
386
+ sourceBranch: options.sourceBranch,
387
+ targetBranch: options.targetBranch,
388
+ targetSha,
389
+ activeCandidate,
390
+ nextCandidate,
391
+ observationDecisionRoot: decision.decisionRoot || null,
392
+ observedAt: options.now,
393
+ ...(supersededCandidate ? { supersededCandidate } : {}),
394
+ };
395
+ return { ...body, stateRoot: evidenceRoot(body) };
396
+ }
397
+
398
+ function pullRequestBody({
399
+ options,
400
+ observedSourceHeadSha,
401
+ sourceSha,
402
+ skippedNewerCommitCount,
403
+ targetSha,
404
+ decision,
405
+ state,
406
+ }) {
407
+ return [
408
+ LEGACY_BODY_MARKER,
409
+ "",
410
+ `- Source branch: \`${options.sourceBranch}\``,
411
+ `- Observed source HEAD: \`${observedSourceHeadSha}\``,
412
+ `- Source SHA: \`${sourceSha}\``,
413
+ `- Skipped newer unqualified commits: \`${skippedNewerCommitCount}\``,
414
+ `- Target branch/head: \`${options.targetBranch}\` / \`${targetSha}\``,
415
+ `- Decision root: \`${decision.decisionRoot}\``,
416
+ ...decision.workflowEvidence.map(
417
+ (row) =>
418
+ `- ${row.workflowName}: [run ${row.runId} attempt ${row.runAttempt}](${row.url})`,
419
+ ),
420
+ "",
421
+ "The source-lock branch must continue to point at the exact source SHA. This patrol never merges the PR, publishes a package, creates a tag, or creates a release.",
422
+ "",
423
+ candidateStateMarker(state),
424
+ ].join("\n");
425
+ }
426
+
191
427
  export async function runDevAlphaCandidatePatrol(
192
428
  optionsInput = {},
193
429
  clientInput,
@@ -214,6 +450,7 @@ export async function runDevAlphaCandidatePatrol(
214
450
  let comparison = headComparison;
215
451
  let workflowEvidence = [];
216
452
  let skippedNewerCommitCount = 0;
453
+ let qualificationError;
217
454
  if (
218
455
  headComparison.status === "ahead" &&
219
456
  Number(headComparison.ahead_by) > 0
@@ -224,71 +461,189 @@ export async function runDevAlphaCandidatePatrol(
224
461
  client.listCompletedWorkflowRuns(workflow, options.sourceBranch),
225
462
  ),
226
463
  ]);
227
- const selected = selectLatestQualifiedSource({
228
- sourceHistory,
229
- workflowRunsByPath: new Map(
230
- requiredWorkflowPaths.map((workflow, index) => [
231
- workflow,
232
- workflowRunSets[index],
233
- ]),
234
- ),
235
- requiredWorkflowPaths,
236
- now: options.now,
237
- maxAgeSeconds: options.maxAgeSeconds,
238
- });
239
- sourceSha = selected.sourceSha;
240
- skippedNewerCommitCount = selected.skippedNewerCommitCount;
241
- workflowEvidence = selected.workflowEvidence;
242
- if (sourceSha !== observedSourceHeadSha)
243
- comparison = await client.compare(targetSha, sourceSha);
464
+ try {
465
+ const selected = selectLatestQualifiedSource({
466
+ sourceHistory,
467
+ workflowRunsByPath: new Map(
468
+ requiredWorkflowPaths.map((workflow, index) => [
469
+ workflow,
470
+ workflowRunSets[index],
471
+ ]),
472
+ ),
473
+ requiredWorkflowPaths,
474
+ now: options.now,
475
+ maxAgeSeconds: options.maxAgeSeconds,
476
+ });
477
+ sourceSha = selected.sourceSha;
478
+ skippedNewerCommitCount = selected.skippedNewerCommitCount;
479
+ workflowEvidence = selected.workflowEvidence;
480
+ if (sourceSha !== observedSourceHeadSha)
481
+ comparison = await client.compare(targetSha, sourceSha);
482
+ } catch (error) {
483
+ qualificationError = error;
484
+ }
244
485
  }
245
- const decision = decideChannelCandidate({
246
- repository: options.repository,
247
- sourceBranch: options.sourceBranch,
248
- targetBranch: options.targetBranch,
249
- sourceSha,
486
+ const decision = qualificationError
487
+ ? blockedCandidateDecision({
488
+ options,
489
+ sourceSha: observedSourceHeadSha,
490
+ targetSha,
491
+ comparison: headComparison,
492
+ reason: qualificationError.message,
493
+ })
494
+ : decideChannelCandidate({
495
+ repository: options.repository,
496
+ sourceBranch: options.sourceBranch,
497
+ targetBranch: options.targetBranch,
498
+ sourceSha,
499
+ targetSha,
500
+ comparison: { status: comparison.status, aheadBy: comparison.ahead_by },
501
+ selection: {
502
+ mode: "latest-qualified-source-ancestor",
503
+ observedSourceHeadSha,
504
+ skippedNewerCommitCount,
505
+ },
506
+ workflowEvidence,
507
+ requiredWorkflowPaths,
508
+ maxAgeSeconds: options.maxAgeSeconds,
509
+ now: options.now,
510
+ });
511
+ const openPullRequests = await client.listOpenPullRequests(
512
+ options.targetBranch,
513
+ );
514
+ const managedCandidates = openPullRequests
515
+ .map((pullRequest) =>
516
+ managedCandidateFromPullRequest(pullRequest, options.targetBranch),
517
+ )
518
+ .filter(Boolean);
519
+ for (const candidate of managedCandidates) {
520
+ if (
521
+ candidate.state &&
522
+ (candidate.state.repository !== options.repository ||
523
+ candidate.state.sourceBranch !== options.sourceBranch)
524
+ ) {
525
+ throw new Error(
526
+ `candidate PR #${candidate.number} state does not bind ${options.repository} ${options.sourceBranch}`,
527
+ );
528
+ }
529
+ }
530
+ if (managedCandidates.length > 1) {
531
+ throw new Error(
532
+ `multiple open Buildchain candidate PRs target ${options.targetBranch}: ${managedCandidates
533
+ .map((candidate) => `#${candidate.number}`)
534
+ .join(", ")}`,
535
+ );
536
+ }
537
+ let activeCandidate = managedCandidates[0] || null;
538
+ const observedCandidate = candidateFromDecision(decision);
539
+ let nextCandidate = null;
540
+ let supersededCandidate = null;
541
+ let controllerState = decision.eligible
542
+ ? "eligible-for-settlement"
543
+ : qualificationError
544
+ ? /stale/u.test(qualificationError.message)
545
+ ? "stale"
546
+ : "blocked"
547
+ : "observed";
548
+ if (activeCandidate) {
549
+ controllerState = "active";
550
+ if (
551
+ observedCandidate &&
552
+ observedCandidate.sourceSha !== activeCandidate.sourceSha
553
+ ) {
554
+ nextCandidate = observedCandidate;
555
+ controllerState = "retained-next";
556
+ }
557
+ if (
558
+ activeCandidate.nextCandidate &&
559
+ activeCandidate.nextCandidate.sourceSha !== nextCandidate?.sourceSha
560
+ ) {
561
+ supersededCandidate = activeCandidate.nextCandidate;
562
+ }
563
+ }
564
+ let state = candidateStateBody({
565
+ options,
250
566
  targetSha,
251
- comparison: { status: comparison.status, aheadBy: comparison.ahead_by },
252
- selection: {
253
- mode: "latest-qualified-source-ancestor",
254
- observedSourceHeadSha,
255
- skippedNewerCommitCount,
256
- },
257
- workflowEvidence,
258
- requiredWorkflowPaths,
259
- maxAgeSeconds: options.maxAgeSeconds,
260
- now: options.now,
567
+ decision,
568
+ activeCandidate: activeCandidate
569
+ ? {
570
+ sourceSha: activeCandidate.sourceSha,
571
+ sourceLockRef: activeCandidate.sourceLockRef,
572
+ decisionRoot: activeCandidate.decisionRoot || null,
573
+ pullRequestNumber: activeCandidate.number,
574
+ pullRequestUrl: activeCandidate.url,
575
+ }
576
+ : null,
577
+ nextCandidate,
578
+ supersededCandidate,
261
579
  });
262
580
  let pullRequest;
263
- if (decision.eligible && options.createPullRequest && !options.dryRun) {
264
- await client.ensureImmutableBranch(decision.sourceLockRef, sourceSha);
265
- pullRequest = await client.ensurePullRequest({
266
- head: decision.sourceLockRef,
267
- base: options.targetBranch,
268
- title: `Promote qualified ${options.sourceBranch} candidate ${sourceSha.slice(0, 12)} to ${options.targetBranch}`,
269
- body: [
270
- "Buildchain exact-source channel candidate.",
271
- "",
272
- `- Source branch: \`${options.sourceBranch}\``,
273
- `- Observed source HEAD: \`${observedSourceHeadSha}\``,
274
- `- Source SHA: \`${sourceSha}\``,
275
- `- Skipped newer unqualified commits: \`${skippedNewerCommitCount}\``,
276
- `- Target branch/head: \`${options.targetBranch}\` / \`${targetSha}\``,
277
- `- Decision root: \`${decision.decisionRoot}\``,
278
- ...decision.workflowEvidence.map(
279
- (row) =>
280
- `- ${row.workflowName}: [run ${row.runId} attempt ${row.runAttempt}](${row.url})`,
281
- ),
282
- "",
283
- "The source-lock branch must continue to point at the exact source SHA. This patrol never merges the PR, publishes a package, creates a tag, or creates a release.",
284
- ].join("\n"),
285
- });
581
+ let settlementAction = "none";
582
+ if (options.settlementAuthorized && !options.dryRun) {
583
+ if (activeCandidate) {
584
+ const nextBody = replaceCandidateStateMarker(activeCandidate.body, state);
585
+ if (nextBody !== activeCandidate.body) {
586
+ await client.updatePullRequestBody(activeCandidate.number, nextBody);
587
+ settlementAction = nextCandidate
588
+ ? supersededCandidate
589
+ ? "supersede-next-candidate"
590
+ : "retain-next-candidate"
591
+ : "reconcile-active-candidate";
592
+ }
593
+ pullRequest = {
594
+ number: activeCandidate.number,
595
+ html_url: activeCandidate.url,
596
+ };
597
+ } else if (decision.eligible) {
598
+ await client.ensureImmutableBranch(decision.sourceLockRef, sourceSha);
599
+ state = candidateStateBody({
600
+ options,
601
+ targetSha,
602
+ decision,
603
+ activeCandidate: observedCandidate,
604
+ nextCandidate: null,
605
+ supersededCandidate: null,
606
+ });
607
+ pullRequest = await client.ensurePullRequest({
608
+ head: decision.sourceLockRef,
609
+ base: options.targetBranch,
610
+ title: `Promote qualified ${options.sourceBranch} candidate ${sourceSha.slice(0, 12)} to ${options.targetBranch}`,
611
+ body: pullRequestBody({
612
+ options,
613
+ observedSourceHeadSha,
614
+ sourceSha,
615
+ skippedNewerCommitCount,
616
+ targetSha,
617
+ decision,
618
+ state,
619
+ }),
620
+ });
621
+ settlementAction = "create-active-candidate";
622
+ controllerState = "active";
623
+ activeCandidate = {
624
+ number: Number(pullRequest.number || 0),
625
+ url: text(pullRequest.html_url),
626
+ sourceSha,
627
+ sourceLockRef: decision.sourceLockRef,
628
+ decisionRoot: decision.decisionRoot,
629
+ };
630
+ }
286
631
  }
287
632
  return {
288
633
  schema: "kungfu-buildchain-dev-alpha-candidate-patrol/v1",
289
634
  dryRun: options.dryRun,
290
635
  createPullRequest: options.createPullRequest,
636
+ settlementAuthorized: options.settlementAuthorized,
291
637
  decision,
638
+ controller: {
639
+ schema: DEV_ALPHA_CANDIDATE_STATE_SCHEMA,
640
+ state: controllerState,
641
+ activeCandidate,
642
+ nextCandidate,
643
+ supersededCandidate,
644
+ settlementAction,
645
+ stateRoot: state.stateRoot,
646
+ },
292
647
  pullRequest: pullRequest || null,
293
648
  };
294
649
  }
@@ -301,6 +656,8 @@ export function createGitHubChannelCandidateClient({
301
656
  repository: repositoryInput,
302
657
  token,
303
658
  fetchImpl = globalThis.fetch,
659
+ sleepImpl = (milliseconds) =>
660
+ new Promise((resolve) => setTimeout(resolve, milliseconds)),
304
661
  }) {
305
662
  const [owner, repo] = repository(repositoryInput).split("/");
306
663
  const headers = {
@@ -313,21 +670,37 @@ export function createGitHubChannelCandidateClient({
313
670
  requestPath,
314
671
  { method = "GET", body, allow404 = false } = {},
315
672
  ) {
316
- const response = await fetchImpl(`https://api.github.com${requestPath}`, {
317
- method,
318
- headers: Object.fromEntries(
319
- Object.entries(headers).filter(([, value]) => value),
320
- ),
321
- body: body === undefined ? undefined : JSON.stringify(body),
322
- });
323
- const raw = await response.text();
324
- const payload = raw ? JSON.parse(raw) : undefined;
325
- if (allow404 && response.status === 404) return undefined;
326
- if (!response.ok)
673
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
674
+ const response = await fetchImpl(`https://api.github.com${requestPath}`, {
675
+ method,
676
+ headers: Object.fromEntries(
677
+ Object.entries(headers).filter(([, value]) => value),
678
+ ),
679
+ body: body === undefined ? undefined : JSON.stringify(body),
680
+ });
681
+ const raw = await response.text();
682
+ const payload = raw ? JSON.parse(raw) : undefined;
683
+ if (allow404 && response.status === 404) return undefined;
684
+ if (response.ok) return payload;
685
+ const retryable = response.status === 429 || response.status >= 500;
686
+ if (retryable && attempt < 3) {
687
+ const retryAfterHeader = response.headers?.get?.("retry-after");
688
+ const retryAfter =
689
+ retryAfterHeader === null || retryAfterHeader === undefined
690
+ ? Number.NaN
691
+ : Number(retryAfterHeader);
692
+ await sleepImpl(
693
+ Number.isFinite(retryAfter) && retryAfter >= 0
694
+ ? Math.min(retryAfter * 1000, 10_000)
695
+ : attempt * 250,
696
+ );
697
+ continue;
698
+ }
327
699
  throw new Error(
328
700
  `GitHub API ${method} ${requestPath} failed with ${response.status}: ${payload?.message || raw}`,
329
701
  );
330
- return payload;
702
+ }
703
+ throw new Error(`GitHub API ${method} ${requestPath} exhausted retries`);
331
704
  }
332
705
  return {
333
706
  async resolveBranch(ref) {
@@ -368,6 +741,19 @@ export function createGitHubChannelCandidateClient({
368
741
  }
369
742
  return commits;
370
743
  },
744
+ async listOpenPullRequests(base) {
745
+ const pullRequests = [];
746
+ for (let page = 1; page <= 10; page += 1) {
747
+ const rows = await api(
748
+ `/repos/${owner}/${repo}/pulls?state=open&base=${encodeURIComponent(base)}&per_page=100&page=${page}`,
749
+ );
750
+ pullRequests.push(...rows);
751
+ if (rows.length < 100) return pullRequests;
752
+ }
753
+ throw new Error(
754
+ `open pull request history for ${base} exceeds 1000 rows`,
755
+ );
756
+ },
371
757
  async ensureImmutableBranch(ref, sourceSha) {
372
758
  const current = await api(
373
759
  `/repos/${owner}/${repo}/git/ref/heads/${encodeRef(ref)}`,
@@ -396,6 +782,12 @@ export function createGitHubChannelCandidateClient({
396
782
  })
397
783
  );
398
784
  },
785
+ async updatePullRequestBody(number, body) {
786
+ return api(`/repos/${owner}/${repo}/pulls/${number}`, {
787
+ method: "PATCH",
788
+ body: { body },
789
+ });
790
+ },
399
791
  };
400
792
  }
401
793
 
@@ -406,6 +798,10 @@ function markdown(result) {
406
798
  `Eligible: \`${result.decision.eligible}\` (${result.decision.reason})`,
407
799
  `Source: \`${result.decision.source.branch}@${result.decision.source.sha}\``,
408
800
  `Target: \`${result.decision.target.branch}@${result.decision.target.sha}\``,
801
+ `Controller state: \`${result.controller.state}\``,
802
+ `Active candidate: ${result.controller.activeCandidate?.url || "none"}`,
803
+ `Next candidate: \`${result.controller.nextCandidate?.sourceSha || "none"}\``,
804
+ `Settlement action: \`${result.controller.settlementAction}\``,
409
805
  `Dry run: \`${result.dryRun}\``,
410
806
  `Pull request: ${result.pullRequest?.html_url || "not created"}`,
411
807
  "",
@@ -428,6 +824,10 @@ async function main() {
428
824
  "selected-sha": result.decision.source.sha,
429
825
  "source-lock-ref": result.decision.sourceLockRef || "",
430
826
  "promotion-pr": result.pullRequest?.html_url || "",
827
+ "controller-state": result.controller.state,
828
+ "active-candidate-pr": result.controller.activeCandidate?.url || "",
829
+ "next-candidate-sha": result.controller.nextCandidate?.sourceSha || "",
830
+ "settlement-action": result.controller.settlementAction,
431
831
  };
432
832
  fs.appendFileSync(
433
833
  process.env.GITHUB_OUTPUT,
@@ -205,6 +205,8 @@ ${publicOutputs(outputs)}
205
205
 
206
206
  permissions:
207
207
  actions: write
208
+ artifact-metadata: write
209
+ attestations: write
208
210
  checks: write
209
211
  contents: write
210
212
  id-token: write
@@ -406,6 +408,8 @@ jobs:
406
408
  uses: kungfu-systems/buildchain/${alphaRoute.workflowPath}@${alphaRoute.callRef}
407
409
  permissions:
408
410
  actions: write
411
+ artifact-metadata: write
412
+ attestations: write
409
413
  checks: write
410
414
  contents: write
411
415
  id-token: write
@@ -422,6 +426,8 @@ ${alphaForwarded}
422
426
  uses: kungfu-systems/buildchain/${stableRoute.workflowPath}@${stableRoute.callRef}
423
427
  permissions:
424
428
  actions: write
429
+ artifact-metadata: write
430
+ attestations: write
425
431
  checks: write
426
432
  contents: write
427
433
  id-token: write