@kungfu-tech/buildchain 4.1.0 → 4.1.1-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/architecture/maintainability-debt.json +3 -3
- package/dist/site/buildchain-contract.json +3 -3
- package/dist/site/buildchain-site.json +9 -9
- package/dist/site/kfd-claims.json +2 -2
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +1 -1
- package/dist/site/page-registry.json +4 -4
- package/dist/site/public-surface-audit.json +2 -2
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +5 -5
- package/dist/site/workflow-registry.json +4 -4
- package/docs/release-governance.md +16 -15
- package/docs/stable-candidate-patrol.md +29 -28
- package/package.json +1 -1
- package/packages/core/providers/github/qualification-artifacts.js +88 -0
- package/packages/core/providers/github-cli-api.js +52 -21
- package/packages/core/release/commands/stable-release-gate.mjs +7 -1
- package/packages/core/release/qualification/canary-evidence.js +89 -0
- package/packages/core/release/stable-release-gate.js +3 -3
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
const LIMIT = 8 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
export function readPublicBuildArchive(archive, digest) {
|
|
7
|
+
if (
|
|
8
|
+
archive.length > LIMIT ||
|
|
9
|
+
`sha256:${createHash("sha256").update(archive).digest("hex")}` !== digest
|
|
10
|
+
)
|
|
11
|
+
throw new Error("public build artifact digest or size mismatch");
|
|
12
|
+
const script = [
|
|
13
|
+
"import io,sys,zipfile",
|
|
14
|
+
"z=zipfile.ZipFile(io.BytesIO(sys.stdin.buffer.read()))",
|
|
15
|
+
"entries=[e for e in z.infolist() if e.filename=='build-summary.json']",
|
|
16
|
+
"assert len(entries)==1 and entries[0].file_size <= 1048576, 'invalid public build archive'",
|
|
17
|
+
"sys.stdout.buffer.write(z.read(entries[0]))",
|
|
18
|
+
].join("\n");
|
|
19
|
+
return JSON.parse(
|
|
20
|
+
execFileSync(
|
|
21
|
+
process.platform === "win32" ? "python" : "python3",
|
|
22
|
+
["-c", script],
|
|
23
|
+
{ input: archive, encoding: "utf8", maxBuffer: 1048576 },
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function fetchQualificationArchive({
|
|
29
|
+
apiUrl,
|
|
30
|
+
token,
|
|
31
|
+
endpoint,
|
|
32
|
+
fetchImpl,
|
|
33
|
+
}) {
|
|
34
|
+
let response = await fetchImpl(`${apiUrl.replace(/\/+$/, "")}${endpoint}`, {
|
|
35
|
+
redirect: "manual",
|
|
36
|
+
headers: {
|
|
37
|
+
accept: "application/vnd.github+json",
|
|
38
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
if (response.status === 302) {
|
|
42
|
+
const location = new URL(response.headers.get("location"));
|
|
43
|
+
if (location.protocol !== "https:")
|
|
44
|
+
throw new Error("invalid artifact download redirect");
|
|
45
|
+
response = await fetchImpl(location.href, { redirect: "error" });
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
throw new Error(
|
|
49
|
+
`public build artifact download failed: ${response.status}`,
|
|
50
|
+
);
|
|
51
|
+
if (Number(response.headers.get("content-length")) > LIMIT)
|
|
52
|
+
throw new Error("public build archive exceeds size limit");
|
|
53
|
+
const chunks = [];
|
|
54
|
+
let size = 0;
|
|
55
|
+
for await (const chunk of response.body) {
|
|
56
|
+
size += chunk.length;
|
|
57
|
+
if (size > LIMIT)
|
|
58
|
+
throw new Error("public build archive exceeds size limit");
|
|
59
|
+
chunks.push(chunk);
|
|
60
|
+
}
|
|
61
|
+
return Buffer.concat(chunks);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function readPublicBuildArtifact({
|
|
65
|
+
api,
|
|
66
|
+
fetchArchive,
|
|
67
|
+
repository,
|
|
68
|
+
run,
|
|
69
|
+
}) {
|
|
70
|
+
const prefix = `/repos/${repository}/actions`;
|
|
71
|
+
const assets = await api(`${prefix}/runs/${run.id}/artifacts?per_page=100`);
|
|
72
|
+
const matches = (assets.artifacts || []).filter(
|
|
73
|
+
(asset) => asset.name === `buildchain-summary-${run.head_sha}`,
|
|
74
|
+
);
|
|
75
|
+
if (
|
|
76
|
+
matches.length !== 1 ||
|
|
77
|
+
matches[0].expired ||
|
|
78
|
+
matches[0].size_in_bytes > LIMIT
|
|
79
|
+
)
|
|
80
|
+
throw new Error(
|
|
81
|
+
"public build summary artifact missing, ambiguous or expired",
|
|
82
|
+
);
|
|
83
|
+
const asset = matches[0];
|
|
84
|
+
return readPublicBuildArchive(
|
|
85
|
+
await fetchArchive(`${prefix}/artifacts/${asset.id}/zip`),
|
|
86
|
+
asset.digest,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -1,26 +1,57 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
function apiFailure(cause) {
|
|
4
|
+
let response;
|
|
5
|
+
try {
|
|
6
|
+
response = JSON.parse(String(cause.stdout || ""));
|
|
7
|
+
} catch {
|
|
8
|
+
// Process diagnostics are not a provider response and may contain secrets.
|
|
9
|
+
}
|
|
10
|
+
const messages = Array.isArray(response?.errors)
|
|
11
|
+
? response.errors
|
|
12
|
+
.map((error) => error.message)
|
|
13
|
+
.filter((message) => typeof message === "string")
|
|
14
|
+
: [];
|
|
15
|
+
const message =
|
|
16
|
+
messages.join("; ") ||
|
|
17
|
+
(typeof response?.message === "string" ? response.message : "") ||
|
|
18
|
+
`GitHub API command failed with exit code ${cause.status ?? 1}`;
|
|
19
|
+
const error = new Error(message);
|
|
20
|
+
error.exitCode = cause.status ?? 1;
|
|
21
|
+
const status = Number(response?.status);
|
|
22
|
+
if (Number.isInteger(status) && status >= 100 && status <= 599)
|
|
23
|
+
error.status = status;
|
|
24
|
+
if (cause.code) error.code = cause.code;
|
|
25
|
+
return error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createGitHubCliApi(execute = execFileSync, env = process.env) {
|
|
4
29
|
function invoke(method, endpoint, body, flags = []) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
let output;
|
|
31
|
+
try {
|
|
32
|
+
output = execute(
|
|
33
|
+
"gh",
|
|
34
|
+
[
|
|
35
|
+
"api",
|
|
36
|
+
"--method",
|
|
37
|
+
method,
|
|
38
|
+
endpoint,
|
|
39
|
+
"-H",
|
|
40
|
+
"Accept: application/vnd.github+json",
|
|
41
|
+
...flags,
|
|
42
|
+
...(body === undefined ? [] : ["--input", "-"]),
|
|
43
|
+
],
|
|
44
|
+
{
|
|
45
|
+
env,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
input: body === undefined ? undefined : JSON.stringify(body),
|
|
48
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
49
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw apiFailure(error);
|
|
54
|
+
}
|
|
24
55
|
return output?.trim() ? JSON.parse(output) : {};
|
|
25
56
|
}
|
|
26
57
|
return {
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { fetchQualificationArchive } from "../../providers/github/qualification-artifacts.js";
|
|
6
|
+
import { resolvePublicBuildCanaryEvidence } from "../qualification/canary-evidence.js";
|
|
5
7
|
import {
|
|
6
8
|
assertStableReleaseGate,
|
|
7
9
|
evaluateStableReleaseGate,
|
|
@@ -278,12 +280,16 @@ export async function collectStableReleaseGateReport({
|
|
|
278
280
|
const canaries = await resolveCanaryEvidence({
|
|
279
281
|
api,
|
|
280
282
|
repository,
|
|
281
|
-
policy,
|
|
283
|
+
policy: { ...policy, requiredCanaries: policy.requiredCanaries.filter((entry) => entry.source !== "public-build") },
|
|
282
284
|
candidateTag: candidate.tag,
|
|
283
285
|
candidateSha,
|
|
284
286
|
releaseCandidateRunId,
|
|
285
287
|
releaseCandidateRunUrl,
|
|
286
288
|
});
|
|
289
|
+
canaries.push(...await resolvePublicBuildCanaryEvidence({
|
|
290
|
+
api, policy, candidateSha, repository: repository.fullName,
|
|
291
|
+
fetchArchive: (endpoint) => fetchQualificationArchive({ apiUrl, token, endpoint, fetchImpl }),
|
|
292
|
+
}));
|
|
287
293
|
return assertStableReleaseGate({
|
|
288
294
|
policy,
|
|
289
295
|
channel,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readPublicBuildArtifact } from "../../providers/github/qualification-artifacts.js";
|
|
2
|
+
import {
|
|
3
|
+
resolveStableCandidateQualificationCandidate,
|
|
4
|
+
validatePublicBuildRun,
|
|
5
|
+
} from "./public-build.js";
|
|
6
|
+
|
|
7
|
+
async function readCanary({
|
|
8
|
+
api,
|
|
9
|
+
fetchArchive,
|
|
10
|
+
repository,
|
|
11
|
+
candidateSha,
|
|
12
|
+
canary,
|
|
13
|
+
statuses,
|
|
14
|
+
}) {
|
|
15
|
+
const status = statuses.find((entry) => entry.context === canary.context);
|
|
16
|
+
const evidence = {
|
|
17
|
+
id: canary.id,
|
|
18
|
+
candidateSha,
|
|
19
|
+
status: "missing",
|
|
20
|
+
attestor: status?.creator?.login || "",
|
|
21
|
+
};
|
|
22
|
+
if (!status || status.state !== "success") return evidence;
|
|
23
|
+
const target =
|
|
24
|
+
/^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/([1-9][0-9]*)\/?$/u.exec(
|
|
25
|
+
status.target_url || "",
|
|
26
|
+
);
|
|
27
|
+
if (!target || target[1] !== repository || canary.repository !== repository)
|
|
28
|
+
return { ...evidence, status: "mismatched" };
|
|
29
|
+
const prefix = `/repos/${repository}/actions`;
|
|
30
|
+
const run = await api(`${prefix}/runs/${target[2]}`);
|
|
31
|
+
const workflow = await api(`${prefix}/workflows/${run.workflow_id}`);
|
|
32
|
+
validatePublicBuildRun(run, workflow, repository);
|
|
33
|
+
if (
|
|
34
|
+
![workflow.name, workflow.path?.split("/").pop()].includes(canary.workflow)
|
|
35
|
+
)
|
|
36
|
+
throw new Error("public build canary workflow differs from policy");
|
|
37
|
+
const summary = await readPublicBuildArtifact({
|
|
38
|
+
api,
|
|
39
|
+
fetchArchive,
|
|
40
|
+
repository,
|
|
41
|
+
run,
|
|
42
|
+
});
|
|
43
|
+
const runtimeSha = resolveStableCandidateQualificationCandidate({
|
|
44
|
+
repositoryName: repository,
|
|
45
|
+
sourceRun: run,
|
|
46
|
+
buildSummary: summary,
|
|
47
|
+
});
|
|
48
|
+
if (runtimeSha !== candidateSha)
|
|
49
|
+
throw new Error("public build canary does not qualify the exact candidate");
|
|
50
|
+
return {
|
|
51
|
+
...evidence,
|
|
52
|
+
status: "success",
|
|
53
|
+
completedAt: run.updated_at,
|
|
54
|
+
evidenceUrl: status.target_url,
|
|
55
|
+
repository,
|
|
56
|
+
workflow: workflow.name,
|
|
57
|
+
workflowId: workflow.id,
|
|
58
|
+
runtimeRef: runtimeSha,
|
|
59
|
+
runtimeRefSource: "public-build-summary",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function resolvePublicBuildCanaryEvidence({
|
|
64
|
+
api,
|
|
65
|
+
fetchArchive,
|
|
66
|
+
repository,
|
|
67
|
+
candidateSha,
|
|
68
|
+
policy,
|
|
69
|
+
}) {
|
|
70
|
+
const canaries = policy.requiredCanaries.filter(
|
|
71
|
+
(entry) => entry.source === "public-build",
|
|
72
|
+
);
|
|
73
|
+
if (!canaries.length) return [];
|
|
74
|
+
const statuses = await api(
|
|
75
|
+
`/repos/${repository}/commits/${candidateSha}/statuses?per_page=100`,
|
|
76
|
+
);
|
|
77
|
+
return Promise.all(
|
|
78
|
+
canaries.map((canary) =>
|
|
79
|
+
readCanary({
|
|
80
|
+
api,
|
|
81
|
+
fetchArchive,
|
|
82
|
+
repository,
|
|
83
|
+
candidateSha,
|
|
84
|
+
canary,
|
|
85
|
+
statuses,
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -74,10 +74,10 @@ export function loadStableReleasePolicy({ cwd = process.cwd(), input = "" } = {}
|
|
|
74
74
|
if (!id) {
|
|
75
75
|
throw new Error(`requiredCanaries[${index}].id is required`);
|
|
76
76
|
}
|
|
77
|
-
if (!new Set(["release-candidate", "commit-status"]).has(source)) {
|
|
78
|
-
throw new Error(`requiredCanaries[${index}].source must be release-candidate or
|
|
77
|
+
if (!new Set(["release-candidate", "commit-status", "public-build"]).has(source)) {
|
|
78
|
+
throw new Error(`requiredCanaries[${index}].source must be release-candidate, commit-status or public-build`);
|
|
79
79
|
}
|
|
80
|
-
if (source
|
|
80
|
+
if (source !== "release-candidate" && !string(canary.context)) {
|
|
81
81
|
throw new Error(`requiredCanaries[${index}].context is required for commit-status canaries`);
|
|
82
82
|
}
|
|
83
83
|
return {
|