@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.
@@ -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 { command } from "../runtime/action-process.mjs";
1
+ import { execFileSync } from "node:child_process";
2
2
 
3
- export function createGitHubCliApi(execute = command, env = process.env) {
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
- const output = execute(
6
- "gh",
7
- [
8
- "api",
9
- "--method",
10
- method,
11
- endpoint,
12
- "-H",
13
- "Accept: application/vnd.github+json",
14
- ...flags,
15
- ...(body === undefined ? [] : ["--input", "-"]),
16
- ],
17
- {
18
- env,
19
- input: body === undefined ? undefined : JSON.stringify(body),
20
- maxBuffer: 8 * 1024 * 1024,
21
- stdio: ["pipe", "pipe", "pipe"],
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 commit-status`);
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 === "commit-status" && !string(canary.context)) {
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 {