@kungfu-tech/buildchain 3.0.8 → 3.0.9-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/actions/promote-buildchain-ref/README.md +8 -7
  2. package/bin/buildchain.mjs +34 -33
  3. package/bin/internal/trust-release-release-handlers.mjs +5 -0
  4. package/contracts/fixtures/kfd-adopter-release-v1/kfd-4-perspective.json +16 -0
  5. package/contracts/fixtures/kfd-adopter-release-v1/kfd-4-replay.json +57 -0
  6. package/contracts/release-cut-v1.schema.json +1 -0
  7. package/contracts/release-train-transition-v1.schema.json +1 -0
  8. package/dist/site/agent-index.json +0 -1
  9. package/dist/site/artifact-schemas.json +0 -2
  10. package/dist/site/badge-endpoint-registry.json +9 -9
  11. package/dist/site/badges/v1/kfd-8/aligned.json +1 -1
  12. package/dist/site/badges/v1/kfd-8/declared.json +1 -1
  13. package/dist/site/badges/v1/kfd-8/downgraded.json +1 -1
  14. package/dist/site/badges/v1/kfd-8/draft.json +1 -1
  15. package/dist/site/badges/v1/kfd-8/failed.json +1 -1
  16. package/dist/site/badges/v1/kfd-8/missing.json +1 -1
  17. package/dist/site/badges/v1/kfd-8/passed.json +1 -1
  18. package/dist/site/badges/v1/kfd-8/planned.json +1 -1
  19. package/dist/site/buildchain-contract.json +38 -34
  20. package/dist/site/buildchain-site.json +59 -39
  21. package/dist/site/capability-registry.json +1 -1
  22. package/dist/site/cli-registry.json +8 -7
  23. package/dist/site/controller-registry.json +13 -5
  24. package/dist/site/kfd-claims.json +35 -8
  25. package/dist/site/kfd-upstream-aggregate.json +10 -10
  26. package/dist/site/manual-registry.json +9 -9
  27. package/dist/site/node-api-registry.json +1143 -320
  28. package/dist/site/page-registry.json +46 -26
  29. package/dist/site/public-surface-audit.json +19 -9
  30. package/dist/site/publication-registry.json +4 -4
  31. package/dist/site/release-passport-check-manifest.json +33 -5
  32. package/dist/site/release-provenance.json +2 -1
  33. package/dist/site/schemas/release-passport-v1.schema.json +3 -0
  34. package/dist/site/site-manifest.json +13 -13
  35. package/dist/site/workflow-registry.json +19 -9
  36. package/docs/aws-us-elastic-runner-burst-plane.md +15 -3
  37. package/docs/cli-reference.md +6 -6
  38. package/docs/cli.md +2 -3
  39. package/docs/dev-alpha-candidate-patrol.md +45 -20
  40. package/docs/dev-delivery-warrant.md +12 -2
  41. package/docs/kfd-support.md +7 -7
  42. package/docs/node-api-reference.md +295 -246
  43. package/docs/release-passport.md +25 -12
  44. package/docs/release-train.md +54 -4
  45. package/docs/reusable-build-surface.md +26 -9
  46. package/docs/runtime-train-validation.md +6 -0
  47. package/docs/versioning.md +3 -2
  48. package/package.json +4 -3
  49. package/packages/core/artifact-verification-envelope.js +114 -0
  50. package/packages/core/buildchain-channel-identity.js +119 -0
  51. package/packages/core/buildchain-compatibility-proof.js +32 -0
  52. package/packages/core/buildchain-contract.js +7 -0
  53. package/packages/core/controller-evidence.js +2 -1
  54. package/packages/core/dev-alpha-active-release-train.js +460 -0
  55. package/packages/core/dev-alpha-candidate-selection.js +181 -0
  56. package/packages/core/dev-delivery-warrant.js +15 -4
  57. package/packages/core/index.js +4 -5
  58. package/packages/core/kfd-adopter-manifest.js +390 -0
  59. package/packages/core/kfd-product-gates.js +0 -300
  60. package/packages/core/release-blocker-priority.js +192 -0
  61. package/packages/core/release-passport-contract.js +219 -6
  62. package/packages/core/release-passport.js +139 -242
  63. package/packages/core/release-train.js +220 -0
  64. package/scripts/aws-macos-jit-controller-core.mjs +76 -0
  65. package/scripts/aws-macos-jit-controller.mjs +29 -0
  66. package/scripts/aws-macos-jit-source-rebind.mjs +503 -0
  67. package/scripts/buildchain-channel-router.mjs +18 -2
  68. package/scripts/buildchain-cli-help.mjs +4 -2
  69. package/scripts/buildchain-contract-lock.mjs +11 -1
  70. package/scripts/check-inventory.mjs +4 -4
  71. package/scripts/dev-alpha-candidate-patrol.mjs +196 -201
  72. package/scripts/generate-buildchain-kfd-witnesses.mjs +88 -94
  73. package/scripts/generate-channel-build-workflow.mjs +78 -31
  74. package/scripts/generate-site-bundle.mjs +1 -8
  75. package/scripts/promotion-channel-router.mjs +5 -3
  76. package/scripts/release-train-self-dogfood.mjs +579 -0
  77. package/scripts/runtime-ref-core.mjs +4 -17
  78. package/scripts/site-capability-metadata.mjs +3 -1
  79. package/dist/site/schemas/kfd-support-projection-v1.schema.json +0 -106
@@ -0,0 +1,460 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ import crypto from "node:crypto";
4
+ import {
5
+ createReleaseTrain,
6
+ observeReleaseTrain,
7
+ validateReleaseTrain,
8
+ } from "./release-train.js";
9
+
10
+ export const RELEASE_TRAIN_HOLD_SCHEMA =
11
+ "kungfu-buildchain-release-train-hold/v1";
12
+
13
+ function canonical(value) {
14
+ if (Array.isArray(value)) return value.map(canonical);
15
+ if (value && typeof value === "object") {
16
+ return Object.fromEntries(
17
+ Object.entries(value)
18
+ .sort(([left], [right]) => left.localeCompare(right))
19
+ .map(([key, item]) => [key, canonical(item)]),
20
+ );
21
+ }
22
+ return value;
23
+ }
24
+
25
+ function evidenceRoot(value) {
26
+ return `sha256:${crypto
27
+ .createHash("sha256")
28
+ .update(JSON.stringify(canonical(value)))
29
+ .digest("hex")}`;
30
+ }
31
+
32
+ function sameHold(left, right) {
33
+ return (
34
+ left?.schema === RELEASE_TRAIN_HOLD_SCHEMA &&
35
+ right?.schema === RELEASE_TRAIN_HOLD_SCHEMA &&
36
+ left.trainRoot === right.trainRoot &&
37
+ left.code === right.code &&
38
+ left.observedDevSha === right.observedDevSha &&
39
+ left.observedCandidateSha === right.observedCandidateSha &&
40
+ left.observedCandidateTreeSha === right.observedCandidateTreeSha &&
41
+ left.observedAlphaBaseSha === right.observedAlphaBaseSha &&
42
+ left.observedRuntimeSha === right.observedRuntimeSha
43
+ );
44
+ }
45
+
46
+ function releaseTrainHold({
47
+ train,
48
+ code,
49
+ reason,
50
+ observedDevSha,
51
+ observedCandidateSha,
52
+ observedCandidateTreeSha,
53
+ observedAlphaBaseSha,
54
+ observedRuntimeSha,
55
+ recordedAt,
56
+ priorHold,
57
+ }) {
58
+ const body = {
59
+ schema: RELEASE_TRAIN_HOLD_SCHEMA,
60
+ trainRoot: train.trainRoot,
61
+ cutRoot: train.releaseCut.cutRoot,
62
+ generation: train.releaseCut.generation,
63
+ candidateSha: train.releaseCut.candidateSha,
64
+ candidateTreeSha: train.releaseCut.candidateTreeSha,
65
+ code,
66
+ reason,
67
+ observedDevSha,
68
+ observedCandidateSha,
69
+ observedCandidateTreeSha,
70
+ observedAlphaBaseSha,
71
+ observedRuntimeSha,
72
+ recordedAt,
73
+ };
74
+ if (sameHold(priorHold, body)) return priorHold;
75
+ return { ...body, holdRoot: evidenceRoot(body) };
76
+ }
77
+
78
+ export function observeDevOnce(train, observedDevSha, observedAt) {
79
+ if (
80
+ train.observations.some(
81
+ (observation) => observation.observedDevSha === observedDevSha,
82
+ )
83
+ ) {
84
+ return train;
85
+ }
86
+ return observeReleaseTrain(train, { observedDevSha, observedAt });
87
+ }
88
+
89
+ export async function resolveManagedCandidate({
90
+ client,
91
+ targetBranch,
92
+ repository,
93
+ sourceBranch,
94
+ parseCandidate,
95
+ }) {
96
+ const openPullRequests = await client.listOpenPullRequests(targetBranch);
97
+ const managed = openPullRequests
98
+ .map((pullRequest) => parseCandidate(pullRequest, targetBranch))
99
+ .filter(Boolean);
100
+ for (const candidate of managed) {
101
+ if (
102
+ candidate.state &&
103
+ (candidate.state.repository !== repository ||
104
+ candidate.state.sourceBranch !== sourceBranch)
105
+ ) {
106
+ throw new Error(
107
+ `candidate PR #${candidate.number} state does not bind ${repository} ${sourceBranch}`,
108
+ );
109
+ }
110
+ }
111
+ if (managed.length > 1) {
112
+ throw new Error(
113
+ `multiple open Buildchain candidate PRs target ${targetBranch}: ${managed
114
+ .map((candidate) => `#${candidate.number}`)
115
+ .join(", ")}`,
116
+ );
117
+ }
118
+ return managed[0] || null;
119
+ }
120
+
121
+ export async function cutInitialReleaseTrain({
122
+ options,
123
+ client,
124
+ decision,
125
+ candidate,
126
+ sourceSha,
127
+ targetSha,
128
+ observedSourceHeadSha,
129
+ }) {
130
+ if (!candidate) return null;
131
+ if (!options.buildchainRuntimeSha) {
132
+ throw new Error(
133
+ "buildchainRuntimeSha is required to cut an active Release Train",
134
+ );
135
+ }
136
+ if (!client.resolveCommitTreeSha) {
137
+ throw new Error(
138
+ "active Release Train creation requires candidate tree readback",
139
+ );
140
+ }
141
+ const candidateTreeSha = await client.resolveCommitTreeSha(sourceSha);
142
+ let train = createReleaseTrain({
143
+ repository: options.repository,
144
+ sourceBranch: options.sourceBranch,
145
+ targetBranch: options.targetBranch,
146
+ originDevSha: sourceSha,
147
+ candidateSha: sourceSha,
148
+ candidateTreeSha,
149
+ alphaBaseSha: targetSha,
150
+ buildchainRuntimeSha: options.buildchainRuntimeSha,
151
+ generation: 1,
152
+ authorityRoots: [decision.decisionRoot, candidate.qualificationRoot].sort(),
153
+ createdAt: options.cutCreatedAt || options.now,
154
+ });
155
+ train = observeDevOnce(train, observedSourceHeadSha, options.now);
156
+ if (
157
+ options.expectedCutRoot &&
158
+ train.releaseCut.cutRoot !== options.expectedCutRoot
159
+ ) {
160
+ throw new Error(
161
+ `Release Cut changed between observation and settlement: expected ${options.expectedCutRoot}, observed ${train.releaseCut.cutRoot}`,
162
+ );
163
+ }
164
+ return train;
165
+ }
166
+
167
+ export function reconcileActiveReleaseTrain({
168
+ releaseTrain,
169
+ repository,
170
+ sourceBranch,
171
+ targetBranch,
172
+ activeCandidateSha,
173
+ sourceLockSha,
174
+ candidateTreeSha,
175
+ alphaBaseSha,
176
+ buildchainRuntimeSha,
177
+ observedDevSha,
178
+ observedAt,
179
+ priorHold = null,
180
+ }) {
181
+ const train = observeDevOnce(
182
+ validateReleaseTrain(releaseTrain),
183
+ observedDevSha,
184
+ observedAt,
185
+ );
186
+ if (train.state.status === "superseded") {
187
+ return { status: "superseded", train, hold: null };
188
+ }
189
+ const cut = train.releaseCut;
190
+ const checks = [
191
+ [
192
+ cut.repository !== repository ||
193
+ cut.sourceBranch !== sourceBranch ||
194
+ cut.targetBranch !== targetBranch,
195
+ "invalid-authority",
196
+ "the active Release Cut route no longer matches the controller route",
197
+ ],
198
+ [
199
+ activeCandidateSha !== cut.candidateSha ||
200
+ sourceLockSha !== cut.candidateSha,
201
+ "candidate-ref-drift",
202
+ "the managed candidate PR or source-lock ref drifted from the frozen candidate",
203
+ ],
204
+ [
205
+ candidateTreeSha !== cut.candidateTreeSha,
206
+ "candidate-tree-drift",
207
+ "the frozen candidate tree readback does not match the Release Cut",
208
+ ],
209
+ [
210
+ alphaBaseSha !== cut.alphaBaseSha,
211
+ "alpha-base-drift",
212
+ "the Alpha base moved after the Release Cut and requires explicit supersession or repair",
213
+ ],
214
+ [
215
+ buildchainRuntimeSha !== cut.buildchainRuntimeSha,
216
+ "runtime-drift",
217
+ "the Buildchain runtime moved after the Release Cut and requires explicit supersession or repair",
218
+ ],
219
+ ];
220
+ const failure = checks.find(([failed]) => failed);
221
+ if (failure) {
222
+ const [, code, reason] = failure;
223
+ return {
224
+ status: "held",
225
+ train,
226
+ hold: releaseTrainHold({
227
+ train,
228
+ code,
229
+ reason,
230
+ observedDevSha,
231
+ observedCandidateSha: sourceLockSha,
232
+ observedCandidateTreeSha: candidateTreeSha,
233
+ observedAlphaBaseSha: alphaBaseSha,
234
+ observedRuntimeSha: buildchainRuntimeSha,
235
+ recordedAt: observedAt,
236
+ priorHold,
237
+ }),
238
+ };
239
+ }
240
+ return {
241
+ status: "active",
242
+ train,
243
+ hold: null,
244
+ drift: {
245
+ observedDevSha,
246
+ originDevSha: cut.originDevSha,
247
+ moved: observedDevSha !== cut.originDevSha,
248
+ observationRoot:
249
+ train.observations.find(
250
+ (observation) => observation.observedDevSha === observedDevSha,
251
+ )?.observationRoot || null,
252
+ },
253
+ };
254
+ }
255
+
256
+ function activeTrainDecision({
257
+ options,
258
+ candidate,
259
+ reconciliation,
260
+ observedSourceHeadSha,
261
+ targetSha,
262
+ }) {
263
+ const cut = reconciliation.train.releaseCut;
264
+ const eligible = reconciliation.status === "active";
265
+ const body = {
266
+ schema: "kungfu-buildchain-channel-candidate-decision/v1",
267
+ eligible,
268
+ reason: eligible
269
+ ? "active-release-train"
270
+ : reconciliation.status === "superseded"
271
+ ? "active-release-train-superseded"
272
+ : "active-release-train-held",
273
+ repository: options.repository,
274
+ source: { branch: options.sourceBranch, sha: cut.candidateSha },
275
+ target: { branch: options.targetBranch, sha: targetSha },
276
+ comparison: { status: "frozen-release-cut", aheadBy: 0 },
277
+ selection: {
278
+ mode: "active-release-train",
279
+ observedSourceHeadSha,
280
+ skippedNewerCommitCount: 0,
281
+ trainRoot: reconciliation.train.trainRoot,
282
+ cutRoot: cut.cutRoot,
283
+ generation: cut.generation,
284
+ candidateTreeSha: cut.candidateTreeSha,
285
+ },
286
+ workflowEvidence: [],
287
+ requiredWorkflowPaths: [],
288
+ sourceLockRef: candidate.sourceLockRef,
289
+ ...(reconciliation.hold
290
+ ? {
291
+ blockReason: reconciliation.hold.reason,
292
+ holdRoot: reconciliation.hold.holdRoot,
293
+ }
294
+ : {}),
295
+ decidedAt: options.now,
296
+ };
297
+ return { ...body, decisionRoot: evidenceRoot(body) };
298
+ }
299
+
300
+ function candidateIdentity(candidate) {
301
+ return {
302
+ sourceSha: candidate.sourceSha,
303
+ sourceLockRef: candidate.sourceLockRef,
304
+ decisionRoot: candidate.decisionRoot || null,
305
+ pullRequestNumber: candidate.number,
306
+ pullRequestUrl: candidate.url,
307
+ };
308
+ }
309
+
310
+ function semanticState(value) {
311
+ if (!value) return null;
312
+ const normalized = { ...value };
313
+ delete normalized.observedAt;
314
+ delete normalized.observationDecisionRoot;
315
+ delete normalized.stateRoot;
316
+ delete normalized.priorStateRoot;
317
+ return normalized;
318
+ }
319
+
320
+ export async function runActiveReleaseTrain({
321
+ options,
322
+ client,
323
+ activeCandidate,
324
+ adapters,
325
+ }) {
326
+ const [observedSourceHeadSha, targetSha, sourceLockSha] = await Promise.all([
327
+ client.resolveBranch(options.sourceBranch),
328
+ client.resolveBranch(options.targetBranch),
329
+ client.resolveBranch(activeCandidate.sourceLockRef),
330
+ ]);
331
+ if (!client.resolveCommitTreeSha) {
332
+ throw new Error(
333
+ "active Release Train validation requires candidate tree readback",
334
+ );
335
+ }
336
+ const candidateTreeSha = await client.resolveCommitTreeSha(
337
+ activeCandidate.sourceSha,
338
+ );
339
+ const reconciliation = reconcileActiveReleaseTrain({
340
+ releaseTrain: activeCandidate.releaseTrain,
341
+ repository: options.repository,
342
+ sourceBranch: options.sourceBranch,
343
+ targetBranch: options.targetBranch,
344
+ activeCandidateSha: activeCandidate.sourceSha,
345
+ sourceLockSha,
346
+ candidateTreeSha,
347
+ alphaBaseSha: targetSha,
348
+ buildchainRuntimeSha: options.buildchainRuntimeSha,
349
+ observedDevSha: observedSourceHeadSha,
350
+ observedAt: options.now,
351
+ priorHold: activeCandidate.hold,
352
+ });
353
+ if (
354
+ options.expectedCutRoot &&
355
+ reconciliation.train.releaseCut.cutRoot !== options.expectedCutRoot
356
+ ) {
357
+ throw new Error(
358
+ `active Release Cut changed between observation and settlement: expected ${options.expectedCutRoot}, observed ${reconciliation.train.releaseCut.cutRoot}`,
359
+ );
360
+ }
361
+ const decision = activeTrainDecision({
362
+ options,
363
+ candidate: activeCandidate,
364
+ reconciliation,
365
+ observedSourceHeadSha,
366
+ targetSha,
367
+ });
368
+ if (
369
+ options.expectedSelectedSha &&
370
+ decision.source.sha !== options.expectedSelectedSha
371
+ ) {
372
+ throw new Error(
373
+ `selected source changed between observation and settlement: expected ${options.expectedSelectedSha}, observed ${decision.source.sha}`,
374
+ );
375
+ }
376
+ const priorStateRoot = adapters.persistedStateRoot(activeCandidate);
377
+ if (
378
+ options.expectedPriorStateRoot &&
379
+ options.expectedPriorStateRoot !== priorStateRoot
380
+ ) {
381
+ throw new Error(
382
+ `candidate controller compare-and-swap failed: expected prior state root ${options.expectedPriorStateRoot}, observed ${priorStateRoot}`,
383
+ );
384
+ }
385
+ const desiredState = adapters.candidateStateBody({
386
+ options,
387
+ targetSha,
388
+ decision,
389
+ activeCandidate: candidateIdentity(activeCandidate),
390
+ nextCandidate: null,
391
+ supersededCandidate: null,
392
+ priorStateRoot,
393
+ generation: reconciliation.train.releaseCut.generation,
394
+ tombstones: activeCandidate.state?.tombstones || [],
395
+ releaseTrain: reconciliation.train,
396
+ hold: reconciliation.hold,
397
+ });
398
+ const transitionNeeded =
399
+ !activeCandidate.state ||
400
+ JSON.stringify(canonical(semanticState(activeCandidate.state))) !==
401
+ JSON.stringify(canonical(semanticState(desiredState)));
402
+ const state = transitionNeeded ? desiredState : activeCandidate.state;
403
+ let settlementAction = "none";
404
+ if (transitionNeeded && options.settlementAuthorized && !options.dryRun) {
405
+ const nextBody = adapters.replaceCandidateStateMarker(
406
+ activeCandidate.body,
407
+ state,
408
+ );
409
+ await client.updatePullRequestBody(
410
+ activeCandidate.number,
411
+ nextBody,
412
+ priorStateRoot,
413
+ );
414
+ settlementAction =
415
+ reconciliation.status === "held"
416
+ ? "hold-active-release-train"
417
+ : reconciliation.status === "superseded"
418
+ ? "record-superseded-release-train"
419
+ : "observe-active-release-train";
420
+ }
421
+ const pullRequest = adapters.activePullRequest(activeCandidate);
422
+ const autoMerge = await adapters.armCandidateAutoMerge(
423
+ options,
424
+ reconciliation.status,
425
+ pullRequest,
426
+ client,
427
+ );
428
+ return {
429
+ schema: "kungfu-buildchain-dev-alpha-candidate-patrol/v1",
430
+ dryRun: options.dryRun,
431
+ createPullRequest: options.createPullRequest,
432
+ settlementAuthorized: options.settlementAuthorized,
433
+ autoMerge,
434
+ decision,
435
+ releaseTrain: reconciliation.train,
436
+ drift: reconciliation.drift || null,
437
+ hold: reconciliation.hold,
438
+ controller: {
439
+ schema: adapters.stateSchema,
440
+ state: reconciliation.status,
441
+ activeCandidate,
442
+ nextCandidate: null,
443
+ supersededCandidate: null,
444
+ settlementAction,
445
+ stateRoot: state.stateRoot,
446
+ priorStateRoot,
447
+ generation: reconciliation.train.releaseCut.generation,
448
+ trainRoot: reconciliation.train.trainRoot,
449
+ cutRoot: reconciliation.train.releaseCut.cutRoot,
450
+ candidateTreeSha: reconciliation.train.releaseCut.candidateTreeSha,
451
+ buildchainRuntimeSha:
452
+ reconciliation.train.releaseCut.buildchainRuntimeSha,
453
+ cutCreatedAt: reconciliation.train.releaseCut.createdAt,
454
+ observedAt: options.now,
455
+ holdRoot: reconciliation.hold?.holdRoot || null,
456
+ tombstones: state.tombstones || [],
457
+ },
458
+ pullRequest,
459
+ };
460
+ }
@@ -0,0 +1,181 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ import crypto from "node:crypto";
4
+
5
+ function text(value = "") {
6
+ return String(value ?? "").trim();
7
+ }
8
+
9
+ function canonical(value) {
10
+ if (Array.isArray(value)) return value.map(canonical);
11
+ if (value && typeof value === "object") {
12
+ return Object.fromEntries(
13
+ Object.entries(value)
14
+ .sort(([left], [right]) => left.localeCompare(right))
15
+ .map(([key, item]) => [key, canonical(item)]),
16
+ );
17
+ }
18
+ return value;
19
+ }
20
+
21
+ function evidenceRoot(value) {
22
+ return `sha256:${crypto
23
+ .createHash("sha256")
24
+ .update(JSON.stringify(canonical(value)))
25
+ .digest("hex")}`;
26
+ }
27
+
28
+ function latestWorkflowEvidence(runs, workflowPath, sourceSha) {
29
+ const matching = runs
30
+ .filter(
31
+ (run) =>
32
+ run.conclusion !== "cancelled" &&
33
+ run.path === workflowPath &&
34
+ run.head_sha === sourceSha,
35
+ )
36
+ .sort((left, right) => Number(right.id) - Number(left.id));
37
+ if (matching.length === 0) {
38
+ throw new Error(`missing completed same-SHA workflow run: ${workflowPath}`);
39
+ }
40
+ const run = matching[0];
41
+ return {
42
+ workflowPath,
43
+ workflowName: run.name,
44
+ runId: run.id,
45
+ runAttempt: run.run_attempt,
46
+ headSha: run.head_sha,
47
+ status: run.status,
48
+ conclusion: run.conclusion,
49
+ completedAt: run.updated_at,
50
+ url: run.html_url,
51
+ };
52
+ }
53
+
54
+ function workflowEvidenceIsFreshAndSuccessful(run, { now, maxAgeSeconds }) {
55
+ const completedAt = Date.parse(run.updated_at);
56
+ const ageSeconds = (Date.parse(now) - completedAt) / 1000;
57
+ return (
58
+ run.status === "completed" &&
59
+ run.conclusion === "success" &&
60
+ Number.isFinite(ageSeconds) &&
61
+ ageSeconds >= 0 &&
62
+ ageSeconds <= maxAgeSeconds
63
+ );
64
+ }
65
+
66
+ function latestRunsBySha(runs, workflowPath) {
67
+ const latest = new Map();
68
+ for (const run of runs.filter(
69
+ (row) => row.path === workflowPath && row.conclusion !== "cancelled",
70
+ )) {
71
+ const current = latest.get(run.head_sha);
72
+ if (!current || Number(run.id) > Number(current.id)) {
73
+ latest.set(run.head_sha, run);
74
+ }
75
+ }
76
+ return latest;
77
+ }
78
+
79
+ export function selectLatestQualifiedSource({
80
+ sourceHistory,
81
+ workflowRunsByPath,
82
+ requiredWorkflowPaths,
83
+ now,
84
+ maxAgeSeconds,
85
+ }) {
86
+ const latestByPath = new Map(
87
+ requiredWorkflowPaths.map((workflow) => [
88
+ workflow,
89
+ latestRunsBySha(workflowRunsByPath.get(workflow) || [], workflow),
90
+ ]),
91
+ );
92
+ for (let index = 0; index < sourceHistory.length; index += 1) {
93
+ const sourceSha = sourceHistory[index];
94
+ const rows = requiredWorkflowPaths.map((workflow) =>
95
+ latestByPath.get(workflow).get(sourceSha),
96
+ );
97
+ if (
98
+ rows.every((run) =>
99
+ workflowEvidenceIsFreshAndSuccessful(run || {}, {
100
+ now,
101
+ maxAgeSeconds,
102
+ }),
103
+ )
104
+ ) {
105
+ return {
106
+ sourceSha,
107
+ skippedNewerCommitCount: index,
108
+ workflowEvidence: requiredWorkflowPaths.map((workflow) =>
109
+ latestWorkflowEvidence(
110
+ workflowRunsByPath.get(workflow) || [],
111
+ workflow,
112
+ sourceSha,
113
+ ),
114
+ ),
115
+ };
116
+ }
117
+ }
118
+ const staleSuccessfulPair = sourceHistory.some((sourceSha) => {
119
+ const rows = requiredWorkflowPaths.map((workflow) =>
120
+ latestByPath.get(workflow).get(sourceSha),
121
+ );
122
+ return (
123
+ rows.every(
124
+ (run) =>
125
+ run?.status === "completed" &&
126
+ run?.conclusion === "success" &&
127
+ run?.head_sha === sourceSha,
128
+ ) &&
129
+ rows.some(
130
+ (run) =>
131
+ !workflowEvidenceIsFreshAndSuccessful(run, { now, maxAgeSeconds }),
132
+ )
133
+ );
134
+ });
135
+ if (staleSuccessfulPair) {
136
+ throw new Error(
137
+ "same-SHA workflow evidence is stale for every qualified source commit",
138
+ );
139
+ }
140
+ throw new Error(
141
+ "no source commit ahead of target has fresh completed successful same-SHA workflow evidence",
142
+ );
143
+ }
144
+
145
+ export function blockedCandidateDecision({
146
+ options,
147
+ sourceSha,
148
+ targetSha,
149
+ comparison,
150
+ reason,
151
+ }) {
152
+ const body = {
153
+ schema: "kungfu-buildchain-channel-candidate-decision/v1",
154
+ eligible: false,
155
+ reason: "qualification-evidence-blocked",
156
+ repository: options.repository,
157
+ source: { branch: options.sourceBranch, sha: sourceSha },
158
+ target: { branch: options.targetBranch, sha: targetSha },
159
+ comparison: {
160
+ status: text(comparison.status || "unknown"),
161
+ aheadBy: Number(comparison.ahead_by || 0),
162
+ },
163
+ blockReason: text(reason),
164
+ decidedAt: options.now,
165
+ };
166
+ return { ...body, decisionRoot: evidenceRoot(body) };
167
+ }
168
+
169
+ export function candidateFromDecision(decision) {
170
+ if (!decision.eligible) return null;
171
+ return {
172
+ sourceSha: decision.source.sha,
173
+ sourceLockRef: decision.sourceLockRef,
174
+ decisionRoot: decision.decisionRoot,
175
+ workflowEvidence: decision.workflowEvidence,
176
+ qualificationRoot: evidenceRoot({
177
+ sourceSha: decision.source.sha,
178
+ workflowEvidence: decision.workflowEvidence,
179
+ }),
180
+ };
181
+ }