@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.
- package/README.md +1 -0
- package/actions/github-artifact-attestation/README.md +10 -0
- package/actions/promote-buildchain-ref/README.md +7 -0
- package/bin/buildchain.mjs +5 -0
- package/bin/internal/trust-release-cli.mjs +74 -3
- package/dist/site/artifact-schemas.json +5 -1
- package/dist/site/buildchain-contract.json +79 -28
- package/dist/site/buildchain-site.json +110 -27
- package/dist/site/capability-registry.json +8 -7
- package/dist/site/cli-registry.json +12 -0
- package/dist/site/controller-registry.json +40 -4
- package/dist/site/kfd-claims.json +205 -19
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +19 -5
- package/dist/site/node-api-registry.json +22 -9
- package/dist/site/page-registry.json +91 -17
- package/dist/site/public-surface-audit.json +132 -17
- package/dist/site/publication-authority-registry.json +27 -2
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-model.json +2 -1
- package/dist/site/release-passport-check-manifest.json +1 -0
- package/dist/site/release-provenance.json +1 -0
- package/dist/site/schemas/release-passport-v1.schema.json +6 -0
- package/dist/site/site-manifest.json +17 -9
- package/dist/site/workflow-registry.json +91 -9
- package/docs/MAP.md +3 -1
- package/docs/binary-distribution.md +7 -0
- package/docs/cli.md +9 -0
- package/docs/dev-alpha-candidate-patrol.md +51 -13
- package/docs/github-artifact-attestation.md +219 -0
- package/docs/release-passport.md +13 -0
- package/docs/reusable-build-surface.md +6 -0
- package/package.json +2 -1
- package/packages/core/buildchain-contract.js +6 -0
- package/packages/core/buildchain-kfd-claims.js +5 -0
- package/packages/core/buildchain-publication-authority.js +1 -0
- package/packages/core/github-artifact-attestation.js +642 -0
- package/packages/core/index.js +19 -0
- package/packages/core/publication-authority.js +1 -1
- package/packages/core/release-passport-contract.js +2 -0
- package/packages/core/release-passport.js +60 -3
- package/scripts/check-inventory.mjs +4 -0
- package/scripts/create-github-artifact-attestation-policy.mjs +62 -0
- package/scripts/dev-alpha-candidate-patrol.mjs +471 -71
- package/scripts/generate-channel-promotion-workflow.mjs +6 -0
- package/scripts/generate-site-bundle.mjs +12 -0
- package/scripts/publish-github-artifact-attestation-evidence.mjs +201 -0
- package/scripts/release-candidate-resolver.mjs +12 -0
- 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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
workflow,
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 =
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
Object.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|