@kungfu-tech/buildchain 2.14.5 → 2.14.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/bin/buildchain.mjs +9 -0
- package/dist/site/buildchain-contract.json +6 -6
- package/dist/site/buildchain-site.json +14 -9
- package/dist/site/capability-registry.json +2 -2
- package/dist/site/cli-registry.json +12 -0
- package/dist/site/kfd-claims.json +38 -7
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +1 -1
- package/dist/site/page-registry.json +9 -4
- package/dist/site/public-surface-audit.json +29 -5
- package/dist/site/publication-authority-registry.json +6 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +5 -5
- package/dist/site/workflow-registry.json +22 -0
- package/docs/release-governance.md +39 -0
- package/fixtures/libnode-shaped/README.md +3 -0
- package/package.json +1 -1
- package/scripts/check-inventory.mjs +3 -0
- package/scripts/generate-channel-build-workflow.mjs +24 -1
- package/scripts/generate-site-bundle.mjs +2 -0
- package/scripts/reconcile-release-governance.mjs +368 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
export const PUBLIC_BUILD_ROUTER_AGGREGATE_JOB = "Summarize build contract";
|
|
6
|
+
|
|
7
|
+
function normalized(value) {
|
|
8
|
+
return String(value ?? "").trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function assertRepository(value) {
|
|
12
|
+
const repository = normalized(value);
|
|
13
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
|
|
14
|
+
throw new Error("repository must use owner/repo form");
|
|
15
|
+
}
|
|
16
|
+
return repository;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertManagedBranch(value) {
|
|
20
|
+
const branch = normalized(value).replace(/^refs\/heads\//, "");
|
|
21
|
+
if (!/^(?:dev|alpha|release)\/v\d+\/v\d+\.\d+$/.test(branch)) {
|
|
22
|
+
throw new Error("branch must be a managed dev/alpha/release ref");
|
|
23
|
+
}
|
|
24
|
+
return branch;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertSha(value) {
|
|
28
|
+
const sha = normalized(value);
|
|
29
|
+
if (!/^[0-9a-f]{40}$/i.test(sha)) {
|
|
30
|
+
throw new Error("candidate SHA must be a 40-character Git SHA");
|
|
31
|
+
}
|
|
32
|
+
return sha.toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checkContextEntries(protection = {}) {
|
|
36
|
+
const policy = protection.required_status_checks || {};
|
|
37
|
+
const entries = [];
|
|
38
|
+
for (const check of policy.checks || []) {
|
|
39
|
+
const context = normalized(check?.context);
|
|
40
|
+
if (context) entries.push({ context, app_id: check.app_id ?? null });
|
|
41
|
+
}
|
|
42
|
+
for (const contextValue of policy.contexts || []) {
|
|
43
|
+
const context = normalized(contextValue);
|
|
44
|
+
if (context && !entries.some((entry) => entry.context === context)) {
|
|
45
|
+
entries.push({ context, app_id: null });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isBuildRouterAggregateContext(
|
|
52
|
+
context,
|
|
53
|
+
aggregateJob = PUBLIC_BUILD_ROUTER_AGGREGATE_JOB,
|
|
54
|
+
) {
|
|
55
|
+
const name = normalized(context);
|
|
56
|
+
return name === aggregateJob || name.endsWith(` / ${aggregateJob}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function contextDepth(context) {
|
|
60
|
+
return normalized(context).split(" / ").length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function resolvePublicBuildRouterAggregateCheck({
|
|
64
|
+
checkRuns = [],
|
|
65
|
+
aggregateJob = PUBLIC_BUILD_ROUTER_AGGREGATE_JOB,
|
|
66
|
+
} = {}) {
|
|
67
|
+
const qualifying = (checkRuns || []).filter(
|
|
68
|
+
(check) =>
|
|
69
|
+
check?.status === "completed" &&
|
|
70
|
+
check?.conclusion === "success" &&
|
|
71
|
+
isBuildRouterAggregateContext(check?.name, aggregateJob),
|
|
72
|
+
);
|
|
73
|
+
const names = [...new Set(qualifying.map((check) => normalized(check.name)))];
|
|
74
|
+
if (names.length === 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`candidate emitted no successful public Buildchain aggregate ending in '${aggregateJob}'`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const shallowestDepth = Math.min(...names.map(contextDepth));
|
|
80
|
+
const shallowest = names.filter(
|
|
81
|
+
(name) => contextDepth(name) === shallowestDepth,
|
|
82
|
+
);
|
|
83
|
+
if (shallowest.length !== 1) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`candidate emitted ambiguous public Buildchain aggregates: ${shallowest.join(", ")}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const name = shallowest[0];
|
|
89
|
+
const matching = qualifying
|
|
90
|
+
.filter((check) => normalized(check.name) === name)
|
|
91
|
+
.sort((left, right) =>
|
|
92
|
+
normalized(right.completed_at).localeCompare(
|
|
93
|
+
normalized(left.completed_at),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
const appId = matching[0]?.app?.id;
|
|
97
|
+
if (!Number.isInteger(appId)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`candidate aggregate '${name}' does not expose a GitHub App id`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return { context: name, app_id: appId };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function planReleaseGovernanceReconciliation({
|
|
106
|
+
repository,
|
|
107
|
+
branch,
|
|
108
|
+
candidateSha,
|
|
109
|
+
protection = {},
|
|
110
|
+
checkRuns = [],
|
|
111
|
+
aggregateJob = PUBLIC_BUILD_ROUTER_AGGREGATE_JOB,
|
|
112
|
+
} = {}) {
|
|
113
|
+
const normalizedRepository = assertRepository(repository);
|
|
114
|
+
const normalizedBranch = assertManagedBranch(branch);
|
|
115
|
+
const normalizedSha = assertSha(candidateSha);
|
|
116
|
+
const policy = protection.required_status_checks;
|
|
117
|
+
if (!policy) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`protected branch ${normalizedBranch} has no required status-check policy`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const expected = resolvePublicBuildRouterAggregateCheck({
|
|
123
|
+
checkRuns,
|
|
124
|
+
aggregateJob,
|
|
125
|
+
});
|
|
126
|
+
const before = checkContextEntries(protection);
|
|
127
|
+
const stale = before.filter(
|
|
128
|
+
(entry) =>
|
|
129
|
+
isBuildRouterAggregateContext(entry.context, aggregateJob) &&
|
|
130
|
+
entry.context !== expected.context,
|
|
131
|
+
);
|
|
132
|
+
const after = before.filter(
|
|
133
|
+
(entry) => !isBuildRouterAggregateContext(entry.context, aggregateJob),
|
|
134
|
+
);
|
|
135
|
+
after.push(expected);
|
|
136
|
+
const uniqueAfter = [];
|
|
137
|
+
for (const entry of after) {
|
|
138
|
+
const existing = uniqueAfter.find(
|
|
139
|
+
(candidate) => candidate.context === entry.context,
|
|
140
|
+
);
|
|
141
|
+
if (!existing) {
|
|
142
|
+
uniqueAfter.push(entry);
|
|
143
|
+
} else if (existing.app_id === null && entry.app_id !== null) {
|
|
144
|
+
existing.app_id = entry.app_id;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const normalizedBefore = before
|
|
148
|
+
.map((entry) => `${entry.context}:${entry.app_id ?? "any"}`)
|
|
149
|
+
.sort();
|
|
150
|
+
const normalizedAfter = uniqueAfter
|
|
151
|
+
.map((entry) => `${entry.context}:${entry.app_id ?? "any"}`)
|
|
152
|
+
.sort();
|
|
153
|
+
const changed =
|
|
154
|
+
JSON.stringify(normalizedBefore) !== JSON.stringify(normalizedAfter);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
schemaVersion: 1,
|
|
158
|
+
contract: "kungfu-buildchain-release-governance-reconciliation",
|
|
159
|
+
repository: normalizedRepository,
|
|
160
|
+
branch: normalizedBranch,
|
|
161
|
+
candidateSha: normalizedSha,
|
|
162
|
+
aggregateJob,
|
|
163
|
+
expected,
|
|
164
|
+
actual: before,
|
|
165
|
+
staleBuildchainContexts: stale,
|
|
166
|
+
changed,
|
|
167
|
+
requiredStatusChecks: {
|
|
168
|
+
strict: policy.strict === true,
|
|
169
|
+
before,
|
|
170
|
+
after: uniqueAfter,
|
|
171
|
+
},
|
|
172
|
+
preservedPolicy: {
|
|
173
|
+
requiredApprovingReviewCount:
|
|
174
|
+
protection.required_pull_request_reviews
|
|
175
|
+
?.required_approving_review_count ?? null,
|
|
176
|
+
enforceAdmins: protection.enforce_admins?.enabled === true,
|
|
177
|
+
requiredConversationResolution:
|
|
178
|
+
protection.required_conversation_resolution?.enabled === true,
|
|
179
|
+
allowForcePushes: protection.allow_force_pushes?.enabled === true,
|
|
180
|
+
allowDeletions: protection.allow_deletions?.enabled === true,
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readFlag(args, name, fallback = "") {
|
|
186
|
+
const index = args.indexOf(`--${name}`);
|
|
187
|
+
return index === -1 ? fallback : args[index + 1] || "";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function hasFlag(args, name) {
|
|
191
|
+
return args.includes(`--${name}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function apiToken() {
|
|
195
|
+
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
196
|
+
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required");
|
|
197
|
+
return token;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function githubRequest({ apiUrl, token, method = "GET", route, body }) {
|
|
201
|
+
const response = await fetch(
|
|
202
|
+
`${apiUrl.replace(/\/$/, "")}/${route.replace(/^\//, "")}`,
|
|
203
|
+
{
|
|
204
|
+
method,
|
|
205
|
+
headers: {
|
|
206
|
+
accept: "application/vnd.github+json",
|
|
207
|
+
authorization: `Bearer ${token}`,
|
|
208
|
+
"content-type": "application/json",
|
|
209
|
+
"x-github-api-version": "2022-11-28",
|
|
210
|
+
},
|
|
211
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
const text = await response.text();
|
|
215
|
+
const data = text ? JSON.parse(text) : undefined;
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`GitHub API ${method} ${route} failed with ${response.status}: ${data?.message || text}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return data;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function assertCandidatePullRequest({
|
|
225
|
+
pullRequests = [],
|
|
226
|
+
branch,
|
|
227
|
+
candidateSha,
|
|
228
|
+
}) {
|
|
229
|
+
const match = pullRequests.find(
|
|
230
|
+
(pullRequest) =>
|
|
231
|
+
pullRequest?.base?.ref === branch &&
|
|
232
|
+
normalized(pullRequest?.head?.sha).toLowerCase() === candidateSha,
|
|
233
|
+
);
|
|
234
|
+
if (!match) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`candidate ${candidateSha} is not the head of a pull request targeting ${branch}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return { number: match.number, url: match.html_url || "" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function reconcileReleaseGovernance({
|
|
243
|
+
repository,
|
|
244
|
+
branch,
|
|
245
|
+
candidateSha,
|
|
246
|
+
apply = false,
|
|
247
|
+
apiUrl = process.env.GITHUB_API_URL || "https://api.github.com",
|
|
248
|
+
token = apiToken(),
|
|
249
|
+
} = {}) {
|
|
250
|
+
const normalizedRepository = assertRepository(repository);
|
|
251
|
+
const normalizedBranch = assertManagedBranch(branch);
|
|
252
|
+
const normalizedSha = assertSha(candidateSha);
|
|
253
|
+
const encodedBranch = encodeURIComponent(normalizedBranch);
|
|
254
|
+
const encodedSha = encodeURIComponent(normalizedSha);
|
|
255
|
+
const [protection, checksResponse, pullRequests] = await Promise.all([
|
|
256
|
+
githubRequest({
|
|
257
|
+
apiUrl,
|
|
258
|
+
token,
|
|
259
|
+
route: `repos/${normalizedRepository}/branches/${encodedBranch}/protection`,
|
|
260
|
+
}),
|
|
261
|
+
githubRequest({
|
|
262
|
+
apiUrl,
|
|
263
|
+
token,
|
|
264
|
+
route: `repos/${normalizedRepository}/commits/${encodedSha}/check-runs?filter=latest&per_page=100`,
|
|
265
|
+
}),
|
|
266
|
+
githubRequest({
|
|
267
|
+
apiUrl,
|
|
268
|
+
token,
|
|
269
|
+
route: `repos/${normalizedRepository}/commits/${encodedSha}/pulls?per_page=100`,
|
|
270
|
+
}),
|
|
271
|
+
]);
|
|
272
|
+
const pullRequest = assertCandidatePullRequest({
|
|
273
|
+
pullRequests,
|
|
274
|
+
branch: normalizedBranch,
|
|
275
|
+
candidateSha: normalizedSha,
|
|
276
|
+
});
|
|
277
|
+
const plan = planReleaseGovernanceReconciliation({
|
|
278
|
+
repository: normalizedRepository,
|
|
279
|
+
branch: normalizedBranch,
|
|
280
|
+
candidateSha: normalizedSha,
|
|
281
|
+
protection,
|
|
282
|
+
checkRuns: checksResponse.check_runs || [],
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (apply && plan.changed) {
|
|
286
|
+
await githubRequest({
|
|
287
|
+
apiUrl,
|
|
288
|
+
token,
|
|
289
|
+
method: "PATCH",
|
|
290
|
+
route: `repos/${normalizedRepository}/branches/${encodedBranch}/protection/required_status_checks`,
|
|
291
|
+
body: {
|
|
292
|
+
strict: plan.requiredStatusChecks.strict,
|
|
293
|
+
contexts: plan.requiredStatusChecks.after
|
|
294
|
+
.filter((entry) => entry.app_id === null)
|
|
295
|
+
.map((entry) => entry.context),
|
|
296
|
+
checks: plan.requiredStatusChecks.after
|
|
297
|
+
.filter((entry) => entry.app_id !== null)
|
|
298
|
+
.map((entry) => ({
|
|
299
|
+
context: entry.context,
|
|
300
|
+
app_id: entry.app_id,
|
|
301
|
+
})),
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
...plan,
|
|
307
|
+
pullRequest,
|
|
308
|
+
applied: apply && plan.changed,
|
|
309
|
+
status: plan.changed ? (apply ? "reconciled" : "drift") : "aligned",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function usage() {
|
|
314
|
+
return `Usage:
|
|
315
|
+
buildchain release-governance reconcile --repository <owner/repo>
|
|
316
|
+
--branch <dev|alpha|release/vN/vN.N> --candidate-sha <sha> [--apply] [--json]
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function runReleaseGovernanceCli(argv = process.argv.slice(2)) {
|
|
321
|
+
const [mode = "", ...args] = argv;
|
|
322
|
+
if (!mode || mode === "--help" || mode === "-h") {
|
|
323
|
+
process.stdout.write(usage());
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (mode !== "reconcile") {
|
|
327
|
+
throw new Error(`unsupported release-governance command: ${mode}`);
|
|
328
|
+
}
|
|
329
|
+
const result = await reconcileReleaseGovernance({
|
|
330
|
+
repository: readFlag(
|
|
331
|
+
args,
|
|
332
|
+
"repository",
|
|
333
|
+
process.env.GITHUB_REPOSITORY || "",
|
|
334
|
+
),
|
|
335
|
+
branch: readFlag(args, "branch"),
|
|
336
|
+
candidateSha: readFlag(args, "candidate-sha"),
|
|
337
|
+
apply: hasFlag(args, "apply"),
|
|
338
|
+
});
|
|
339
|
+
if (hasFlag(args, "json")) {
|
|
340
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
341
|
+
} else {
|
|
342
|
+
process.stdout.write(
|
|
343
|
+
`release governance ${result.status}: ${result.repository} ${result.branch}\n`,
|
|
344
|
+
);
|
|
345
|
+
process.stdout.write(`- expected: ${result.expected.context}\n`);
|
|
346
|
+
process.stdout.write(
|
|
347
|
+
`- actual: ${result.actual.map((entry) => entry.context).join(", ") || "none"}\n`,
|
|
348
|
+
);
|
|
349
|
+
process.stdout.write(`- candidate: ${result.candidateSha}\n`);
|
|
350
|
+
process.stdout.write(
|
|
351
|
+
result.applied
|
|
352
|
+
? "Required status checks were reconciled without changing other branch-protection settings.\n"
|
|
353
|
+
: "No branch-protection settings were modified.\n",
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (
|
|
360
|
+
!process.env.BUILDCHAIN_EMBEDDED_ENTRYPOINT &&
|
|
361
|
+
process.argv[1] &&
|
|
362
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
363
|
+
) {
|
|
364
|
+
runReleaseGovernanceCli().catch((error) => {
|
|
365
|
+
console.error(error.message);
|
|
366
|
+
process.exitCode = 1;
|
|
367
|
+
});
|
|
368
|
+
}
|