@kungfu-tech/buildchain 4.1.0 → 4.1.1-alpha.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "4.1.0",
3
+ "version": "4.1.1-alpha.1",
4
4
  "private": false,
5
5
  "description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -0,0 +1,34 @@
1
+ export function createGitHubGraphqlError(errors) {
2
+ const details = errors.map((error) => ({
3
+ message: typeof error.message === "string" ? error.message : "",
4
+ ...(error.type ? { type: error.type } : {}),
5
+ ...(error.extensions?.code
6
+ ? { extensions: { code: error.extensions.code } }
7
+ : {}),
8
+ }));
9
+ return Object.assign(
10
+ new Error(
11
+ details
12
+ .map((error) => error.message)
13
+ .filter(Boolean)
14
+ .join("; ") || "GitHub GraphQL request failed",
15
+ ),
16
+ { errors: details },
17
+ );
18
+ }
19
+
20
+ export function isTransientGitHubGraphqlError(error) {
21
+ const errors = error?.errors;
22
+ return (
23
+ Array.isArray(errors) &&
24
+ errors.length > 0 &&
25
+ errors.every((detail) => {
26
+ const code = detail.type || detail.extensions?.code;
27
+ return code
28
+ ? code === "INTERNAL"
29
+ : /^Something went wrong while executing your query on /u.test(
30
+ detail.message || "",
31
+ );
32
+ })
33
+ );
34
+ }
@@ -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,60 @@
1
- import { command } from "../runtime/action-process.mjs";
1
+ import { execFileSync } from "node:child_process";
2
+ import { createGitHubGraphqlError } from "./github/graphql-errors.js";
2
3
 
3
- export function createGitHubCliApi(execute = command, env = process.env) {
4
+ function apiFailure(cause) {
5
+ let response;
6
+ try {
7
+ response = JSON.parse(String(cause.stdout || ""));
8
+ } catch {
9
+ // Process diagnostics are not a provider response and may contain secrets.
10
+ }
11
+ const messages = Array.isArray(response?.errors)
12
+ ? response.errors
13
+ .map((error) => error.message)
14
+ .filter((message) => typeof message === "string")
15
+ : [];
16
+ const message =
17
+ messages.join("; ") ||
18
+ (typeof response?.message === "string" ? response.message : "") ||
19
+ `GitHub API command failed with exit code ${cause.status ?? 1}`;
20
+ const error = messages.length
21
+ ? createGitHubGraphqlError(response.errors)
22
+ : new Error(message);
23
+ error.exitCode = cause.status ?? 1;
24
+ const status = Number(response?.status);
25
+ if (Number.isInteger(status) && status >= 100 && status <= 599)
26
+ error.status = status;
27
+ if (cause.code) error.code = cause.code;
28
+ return error;
29
+ }
30
+
31
+ export function createGitHubCliApi(execute = execFileSync, env = process.env) {
4
32
  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
- );
33
+ let output;
34
+ try {
35
+ output = execute(
36
+ "gh",
37
+ [
38
+ "api",
39
+ "--method",
40
+ method,
41
+ endpoint,
42
+ "-H",
43
+ "Accept: application/vnd.github+json",
44
+ ...flags,
45
+ ...(body === undefined ? [] : ["--input", "-"]),
46
+ ],
47
+ {
48
+ env,
49
+ encoding: "utf8",
50
+ input: body === undefined ? undefined : JSON.stringify(body),
51
+ maxBuffer: 8 * 1024 * 1024,
52
+ stdio: ["pipe", "pipe", "pipe"],
53
+ },
54
+ );
55
+ } catch (error) {
56
+ throw apiFailure(error);
57
+ }
24
58
  return output?.trim() ? JSON.parse(output) : {};
25
59
  }
26
60
  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,
@@ -2,6 +2,7 @@ import { requireCurrentIndependentApproval } from "./approval.js";
2
2
  import { REVIEWER } from "./review-policy.js";
3
3
  import { observe } from "./observation.js";
4
4
  import { enqueueNextDevelopmentPullRequest } from "../promote-candidate/next-development-queue.js";
5
+ import { createGitHubGraphqlError } from "../../providers/github/graphql-errors.js";
5
6
  export async function enqueueVerifiedDevelopmentReview({
6
7
  client,
7
8
  repository,
@@ -39,9 +40,7 @@ export async function enqueueVerifiedDevelopmentReview({
39
40
  graphql: async (query, variables) => {
40
41
  const result = client.post("graphql", { query, variables });
41
42
  if (result.errors?.length)
42
- throw new Error(
43
- result.errors.map((error) => error.message).join("; "),
44
- );
43
+ throw createGitHubGraphqlError(result.errors);
45
44
  return result.data;
46
45
  },
47
46
  },
@@ -0,0 +1,25 @@
1
+ export async function observeNextDevelopmentQueue({
2
+ mutationOctokit,
3
+ pull,
4
+ headSha,
5
+ }) {
6
+ const { node } = await mutationOctokit.graphql(
7
+ `query BuildchainObserveQueuedPullRequest($id: ID!) {
8
+ node(id: $id) { ... on PullRequest {
9
+ id headRefOid baseRefName state merged mergeQueueEntry { id }
10
+ } }
11
+ }`,
12
+ { id: pull.node_id },
13
+ );
14
+ if (
15
+ node?.id !== pull.node_id ||
16
+ node.headRefOid !== headSha ||
17
+ (pull.base?.ref && node.baseRefName !== pull.base.ref) ||
18
+ !["OPEN", "MERGED"].includes(node.state) ||
19
+ node.merged !== (node.state === "MERGED")
20
+ )
21
+ throw new Error(
22
+ "next-development queue readback cannot prove the exact open or merged pull request",
23
+ );
24
+ return node.merged || Boolean(node.mergeQueueEntry?.id);
25
+ }
@@ -4,6 +4,21 @@ const MAX_TRANSPORT_RETRIES = 4;
4
4
  export function nextDevelopmentQueueFailure(error) {
5
5
  const status = Number(error?.status || error?.response?.status || 0);
6
6
  const message = String(error?.message || "");
7
+ if (
8
+ [401, 403, 404, 409, 422].includes(status) &&
9
+ !(status === 403 && /rate limit/iu.test(message))
10
+ )
11
+ return "rejected";
12
+ if (Array.isArray(error?.errors) && error.errors.length) {
13
+ if (isTransientGitHubGraphqlError(error)) return "transient";
14
+ const kinds = error.errors.map((detail) => {
15
+ const code = detail.type || detail.extensions?.code;
16
+ return code && code !== "UNPROCESSABLE"
17
+ ? "rejected"
18
+ : nextDevelopmentQueueFailure({ message: detail.message });
19
+ });
20
+ return kinds.every((kind) => kind === kinds[0]) ? kinds[0] : "rejected";
21
+ }
7
22
  if (/already.*queue|queue.*already/iu.test(message)) return "queued";
8
23
  if (/already.*merged|merged.*already/iu.test(message)) return "merged";
9
24
  if (
@@ -28,6 +43,16 @@ export function assertNextDevelopmentPull(pull, headSha, base) {
28
43
  throw new Error("next-development pull request was closed without merge");
29
44
  }
30
45
 
46
+ async function recoverQueuedMutation(options) {
47
+ try {
48
+ return await observeNextDevelopmentQueue(options);
49
+ } catch (error) {
50
+ if (nextDevelopmentQueueFailure(error) !== "transient")
51
+ throw Object.assign(error, { releaseTailClass: "conflict" });
52
+ return false;
53
+ }
54
+ }
55
+
31
56
  export async function enqueueNextDevelopmentPullRequest({
32
57
  mutationOctokit,
33
58
  pull,
@@ -48,6 +73,11 @@ export async function enqueueNextDevelopmentPullRequest({
48
73
  } catch (error) {
49
74
  const kind = nextDevelopmentQueueFailure(error);
50
75
  if (kind === "queued" || kind === "merged") return;
76
+ if (
77
+ kind === "transient" &&
78
+ (await recoverQueuedMutation({ mutationOctokit, pull, headSha }))
79
+ )
80
+ return;
51
81
  if (
52
82
  kind === "rejected" ||
53
83
  poll === maxPolls ||
@@ -61,3 +91,5 @@ export async function enqueueNextDevelopmentPullRequest({
61
91
  }
62
92
  }
63
93
  }
94
+ import { isTransientGitHubGraphqlError } from "../../providers/github/graphql-errors.js";
95
+ import { observeNextDevelopmentQueue } from "./next-development-queue-observation.js";
@@ -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 {
@@ -281,7 +281,7 @@ for (const [channel, ref] of [["alpha", `v${selfDogfoodMajor}-alpha`], ["stable"
281
281
  const workflow = fs.readFileSync(path.join(root, `.github/workflows/self-build-${channel}-dogfood.yml`), "utf8");
282
282
  if (!workflow.includes(`/.github/workflows/build.yml@${ref}`) || /steps:|buildchain-channel:|runner-preset:|working-directory:/u.test(workflow)) throw new Error(`${channel} self-dogfood must remain a thin public TOML build caller`);
283
283
  if (!workflow.includes(`group: buildchain-${channel}-self-dogfood-`) || !workflow.includes("cancel-in-progress: false")) throw new Error(`${channel} self-dogfood must serialize its own runs`);
284
- const expectedInputs = channel === "alpha" ? [] : ["config-path"];
284
+ const expectedInputs = [];
285
285
  const call = parseWorkflowCallJobs(workflow).find((job) => job.id === `${channel}-consumer`);
286
286
  if (JSON.stringify(Object.keys(call.with || {})) !== JSON.stringify(expectedInputs)) throw new Error(`${channel} self-dogfood input contract drift`);
287
287
  }