@kungfu-tech/buildchain 3.0.6-alpha.0 → 3.0.6-alpha.10

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.
Files changed (83) hide show
  1. package/README.md +4 -4
  2. package/actions/promote-buildchain-ref/README.md +13 -1
  3. package/bin/buildchain.mjs +13 -1
  4. package/contracts/auditable-demo-scenario-v1.schema.json +52 -0
  5. package/contracts/release-candidate-recovery-v1.schema.json +104 -0
  6. package/dist/site/buildchain-contract.json +134 -34
  7. package/dist/site/buildchain-site.json +178 -47
  8. package/dist/site/capability-registry.json +5 -5
  9. package/dist/site/cli-registry.json +40 -4
  10. package/dist/site/controller-registry.json +60 -4
  11. package/dist/site/kfd-claims.json +190 -20
  12. package/dist/site/kfd-upstream-aggregate.json +9 -9
  13. package/dist/site/manual-registry.json +10 -10
  14. package/dist/site/node-api-registry.json +1295 -80
  15. package/dist/site/page-registry.json +164 -33
  16. package/dist/site/public-surface-audit.json +428 -21
  17. package/dist/site/publication-authority-registry.json +66 -1
  18. package/dist/site/publication-registry.json +4 -4
  19. package/dist/site/release-provenance.json +2 -0
  20. package/dist/site/site-manifest.json +14 -14
  21. package/dist/site/workflow-registry.json +203 -15
  22. package/docs/MAP.md +2 -0
  23. package/docs/auditable-demo.md +58 -11
  24. package/docs/aws-us-elastic-runner-burst-plane.md +114 -80
  25. package/docs/cli-reference.md +154 -0
  26. package/docs/dev-alpha-candidate-patrol.md +13 -5
  27. package/docs/dev-delivery-warrant.md +158 -0
  28. package/docs/node-api-reference.md +105 -44
  29. package/docs/publication-authority.md +11 -0
  30. package/docs/publish-transaction.md +10 -1
  31. package/docs/release-candidate.md +140 -12
  32. package/docs/release-governance.md +66 -1
  33. package/docs/reusable-build-surface.md +11 -1
  34. package/docs/shifu-gate-profiles.md +12 -1
  35. package/docs/versioning.md +2 -0
  36. package/package.json +5 -2
  37. package/packages/core/buildchain-contract.js +30 -2
  38. package/packages/core/buildchain-publication-authority.js +3 -1
  39. package/packages/core/channel-candidate.js +2 -21
  40. package/packages/core/channel-promotion-baseline.js +199 -0
  41. package/packages/core/dev-delivery-candidate-identity.js +94 -0
  42. package/packages/core/dev-delivery-common.js +73 -0
  43. package/packages/core/dev-delivery-proof.js +252 -0
  44. package/packages/core/dev-delivery-warrant-cancellation.js +94 -0
  45. package/packages/core/dev-delivery-warrant-settlement.js +73 -0
  46. package/packages/core/dev-delivery-warrant.js +591 -0
  47. package/packages/core/index.js +7 -0
  48. package/packages/core/publication-sealed-bundle.js +10 -1
  49. package/packages/core/release-candidate-recovery.js +539 -0
  50. package/scripts/audit-publication-control-plane.mjs +12 -4
  51. package/scripts/auditable-demo-bundle-verification.mjs +148 -0
  52. package/scripts/auditable-demo-platform.mjs +86 -50
  53. package/scripts/auditable-demo-presentation.mjs +83 -0
  54. package/scripts/auditable-demo-renditions.mjs +264 -0
  55. package/scripts/auditable-demo.mjs +24 -30
  56. package/scripts/aws-windows-jit-campaign-core.mjs +7 -8
  57. package/scripts/aws-windows-jit-controller.mjs +1 -0
  58. package/scripts/aws-windows-jit-core.mjs +1 -1
  59. package/scripts/build-contract-core.mjs +58 -3
  60. package/scripts/buildchain-cli-help.mjs +8 -0
  61. package/scripts/buildchain-patrol.mjs +9 -0
  62. package/scripts/check-inventory.mjs +25 -3
  63. package/scripts/dev-alpha-candidate-patrol.mjs +45 -48
  64. package/scripts/dev-delivery-proof.mjs +193 -0
  65. package/scripts/dev-delivery-warrant.mjs +426 -0
  66. package/scripts/dev-pr-auto-merge.mjs +497 -55
  67. package/scripts/dev-pr-delivery-warrant.mjs +209 -0
  68. package/scripts/dispatch-artifact-signing-authority.mjs +2 -4
  69. package/scripts/gate-profile-core.mjs +24 -0
  70. package/scripts/generate-channel-promotion-workflow.mjs +30 -14
  71. package/scripts/generate-site-bundle.mjs +2 -2
  72. package/scripts/git-fetch-process-tree.mjs +142 -0
  73. package/scripts/lifecycle-substage-evidence.mjs +274 -0
  74. package/scripts/locked-source-checkout.mjs +6 -3
  75. package/scripts/publication-candidate-sealer.mjs +104 -0
  76. package/scripts/release-candidate-resolver.mjs +91 -5
  77. package/scripts/resolve-artifact-transfer-mode.mjs +9 -0
  78. package/scripts/resolve-build-contract.mjs +7 -0
  79. package/scripts/resume-from-candidate-run.mjs +598 -0
  80. package/scripts/route-offline-runners.mjs +1 -0
  81. package/scripts/run-lifecycle-core.mjs +9 -9
  82. package/scripts/shifu-gate-profile.mjs +10 -16
  83. package/scripts/site-capability-metadata.mjs +15 -0
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const ROOT_PATTERN = /^sha256:[0-9a-f]{64}$/u;
9
+ const SHA_PATTERN = /^[0-9a-f]{40}$/u;
10
+
11
+ function canonical(value) {
12
+ if (Array.isArray(value)) return value.map(canonical);
13
+ if (value && typeof value === "object") {
14
+ return Object.fromEntries(
15
+ Object.entries(value)
16
+ .sort(([left], [right]) => left.localeCompare(right))
17
+ .map(([key, item]) => [key, canonical(item)]),
18
+ );
19
+ }
20
+ return value;
21
+ }
22
+
23
+ function digest(value) {
24
+ return `sha256:${crypto
25
+ .createHash("sha256")
26
+ .update(JSON.stringify(canonical(value)))
27
+ .digest("hex")}`;
28
+ }
29
+
30
+ function withoutRoot(value, field = "evidenceRoot") {
31
+ const body = structuredClone(value);
32
+ Reflect.deleteProperty(body, field);
33
+ return body;
34
+ }
35
+
36
+ function requireIso(value, label) {
37
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
38
+ throw new Error(`${label} must be an ISO timestamp`);
39
+ }
40
+ }
41
+
42
+ function verifyEvidenceHeader(
43
+ evidence,
44
+ { lifecycleStage, sourceSha, sourceTree, platformId },
45
+ ) {
46
+ if (evidence?.schema !== "kungfu.lifecycle-substage-evidence/v1") {
47
+ throw new Error(
48
+ `unsupported lifecycle substage evidence schema: ${evidence?.schema || "missing"}`,
49
+ );
50
+ }
51
+ if (!ROOT_PATTERN.test(evidence.evidenceRoot || "")) {
52
+ throw new Error("lifecycle substage evidence root is invalid");
53
+ }
54
+ if (evidence.evidenceRoot !== digest(withoutRoot(evidence))) {
55
+ throw new Error("lifecycle substage evidence root mismatch");
56
+ }
57
+ if (lifecycleStage && evidence.lifecycleStage !== lifecycleStage) {
58
+ throw new Error(
59
+ `lifecycle stage mismatch: expected ${lifecycleStage}, got ${evidence.lifecycleStage}`,
60
+ );
61
+ }
62
+ if (
63
+ !SHA_PATTERN.test(evidence.source?.sha || "") ||
64
+ !SHA_PATTERN.test(evidence.source?.tree || "")
65
+ ) {
66
+ throw new Error("lifecycle substage source identity is invalid");
67
+ }
68
+ if (sourceSha && evidence.source.sha !== sourceSha) {
69
+ throw new Error(
70
+ `lifecycle substage source SHA mismatch: expected ${sourceSha}, got ${evidence.source.sha}`,
71
+ );
72
+ }
73
+ if (sourceTree && evidence.source.tree !== sourceTree) {
74
+ throw new Error(
75
+ `lifecycle substage source tree mismatch: expected ${sourceTree}, got ${evidence.source.tree}`,
76
+ );
77
+ }
78
+ if (platformId && evidence.platform?.id !== platformId) {
79
+ throw new Error(
80
+ `lifecycle substage platform mismatch: expected ${platformId}, got ${evidence.platform?.id}`,
81
+ );
82
+ }
83
+ if (!["passed", "failed"].includes(evidence.conclusion)) {
84
+ throw new Error("lifecycle substage conclusion must be passed or failed");
85
+ }
86
+ requireIso(evidence.startedAt, "lifecycle substage startedAt");
87
+ requireIso(evidence.completedAt, "lifecycle substage completedAt");
88
+ if (!Array.isArray(evidence.substages) || evidence.substages.length === 0) {
89
+ throw new Error("lifecycle substage evidence has no substages");
90
+ }
91
+ }
92
+
93
+ function verifySubstage(substage, index, names) {
94
+ if (typeof substage.stage !== "string" || !substage.stage) {
95
+ throw new Error(`substage ${index} has no name`);
96
+ }
97
+ if (names.has(substage.stage)) {
98
+ throw new Error(`duplicate lifecycle substage: ${substage.stage}`);
99
+ }
100
+ names.add(substage.stage);
101
+ requireIso(substage.startedAt, `${substage.stage}.startedAt`);
102
+ requireIso(substage.completedAt, `${substage.stage}.completedAt`);
103
+ if (
104
+ !Number.isFinite(substage.durationSeconds) ||
105
+ substage.durationSeconds < 0
106
+ ) {
107
+ throw new Error(`${substage.stage}.durationSeconds is invalid`);
108
+ }
109
+ if (
110
+ !Number.isInteger(substage.status) ||
111
+ !["passed", "failed"].includes(substage.conclusion)
112
+ ) {
113
+ throw new Error(`${substage.stage} result is invalid`);
114
+ }
115
+ if ((substage.status === 0) !== (substage.conclusion === "passed")) {
116
+ throw new Error(`${substage.stage} status and conclusion disagree`);
117
+ }
118
+ if (
119
+ !["platform-native", "exact-source-reuse"].includes(substage.executionMode)
120
+ ) {
121
+ throw new Error(`${substage.stage}.executionMode is invalid`);
122
+ }
123
+ if (
124
+ !ROOT_PATTERN.test(substage.evidenceRoot || "") ||
125
+ substage.evidenceRoot !== digest(withoutRoot(substage))
126
+ ) {
127
+ throw new Error(`${substage.stage} evidence root mismatch`);
128
+ }
129
+ }
130
+
131
+ function verifyAggregate(evidence) {
132
+ const failed = evidence.substages.some((substage) => substage.status !== 0);
133
+ const expectedFailureReason = failed
134
+ ? "substage-failed"
135
+ : evidence.conclusion === "failed"
136
+ ? "budget-exceeded"
137
+ : undefined;
138
+ if (
139
+ (failed && evidence.conclusion !== "failed") ||
140
+ (!failed && evidence.conclusion === "passed" && evidence.failureReason) ||
141
+ evidence.failureReason !== expectedFailureReason
142
+ ) {
143
+ throw new Error("lifecycle substage aggregate conclusion is inconsistent");
144
+ }
145
+ }
146
+
147
+ export function verifyLifecycleSubstageEvidence(
148
+ value,
149
+ {
150
+ lifecycleStage = "",
151
+ sourceSha = "",
152
+ sourceTree = "",
153
+ platformId = "",
154
+ } = {},
155
+ ) {
156
+ const evidence = value?.substageEvidence || value;
157
+ verifyEvidenceHeader(evidence, {
158
+ lifecycleStage,
159
+ sourceSha,
160
+ sourceTree,
161
+ platformId,
162
+ });
163
+ const names = new Set();
164
+ for (const [index, substage] of evidence.substages.entries()) {
165
+ verifySubstage(substage, index, names);
166
+ }
167
+ verifyAggregate(evidence);
168
+ return structuredClone(evidence);
169
+ }
170
+
171
+ export function lifecycleSubstageEvidenceContext({
172
+ substageEvidencePath = "",
173
+ cwd,
174
+ workspace,
175
+ diagnosticsDir,
176
+ lifecycleStage,
177
+ sourceSha = process.env.BUILDCHAIN_SOURCE_SHA || "",
178
+ sourceTree = process.env.BUILDCHAIN_SOURCE_TREE_SHA || "",
179
+ platformId,
180
+ }) {
181
+ if (!substageEvidencePath) {
182
+ return {
183
+ evidence: undefined,
184
+ observability: {},
185
+ lifecycle: {},
186
+ links: {},
187
+ sourcePath: "",
188
+ targetPath: "",
189
+ sidecar: {},
190
+ };
191
+ }
192
+ const sourcePath = path.resolve(cwd, substageEvidencePath);
193
+ const targetPath = path.join(diagnosticsDir, "verify-substages.json");
194
+ const relativePath = path
195
+ .relative(workspace, targetPath)
196
+ .split(path.sep)
197
+ .join("/");
198
+ const evidence = readLifecycleSubstageEvidence(sourcePath, {
199
+ lifecycleStage,
200
+ sourceSha,
201
+ sourceTree,
202
+ platformId,
203
+ });
204
+ return {
205
+ evidence,
206
+ observability: {
207
+ substages: {
208
+ contract: evidence.schema,
209
+ evidenceRoot: evidence.evidenceRoot,
210
+ conclusion: evidence.conclusion,
211
+ path: relativePath,
212
+ },
213
+ },
214
+ lifecycle: { substageEvidence: evidence },
215
+ links: { lifecycleSubstages: relativePath },
216
+ sourcePath,
217
+ targetPath,
218
+ sidecar: {
219
+ kind: "lifecycle-substages",
220
+ filePath: targetPath,
221
+ required: true,
222
+ },
223
+ };
224
+ }
225
+
226
+ export function readLifecycleSubstageEvidence(file, options = {}) {
227
+ if (!file) return undefined;
228
+ const absolute = path.resolve(file);
229
+ if (!fs.existsSync(absolute))
230
+ throw new Error(`lifecycle substage evidence file not found: ${file}`);
231
+ return verifyLifecycleSubstageEvidence(
232
+ JSON.parse(fs.readFileSync(absolute, "utf8")),
233
+ options,
234
+ );
235
+ }
236
+
237
+ function parse(argv) {
238
+ const options = {};
239
+ for (let index = 0; index < argv.length; index += 2) {
240
+ const flag = argv[index];
241
+ if (!flag?.startsWith("--") || index + 1 >= argv.length)
242
+ throw new Error(`invalid option: ${flag || "missing"}`);
243
+ options[flag.slice(2)] = argv[index + 1];
244
+ }
245
+ return options;
246
+ }
247
+
248
+ function main(argv = process.argv.slice(2)) {
249
+ const options = parse(argv);
250
+ const evidence = readLifecycleSubstageEvidence(options.file, {
251
+ lifecycleStage: options.stage || "",
252
+ sourceSha: options["source-sha"] || "",
253
+ sourceTree: options["source-tree"] || "",
254
+ platformId: options["platform-id"] || "",
255
+ });
256
+ process.stdout.write(
257
+ `${JSON.stringify({ ok: true, evidenceRoot: evidence.evidenceRoot, conclusion: evidence.conclusion })}\n`,
258
+ );
259
+ }
260
+
261
+ if (
262
+ process.argv[1] &&
263
+ path.basename(process.argv[1]) === "lifecycle-substage-evidence.mjs" &&
264
+ fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
265
+ ) {
266
+ try {
267
+ main();
268
+ } catch (error) {
269
+ console.error(
270
+ `[lifecycle-substages] ${error instanceof Error ? error.message : String(error)}`,
271
+ );
272
+ process.exit(1);
273
+ }
274
+ }
@@ -4,6 +4,7 @@ import crypto from "node:crypto";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
+ import { runGitFetchSync } from "./git-fetch-process-tree.mjs";
7
8
 
8
9
  export const LOCKED_SOURCE_CHECKOUT_CONTRACT = "kungfu-buildchain-locked-source-checkout-cache";
9
10
  export const ISOLATED_GIT_GLOBAL_CONFIG = process.platform === "win32" ? "NUL" : "/dev/null";
@@ -111,9 +112,10 @@ function ensureCheckoutTarget(targetPath, workspace) {
111
112
 
112
113
  function git(args, { cwd, env = {}, timeoutMs = 60000, stdio = ["ignore", "pipe", "pipe"] } = {}) {
113
114
  try {
114
- const output = execFileSync("git", args, {
115
+ const commandEnv = { ...process.env, ...env };
116
+ const output = args[0] === "fetch" ? runGitFetchSync({ args, cwd, env: commandEnv, timeoutMs, stdio }) : execFileSync("git", args, {
115
117
  cwd,
116
- env: { ...process.env, ...env },
118
+ env: commandEnv,
117
119
  encoding: "utf8",
118
120
  stdio,
119
121
  timeout: timeoutMs,
@@ -233,7 +235,8 @@ function checkoutFetchedCommit(targetPath, sha, timeoutMs) {
233
235
  function retryableGitFetchError(error) {
234
236
  const code = String(error?.code || "").toUpperCase();
235
237
  if (["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ENETUNREACH", "EPIPE"].includes(code)) return true;
236
- return /timed?\s*out|timeout|connection (?:reset|refused)|remote end hung up|early eof|rpc failed|http (?:429|5\d\d)|temporary failure|network is unreachable/i.test(String(error?.message || error || ""));
238
+ const commandOutput = [error?.stderr, error?.stdout].filter(Boolean).map(String).join("\n").trim();
239
+ return /timed?\s*out|timeout|connection (?:reset|refused)|remote end hung up|early eof|rpc failed|http (?:429|5\d\d)|temporary failure|network is unreachable/i.test(commandOutput || String(error?.message || error || ""));
237
240
  }
238
241
 
239
242
  export function fetchSourceCommit({
@@ -0,0 +1,104 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
7
+ publicationArtifactCandidateDigest,
8
+ } from "../packages/core/publication-artifact-candidate.js";
9
+ import { createPublicationSealedBundle } from "../packages/core/publication-sealed-bundle.js";
10
+
11
+ function requiredString(value, label) {
12
+ const normalized = String(value || "").trim();
13
+ if (!normalized) throw new Error(`${label} must be a non-empty string`);
14
+ return normalized;
15
+ }
16
+
17
+ function normalizeSha256(value, label) {
18
+ const digest = requiredString(value, label).replace(/^sha256:/, "").toLowerCase();
19
+ if (!/^[0-9a-f]{64}$/.test(digest)) throw new Error(`${label} must be a sha256 digest`);
20
+ return digest;
21
+ }
22
+
23
+ function assertGitSha(value, label) {
24
+ const sha = requiredString(value, label);
25
+ if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error(`${label} must be a 40-character Git SHA`);
26
+ return sha.toLowerCase();
27
+ }
28
+
29
+ function collectCandidateFiles(root) {
30
+ const resolvedRoot = path.resolve(requiredString(root, "bundleRoot"));
31
+ const files = [];
32
+ const stack = fs.existsSync(resolvedRoot) ? [resolvedRoot] : [];
33
+ while (stack.length) {
34
+ const current = stack.pop();
35
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
36
+ const absolutePath = path.join(current, entry.name);
37
+ if (entry.isDirectory()) {
38
+ stack.push(absolutePath);
39
+ } else if (entry.isFile()) {
40
+ files.push({
41
+ path: path.relative(resolvedRoot, absolutePath).split(path.sep).join("/"),
42
+ absolutePath,
43
+ size: fs.statSync(absolutePath).size,
44
+ sha256: crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex"),
45
+ });
46
+ }
47
+ }
48
+ }
49
+ return { resolvedRoot, files: files.sort((left, right) => left.path.localeCompare(right.path)) };
50
+ }
51
+
52
+ export function createResolvedPublicationSealedBundle({
53
+ bundleRoot,
54
+ repository,
55
+ sourceSha,
56
+ sourceTreeSha,
57
+ runtimeSha,
58
+ releaseCandidateRoot,
59
+ npmArtifacts = [],
60
+ releaseAssetPaths = [],
61
+ } = {}) {
62
+ if (npmArtifacts.length === 0) return undefined;
63
+ const { resolvedRoot, files } = collectCandidateFiles(bundleRoot);
64
+ const byAbsolutePath = new Map(files.map((file) => [path.resolve(file.absolutePath), file]));
65
+ const resolvedNpmArtifacts = npmArtifacts.map((artifact, index) => {
66
+ const file = byAbsolutePath.get(path.resolve(requiredString(artifact.path, `npmArtifacts[${index}].path`)));
67
+ if (!file) throw new Error(`npmArtifacts[${index}].path is outside the sealed payload root`);
68
+ return { file, metadata: artifact };
69
+ });
70
+ const main = resolvedNpmArtifacts.find((entry) => entry.metadata.role === "main")
71
+ || (resolvedNpmArtifacts.length === 1 ? resolvedNpmArtifacts[0] : undefined);
72
+ if (!main) throw new Error("candidate npm payload set has no unique main package tarball");
73
+ const selectedReleaseAssets = (releaseAssetPaths.length > 0
74
+ ? releaseAssetPaths
75
+ : resolvedNpmArtifacts.map((entry) => entry.file.absolutePath))
76
+ .map((assetPath, index) => {
77
+ const file = byAbsolutePath.get(path.resolve(assetPath));
78
+ if (!file) throw new Error(`releaseAssetPaths[${index}] is outside the sealed payload root`);
79
+ return file;
80
+ });
81
+ const candidatePayload = {
82
+ schemaVersion: 1,
83
+ contract: PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
84
+ repository: requiredString(repository, "repository"),
85
+ sourceSha: assertGitSha(sourceSha, "sourceSha"),
86
+ sourceTreeSha: assertGitSha(sourceTreeSha, "sourceTreeSha"),
87
+ runtimeSha: assertGitSha(runtimeSha, "runtimeSha"),
88
+ releaseCandidateRoot: `sha256:${normalizeSha256(releaseCandidateRoot, "releaseCandidateRoot")}`,
89
+ files: files.map(({ path: filePath, size, sha256 }) => ({ path: filePath, size, sha256 })),
90
+ };
91
+ const candidate = {
92
+ ...candidatePayload,
93
+ candidateDigest: publicationArtifactCandidateDigest(candidatePayload),
94
+ };
95
+ const manifest = createPublicationSealedBundle({
96
+ candidate,
97
+ packageName: main.metadata.name,
98
+ packageVersion: main.metadata.ref,
99
+ npmTarballPath: main.file.path,
100
+ npmIntegrity: main.metadata.integrity,
101
+ releaseAssetPaths: selectedReleaseAssets.map((file) => file.path),
102
+ });
103
+ return { root: resolvedRoot, manifest, npmArtifacts: resolvedNpmArtifacts, files };
104
+ }
@@ -4,7 +4,10 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { execFileSync } from "node:child_process";
7
+ import { Readable } from "node:stream";
8
+ import { pipeline } from "node:stream/promises";
7
9
  import { pathToFileURL } from "node:url";
10
+ import { createResolvedPublicationSealedBundle } from "./publication-candidate-sealer.mjs";
8
11
  import { writeGitHubOutputs } from "./build-contract-core.mjs";
9
12
 
10
13
  const DEFAULT_WORKFLOW_FILE = "build-surface-fixture.yml";
@@ -37,6 +40,9 @@ function assertSha(value, label = "sha") {
37
40
  return sha;
38
41
  }
39
42
 
43
+ export const releaseCandidateRuntimeSha = (passport) =>
44
+ assertSha(passport?.buildchain?.sha, "release candidate Passport Buildchain runtime SHA").toLowerCase();
45
+
40
46
  function githubHeaders(token) {
41
47
  const headers = {
42
48
  accept: "application/vnd.github+json",
@@ -49,7 +55,7 @@ function githubHeaders(token) {
49
55
  return headers;
50
56
  }
51
57
 
52
- async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fetchImpl = globalThis.fetch }) {
58
+ export async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fetchImpl = globalThis.fetch, allowNotFound = false }) {
53
59
  if (typeof fetchImpl !== "function") {
54
60
  throw new Error("fetch is required to resolve release candidate artifacts");
55
61
  }
@@ -57,13 +63,14 @@ async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fe
57
63
  const response = await fetchImpl(url, { method, headers: githubHeaders(token) });
58
64
  const text = await response.text();
59
65
  const body = text ? JSON.parse(text) : {};
66
+ if (allowNotFound && response.status === 404) return undefined;
60
67
  if (!response.ok) {
61
68
  throw new Error(`GitHub API ${method} ${requestPath} failed with ${response.status}: ${body.message || text}`);
62
69
  }
63
70
  return body;
64
71
  }
65
72
 
66
- async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fetchImpl = globalThis.fetch }) {
73
+ export async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fetchImpl = globalThis.fetch }) {
67
74
  const url = `${String(apiUrl || "https://api.github.com").replace(/\/+$/, "")}${requestPath}`;
68
75
  const response = await fetchImpl(url, { headers: githubHeaders(token) });
69
76
  if (!response.ok) {
@@ -75,10 +82,32 @@ async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fe
75
82
  }
76
83
  throw new Error(`GitHub artifact download ${requestPath} failed with ${response.status}${detail ? `: ${detail}` : ""}`);
77
84
  }
78
- fs.writeFileSync(outputPath, Buffer.from(await response.arrayBuffer()));
85
+ try {
86
+ if (response.body && typeof response.body.getReader === "function") {
87
+ await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(outputPath));
88
+ } else {
89
+ fs.writeFileSync(outputPath, Buffer.from(await response.arrayBuffer()));
90
+ }
91
+ } catch (error) {
92
+ fs.rmSync(outputPath, { force: true });
93
+ throw error;
94
+ }
79
95
  return outputPath;
80
96
  }
81
97
 
98
+ function digestFileSync(filePath, algorithm, encoding) {
99
+ const hash = crypto.createHash(algorithm);
100
+ const descriptor = fs.openSync(filePath, "r");
101
+ const chunk = Buffer.allocUnsafe(8 * 1024 * 1024);
102
+ try {
103
+ let bytesRead = 0;
104
+ while ((bytesRead = fs.readSync(descriptor, chunk, 0, chunk.length, null)) > 0) hash.update(chunk.subarray(0, bytesRead));
105
+ } finally {
106
+ fs.closeSync(descriptor);
107
+ }
108
+ return hash.digest(encoding);
109
+ }
110
+
82
111
  export function selectMergedChannelPullRequest({ pullRequests = [], targetRef, repository }) {
83
112
  const normalizedTarget = normalizeBranch(targetRef);
84
113
  const candidates = pullRequests.filter((pr) => {
@@ -254,7 +283,7 @@ function packageNameFromArtifactPath(filePath) {
254
283
  }
255
284
 
256
285
  function npmIntegrity(filePath) {
257
- return `sha512-${crypto.createHash("sha512").update(fs.readFileSync(filePath)).digest("base64")}`;
286
+ return `sha512-${digestFileSync(filePath, "sha512", "base64")}`;
258
287
  }
259
288
 
260
289
  function readNpmPackageJsonFromTarball(tarballPath) {
@@ -357,7 +386,34 @@ export function selectReleaseCandidateArtifacts({ artifacts = [], artifactName =
357
386
  return { passport, summary: summaries[0], prefix, sourceSha: sha };
358
387
  }
359
388
 
360
- function unzip(zipPath, outputDir) {
389
+ export function verifyArtifactArchive({ artifact, archivePath } = {}) {
390
+ const size = fs.statSync(archivePath).size;
391
+ const digest = `sha256:${digestFileSync(archivePath, "sha256", "hex")}`;
392
+ if (!artifact || artifact.expired === true) {
393
+ throw new Error(`candidate artifact is missing or expired: ${artifact?.name || "<unknown>"}`);
394
+ }
395
+ if (Number(artifact.size_in_bytes) !== size) {
396
+ throw new Error(`candidate artifact size mismatch for ${artifact.name}: expected ${artifact.size_in_bytes}, got ${size}`);
397
+ }
398
+ if (!/^sha256:[0-9a-f]{64}$/i.test(String(artifact.digest || ""))) {
399
+ throw new Error(`candidate artifact ${artifact.name} has no trusted sha256 digest metadata`);
400
+ }
401
+ if (String(artifact.digest).toLowerCase() !== digest) {
402
+ throw new Error(`candidate artifact digest mismatch for ${artifact.name}: expected ${artifact.digest}, got ${digest}`);
403
+ }
404
+ return { size, digest };
405
+ }
406
+
407
+ export function unzip(zipPath, outputDir) {
408
+ const entries = execFileSync("unzip", ["-Z1", zipPath], { encoding: "utf8" })
409
+ .split(/\r?\n/)
410
+ .filter(Boolean);
411
+ for (const entry of entries) {
412
+ const normalized = entry.replaceAll("\\", "/");
413
+ if (normalized.startsWith("/") || normalized.split("/").some((part) => part === "..")) {
414
+ throw new Error(`candidate artifact contains an unsafe zip entry: ${entry}`);
415
+ }
416
+ }
361
417
  fs.mkdirSync(outputDir, { recursive: true });
362
418
  execFileSync("unzip", ["-q", "-o", zipPath, "-d", outputDir], { stdio: "inherit" });
363
419
  }
@@ -392,6 +448,7 @@ export async function resolveReleaseCandidateArtifacts({
392
448
  requiredArtifactCount = 0,
393
449
  publishArtifactKind = "npm",
394
450
  publishPackageMain = "",
451
+ runtimeSha = "",
395
452
  outputDir = ".buildchain/release-candidate",
396
453
  fetchImpl = globalThis.fetch,
397
454
  download = true,
@@ -579,6 +636,7 @@ export async function resolveReleaseCandidateArtifacts({
579
636
  outputPath: passportZip,
580
637
  path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${selected.passport.id}/zip`,
581
638
  });
639
+ verifyArtifactArchive({ artifact: selected.passport, archivePath: passportZip });
582
640
  await githubDownload({
583
641
  apiUrl,
584
642
  token,
@@ -586,6 +644,7 @@ export async function resolveReleaseCandidateArtifacts({
586
644
  outputPath: summaryZip,
587
645
  path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${selected.summary.id}/zip`,
588
646
  });
647
+ verifyArtifactArchive({ artifact: selected.summary, archivePath: summaryZip });
589
648
  for (const artifact of payloadArtifacts) {
590
649
  const safeName = String(artifact.name || `artifact-${artifact.id}`).replace(/[^A-Za-z0-9._-]/g, "_");
591
650
  const payloadZip = path.join(tempDir, `${safeName}.zip`);
@@ -596,6 +655,7 @@ export async function resolveReleaseCandidateArtifacts({
596
655
  outputPath: payloadZip,
597
656
  path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${artifact.id}/zip`,
598
657
  });
658
+ verifyArtifactArchive({ artifact, archivePath: payloadZip });
599
659
  unzip(payloadZip, path.join(payloadDir, safeName));
600
660
  }
601
661
  unzip(passportZip, passportDir);
@@ -636,6 +696,27 @@ export async function resolveReleaseCandidateArtifacts({
636
696
  });
637
697
  const requiredArtifactsPath = path.join(resolvedOutput, "publish-required-artifacts.json");
638
698
  fs.writeFileSync(requiredArtifactsPath, `${JSON.stringify(generatedRequiredArtifacts, null, 2)}\n`);
699
+ const sealedBundle = publishArtifactKind === "npm"
700
+ ? createResolvedPublicationSealedBundle({
701
+ bundleRoot: payloadDir,
702
+ repository: repoInfo.fullName,
703
+ sourceSha: passport.source?.headSha,
704
+ sourceTreeSha: passport.source?.treeHash,
705
+ runtimeSha: releaseCandidateRuntimeSha(passport),
706
+ releaseCandidateRoot: passport.candidateHash,
707
+ npmArtifacts: npmTarballPaths.map((tarballPath) => ({
708
+ path: tarballPath,
709
+ ...readNpmPackageArtifact({ tarballPath, mainPackage: publishPackageMain }),
710
+ })),
711
+ releaseAssetPaths,
712
+ })
713
+ : undefined;
714
+ const sealedBundleManifestPath = sealedBundle
715
+ ? path.join(resolvedOutput, "sealed-bundle.json")
716
+ : "";
717
+ if (sealedBundleManifestPath) {
718
+ fs.writeFileSync(sealedBundleManifestPath, `${JSON.stringify(sealedBundle.manifest, null, 2)}\n`);
719
+ }
639
720
  return {
640
721
  ...result,
641
722
  paths: {
@@ -647,6 +728,8 @@ export async function resolveReleaseCandidateArtifacts({
647
728
  npmTarballs: npmTarballPaths.map(outputPath),
648
729
  releaseAssets: releaseAssetPaths.map(outputPath),
649
730
  publishRequiredArtifacts: outputPath(requiredArtifactsPath),
731
+ sealedBundleRoot: sealedBundle ? outputPath(sealedBundle.root) : "",
732
+ sealedBundleManifest: sealedBundleManifestPath ? outputPath(sealedBundleManifestPath) : "",
650
733
  },
651
734
  version: passport.target?.version || "",
652
735
  candidateHash: passport.candidateHash || "",
@@ -673,6 +756,7 @@ export async function resolveReleaseCandidateArtifactsCli() {
673
756
  requiredArtifactCount: env("BUILDCHAIN_REQUIRED_ARTIFACT_COUNT", "0"),
674
757
  publishArtifactKind: env("BUILDCHAIN_PUBLISH_ARTIFACT_KIND", "npm"),
675
758
  publishPackageMain: env("BUILDCHAIN_PUBLISH_PACKAGE_MAIN"),
759
+ runtimeSha: env("BUILDCHAIN_CURRENT_RUNTIME_SHA", env("BUILDCHAIN_RUNTIME_SHA", env("BUILDCHAIN_TARGET_SHA"))),
676
760
  outputDir: env("BUILDCHAIN_RC_OUTPUT_DIR", ".buildchain/release-candidate"),
677
761
  download: releaseCandidateDownloadEnabled(env("BUILDCHAIN_RC_DOWNLOAD", "true")),
678
762
  waitSeconds: env("BUILDCHAIN_RC_WAIT_SECONDS", "600"),
@@ -703,6 +787,8 @@ export async function resolveReleaseCandidateArtifactsCli() {
703
787
  ).join("\n"),
704
788
  "publish-required-artifacts-json": JSON.stringify(result.publishRequiredArtifacts || []),
705
789
  "publish-required-artifacts-path": result.paths?.publishRequiredArtifacts || "",
790
+ "publish-sealed-bundle-root": result.paths?.sealedBundleRoot || "",
791
+ "publish-sealed-bundle-manifest": result.paths?.sealedBundleManifest || "",
706
792
  "release-candidate-run-id": result.run?.id || "",
707
793
  "release-candidate-run-url": result.run?.url || "",
708
794
  "release-candidate-pr": result.pullRequest?.number ? String(result.pullRequest.number) : "",
@@ -26,6 +26,15 @@ function resolveArtifactTransferMode(env = process.env) {
26
26
  oidcAudience: "",
27
27
  };
28
28
  }
29
+ if (env.INPUT_RELAY_REQUIRED === "false") {
30
+ return {
31
+ mode: "github-artifacts",
32
+ s3Bucket: "",
33
+ s3Region: "",
34
+ s3Prefix: "",
35
+ oidcAudience: "",
36
+ };
37
+ }
29
38
  const s3Bucket = firstValue(env.INPUT_S3_BUCKET, env.VAR_S3_BUCKET);
30
39
  const s3Region = firstValue(env.INPUT_S3_REGION, env.VAR_S3_REGION);
31
40
  const s3Prefix = firstValue(
@@ -41,6 +41,13 @@ export function resolveBuildContractCli() {
41
41
  "native-platform-count": String(resolved.nativePlatformCount),
42
42
  "container-platforms-json": resolved.containerPlatformsJson,
43
43
  "container-platform-count": String(resolved.containerPlatformCount),
44
+ "github-hosted-platforms-json": resolved.githubHostedPlatformsJson,
45
+ "github-hosted-platform-ids-json": resolved.githubHostedPlatformIdsJson,
46
+ "github-hosted-platform-count": String(
47
+ resolved.githubHostedPlatformCount,
48
+ ),
49
+ "relay-platforms-json": resolved.relayPlatformsJson,
50
+ "relay-platform-count": String(resolved.relayPlatformCount),
44
51
  "linux-container-enabled": String(resolved.linuxContainer.enabled),
45
52
  "linux-container-preset": resolved.linuxContainer.preset,
46
53
  "linux-container-image": resolved.linuxContainer.image,