@kungfu-tech/buildchain 3.0.5 → 3.0.6-alpha.0
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 +31 -0
- package/actions/macos-credential-island/README.md +8 -0
- package/bin/buildchain.mjs +1 -1
- package/contracts/auditable-demo-media-profiles-v1.json +61 -0
- package/contracts/auditable-demo-scenario-v1.schema.json +164 -0
- package/contracts/evidence/auditable-demo-responsive-web-delivery-v1.json +3 -3
- package/contracts/evidence/auditable-demo-web-delivery-v1.json +3 -3
- package/dist/site/buildchain-contract.json +107 -26
- package/dist/site/buildchain-site.json +121 -49
- package/dist/site/capability-registry.json +3 -3
- package/dist/site/cli-registry.json +9 -3
- package/dist/site/controller-registry.json +27 -3
- package/dist/site/kfd-claims.json +101 -15
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +10 -9
- package/dist/site/node-api-registry.json +540 -31
- package/dist/site/page-registry.json +105 -29
- package/dist/site/public-surface-audit.json +184 -11
- package/dist/site/publication-authority-registry.json +62 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +13 -13
- package/dist/site/workflow-registry.json +118 -4
- package/docs/MAP.md +4 -1
- package/docs/auditable-demo.md +90 -7
- package/docs/aws-us-elastic-runner-burst-plane.md +151 -16
- package/docs/cli-reference.md +35 -2
- package/docs/dev-alpha-candidate-patrol.md +14 -5
- package/docs/dev-qualification-patrol.md +108 -0
- package/docs/node-api-reference.md +123 -72
- package/docs/publish-transaction.md +7 -5
- package/docs/release-flow.md +4 -0
- package/docs/release-governance.md +39 -0
- package/docs/release-propagation.md +93 -0
- package/docs/reusable-build-surface.md +53 -12
- package/docs/stable-candidate-patrol.md +5 -2
- package/package.json +1 -1
- package/packages/core/buildchain-contract.js +36 -1
- package/packages/core/buildchain-publication-authority.js +3 -0
- package/packages/core/public-surface-audit.js +4 -35
- package/packages/core/release-propagation-agent-entry.js +185 -0
- package/packages/core/release-propagation-pickup.js +387 -0
- package/packages/core/release-propagation-push.js +293 -0
- package/packages/core/release-propagation-release.js +61 -18
- package/packages/core/release-propagation-stage-evidence.js +36 -0
- package/packages/core/release-propagation-work.js +8 -4
- package/packages/core/release-propagation.js +27 -2
- package/packages/core/workflow-call-contract.js +308 -0
- package/packages/core/workflow-yaml-contract.js +272 -0
- package/scripts/artifact-signing-controller-core.mjs +525 -0
- package/scripts/artifact-signing-controller.mjs +302 -0
- package/scripts/artifact-signing-delegation.mjs +51 -3
- package/scripts/auditable-demo-platform.mjs +564 -0
- package/scripts/auditable-demo-renditions.mjs +7 -2
- package/scripts/auditable-demo-transport-smoke.mjs +176 -0
- package/scripts/auditable-demo.mjs +27 -21
- package/scripts/aws-macos-jit-controller-core.mjs +389 -0
- package/scripts/aws-macos-jit-controller-runtime.mjs +82 -0
- package/scripts/aws-macos-jit-controller.mjs +558 -0
- package/scripts/aws-macos-jit-job-controller.mjs +401 -0
- package/scripts/aws-windows-jit-campaign-core.mjs +296 -0
- package/scripts/aws-windows-jit-campaign.mjs +202 -0
- package/scripts/aws-windows-jit-controller-core.mjs +20 -2
- package/scripts/aws-windows-jit-controller.mjs +184 -109
- package/scripts/aws-windows-jit-core.mjs +21 -3
- package/scripts/aws-windows-jit.mjs +2 -0
- package/scripts/build-standalone-binary.mjs +6 -0
- package/scripts/buildchain-cli-help.mjs +2 -1
- package/scripts/capture-package-release-propagation.mjs +24 -1
- package/scripts/check-inventory.mjs +6 -0
- package/scripts/compiler-cache-evidence.mjs +90 -7
- package/scripts/dev-alpha-candidate-patrol.mjs +80 -5
- package/scripts/dev-pr-auto-merge.mjs +4 -4
- package/scripts/dev-qualification-patrol.mjs +594 -0
- package/scripts/dispatch-artifact-signing-authority.mjs +312 -61
- package/scripts/finalize-native-artifact-signing-result.mjs +37 -15
- package/scripts/generate-site-bundle.mjs +5 -0
- package/scripts/inspect-artifact-signing-requests.mjs +12 -0
- package/scripts/locked-source-checkout.mjs +17 -3
- package/scripts/release-propagation.mjs +150 -2
- package/scripts/run-lifecycle-core.mjs +25 -0
- package/scripts/site-capability-metadata.mjs +1 -1
- package/scripts/stable-candidate-patrol.mjs +30 -4
- package/scripts/workflow-call-contract.mjs +133 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
const EXACT_SHA = /^[0-9a-f]{40}$/u;
|
|
9
|
+
const ACTIVE_STATUSES = new Set([
|
|
10
|
+
"queued",
|
|
11
|
+
"in_progress",
|
|
12
|
+
"pending",
|
|
13
|
+
"waiting",
|
|
14
|
+
"requested",
|
|
15
|
+
]);
|
|
16
|
+
const RETRYABLE_CONCLUSIONS = new Set([
|
|
17
|
+
"cancelled",
|
|
18
|
+
"timed_out",
|
|
19
|
+
"startup_failure",
|
|
20
|
+
]);
|
|
21
|
+
const RETRYABLE_STEP =
|
|
22
|
+
/^(Checkout |Download |Upload |Setup |Expose |Reset prior Windows Gate source workspace)/u;
|
|
23
|
+
const DETERMINISTIC_STEP =
|
|
24
|
+
/^(Run and validate Shifu Gate profile|Enforce Shifu Gate qualification|Aggregate Shifu Gate receipts|Enforce aggregate qualification|Create Shifu profile envelope controller receipt)/u;
|
|
25
|
+
|
|
26
|
+
function text(value = "") {
|
|
27
|
+
return String(value ?? "").trim();
|
|
28
|
+
}
|
|
29
|
+
function bool(value, fallback = false) {
|
|
30
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
31
|
+
return ["1", "true", "yes", "on"].includes(text(value).toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
function integer(value, fallback) {
|
|
34
|
+
const parsed = Number(value);
|
|
35
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
36
|
+
}
|
|
37
|
+
function repository(value) {
|
|
38
|
+
const normalized = text(value);
|
|
39
|
+
if (!/^[^/\s]+\/[^/\s]+$/u.test(normalized)) {
|
|
40
|
+
throw new Error(`repository must be owner/repo, got ${value || "<empty>"}`);
|
|
41
|
+
}
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
function branch(value, name) {
|
|
45
|
+
const normalized = text(value).replace(/^refs\/heads\//u, "");
|
|
46
|
+
if (
|
|
47
|
+
!normalized ||
|
|
48
|
+
normalized.startsWith("-") ||
|
|
49
|
+
/[\s~^:?*[\\]/u.test(normalized)
|
|
50
|
+
) {
|
|
51
|
+
throw new Error(`${name} is not a valid branch name`);
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
function workflowPath(value, name) {
|
|
56
|
+
const normalized = text(value);
|
|
57
|
+
if (!/^\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml$/u.test(normalized)) {
|
|
58
|
+
throw new Error(`${name} must be a repository workflow path`);
|
|
59
|
+
}
|
|
60
|
+
return normalized;
|
|
61
|
+
}
|
|
62
|
+
function workflowPaths(value, name) {
|
|
63
|
+
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
64
|
+
if (!Array.isArray(parsed)) throw new Error(`${name} must be a JSON array`);
|
|
65
|
+
return [...new Set(parsed.map((item) => workflowPath(item, name)))];
|
|
66
|
+
}
|
|
67
|
+
function dispatchInputs(value) {
|
|
68
|
+
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
|
69
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
70
|
+
throw new Error("dispatchInputs must be a JSON object");
|
|
71
|
+
}
|
|
72
|
+
return Object.fromEntries(
|
|
73
|
+
Object.entries(parsed).map(([key, item]) => {
|
|
74
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(key))
|
|
75
|
+
throw new Error(`invalid dispatch input ${key}`);
|
|
76
|
+
if (!["string", "number", "boolean"].includes(typeof item)) {
|
|
77
|
+
throw new Error(`dispatch input ${key} must be scalar`);
|
|
78
|
+
}
|
|
79
|
+
return [key, String(item)];
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
function canonical(value) {
|
|
84
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
85
|
+
if (value && typeof value === "object") {
|
|
86
|
+
return Object.fromEntries(
|
|
87
|
+
Object.entries(value)
|
|
88
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
89
|
+
.map(([key, item]) => [key, canonical(item)]),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
function evidenceRoot(value) {
|
|
95
|
+
return `sha256:${crypto
|
|
96
|
+
.createHash("sha256")
|
|
97
|
+
.update(JSON.stringify(canonical(value)))
|
|
98
|
+
.digest("hex")}`;
|
|
99
|
+
}
|
|
100
|
+
function newest(runs) {
|
|
101
|
+
return [...runs].sort((left, right) => {
|
|
102
|
+
const time = text(
|
|
103
|
+
right.created_at || right.run_started_at || right.updated_at,
|
|
104
|
+
).localeCompare(
|
|
105
|
+
text(left.created_at || left.run_started_at || left.updated_at),
|
|
106
|
+
);
|
|
107
|
+
return time || Number(right.id || 0) - Number(left.id || 0);
|
|
108
|
+
})[0];
|
|
109
|
+
}
|
|
110
|
+
function runEvidence(run) {
|
|
111
|
+
if (!run) return null;
|
|
112
|
+
return {
|
|
113
|
+
id: Number(run.id),
|
|
114
|
+
attempt: Number(run.run_attempt || 1),
|
|
115
|
+
headSha: text(run.head_sha),
|
|
116
|
+
status: text(run.status),
|
|
117
|
+
conclusion: text(run.conclusion),
|
|
118
|
+
workflowPath: text(run.path),
|
|
119
|
+
url: text(run.html_url),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function classifyRetryableFailure(run, jobs = []) {
|
|
123
|
+
const conclusion = text(run?.conclusion);
|
|
124
|
+
if (RETRYABLE_CONCLUSIONS.has(conclusion)) {
|
|
125
|
+
return { retryable: true, reason: `run-${conclusion}` };
|
|
126
|
+
}
|
|
127
|
+
if (conclusion !== "failure") {
|
|
128
|
+
return { retryable: false, reason: `run-${conclusion || "unknown"}` };
|
|
129
|
+
}
|
|
130
|
+
const failedJobs = jobs.filter((job) =>
|
|
131
|
+
["failure", "cancelled", "timed_out", "startup_failure"].includes(
|
|
132
|
+
text(job.conclusion),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
const rootJobs = failedJobs.some(
|
|
136
|
+
(job) => !/Gate profile \/ aggregate$/u.test(text(job.name)),
|
|
137
|
+
)
|
|
138
|
+
? failedJobs.filter(
|
|
139
|
+
(job) => !/Gate profile \/ aggregate$/u.test(text(job.name)),
|
|
140
|
+
)
|
|
141
|
+
: failedJobs;
|
|
142
|
+
if (rootJobs.length === 0) {
|
|
143
|
+
return { retryable: true, reason: "runner-ended-without-failed-job" };
|
|
144
|
+
}
|
|
145
|
+
const failedSteps = rootJobs.flatMap((job) =>
|
|
146
|
+
(job.steps || []).filter((step) => text(step.conclusion) === "failure"),
|
|
147
|
+
);
|
|
148
|
+
if (failedSteps.some((step) => DETERMINISTIC_STEP.test(text(step.name)))) {
|
|
149
|
+
return { retryable: false, reason: "deterministic-qualification-step" };
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
failedSteps.length > 0 &&
|
|
153
|
+
failedSteps.every((step) => RETRYABLE_STEP.test(text(step.name)))
|
|
154
|
+
) {
|
|
155
|
+
return { retryable: true, reason: "external-boundary-step" };
|
|
156
|
+
}
|
|
157
|
+
if (
|
|
158
|
+
failedSteps.length === 0 &&
|
|
159
|
+
rootJobs.every((job) => RETRYABLE_CONCLUSIONS.has(text(job.conclusion)))
|
|
160
|
+
) {
|
|
161
|
+
return { retryable: true, reason: "runner-job-interrupted" };
|
|
162
|
+
}
|
|
163
|
+
return { retryable: false, reason: "unclassified-failure" };
|
|
164
|
+
}
|
|
165
|
+
export function normalizeDevQualificationOptions(options = {}) {
|
|
166
|
+
return {
|
|
167
|
+
repository: repository(
|
|
168
|
+
options.repository ??
|
|
169
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_REPOSITORY ??
|
|
170
|
+
process.env.GITHUB_REPOSITORY,
|
|
171
|
+
),
|
|
172
|
+
sourceBranch: branch(
|
|
173
|
+
options.sourceBranch ??
|
|
174
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_SOURCE_BRANCH ??
|
|
175
|
+
"dev/v4/v4.0",
|
|
176
|
+
"sourceBranch",
|
|
177
|
+
),
|
|
178
|
+
devWorkflowPath: workflowPath(
|
|
179
|
+
options.devWorkflowPath ??
|
|
180
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_WORKFLOW ??
|
|
181
|
+
".github/workflows/dev-verify-patrol.yml",
|
|
182
|
+
"devWorkflowPath",
|
|
183
|
+
),
|
|
184
|
+
preflightWorkflowPath: workflowPath(
|
|
185
|
+
options.preflightWorkflowPath ??
|
|
186
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_PREFLIGHT_WORKFLOW ??
|
|
187
|
+
".github/workflows/alpha-promotion-preflight.yml",
|
|
188
|
+
"preflightWorkflowPath",
|
|
189
|
+
),
|
|
190
|
+
priorityWorkflowPaths: workflowPaths(
|
|
191
|
+
options.priorityWorkflowPaths ??
|
|
192
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_PRIORITY_WORKFLOWS ??
|
|
193
|
+
"[]",
|
|
194
|
+
"priorityWorkflowPaths",
|
|
195
|
+
),
|
|
196
|
+
dispatchInputs: dispatchInputs(
|
|
197
|
+
options.dispatchInputs ??
|
|
198
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_DISPATCH_INPUTS ??
|
|
199
|
+
{},
|
|
200
|
+
),
|
|
201
|
+
maxAttempts: integer(
|
|
202
|
+
options.maxAttempts ??
|
|
203
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_MAX_ATTEMPTS,
|
|
204
|
+
2,
|
|
205
|
+
),
|
|
206
|
+
mutationAuthorized: bool(
|
|
207
|
+
options.mutationAuthorized ??
|
|
208
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_MUTATION_AUTHORIZED,
|
|
209
|
+
false,
|
|
210
|
+
),
|
|
211
|
+
expectedAction: text(
|
|
212
|
+
options.expectedAction ??
|
|
213
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_EXPECTED_ACTION,
|
|
214
|
+
),
|
|
215
|
+
expectedSourceSha: text(
|
|
216
|
+
options.expectedSourceSha ??
|
|
217
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_EXPECTED_SOURCE_SHA,
|
|
218
|
+
),
|
|
219
|
+
outputPath: text(
|
|
220
|
+
options.outputPath ??
|
|
221
|
+
process.env.BUILDCHAIN_DEV_QUALIFICATION_OUTPUT_PATH ??
|
|
222
|
+
".buildchain/patrol/dev-qualification.json",
|
|
223
|
+
),
|
|
224
|
+
now:
|
|
225
|
+
text(options.now ?? process.env.BUILDCHAIN_DEV_QUALIFICATION_NOW) ||
|
|
226
|
+
new Date().toISOString(),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function decisionBody(options, fields) {
|
|
230
|
+
const body = {
|
|
231
|
+
schema: "kungfu-buildchain-dev-qualification-patrol/v1",
|
|
232
|
+
repository: options.repository,
|
|
233
|
+
sourceBranch: options.sourceBranch,
|
|
234
|
+
observedAt: options.now,
|
|
235
|
+
...fields,
|
|
236
|
+
};
|
|
237
|
+
return { ...body, decisionRoot: evidenceRoot(body) };
|
|
238
|
+
}
|
|
239
|
+
function decideObservedState({
|
|
240
|
+
sourceSha,
|
|
241
|
+
latestExactRun,
|
|
242
|
+
activeRun,
|
|
243
|
+
successfulPreflight,
|
|
244
|
+
priorityRuns,
|
|
245
|
+
}) {
|
|
246
|
+
const common = {
|
|
247
|
+
sourceSha,
|
|
248
|
+
qualificationRun: runEvidence(latestExactRun),
|
|
249
|
+
preflightRun: runEvidence(successfulPreflight),
|
|
250
|
+
};
|
|
251
|
+
if (
|
|
252
|
+
latestExactRun?.status === "completed" &&
|
|
253
|
+
latestExactRun?.conclusion === "success"
|
|
254
|
+
) {
|
|
255
|
+
return {
|
|
256
|
+
...common,
|
|
257
|
+
state: "qualified",
|
|
258
|
+
action: "none",
|
|
259
|
+
reason: "latest-source-already-qualified",
|
|
260
|
+
pendingSha: null,
|
|
261
|
+
activeRun: null,
|
|
262
|
+
priorityRuns: priorityRuns.map(runEvidence),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (priorityRuns.length > 0) {
|
|
266
|
+
return {
|
|
267
|
+
...common,
|
|
268
|
+
state: "waiting-priority",
|
|
269
|
+
action: "none",
|
|
270
|
+
reason: "alpha-or-release-workflow-active",
|
|
271
|
+
pendingSha: sourceSha,
|
|
272
|
+
activeRun: runEvidence(activeRun),
|
|
273
|
+
priorityRuns: priorityRuns.map(runEvidence),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (activeRun) {
|
|
277
|
+
const activeIsLatest = text(activeRun.head_sha) === sourceSha;
|
|
278
|
+
return {
|
|
279
|
+
...common,
|
|
280
|
+
state: "running",
|
|
281
|
+
action: "none",
|
|
282
|
+
reason: activeIsLatest
|
|
283
|
+
? "latest-source-running"
|
|
284
|
+
: "newer-source-coalesced",
|
|
285
|
+
pendingSha: activeIsLatest ? null : sourceSha,
|
|
286
|
+
activeRun: runEvidence(activeRun),
|
|
287
|
+
priorityRuns: [],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (!successfulPreflight) {
|
|
291
|
+
return {
|
|
292
|
+
...common,
|
|
293
|
+
state: "waiting-preflight",
|
|
294
|
+
action: "none",
|
|
295
|
+
reason: "latest-source-preflight-not-qualified",
|
|
296
|
+
pendingSha: sourceSha,
|
|
297
|
+
activeRun: null,
|
|
298
|
+
preflightRun: null,
|
|
299
|
+
priorityRuns: [],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (
|
|
303
|
+
latestExactRun?.status === "completed" &&
|
|
304
|
+
latestExactRun?.conclusion !== "success"
|
|
305
|
+
) {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
...common,
|
|
310
|
+
state: "dispatch-ready",
|
|
311
|
+
action: "dispatch",
|
|
312
|
+
reason: "latest-source-preflight-qualified",
|
|
313
|
+
pendingSha: sourceSha,
|
|
314
|
+
activeRun: null,
|
|
315
|
+
qualificationRun: null,
|
|
316
|
+
priorityRuns: [],
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
async function decideFailedRun({
|
|
320
|
+
options,
|
|
321
|
+
client,
|
|
322
|
+
sourceSha,
|
|
323
|
+
latestExactRun,
|
|
324
|
+
successfulPreflight,
|
|
325
|
+
}) {
|
|
326
|
+
const jobs = await client.listRunJobs(
|
|
327
|
+
latestExactRun.id,
|
|
328
|
+
latestExactRun.run_attempt || 1,
|
|
329
|
+
);
|
|
330
|
+
const classification = classifyRetryableFailure(latestExactRun, jobs);
|
|
331
|
+
const attempt = Number(latestExactRun.run_attempt || 1);
|
|
332
|
+
const retryReady = classification.retryable && attempt < options.maxAttempts;
|
|
333
|
+
return {
|
|
334
|
+
state: retryReady ? "retry-ready" : "blocked",
|
|
335
|
+
action: retryReady ? "rerun-failed-jobs" : "none",
|
|
336
|
+
reason:
|
|
337
|
+
classification.retryable && !retryReady
|
|
338
|
+
? "bounded-retry-exhausted"
|
|
339
|
+
: classification.reason,
|
|
340
|
+
sourceSha,
|
|
341
|
+
pendingSha: retryReady ? sourceSha : null,
|
|
342
|
+
activeRun: null,
|
|
343
|
+
qualificationRun: runEvidence(latestExactRun),
|
|
344
|
+
preflightRun: runEvidence(successfulPreflight),
|
|
345
|
+
priorityRuns: [],
|
|
346
|
+
retryClassification: classification,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
export async function runDevQualificationPatrol(
|
|
350
|
+
optionsInput = {},
|
|
351
|
+
clientInput,
|
|
352
|
+
) {
|
|
353
|
+
const options = normalizeDevQualificationOptions(optionsInput);
|
|
354
|
+
const client =
|
|
355
|
+
clientInput ||
|
|
356
|
+
createGitHubDevQualificationClient({
|
|
357
|
+
repository: options.repository,
|
|
358
|
+
token: process.env.GITHUB_TOKEN,
|
|
359
|
+
});
|
|
360
|
+
const [sourceSha, devRuns, preflightRuns, ...priorityRunSets] =
|
|
361
|
+
await Promise.all([
|
|
362
|
+
client.resolveBranch(options.sourceBranch),
|
|
363
|
+
client.listWorkflowRuns(options.devWorkflowPath, options.sourceBranch),
|
|
364
|
+
client.listWorkflowRuns(
|
|
365
|
+
options.preflightWorkflowPath,
|
|
366
|
+
options.sourceBranch,
|
|
367
|
+
),
|
|
368
|
+
...options.priorityWorkflowPaths.map((workflow) =>
|
|
369
|
+
client.listWorkflowRuns(workflow, undefined, { activeOnly: true }),
|
|
370
|
+
),
|
|
371
|
+
]);
|
|
372
|
+
if (!EXACT_SHA.test(sourceSha))
|
|
373
|
+
throw new Error(`source branch resolved invalid SHA ${sourceSha}`);
|
|
374
|
+
|
|
375
|
+
const exactRuns = devRuns.filter((run) => text(run.head_sha) === sourceSha);
|
|
376
|
+
const latestExactRun = newest(exactRuns);
|
|
377
|
+
const activeRun = newest(
|
|
378
|
+
devRuns.filter((run) => ACTIVE_STATUSES.has(text(run.status))),
|
|
379
|
+
);
|
|
380
|
+
const successfulPreflight = newest(
|
|
381
|
+
preflightRuns.filter(
|
|
382
|
+
(run) =>
|
|
383
|
+
text(run.head_sha) === sourceSha &&
|
|
384
|
+
text(run.status) === "completed" &&
|
|
385
|
+
text(run.conclusion) === "success",
|
|
386
|
+
),
|
|
387
|
+
);
|
|
388
|
+
const priorityRuns = priorityRunSets
|
|
389
|
+
.flat()
|
|
390
|
+
.filter((run) => ACTIVE_STATUSES.has(text(run.status)));
|
|
391
|
+
let fields = decideObservedState({
|
|
392
|
+
sourceSha,
|
|
393
|
+
latestExactRun,
|
|
394
|
+
activeRun,
|
|
395
|
+
successfulPreflight,
|
|
396
|
+
priorityRuns,
|
|
397
|
+
});
|
|
398
|
+
if (!fields) {
|
|
399
|
+
fields = await decideFailedRun({
|
|
400
|
+
options,
|
|
401
|
+
client,
|
|
402
|
+
sourceSha,
|
|
403
|
+
latestExactRun,
|
|
404
|
+
successfulPreflight,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (options.expectedAction && fields.action !== options.expectedAction) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`qualification controller action changed: expected ${options.expectedAction}, observed ${fields.action}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (options.expectedSourceSha && sourceSha !== options.expectedSourceSha) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`qualification controller source changed: expected ${options.expectedSourceSha}, observed ${sourceSha}`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
let mutation = null;
|
|
419
|
+
if (options.mutationAuthorized && fields.action === "dispatch") {
|
|
420
|
+
mutation = await client.dispatchWorkflow(
|
|
421
|
+
options.devWorkflowPath,
|
|
422
|
+
options.sourceBranch,
|
|
423
|
+
{
|
|
424
|
+
...options.dispatchInputs,
|
|
425
|
+
"source-sha": sourceSha,
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
} else if (
|
|
429
|
+
options.mutationAuthorized &&
|
|
430
|
+
fields.action === "rerun-failed-jobs"
|
|
431
|
+
) {
|
|
432
|
+
mutation = await client.rerunFailedJobs(latestExactRun.id);
|
|
433
|
+
}
|
|
434
|
+
return decisionBody(options, {
|
|
435
|
+
...fields,
|
|
436
|
+
mutationAuthorized: options.mutationAuthorized,
|
|
437
|
+
mutation,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function encodeRef(value) {
|
|
441
|
+
return value.split("/").map(encodeURIComponent).join("/");
|
|
442
|
+
}
|
|
443
|
+
export function createGitHubDevQualificationClient({
|
|
444
|
+
repository: repositoryInput,
|
|
445
|
+
token,
|
|
446
|
+
fetchImpl = globalThis.fetch,
|
|
447
|
+
sleepImpl = (milliseconds) =>
|
|
448
|
+
new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
449
|
+
}) {
|
|
450
|
+
const [owner, repo] = repository(repositoryInput).split("/");
|
|
451
|
+
const headers = {
|
|
452
|
+
accept: "application/vnd.github+json",
|
|
453
|
+
authorization: token ? `Bearer ${token}` : undefined,
|
|
454
|
+
"user-agent": "buildchain-dev-qualification-patrol",
|
|
455
|
+
"x-github-api-version": "2022-11-28",
|
|
456
|
+
};
|
|
457
|
+
async function api(requestPath, { method = "GET", body } = {}) {
|
|
458
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
459
|
+
const response = await fetchImpl(`https://api.github.com${requestPath}`, {
|
|
460
|
+
method,
|
|
461
|
+
headers: Object.fromEntries(
|
|
462
|
+
Object.entries(headers).filter(([, value]) => value),
|
|
463
|
+
),
|
|
464
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
465
|
+
});
|
|
466
|
+
const raw = await response.text();
|
|
467
|
+
const payload = raw ? JSON.parse(raw) : undefined;
|
|
468
|
+
if (response.ok) return payload;
|
|
469
|
+
if ((response.status === 429 || response.status >= 500) && attempt < 3) {
|
|
470
|
+
await sleepImpl(attempt * 250);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
throw new Error(
|
|
474
|
+
`GitHub API ${method} ${requestPath} failed with ${response.status}: ${payload?.message || raw}`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
throw new Error(`GitHub API ${method} ${requestPath} exhausted retries`);
|
|
478
|
+
}
|
|
479
|
+
async function paged(requestPath, key) {
|
|
480
|
+
const rows = [];
|
|
481
|
+
for (let page = 1; page <= 10; page += 1) {
|
|
482
|
+
const separator = requestPath.includes("?") ? "&" : "?";
|
|
483
|
+
const payload = await api(
|
|
484
|
+
`${requestPath}${separator}per_page=100&page=${page}`,
|
|
485
|
+
);
|
|
486
|
+
const pageRows = key ? payload[key] || [] : payload;
|
|
487
|
+
rows.push(...pageRows);
|
|
488
|
+
if (pageRows.length < 100) return rows;
|
|
489
|
+
}
|
|
490
|
+
throw new Error(`${requestPath} history exceeds 1000 rows`);
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
async resolveBranch(ref) {
|
|
494
|
+
const payload = await api(
|
|
495
|
+
`/repos/${owner}/${repo}/git/ref/heads/${encodeRef(ref)}`,
|
|
496
|
+
);
|
|
497
|
+
return text(payload.object?.sha);
|
|
498
|
+
},
|
|
499
|
+
async listWorkflowRuns(
|
|
500
|
+
workflow,
|
|
501
|
+
sourceBranch,
|
|
502
|
+
{ activeOnly = false } = {},
|
|
503
|
+
) {
|
|
504
|
+
const query = sourceBranch
|
|
505
|
+
? `?branch=${encodeURIComponent(sourceBranch)}`
|
|
506
|
+
: "";
|
|
507
|
+
const runs = await paged(
|
|
508
|
+
`/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow)}/runs${query}`,
|
|
509
|
+
"workflow_runs",
|
|
510
|
+
);
|
|
511
|
+
return activeOnly
|
|
512
|
+
? runs.filter((run) => ACTIVE_STATUSES.has(text(run.status)))
|
|
513
|
+
: runs;
|
|
514
|
+
},
|
|
515
|
+
async listRunJobs(runId, runAttempt) {
|
|
516
|
+
return paged(
|
|
517
|
+
`/repos/${owner}/${repo}/actions/runs/${Number(runId)}/attempts/${Number(runAttempt)}/jobs`,
|
|
518
|
+
"jobs",
|
|
519
|
+
);
|
|
520
|
+
},
|
|
521
|
+
async dispatchWorkflow(workflow, ref, inputs) {
|
|
522
|
+
await api(
|
|
523
|
+
`/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`,
|
|
524
|
+
{
|
|
525
|
+
method: "POST",
|
|
526
|
+
body: { ref, inputs },
|
|
527
|
+
},
|
|
528
|
+
);
|
|
529
|
+
return {
|
|
530
|
+
action: "dispatch",
|
|
531
|
+
workflow,
|
|
532
|
+
ref,
|
|
533
|
+
sourceSha: inputs["source-sha"],
|
|
534
|
+
};
|
|
535
|
+
},
|
|
536
|
+
async rerunFailedJobs(runId) {
|
|
537
|
+
await api(
|
|
538
|
+
`/repos/${owner}/${repo}/actions/runs/${Number(runId)}/rerun-failed-jobs`,
|
|
539
|
+
{
|
|
540
|
+
method: "POST",
|
|
541
|
+
},
|
|
542
|
+
);
|
|
543
|
+
return { action: "rerun-failed-jobs", runId: Number(runId) };
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function markdown(result) {
|
|
548
|
+
return [
|
|
549
|
+
"## Buildchain Dev qualification patrol",
|
|
550
|
+
"",
|
|
551
|
+
`State: \`${result.state}\``,
|
|
552
|
+
`Action: \`${result.action}\` (${result.reason})`,
|
|
553
|
+
`Source: \`${result.sourceBranch}@${result.sourceSha}\``,
|
|
554
|
+
`Active run: ${result.activeRun?.url || "none"}`,
|
|
555
|
+
`Pending SHA: \`${result.pendingSha || "none"}\``,
|
|
556
|
+
`Mutation authorized: \`${result.mutationAuthorized}\``,
|
|
557
|
+
"",
|
|
558
|
+
].join("\n");
|
|
559
|
+
}
|
|
560
|
+
async function main() {
|
|
561
|
+
const options = normalizeDevQualificationOptions();
|
|
562
|
+
const result = await runDevQualificationPatrol(options);
|
|
563
|
+
fs.mkdirSync(path.dirname(options.outputPath), { recursive: true });
|
|
564
|
+
fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
565
|
+
const summary = markdown(result);
|
|
566
|
+
if (process.env.GITHUB_STEP_SUMMARY)
|
|
567
|
+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
|
568
|
+
else process.stdout.write(summary);
|
|
569
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
570
|
+
const outputs = {
|
|
571
|
+
"result-path": options.outputPath,
|
|
572
|
+
state: result.state,
|
|
573
|
+
action: result.action,
|
|
574
|
+
reason: result.reason,
|
|
575
|
+
"source-sha": result.sourceSha,
|
|
576
|
+
"pending-sha": result.pendingSha || "",
|
|
577
|
+
"active-run-id": result.activeRun?.id || "",
|
|
578
|
+
"decision-root": result.decisionRoot,
|
|
579
|
+
};
|
|
580
|
+
fs.appendFileSync(
|
|
581
|
+
process.env.GITHUB_OUTPUT,
|
|
582
|
+
`${Object.entries(outputs)
|
|
583
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
584
|
+
.join("\n")}\n`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
590
|
+
main().catch((error) => {
|
|
591
|
+
console.error(error.stack || error.message);
|
|
592
|
+
process.exit(1);
|
|
593
|
+
});
|
|
594
|
+
}
|