@kungfu-tech/buildchain 3.0.2-alpha.0 → 3.0.2-alpha.10

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 (83) hide show
  1. package/README.md +4 -2
  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/contracts/auditable-demo-media-profiles-v1.json +168 -0
  7. package/contracts/evidence/auditable-demo-web-delivery-v1.json +103 -0
  8. package/contracts/fixtures/auditable-demo-web-delivery-v1/complete-transcript.txt +2 -0
  9. package/contracts/fixtures/auditable-demo-web-delivery-v1/public-projection.json +16 -0
  10. package/contracts/fixtures/auditable-demo-web-delivery-v1/scene.json +12 -0
  11. package/dist/site/artifact-schemas.json +5 -1
  12. package/dist/site/buildchain-contract.json +133 -34
  13. package/dist/site/buildchain-site.json +159 -40
  14. package/dist/site/capability-registry.json +14 -13
  15. package/dist/site/cli-registry.json +12 -0
  16. package/dist/site/controller-registry.json +72 -4
  17. package/dist/site/kfd-claims.json +338 -20
  18. package/dist/site/kfd-upstream-aggregate.json +1 -1
  19. package/dist/site/manual-registry.json +24 -9
  20. package/dist/site/node-api-registry.json +89 -11
  21. package/dist/site/page-registry.json +135 -25
  22. package/dist/site/public-surface-audit.json +203 -21
  23. package/dist/site/publication-authority-registry.json +72 -2
  24. package/dist/site/publication-registry.json +4 -4
  25. package/dist/site/release-model.json +2 -1
  26. package/dist/site/release-passport-check-manifest.json +1 -0
  27. package/dist/site/release-provenance.json +6 -0
  28. package/dist/site/schemas/release-passport-v1.schema.json +6 -0
  29. package/dist/site/site-manifest.json +21 -13
  30. package/dist/site/workflow-registry.json +167 -13
  31. package/docs/MAP.md +5 -1
  32. package/docs/auditable-demo.md +55 -3
  33. package/docs/binary-distribution.md +7 -0
  34. package/docs/cli.md +9 -0
  35. package/docs/dev-alpha-candidate-patrol.md +111 -0
  36. package/docs/github-artifact-attestation.md +219 -0
  37. package/docs/release-governance.md +32 -0
  38. package/docs/release-passport.md +13 -0
  39. package/docs/reusable-build-surface.md +83 -61
  40. package/docs/runtime-train-validation.md +21 -0
  41. package/docs/versioning.md +1 -0
  42. package/package.json +8 -1
  43. package/packages/core/artifact-signing-result.js +228 -0
  44. package/packages/core/artifact-signing.js +412 -0
  45. package/packages/core/buildchain-config.js +58 -0
  46. package/packages/core/buildchain-contract.js +14 -0
  47. package/packages/core/buildchain-kfd-claims.js +5 -0
  48. package/packages/core/buildchain-publication-authority.js +3 -0
  49. package/packages/core/cache-evidence.js +288 -0
  50. package/packages/core/channel-candidate.js +186 -0
  51. package/packages/core/detached-artifact-signature.js +121 -0
  52. package/packages/core/diagnostics.js +276 -10
  53. package/packages/core/github-artifact-attestation.js +642 -0
  54. package/packages/core/github-governance-authority.js +77 -16
  55. package/packages/core/index.js +62 -0
  56. package/packages/core/publication-authority.js +1 -1
  57. package/packages/core/release-passport-contract.js +2 -0
  58. package/packages/core/release-passport.js +60 -3
  59. package/scripts/auditable-demo.mjs +520 -28
  60. package/scripts/build-contract-core.mjs +31 -0
  61. package/scripts/buildchain-channel-router.mjs +8 -2
  62. package/scripts/check-inventory.mjs +9 -0
  63. package/scripts/create-github-artifact-attestation-policy.mjs +62 -0
  64. package/scripts/dev-alpha-candidate-patrol.mjs +864 -0
  65. package/scripts/dispatch-artifact-signing-authority.mjs +152 -0
  66. package/scripts/finalize-native-artifact-signing-result.mjs +96 -0
  67. package/scripts/generate-channel-promotion-workflow.mjs +6 -0
  68. package/scripts/generate-site-bundle.mjs +20 -0
  69. package/scripts/import-artifact-signing-results.mjs +76 -0
  70. package/scripts/inspect-artifact-signing-requests.mjs +101 -0
  71. package/scripts/locked-source-checkout.mjs +48 -0
  72. package/scripts/materialize-artifact-signing-request.mjs +66 -0
  73. package/scripts/merge-artifact-signing-results.mjs +76 -0
  74. package/scripts/publish-github-artifact-attestation-evidence.mjs +201 -0
  75. package/scripts/reconcile-github-governance.mjs +158 -25
  76. package/scripts/release-candidate-resolver.mjs +12 -0
  77. package/scripts/release-line-policy.mjs +27 -0
  78. package/scripts/runtime-ref-core.mjs +10 -6
  79. package/scripts/seal-artifact-signing-requests.mjs +368 -0
  80. package/scripts/shifu-gate-profile.mjs +26 -20
  81. package/scripts/sign-detached-artifact-requests.mjs +237 -0
  82. package/scripts/stage-github-artifact-attestation-inputs.mjs +65 -0
  83. package/scripts/verify-artifact-signing-results.mjs +99 -0
@@ -0,0 +1,864 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import crypto from "node:crypto";
7
+ import { decideChannelCandidate } from "../packages/core/channel-candidate.js";
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
+
16
+ function text(value = "") {
17
+ return String(value ?? "").trim();
18
+ }
19
+
20
+ function bool(value, fallback = false) {
21
+ if (value === undefined || value === null || value === "") return fallback;
22
+ return ["1", "true", "yes", "on"].includes(text(value).toLowerCase());
23
+ }
24
+
25
+ function repository(value) {
26
+ const normalized = text(value);
27
+ if (!/^[^/\s]+\/[^/\s]+$/.test(normalized))
28
+ throw new Error(`repository must be owner/repo, got ${value || "<empty>"}`);
29
+ return normalized;
30
+ }
31
+
32
+ function branch(value, name) {
33
+ const normalized = text(value).replace(/^refs\/heads\//, "");
34
+ if (
35
+ !normalized ||
36
+ normalized.startsWith("-") ||
37
+ /[\s~^:?*[\\]/.test(normalized)
38
+ ) {
39
+ throw new Error(`${name} is not a valid branch name`);
40
+ }
41
+ return normalized;
42
+ }
43
+
44
+ function workflowPath(value, name) {
45
+ const normalized = text(value);
46
+ if (!/^\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml$/.test(normalized)) {
47
+ throw new Error(`${name} must be a repository workflow path`);
48
+ }
49
+ return normalized;
50
+ }
51
+
52
+ function integer(value, fallback) {
53
+ const parsed = Number(value);
54
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
55
+ }
56
+
57
+ function pullRequestBodyPrefix(value) {
58
+ const normalized = String(value ?? "").trim();
59
+ if (normalized.length > 32768)
60
+ throw new Error("pullRequestBodyPrefix exceeds 32768 characters");
61
+ if (normalized.includes(STATE_MARKER_START))
62
+ throw new Error(
63
+ "pullRequestBodyPrefix must not contain the managed candidate state marker",
64
+ );
65
+ return normalized;
66
+ }
67
+
68
+ function canonical(value) {
69
+ if (Array.isArray(value)) return value.map(canonical);
70
+ if (value && typeof value === "object") {
71
+ return Object.fromEntries(
72
+ Object.entries(value)
73
+ .sort(([left], [right]) => left.localeCompare(right))
74
+ .map(([key, item]) => [key, canonical(item)]),
75
+ );
76
+ }
77
+ return value;
78
+ }
79
+
80
+ function evidenceRoot(value) {
81
+ return `sha256:${crypto
82
+ .createHash("sha256")
83
+ .update(JSON.stringify(canonical(value)))
84
+ .digest("hex")}`;
85
+ }
86
+
87
+ function candidateStateMarker(state) {
88
+ return `${STATE_MARKER_START}\n${JSON.stringify(state)}\n${STATE_MARKER_END}`;
89
+ }
90
+
91
+ export function parseCandidateStateMarker(bodyInput) {
92
+ const body = String(bodyInput || "");
93
+ const start = body.indexOf(STATE_MARKER_START);
94
+ if (start < 0) return undefined;
95
+ const jsonStart = body.indexOf("\n", start);
96
+ const end = body.indexOf(STATE_MARKER_END, jsonStart + 1);
97
+ if (jsonStart < 0 || end < 0)
98
+ throw new Error("malformed Buildchain candidate state marker");
99
+ const state = JSON.parse(body.slice(jsonStart + 1, end).trim());
100
+ if (state.schema !== DEV_ALPHA_CANDIDATE_STATE_SCHEMA)
101
+ throw new Error(
102
+ `unsupported candidate state schema ${state.schema || "<empty>"}`,
103
+ );
104
+ return state;
105
+ }
106
+
107
+ function replaceCandidateStateMarker(bodyInput, state) {
108
+ const body = String(bodyInput || "").trimEnd();
109
+ const start = body.indexOf(STATE_MARKER_START);
110
+ if (start < 0) return `${body}\n\n${candidateStateMarker(state)}\n`;
111
+ const end = body.indexOf(STATE_MARKER_END, start);
112
+ if (end < 0) throw new Error("malformed Buildchain candidate state marker");
113
+ return `${body.slice(0, start).trimEnd()}\n\n${candidateStateMarker(state)}\n`;
114
+ }
115
+
116
+ function sourceShaFromLegacyBody(bodyInput) {
117
+ return String(bodyInput || "").match(/- Source SHA: `([0-9a-f]{40})`/u)?.[1];
118
+ }
119
+
120
+ function targetSlug(targetBranch) {
121
+ return targetBranch.replace(/[^A-Za-z0-9._-]+/g, "-");
122
+ }
123
+
124
+ export function managedCandidateFromPullRequest(pullRequest, targetBranch) {
125
+ const body = String(pullRequest.body || "");
126
+ const marker = parseCandidateStateMarker(body);
127
+ const expectedPrefix = `buildchain/candidate/${targetSlug(targetBranch)}/`;
128
+ const headRef = text(pullRequest.head?.ref);
129
+ const baseRef = text(pullRequest.base?.ref || targetBranch);
130
+ if (baseRef !== targetBranch) return undefined;
131
+ if (marker) {
132
+ if (marker.targetBranch !== targetBranch)
133
+ throw new Error(
134
+ `candidate PR #${pullRequest.number} state targets ${marker.targetBranch}, not ${targetBranch}`,
135
+ );
136
+ if (!headRef.startsWith(expectedPrefix))
137
+ throw new Error(
138
+ `candidate PR #${pullRequest.number} head ${headRef} is outside ${expectedPrefix}`,
139
+ );
140
+ const sourceSha = text(marker.activeCandidate?.sourceSha);
141
+ if (
142
+ !EXACT_SHA.test(sourceSha) ||
143
+ headRef !== `${expectedPrefix}${sourceSha.slice(0, 12)}`
144
+ ) {
145
+ throw new Error(
146
+ `candidate PR #${pullRequest.number} state does not bind its exact source-lock head`,
147
+ );
148
+ }
149
+ return {
150
+ number: Number(pullRequest.number),
151
+ url: text(pullRequest.html_url),
152
+ body,
153
+ sourceSha,
154
+ sourceLockRef: headRef,
155
+ decisionRoot: text(marker.activeCandidate?.decisionRoot),
156
+ nextCandidate: marker.nextCandidate || null,
157
+ state: marker,
158
+ };
159
+ }
160
+ if (!body.includes(LEGACY_BODY_MARKER) || !headRef.startsWith(expectedPrefix))
161
+ return undefined;
162
+ const sourceSha = sourceShaFromLegacyBody(body);
163
+ if (!sourceSha)
164
+ throw new Error(
165
+ `legacy candidate PR #${pullRequest.number} has no exact source SHA`,
166
+ );
167
+ if (headRef !== `${expectedPrefix}${sourceSha.slice(0, 12)}`)
168
+ throw new Error(
169
+ `legacy candidate PR #${pullRequest.number} does not bind its exact source-lock head`,
170
+ );
171
+ return {
172
+ number: Number(pullRequest.number),
173
+ url: text(pullRequest.html_url),
174
+ body,
175
+ sourceSha,
176
+ sourceLockRef: headRef,
177
+ decisionRoot: "",
178
+ nextCandidate: null,
179
+ state: null,
180
+ };
181
+ }
182
+
183
+ export function normalizeDevAlphaPatrolOptions(options = {}) {
184
+ const createPullRequest = bool(
185
+ options.createPullRequest ??
186
+ process.env.BUILDCHAIN_CHANNEL_PATROL_CREATE_PR,
187
+ false,
188
+ );
189
+ return {
190
+ repository: repository(
191
+ options.repository ??
192
+ process.env.BUILDCHAIN_CHANNEL_PATROL_REPOSITORY ??
193
+ process.env.GITHUB_REPOSITORY,
194
+ ),
195
+ sourceBranch: branch(
196
+ options.sourceBranch ??
197
+ process.env.BUILDCHAIN_CHANNEL_PATROL_SOURCE_BRANCH ??
198
+ "dev/v4/v4.0",
199
+ "sourceBranch",
200
+ ),
201
+ targetBranch: branch(
202
+ options.targetBranch ??
203
+ process.env.BUILDCHAIN_CHANNEL_PATROL_TARGET_BRANCH ??
204
+ "alpha/v4/v4.0",
205
+ "targetBranch",
206
+ ),
207
+ devWorkflowPath: workflowPath(
208
+ options.devWorkflowPath ??
209
+ process.env.BUILDCHAIN_CHANNEL_PATROL_DEV_WORKFLOW ??
210
+ ".github/workflows/dev-verify-patrol.yml",
211
+ "devWorkflowPath",
212
+ ),
213
+ alphaWorkflowPath: workflowPath(
214
+ options.alphaWorkflowPath ??
215
+ process.env.BUILDCHAIN_CHANNEL_PATROL_ALPHA_WORKFLOW ??
216
+ ".github/workflows/alpha-promotion-preflight.yml",
217
+ "alphaWorkflowPath",
218
+ ),
219
+ maxAgeSeconds: integer(
220
+ options.maxAgeSeconds ??
221
+ process.env.BUILDCHAIN_CHANNEL_PATROL_MAX_AGE_SECONDS,
222
+ 7 * 24 * 60 * 60,
223
+ ),
224
+ pullRequestBodyPrefix: pullRequestBodyPrefix(
225
+ options.pullRequestBodyPrefix ??
226
+ process.env.BUILDCHAIN_CHANNEL_PATROL_PR_BODY_PREFIX,
227
+ ),
228
+ createPullRequest,
229
+ settlementAuthorized: bool(
230
+ options.settlementAuthorized ??
231
+ process.env.BUILDCHAIN_CHANNEL_PATROL_SETTLEMENT_AUTHORIZED,
232
+ createPullRequest,
233
+ ),
234
+ dryRun: bool(
235
+ options.dryRun ?? process.env.BUILDCHAIN_CHANNEL_PATROL_DRY_RUN,
236
+ true,
237
+ ),
238
+ now:
239
+ text(options.now ?? process.env.BUILDCHAIN_CHANNEL_PATROL_NOW) ||
240
+ new Date().toISOString(),
241
+ outputPath:
242
+ text(
243
+ options.outputPath ?? process.env.BUILDCHAIN_CHANNEL_PATROL_OUTPUT_PATH,
244
+ ) || ".buildchain/patrol/dev-alpha-candidate.json",
245
+ };
246
+ }
247
+
248
+ function latestWorkflowEvidence(runs, workflowPathValue, sourceSha) {
249
+ const matching = runs
250
+ .filter(
251
+ (run) => run.path === workflowPathValue && run.head_sha === sourceSha,
252
+ )
253
+ .sort((left, right) => Number(right.id) - Number(left.id));
254
+ if (matching.length === 0)
255
+ throw new Error(
256
+ `missing completed same-SHA workflow run: ${workflowPathValue}`,
257
+ );
258
+ const run = matching[0];
259
+ return {
260
+ workflowPath: workflowPathValue,
261
+ workflowName: run.name,
262
+ runId: run.id,
263
+ runAttempt: run.run_attempt,
264
+ headSha: run.head_sha,
265
+ status: run.status,
266
+ conclusion: run.conclusion,
267
+ completedAt: run.updated_at,
268
+ url: run.html_url,
269
+ };
270
+ }
271
+
272
+ function workflowEvidenceIsFreshAndSuccessful(run, { now, maxAgeSeconds }) {
273
+ const completedAt = Date.parse(run.updated_at);
274
+ const ageSeconds = (Date.parse(now) - completedAt) / 1000;
275
+ return (
276
+ run.status === "completed" &&
277
+ run.conclusion === "success" &&
278
+ Number.isFinite(ageSeconds) &&
279
+ ageSeconds >= 0 &&
280
+ ageSeconds <= maxAgeSeconds
281
+ );
282
+ }
283
+
284
+ function latestRunsBySha(runs, workflowPathValue) {
285
+ const latest = new Map();
286
+ for (const run of runs.filter((row) => row.path === workflowPathValue)) {
287
+ const current = latest.get(run.head_sha);
288
+ if (!current || Number(run.id) > Number(current.id))
289
+ latest.set(run.head_sha, run);
290
+ }
291
+ return latest;
292
+ }
293
+
294
+ export function selectLatestQualifiedSource({
295
+ sourceHistory,
296
+ workflowRunsByPath,
297
+ requiredWorkflowPaths,
298
+ now,
299
+ maxAgeSeconds,
300
+ }) {
301
+ const latestByPath = new Map(
302
+ requiredWorkflowPaths.map((workflow) => [
303
+ workflow,
304
+ latestRunsBySha(workflowRunsByPath.get(workflow) || [], workflow),
305
+ ]),
306
+ );
307
+ for (let index = 0; index < sourceHistory.length; index += 1) {
308
+ const sourceSha = sourceHistory[index];
309
+ const rows = requiredWorkflowPaths.map((workflow) =>
310
+ latestByPath.get(workflow).get(sourceSha),
311
+ );
312
+ if (
313
+ rows.every((run) =>
314
+ workflowEvidenceIsFreshAndSuccessful(run || {}, { now, maxAgeSeconds }),
315
+ )
316
+ ) {
317
+ return {
318
+ sourceSha,
319
+ skippedNewerCommitCount: index,
320
+ workflowEvidence: requiredWorkflowPaths.map((workflow) =>
321
+ latestWorkflowEvidence(
322
+ workflowRunsByPath.get(workflow) || [],
323
+ workflow,
324
+ sourceSha,
325
+ ),
326
+ ),
327
+ };
328
+ }
329
+ }
330
+ const staleSuccessfulPair = sourceHistory.some((sourceSha) => {
331
+ const rows = requiredWorkflowPaths.map((workflow) =>
332
+ latestByPath.get(workflow).get(sourceSha),
333
+ );
334
+ return (
335
+ rows.every(
336
+ (run) =>
337
+ run?.status === "completed" &&
338
+ run?.conclusion === "success" &&
339
+ run?.head_sha === sourceSha,
340
+ ) &&
341
+ rows.some(
342
+ (run) =>
343
+ !workflowEvidenceIsFreshAndSuccessful(run, { now, maxAgeSeconds }),
344
+ )
345
+ );
346
+ });
347
+ if (staleSuccessfulPair)
348
+ throw new Error(
349
+ "same-SHA workflow evidence is stale for every qualified source commit",
350
+ );
351
+ throw new Error(
352
+ "no source commit ahead of target has fresh completed successful same-SHA workflow evidence",
353
+ );
354
+ }
355
+
356
+ function blockedCandidateDecision({
357
+ options,
358
+ sourceSha,
359
+ targetSha,
360
+ comparison,
361
+ reason,
362
+ }) {
363
+ const body = {
364
+ schema: "kungfu-buildchain-channel-candidate-decision/v1",
365
+ eligible: false,
366
+ reason: "qualification-evidence-blocked",
367
+ repository: options.repository,
368
+ source: { branch: options.sourceBranch, sha: sourceSha },
369
+ target: { branch: options.targetBranch, sha: targetSha },
370
+ comparison: {
371
+ status: text(comparison.status || "unknown"),
372
+ aheadBy: Number(comparison.ahead_by || 0),
373
+ },
374
+ blockReason: text(reason),
375
+ decidedAt: options.now,
376
+ };
377
+ return { ...body, decisionRoot: evidenceRoot(body) };
378
+ }
379
+
380
+ function candidateFromDecision(decision) {
381
+ if (!decision.eligible) return null;
382
+ return {
383
+ sourceSha: decision.source.sha,
384
+ sourceLockRef: decision.sourceLockRef,
385
+ decisionRoot: decision.decisionRoot,
386
+ workflowEvidence: decision.workflowEvidence,
387
+ };
388
+ }
389
+
390
+ function candidateStateBody({
391
+ options,
392
+ targetSha,
393
+ decision,
394
+ activeCandidate,
395
+ nextCandidate,
396
+ supersededCandidate,
397
+ }) {
398
+ const body = {
399
+ schema: DEV_ALPHA_CANDIDATE_STATE_SCHEMA,
400
+ repository: options.repository,
401
+ sourceBranch: options.sourceBranch,
402
+ targetBranch: options.targetBranch,
403
+ targetSha,
404
+ activeCandidate,
405
+ nextCandidate,
406
+ observationDecisionRoot: decision.decisionRoot || null,
407
+ observedAt: options.now,
408
+ ...(supersededCandidate ? { supersededCandidate } : {}),
409
+ };
410
+ return { ...body, stateRoot: evidenceRoot(body) };
411
+ }
412
+
413
+ function pullRequestBody({
414
+ options,
415
+ observedSourceHeadSha,
416
+ sourceSha,
417
+ skippedNewerCommitCount,
418
+ targetSha,
419
+ decision,
420
+ state,
421
+ }) {
422
+ return [
423
+ ...(options.pullRequestBodyPrefix
424
+ ? [options.pullRequestBodyPrefix, ""]
425
+ : []),
426
+ LEGACY_BODY_MARKER,
427
+ "",
428
+ `- Source branch: \`${options.sourceBranch}\``,
429
+ `- Observed source HEAD: \`${observedSourceHeadSha}\``,
430
+ `- Source SHA: \`${sourceSha}\``,
431
+ `- Skipped newer unqualified commits: \`${skippedNewerCommitCount}\``,
432
+ `- Target branch/head: \`${options.targetBranch}\` / \`${targetSha}\``,
433
+ `- Decision root: \`${decision.decisionRoot}\``,
434
+ ...decision.workflowEvidence.map(
435
+ (row) =>
436
+ `- ${row.workflowName}: [run ${row.runId} attempt ${row.runAttempt}](${row.url})`,
437
+ ),
438
+ "",
439
+ "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.",
440
+ "",
441
+ candidateStateMarker(state),
442
+ ].join("\n");
443
+ }
444
+
445
+ export async function runDevAlphaCandidatePatrol(
446
+ optionsInput = {},
447
+ clientInput,
448
+ ) {
449
+ const options = normalizeDevAlphaPatrolOptions(optionsInput);
450
+ if (options.sourceBranch === options.targetBranch)
451
+ throw new Error("source and target branches must differ");
452
+ const client =
453
+ clientInput ||
454
+ createGitHubChannelCandidateClient({
455
+ repository: options.repository,
456
+ token: process.env.GITHUB_TOKEN,
457
+ });
458
+ const [observedSourceHeadSha, targetSha] = await Promise.all([
459
+ client.resolveBranch(options.sourceBranch),
460
+ client.resolveBranch(options.targetBranch),
461
+ ]);
462
+ const headComparison = await client.compare(targetSha, observedSourceHeadSha);
463
+ const requiredWorkflowPaths = [
464
+ options.devWorkflowPath,
465
+ options.alphaWorkflowPath,
466
+ ];
467
+ let sourceSha = observedSourceHeadSha;
468
+ let comparison = headComparison;
469
+ let workflowEvidence = [];
470
+ let skippedNewerCommitCount = 0;
471
+ let qualificationError;
472
+ if (
473
+ headComparison.status === "ahead" &&
474
+ Number(headComparison.ahead_by) > 0
475
+ ) {
476
+ const [sourceHistory, ...workflowRunSets] = await Promise.all([
477
+ client.listBranchHistory(options.sourceBranch, targetSha),
478
+ ...requiredWorkflowPaths.map((workflow) =>
479
+ client.listCompletedWorkflowRuns(workflow, options.sourceBranch),
480
+ ),
481
+ ]);
482
+ try {
483
+ const selected = selectLatestQualifiedSource({
484
+ sourceHistory,
485
+ workflowRunsByPath: new Map(
486
+ requiredWorkflowPaths.map((workflow, index) => [
487
+ workflow,
488
+ workflowRunSets[index],
489
+ ]),
490
+ ),
491
+ requiredWorkflowPaths,
492
+ now: options.now,
493
+ maxAgeSeconds: options.maxAgeSeconds,
494
+ });
495
+ sourceSha = selected.sourceSha;
496
+ skippedNewerCommitCount = selected.skippedNewerCommitCount;
497
+ workflowEvidence = selected.workflowEvidence;
498
+ if (sourceSha !== observedSourceHeadSha)
499
+ comparison = await client.compare(targetSha, sourceSha);
500
+ } catch (error) {
501
+ qualificationError = error;
502
+ }
503
+ }
504
+ const decision = qualificationError
505
+ ? blockedCandidateDecision({
506
+ options,
507
+ sourceSha: observedSourceHeadSha,
508
+ targetSha,
509
+ comparison: headComparison,
510
+ reason: qualificationError.message,
511
+ })
512
+ : decideChannelCandidate({
513
+ repository: options.repository,
514
+ sourceBranch: options.sourceBranch,
515
+ targetBranch: options.targetBranch,
516
+ sourceSha,
517
+ targetSha,
518
+ comparison: { status: comparison.status, aheadBy: comparison.ahead_by },
519
+ selection: {
520
+ mode: "latest-qualified-source-ancestor",
521
+ observedSourceHeadSha,
522
+ skippedNewerCommitCount,
523
+ },
524
+ workflowEvidence,
525
+ requiredWorkflowPaths,
526
+ maxAgeSeconds: options.maxAgeSeconds,
527
+ now: options.now,
528
+ });
529
+ const openPullRequests = await client.listOpenPullRequests(
530
+ options.targetBranch,
531
+ );
532
+ const managedCandidates = openPullRequests
533
+ .map((pullRequest) =>
534
+ managedCandidateFromPullRequest(pullRequest, options.targetBranch),
535
+ )
536
+ .filter(Boolean);
537
+ for (const candidate of managedCandidates) {
538
+ if (
539
+ candidate.state &&
540
+ (candidate.state.repository !== options.repository ||
541
+ candidate.state.sourceBranch !== options.sourceBranch)
542
+ ) {
543
+ throw new Error(
544
+ `candidate PR #${candidate.number} state does not bind ${options.repository} ${options.sourceBranch}`,
545
+ );
546
+ }
547
+ }
548
+ if (managedCandidates.length > 1) {
549
+ throw new Error(
550
+ `multiple open Buildchain candidate PRs target ${options.targetBranch}: ${managedCandidates
551
+ .map((candidate) => `#${candidate.number}`)
552
+ .join(", ")}`,
553
+ );
554
+ }
555
+ let activeCandidate = managedCandidates[0] || null;
556
+ const observedCandidate = candidateFromDecision(decision);
557
+ let nextCandidate = null;
558
+ let supersededCandidate = null;
559
+ let controllerState = decision.eligible
560
+ ? "eligible-for-settlement"
561
+ : qualificationError
562
+ ? /stale/u.test(qualificationError.message)
563
+ ? "stale"
564
+ : "blocked"
565
+ : "observed";
566
+ if (activeCandidate) {
567
+ controllerState = "active";
568
+ if (
569
+ observedCandidate &&
570
+ observedCandidate.sourceSha !== activeCandidate.sourceSha
571
+ ) {
572
+ nextCandidate = observedCandidate;
573
+ controllerState = "retained-next";
574
+ }
575
+ if (
576
+ activeCandidate.nextCandidate &&
577
+ activeCandidate.nextCandidate.sourceSha !== nextCandidate?.sourceSha
578
+ ) {
579
+ supersededCandidate = activeCandidate.nextCandidate;
580
+ }
581
+ }
582
+ let state = candidateStateBody({
583
+ options,
584
+ targetSha,
585
+ decision,
586
+ activeCandidate: activeCandidate
587
+ ? {
588
+ sourceSha: activeCandidate.sourceSha,
589
+ sourceLockRef: activeCandidate.sourceLockRef,
590
+ decisionRoot: activeCandidate.decisionRoot || null,
591
+ pullRequestNumber: activeCandidate.number,
592
+ pullRequestUrl: activeCandidate.url,
593
+ }
594
+ : null,
595
+ nextCandidate,
596
+ supersededCandidate,
597
+ });
598
+ let pullRequest;
599
+ let settlementAction = "none";
600
+ if (options.settlementAuthorized && !options.dryRun) {
601
+ if (activeCandidate) {
602
+ const nextBody = replaceCandidateStateMarker(activeCandidate.body, state);
603
+ if (nextBody !== activeCandidate.body) {
604
+ await client.updatePullRequestBody(activeCandidate.number, nextBody);
605
+ settlementAction = nextCandidate
606
+ ? supersededCandidate
607
+ ? "supersede-next-candidate"
608
+ : "retain-next-candidate"
609
+ : "reconcile-active-candidate";
610
+ }
611
+ pullRequest = {
612
+ number: activeCandidate.number,
613
+ html_url: activeCandidate.url,
614
+ };
615
+ } else if (decision.eligible) {
616
+ await client.ensureImmutableBranch(decision.sourceLockRef, sourceSha);
617
+ state = candidateStateBody({
618
+ options,
619
+ targetSha,
620
+ decision,
621
+ activeCandidate: observedCandidate,
622
+ nextCandidate: null,
623
+ supersededCandidate: null,
624
+ });
625
+ pullRequest = await client.ensurePullRequest({
626
+ head: decision.sourceLockRef,
627
+ base: options.targetBranch,
628
+ title: `Promote qualified ${options.sourceBranch} candidate ${sourceSha.slice(0, 12)} to ${options.targetBranch}`,
629
+ body: pullRequestBody({
630
+ options,
631
+ observedSourceHeadSha,
632
+ sourceSha,
633
+ skippedNewerCommitCount,
634
+ targetSha,
635
+ decision,
636
+ state,
637
+ }),
638
+ });
639
+ settlementAction = "create-active-candidate";
640
+ controllerState = "active";
641
+ activeCandidate = {
642
+ number: Number(pullRequest.number || 0),
643
+ url: text(pullRequest.html_url),
644
+ sourceSha,
645
+ sourceLockRef: decision.sourceLockRef,
646
+ decisionRoot: decision.decisionRoot,
647
+ };
648
+ }
649
+ }
650
+ return {
651
+ schema: "kungfu-buildchain-dev-alpha-candidate-patrol/v1",
652
+ dryRun: options.dryRun,
653
+ createPullRequest: options.createPullRequest,
654
+ settlementAuthorized: options.settlementAuthorized,
655
+ decision,
656
+ controller: {
657
+ schema: DEV_ALPHA_CANDIDATE_STATE_SCHEMA,
658
+ state: controllerState,
659
+ activeCandidate,
660
+ nextCandidate,
661
+ supersededCandidate,
662
+ settlementAction,
663
+ stateRoot: state.stateRoot,
664
+ },
665
+ pullRequest: pullRequest || null,
666
+ };
667
+ }
668
+
669
+ function encodeRef(value) {
670
+ return value.split("/").map(encodeURIComponent).join("/");
671
+ }
672
+
673
+ export function createGitHubChannelCandidateClient({
674
+ repository: repositoryInput,
675
+ token,
676
+ fetchImpl = globalThis.fetch,
677
+ sleepImpl = (milliseconds) =>
678
+ new Promise((resolve) => setTimeout(resolve, milliseconds)),
679
+ }) {
680
+ const [owner, repo] = repository(repositoryInput).split("/");
681
+ const headers = {
682
+ accept: "application/vnd.github+json",
683
+ authorization: token ? `Bearer ${token}` : undefined,
684
+ "user-agent": "buildchain-dev-alpha-candidate-patrol",
685
+ "x-github-api-version": "2022-11-28",
686
+ };
687
+ async function api(
688
+ requestPath,
689
+ { method = "GET", body, allow404 = false } = {},
690
+ ) {
691
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
692
+ const response = await fetchImpl(`https://api.github.com${requestPath}`, {
693
+ method,
694
+ headers: Object.fromEntries(
695
+ Object.entries(headers).filter(([, value]) => value),
696
+ ),
697
+ body: body === undefined ? undefined : JSON.stringify(body),
698
+ });
699
+ const raw = await response.text();
700
+ const payload = raw ? JSON.parse(raw) : undefined;
701
+ if (allow404 && response.status === 404) return undefined;
702
+ if (response.ok) return payload;
703
+ const retryable = response.status === 429 || response.status >= 500;
704
+ if (retryable && attempt < 3) {
705
+ const retryAfterHeader = response.headers?.get?.("retry-after");
706
+ const retryAfter =
707
+ retryAfterHeader === null || retryAfterHeader === undefined
708
+ ? Number.NaN
709
+ : Number(retryAfterHeader);
710
+ await sleepImpl(
711
+ Number.isFinite(retryAfter) && retryAfter >= 0
712
+ ? Math.min(retryAfter * 1000, 10_000)
713
+ : attempt * 250,
714
+ );
715
+ continue;
716
+ }
717
+ throw new Error(
718
+ `GitHub API ${method} ${requestPath} failed with ${response.status}: ${payload?.message || raw}`,
719
+ );
720
+ }
721
+ throw new Error(`GitHub API ${method} ${requestPath} exhausted retries`);
722
+ }
723
+ return {
724
+ async resolveBranch(ref) {
725
+ const payload = await api(
726
+ `/repos/${owner}/${repo}/git/ref/heads/${encodeRef(ref)}`,
727
+ );
728
+ return text(payload.object?.sha);
729
+ },
730
+ async compare(baseSha, headSha) {
731
+ return api(`/repos/${owner}/${repo}/compare/${baseSha}...${headSha}`);
732
+ },
733
+ async listCompletedWorkflowRuns(workflowPathValue, sourceBranch) {
734
+ const runs = [];
735
+ for (let page = 1; page <= 10; page += 1) {
736
+ const payload = await api(
737
+ `/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflowPathValue)}/runs?branch=${encodeURIComponent(sourceBranch)}&status=completed&per_page=100&page=${page}`,
738
+ );
739
+ const rows = payload.workflow_runs || [];
740
+ runs.push(...rows);
741
+ if (rows.length < 100) return runs;
742
+ }
743
+ throw new Error(
744
+ `${workflowPathValue} completed workflow history exceeds 1000 runs`,
745
+ );
746
+ },
747
+ async listBranchHistory(sourceBranch, targetSha) {
748
+ const commits = [];
749
+ for (let page = 1; page <= 10; page += 1) {
750
+ const rows = await api(
751
+ `/repos/${owner}/${repo}/commits?sha=${encodeURIComponent(sourceBranch)}&per_page=100&page=${page}`,
752
+ );
753
+ for (const commit of rows) {
754
+ const commitSha = text(commit.sha);
755
+ if (commitSha === targetSha) return commits;
756
+ commits.push(commitSha);
757
+ }
758
+ if (rows.length < 100) return commits;
759
+ }
760
+ return commits;
761
+ },
762
+ async listOpenPullRequests(base) {
763
+ const pullRequests = [];
764
+ for (let page = 1; page <= 10; page += 1) {
765
+ const rows = await api(
766
+ `/repos/${owner}/${repo}/pulls?state=open&base=${encodeURIComponent(base)}&per_page=100&page=${page}`,
767
+ );
768
+ pullRequests.push(...rows);
769
+ if (rows.length < 100) return pullRequests;
770
+ }
771
+ throw new Error(
772
+ `open pull request history for ${base} exceeds 1000 rows`,
773
+ );
774
+ },
775
+ async ensureImmutableBranch(ref, sourceSha) {
776
+ const current = await api(
777
+ `/repos/${owner}/${repo}/git/ref/heads/${encodeRef(ref)}`,
778
+ { allow404: true },
779
+ );
780
+ if (current && current.object?.sha !== sourceSha) {
781
+ throw new Error(
782
+ `source-lock branch ${ref} points to ${current.object?.sha}, not ${sourceSha}`,
783
+ );
784
+ }
785
+ if (current) return current;
786
+ return api(`/repos/${owner}/${repo}/git/refs`, {
787
+ method: "POST",
788
+ body: { ref: `refs/heads/${ref}`, sha: sourceSha },
789
+ });
790
+ },
791
+ async ensurePullRequest({ head, base, title, body }) {
792
+ const open = await api(
793
+ `/repos/${owner}/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${head}`)}&base=${encodeURIComponent(base)}&per_page=20`,
794
+ );
795
+ return (
796
+ open[0] ||
797
+ api(`/repos/${owner}/${repo}/pulls`, {
798
+ method: "POST",
799
+ body: { head, base, title, body },
800
+ })
801
+ );
802
+ },
803
+ async updatePullRequestBody(number, body) {
804
+ return api(`/repos/${owner}/${repo}/pulls/${number}`, {
805
+ method: "PATCH",
806
+ body: { body },
807
+ });
808
+ },
809
+ };
810
+ }
811
+
812
+ function markdown(result) {
813
+ return [
814
+ "## Buildchain Dev to Alpha candidate patrol",
815
+ "",
816
+ `Eligible: \`${result.decision.eligible}\` (${result.decision.reason})`,
817
+ `Source: \`${result.decision.source.branch}@${result.decision.source.sha}\``,
818
+ `Target: \`${result.decision.target.branch}@${result.decision.target.sha}\``,
819
+ `Controller state: \`${result.controller.state}\``,
820
+ `Active candidate: ${result.controller.activeCandidate?.url || "none"}`,
821
+ `Next candidate: \`${result.controller.nextCandidate?.sourceSha || "none"}\``,
822
+ `Settlement action: \`${result.controller.settlementAction}\``,
823
+ `Dry run: \`${result.dryRun}\``,
824
+ `Pull request: ${result.pullRequest?.html_url || "not created"}`,
825
+ "",
826
+ ].join("\n");
827
+ }
828
+
829
+ async function main() {
830
+ const options = normalizeDevAlphaPatrolOptions();
831
+ const result = await runDevAlphaCandidatePatrol(options);
832
+ fs.mkdirSync(path.dirname(options.outputPath), { recursive: true });
833
+ fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`);
834
+ const summary = markdown(result);
835
+ if (process.env.GITHUB_STEP_SUMMARY)
836
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
837
+ else process.stdout.write(summary);
838
+ if (process.env.GITHUB_OUTPUT) {
839
+ const outputs = {
840
+ "result-path": options.outputPath,
841
+ eligible: String(result.decision.eligible),
842
+ "selected-sha": result.decision.source.sha,
843
+ "source-lock-ref": result.decision.sourceLockRef || "",
844
+ "promotion-pr": result.pullRequest?.html_url || "",
845
+ "controller-state": result.controller.state,
846
+ "active-candidate-pr": result.controller.activeCandidate?.url || "",
847
+ "next-candidate-sha": result.controller.nextCandidate?.sourceSha || "",
848
+ "settlement-action": result.controller.settlementAction,
849
+ };
850
+ fs.appendFileSync(
851
+ process.env.GITHUB_OUTPUT,
852
+ `${Object.entries(outputs)
853
+ .map(([key, value]) => `${key}=${value}`)
854
+ .join("\n")}\n`,
855
+ );
856
+ }
857
+ }
858
+
859
+ if (import.meta.url === `file://${process.argv[1]}`) {
860
+ main().catch((error) => {
861
+ console.error(error.stack || error.message);
862
+ process.exit(1);
863
+ });
864
+ }