@kungfu-tech/buildchain 3.0.6-alpha.1 → 3.0.6-alpha.3

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 (75) hide show
  1. package/README.md +4 -4
  2. package/actions/promote-buildchain-ref/README.md +8 -0
  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 +102 -0
  6. package/dist/site/buildchain-contract.json +129 -34
  7. package/dist/site/buildchain-site.json +171 -45
  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 +56 -4
  11. package/dist/site/kfd-claims.json +184 -20
  12. package/dist/site/kfd-upstream-aggregate.json +1 -1
  13. package/dist/site/manual-registry.json +10 -10
  14. package/dist/site/node-api-registry.json +1242 -121
  15. package/dist/site/page-registry.json +157 -31
  16. package/dist/site/public-surface-audit.json +422 -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 +197 -15
  22. package/docs/MAP.md +1 -0
  23. package/docs/auditable-demo.md +58 -11
  24. package/docs/aws-us-elastic-runner-burst-plane.md +23 -19
  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 +96 -38
  29. package/docs/publication-authority.md +11 -0
  30. package/docs/publish-transaction.md +10 -1
  31. package/docs/release-candidate.md +106 -12
  32. package/docs/release-governance.md +52 -0
  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 +3 -1
  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/release-candidate-recovery.js +386 -0
  49. package/scripts/auditable-demo-bundle-verification.mjs +148 -0
  50. package/scripts/auditable-demo-platform.mjs +86 -50
  51. package/scripts/auditable-demo-presentation.mjs +83 -0
  52. package/scripts/auditable-demo-renditions.mjs +264 -0
  53. package/scripts/auditable-demo.mjs +24 -30
  54. package/scripts/build-contract-core.mjs +58 -3
  55. package/scripts/buildchain-cli-help.mjs +8 -0
  56. package/scripts/buildchain-patrol.mjs +9 -0
  57. package/scripts/check-inventory.mjs +13 -2
  58. package/scripts/dev-alpha-candidate-patrol.mjs +45 -48
  59. package/scripts/dev-delivery-proof.mjs +193 -0
  60. package/scripts/dev-delivery-warrant.mjs +426 -0
  61. package/scripts/dev-pr-auto-merge.mjs +488 -46
  62. package/scripts/dev-pr-delivery-warrant.mjs +209 -0
  63. package/scripts/gate-profile-core.mjs +24 -0
  64. package/scripts/generate-site-bundle.mjs +2 -2
  65. package/scripts/git-fetch-process-tree.mjs +142 -0
  66. package/scripts/lifecycle-substage-evidence.mjs +274 -0
  67. package/scripts/locked-source-checkout.mjs +6 -3
  68. package/scripts/release-candidate-resolver.mjs +35 -3
  69. package/scripts/resolve-artifact-transfer-mode.mjs +9 -0
  70. package/scripts/resolve-build-contract.mjs +7 -0
  71. package/scripts/resume-from-candidate-run.mjs +498 -0
  72. package/scripts/route-offline-runners.mjs +1 -0
  73. package/scripts/run-lifecycle-core.mjs +9 -9
  74. package/scripts/shifu-gate-profile.mjs +10 -16
  75. package/scripts/site-capability-metadata.mjs +15 -0
@@ -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({
@@ -49,7 +49,7 @@ function githubHeaders(token) {
49
49
  return headers;
50
50
  }
51
51
 
52
- async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fetchImpl = globalThis.fetch }) {
52
+ export async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fetchImpl = globalThis.fetch, allowNotFound = false }) {
53
53
  if (typeof fetchImpl !== "function") {
54
54
  throw new Error("fetch is required to resolve release candidate artifacts");
55
55
  }
@@ -57,13 +57,14 @@ async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fe
57
57
  const response = await fetchImpl(url, { method, headers: githubHeaders(token) });
58
58
  const text = await response.text();
59
59
  const body = text ? JSON.parse(text) : {};
60
+ if (allowNotFound && response.status === 404) return undefined;
60
61
  if (!response.ok) {
61
62
  throw new Error(`GitHub API ${method} ${requestPath} failed with ${response.status}: ${body.message || text}`);
62
63
  }
63
64
  return body;
64
65
  }
65
66
 
66
- async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fetchImpl = globalThis.fetch }) {
67
+ export async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fetchImpl = globalThis.fetch }) {
67
68
  const url = `${String(apiUrl || "https://api.github.com").replace(/\/+$/, "")}${requestPath}`;
68
69
  const response = await fetchImpl(url, { headers: githubHeaders(token) });
69
70
  if (!response.ok) {
@@ -357,7 +358,35 @@ export function selectReleaseCandidateArtifacts({ artifacts = [], artifactName =
357
358
  return { passport, summary: summaries[0], prefix, sourceSha: sha };
358
359
  }
359
360
 
360
- function unzip(zipPath, outputDir) {
361
+ export function verifyArtifactArchive({ artifact, archivePath } = {}) {
362
+ const bytes = fs.readFileSync(archivePath);
363
+ const size = bytes.length;
364
+ const digest = `sha256:${crypto.createHash("sha256").update(bytes).digest("hex")}`;
365
+ if (!artifact || artifact.expired === true) {
366
+ throw new Error(`candidate artifact is missing or expired: ${artifact?.name || "<unknown>"}`);
367
+ }
368
+ if (Number(artifact.size_in_bytes) !== size) {
369
+ throw new Error(`candidate artifact size mismatch for ${artifact.name}: expected ${artifact.size_in_bytes}, got ${size}`);
370
+ }
371
+ if (!/^sha256:[0-9a-f]{64}$/i.test(String(artifact.digest || ""))) {
372
+ throw new Error(`candidate artifact ${artifact.name} has no trusted sha256 digest metadata`);
373
+ }
374
+ if (String(artifact.digest).toLowerCase() !== digest) {
375
+ throw new Error(`candidate artifact digest mismatch for ${artifact.name}: expected ${artifact.digest}, got ${digest}`);
376
+ }
377
+ return { size, digest };
378
+ }
379
+
380
+ export function unzip(zipPath, outputDir) {
381
+ const entries = execFileSync("unzip", ["-Z1", zipPath], { encoding: "utf8" })
382
+ .split(/\r?\n/)
383
+ .filter(Boolean);
384
+ for (const entry of entries) {
385
+ const normalized = entry.replaceAll("\\", "/");
386
+ if (normalized.startsWith("/") || normalized.split("/").some((part) => part === "..")) {
387
+ throw new Error(`candidate artifact contains an unsafe zip entry: ${entry}`);
388
+ }
389
+ }
361
390
  fs.mkdirSync(outputDir, { recursive: true });
362
391
  execFileSync("unzip", ["-q", "-o", zipPath, "-d", outputDir], { stdio: "inherit" });
363
392
  }
@@ -579,6 +608,7 @@ export async function resolveReleaseCandidateArtifacts({
579
608
  outputPath: passportZip,
580
609
  path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${selected.passport.id}/zip`,
581
610
  });
611
+ verifyArtifactArchive({ artifact: selected.passport, archivePath: passportZip });
582
612
  await githubDownload({
583
613
  apiUrl,
584
614
  token,
@@ -586,6 +616,7 @@ export async function resolveReleaseCandidateArtifacts({
586
616
  outputPath: summaryZip,
587
617
  path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${selected.summary.id}/zip`,
588
618
  });
619
+ verifyArtifactArchive({ artifact: selected.summary, archivePath: summaryZip });
589
620
  for (const artifact of payloadArtifacts) {
590
621
  const safeName = String(artifact.name || `artifact-${artifact.id}`).replace(/[^A-Za-z0-9._-]/g, "_");
591
622
  const payloadZip = path.join(tempDir, `${safeName}.zip`);
@@ -596,6 +627,7 @@ export async function resolveReleaseCandidateArtifacts({
596
627
  outputPath: payloadZip,
597
628
  path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${artifact.id}/zip`,
598
629
  });
630
+ verifyArtifactArchive({ artifact, archivePath: payloadZip });
599
631
  unzip(payloadZip, path.join(payloadDir, safeName));
600
632
  }
601
633
  unzip(passportZip, passportDir);
@@ -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,
@@ -0,0 +1,498 @@
1
+ #!/usr/bin/env node
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ import { writeGitHubOutputs } from "./build-contract-core.mjs";
9
+ import {
10
+ generatePublishRequiredArtifacts,
11
+ readNpmPackageArtifact,
12
+ selectReleaseCandidateArtifacts,
13
+ } from "./release-candidate-resolver.mjs";
14
+ import {
15
+ githubDownload,
16
+ githubJson,
17
+ unzip,
18
+ verifyArtifactArchive,
19
+ } from "./release-candidate-resolver.mjs";
20
+ import {
21
+ recoveryFailure,
22
+ verifyReleaseCandidateRecovery,
23
+ } from "../packages/core/release-candidate-recovery.js";
24
+ import {
25
+ PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
26
+ publicationArtifactCandidateDigest,
27
+ } from "../packages/core/publication-artifact-candidate.js";
28
+ import { createPublicationSealedBundle } from "../packages/core/publication-sealed-bundle.js";
29
+ import { releaseTransactionStateRef } from "../packages/core/publish-transaction.js";
30
+
31
+ function env(name, fallback = "") {
32
+ return process.env[name] || fallback;
33
+ }
34
+
35
+ function requiredEnv(name) {
36
+ const value = env(name).trim();
37
+ if (!value) throw new Error(`${name} is required for candidate recovery`);
38
+ return value;
39
+ }
40
+
41
+ function splitRepository(repository) {
42
+ const match = String(repository || "").trim().match(/^([^/\s]+)\/([^/\s]+)$/);
43
+ if (!match) throw new Error(`candidate repository must be owner/repo, got ${repository || "<empty>"}`);
44
+ return { owner: match[1], repo: match[2], fullName: `${match[1]}/${match[2]}` };
45
+ }
46
+
47
+ function splitPatterns(value = "") {
48
+ return String(value || "").split(/\r?\n|,/).map((entry) => entry.trim()).filter(Boolean);
49
+ }
50
+
51
+ function patternMatcher(pattern) {
52
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
53
+ return new RegExp(`^${escaped}$`);
54
+ }
55
+
56
+ function safeName(value) {
57
+ return String(value || "artifact").replace(/[^A-Za-z0-9._-]/g, "_");
58
+ }
59
+
60
+ function sha256File(filePath) {
61
+ return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex")}`;
62
+ }
63
+
64
+ function collectFiles(root) {
65
+ const resolvedRoot = path.resolve(root);
66
+ const files = [];
67
+ const pending = [resolvedRoot];
68
+ while (pending.length) {
69
+ const current = pending.pop();
70
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
71
+ const fullPath = path.join(current, entry.name);
72
+ if (entry.isDirectory()) pending.push(fullPath);
73
+ else if (entry.isFile()) files.push({
74
+ path: path.relative(resolvedRoot, fullPath).split(path.sep).join("/"),
75
+ size: fs.statSync(fullPath).size,
76
+ sha256: sha256File(fullPath),
77
+ absolutePath: fullPath,
78
+ });
79
+ }
80
+ }
81
+ return files.sort((left, right) => left.path.localeCompare(right.path));
82
+ }
83
+
84
+ function findFiles(root, predicate) {
85
+ return collectFiles(root).filter((file) => predicate(file.path, file.absolutePath));
86
+ }
87
+
88
+ function readOnlyJson(files, label) {
89
+ if (files.length !== 1) throw new Error(`expected exactly one ${label}, found ${files.length}`);
90
+ return JSON.parse(fs.readFileSync(files[0].absolutePath, "utf8"));
91
+ }
92
+
93
+ async function readExistingTransaction({ repoInfo, apiUrl, token, fetchImpl, version }) {
94
+ const stateRef = releaseTransactionStateRef(version);
95
+ const response = await githubJson({
96
+ apiUrl,
97
+ token,
98
+ fetchImpl,
99
+ allowNotFound: true,
100
+ path: `/repos/${repoInfo.owner}/${repoInfo.repo}/contents/state.json?ref=${encodeURIComponent(stateRef)}`,
101
+ });
102
+ if (!response) return undefined;
103
+ if (response.type !== "file" || response.encoding !== "base64" || !response.content) {
104
+ throw new Error(`durable transaction ${stateRef} did not expose a base64 state.json file`);
105
+ }
106
+ return JSON.parse(Buffer.from(String(response.content).replace(/\s/g, ""), "base64").toString("utf8"));
107
+ }
108
+
109
+ function outputPath(filePath) {
110
+ const relative = path.relative(process.cwd(), filePath).split(path.sep).join("/");
111
+ return relative.startsWith("../") ? filePath : relative;
112
+ }
113
+
114
+ async function downloadArtifact({ artifact, repoInfo, apiUrl, token, archiveDir, bundleRoot, fetchImpl }) {
115
+ const name = safeName(artifact.name);
116
+ const archivePath = path.join(archiveDir, `${name}.zip`);
117
+ const artifactRoot = path.join(bundleRoot, "artifacts", name);
118
+ await githubDownload({
119
+ apiUrl,
120
+ token,
121
+ fetchImpl,
122
+ outputPath: archivePath,
123
+ path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${artifact.id}/zip`,
124
+ });
125
+ const archive = verifyArtifactArchive({ artifact, archivePath });
126
+ unzip(archivePath, artifactRoot);
127
+ const files = collectFiles(artifactRoot);
128
+ return {
129
+ artifact,
130
+ artifactRoot,
131
+ record: {
132
+ name: artifact.name,
133
+ kind: "candidate",
134
+ size: Number(artifact.size_in_bytes),
135
+ downloadedSize: archive.size,
136
+ digest: artifact.digest,
137
+ downloadedDigest: archive.digest,
138
+ expired: artifact.expired === true,
139
+ files: files.map(({ path: filePath, size, sha256 }) => ({ path: filePath, size, sha256 })),
140
+ },
141
+ files,
142
+ };
143
+ }
144
+
145
+ function candidateArtifactNames({ passport, selected, artifacts, artifactPatterns }) {
146
+ const names = new Set([selected.passport.name, selected.summary.name]);
147
+ for (const reference of passport.controllerReceipts || []) {
148
+ if (!reference.artifact) throw new Error(`Passport controller receipt ${reference.controllerId} has no artifact identity`);
149
+ names.add(reference.artifact);
150
+ }
151
+ for (const platform of passport.platformMatrix || []) names.add(platform.artifactName);
152
+ const manifestPattern = new RegExp(`^${selected.prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-manifest-.+-${selected.sourceSha}$`);
153
+ for (const artifact of artifacts) {
154
+ if (manifestPattern.test(String(artifact.name || ""))) names.add(artifact.name);
155
+ }
156
+ const matchers = splitPatterns(artifactPatterns).map(patternMatcher);
157
+ for (const artifact of artifacts) {
158
+ if (matchers.some((matcher) => matcher.test(String(artifact.name || "")))) names.add(artifact.name);
159
+ }
160
+ return names;
161
+ }
162
+
163
+ function normalizePlatformManifests(downloads, passport) {
164
+ const manifests = [];
165
+ for (const download of downloads) {
166
+ if (!String(download.artifact.name).includes("-manifest-")) continue;
167
+ for (const file of download.files.filter((entry) => path.basename(entry.path) === "manifest.json")) {
168
+ const manifest = JSON.parse(fs.readFileSync(file.absolutePath, "utf8"));
169
+ if (!manifest.artifactName) {
170
+ const platformId = String(manifest.platform?.id || manifest.platformId || "");
171
+ manifest.artifactName = (passport.platformMatrix || []).find((entry) => entry.platformId === platformId)?.artifactName || "";
172
+ }
173
+ manifests.push(manifest);
174
+ }
175
+ }
176
+ return manifests;
177
+ }
178
+
179
+ function normalizeControllerReceipts(downloads, passport) {
180
+ const artifactNames = new Set((passport.controllerReceipts || []).map((reference) => reference.artifact));
181
+ const receipts = [];
182
+ for (const download of downloads.filter((entry) => artifactNames.has(entry.artifact.name))) {
183
+ const candidates = download.files.filter((file) => file.path.endsWith(".json")).map((file) => {
184
+ try { return JSON.parse(fs.readFileSync(file.absolutePath, "utf8")); } catch { return undefined; }
185
+ }).filter((value) => value?.contract === "buildchain.controller-evidence/v1" && value?.kind === "receipt");
186
+ if (candidates.length !== 1) throw new Error(`controller artifact ${download.artifact.name} must contain exactly one controller receipt`);
187
+ receipts.push(candidates[0]);
188
+ }
189
+ return receipts;
190
+ }
191
+
192
+ function normalizeProductPayloadManifests(downloads) {
193
+ return downloads.flatMap((download) => download.files
194
+ .filter((file) => path.basename(file.path) === "product-payload-manifest.json")
195
+ .map((file) => JSON.parse(fs.readFileSync(file.absolutePath, "utf8"))));
196
+ }
197
+
198
+ function createSealedBundle({ downloads, bundleRoot, repository, passport, runtimeSha, publishPackageMain, releasePatterns }) {
199
+ const allFiles = downloads.flatMap((download) => download.files.map((file) => ({
200
+ path: path.relative(bundleRoot, file.absolutePath).split(path.sep).join("/"),
201
+ size: file.size,
202
+ sha256: file.sha256.replace(/^sha256:/, ""),
203
+ absolutePath: file.absolutePath,
204
+ }))).sort((left, right) => left.path.localeCompare(right.path));
205
+ const tarballs = allFiles.filter((file) => file.path.toLowerCase().endsWith(".tgz"));
206
+ if (tarballs.length === 0) throw new Error("candidate recovery for npm publication requires at least one exact .tgz payload artifact");
207
+ const npmArtifacts = tarballs.map((file) => ({ file, metadata: readNpmPackageArtifact({ tarballPath: file.absolutePath, mainPackage: publishPackageMain }) }));
208
+ const main = npmArtifacts.find((entry) => entry.metadata.role === "main") || (npmArtifacts.length === 1 ? npmArtifacts[0] : undefined);
209
+ if (!main) throw new Error("candidate npm payload set has no unique main package tarball");
210
+ const releaseMatchers = splitPatterns(releasePatterns).map(patternMatcher);
211
+ const releaseAssets = allFiles.filter((file) => releaseMatchers.length
212
+ ? releaseMatchers.some((matcher) => matcher.test(path.basename(file.path)))
213
+ : file.path.toLowerCase().endsWith(".tgz"));
214
+ const payload = {
215
+ schemaVersion: 1,
216
+ contract: PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
217
+ repository,
218
+ sourceSha: passport.source.headSha,
219
+ sourceTreeSha: passport.source.treeHash,
220
+ runtimeSha,
221
+ releaseCandidateRoot: `sha256:${passport.candidateHash}`,
222
+ files: allFiles.map(({ path: filePath, size, sha256 }) => ({ path: filePath, size, sha256 })),
223
+ };
224
+ const candidate = { ...payload, candidateDigest: publicationArtifactCandidateDigest(payload) };
225
+ const manifest = createPublicationSealedBundle({
226
+ candidate,
227
+ packageName: main.metadata.name,
228
+ packageVersion: main.metadata.ref,
229
+ npmTarballPath: main.file.path,
230
+ npmIntegrity: main.metadata.integrity,
231
+ releaseAssetPaths: releaseAssets.map((file) => file.path),
232
+ });
233
+ return { manifest, npmArtifacts, allFiles };
234
+ }
235
+
236
+ async function recoverCandidateEvidence({
237
+ repoInfo,
238
+ runId,
239
+ artifactName,
240
+ artifactPatterns,
241
+ requiredArtifactCount,
242
+ outputDir,
243
+ apiUrl,
244
+ token,
245
+ fetchImpl,
246
+ archiveDir,
247
+ }) {
248
+ const run = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}` });
249
+ const workflow = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/workflows/${run.workflow_id}` });
250
+ const artifactResponse = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}/artifacts?per_page=100` });
251
+ const artifacts = Array.isArray(artifactResponse.artifacts) ? artifactResponse.artifacts : [];
252
+ if (Number(artifactResponse.total_count || artifacts.length) !== artifacts.length) throw new Error("candidate run has more than 100 artifacts; complete pagination is required before recovery");
253
+ const selected = selectReleaseCandidateArtifacts({ artifacts, artifactName });
254
+ const resolvedOutput = path.resolve(outputDir);
255
+ const bundleRoot = path.join(resolvedOutput, "sealed-candidate");
256
+ fs.mkdirSync(bundleRoot, { recursive: true });
257
+ const initialDownloads = [];
258
+ for (const artifact of [selected.passport, selected.summary]) initialDownloads.push(await downloadArtifact({ artifact, repoInfo, apiUrl, token, archiveDir, bundleRoot, fetchImpl }));
259
+ const passport = readOnlyJson(initialDownloads[0].files.filter((file) => path.basename(file.path) === "release-candidate-passport.json"), "release-candidate-passport.json");
260
+ const buildSummary = readOnlyJson(initialDownloads[1].files.filter((file) => path.basename(file.path) === "build-summary.json"), "build-summary.json");
261
+ const candidateVersion = String(passport.target?.version || "").trim();
262
+ if (!candidateVersion) throw new Error("Release Candidate Passport has no exact target version");
263
+ const existingTransaction = await readExistingTransaction({ repoInfo, apiUrl, token, fetchImpl, version: candidateVersion });
264
+ const requiredNames = candidateArtifactNames({ passport, selected, artifacts, artifactPatterns });
265
+ const chosen = artifacts.filter((artifact) => requiredNames.has(artifact.name));
266
+ if (chosen.length !== requiredNames.size) {
267
+ const found = new Set(chosen.map((artifact) => artifact.name));
268
+ throw new Error(`candidate artifacts are missing: ${[...requiredNames].filter((name) => !found.has(name)).join(", ")}`);
269
+ }
270
+ if (Number(requiredArtifactCount || 0) > 0 && chosen.length < Number(requiredArtifactCount)) throw new Error(`candidate artifact count ${chosen.length} is below required ${requiredArtifactCount}`);
271
+ const downloads = [...initialDownloads];
272
+ for (const artifact of chosen.filter((entry) => ![selected.passport.id, selected.summary.id].includes(entry.id))) downloads.push(await downloadArtifact({ artifact, repoInfo, apiUrl, token, archiveDir, bundleRoot, fetchImpl }));
273
+ return {
274
+ run, workflow, selected, resolvedOutput, bundleRoot, initialDownloads,
275
+ passport, buildSummary, candidateVersion, existingTransaction, chosen, downloads,
276
+ };
277
+ }
278
+
279
+ export async function resumeFromCandidateRun({
280
+ repository,
281
+ targetRepository = repository,
282
+ candidateRunId,
283
+ expectedWorkflowFile,
284
+ expectedWorkflowName,
285
+ channel,
286
+ targetRef,
287
+ targetSha,
288
+ expectedSourceTree = "",
289
+ expectedCandidateRoot = "",
290
+ candidateRuntimeSha,
291
+ runtimeSha,
292
+ transactionId = "",
293
+ artifactName = "",
294
+ artifactPatterns = "",
295
+ releasePatterns = "",
296
+ requiredArtifactCount = 0,
297
+ publishPackageMain = "",
298
+ outputDir = ".buildchain/release-candidate-recovery",
299
+ token = env("GITHUB_TOKEN"),
300
+ apiUrl = env("GITHUB_API_URL", "https://api.github.com"),
301
+ recoveryRunId = env("GITHUB_RUN_ID"),
302
+ fetchImpl = globalThis.fetch,
303
+ } = {}) {
304
+ const repoInfo = splitRepository(repository);
305
+ const runId = String(candidateRunId || "").trim();
306
+ if (!/^\d+$/.test(runId)) throw new Error("candidate run ID must be numeric");
307
+ if (!String(expectedSourceTree || "").trim() && !String(expectedCandidateRoot || "").trim()) {
308
+ throw new Error("candidate recovery requires expectedSourceTree or expectedCandidateRoot");
309
+ }
310
+ const archiveDir = fs.mkdtempSync(path.join(os.tmpdir(), "buildchain-resume-"));
311
+ try {
312
+ const {
313
+ run, workflow, selected, resolvedOutput, bundleRoot, initialDownloads,
314
+ passport, buildSummary, candidateVersion, existingTransaction, chosen, downloads,
315
+ } = await recoverCandidateEvidence({
316
+ repoInfo, runId, artifactName, artifactPatterns, requiredArtifactCount,
317
+ outputDir, apiUrl, token, fetchImpl, archiveDir,
318
+ });
319
+ const prNumber = Number(passport.pullRequest?.number || 0);
320
+ if (!prNumber) throw new Error("Release Candidate Passport has no PR identity");
321
+ const pullRequest = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}` });
322
+ const targetCommit = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/git/commits/${targetSha}` });
323
+ const targetRefState = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/git/ref/heads/${targetRef.replace(/^refs\/heads\//, "")}` });
324
+ if (targetRefState.object?.sha !== targetSha) throw new Error(`target ref ${targetRef} no longer points at ${targetSha}`);
325
+ const compare = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/compare/${pullRequest.merge_commit_sha}...${targetSha}` });
326
+ const platformManifests = normalizePlatformManifests(downloads, passport);
327
+ const controllerReceipts = normalizeControllerReceipts(downloads, passport);
328
+ const productPayloadManifests = normalizeProductPayloadManifests(downloads);
329
+ const recovery = verifyReleaseCandidateRecovery({
330
+ candidateRepository: repoInfo.fullName,
331
+ targetRepository,
332
+ expectedRunId: runId,
333
+ expectedWorkflowFile,
334
+ expectedWorkflowName,
335
+ channel,
336
+ targetRef,
337
+ targetSha,
338
+ targetTree: targetCommit.tree?.sha,
339
+ expectedSourceTree,
340
+ expectedCandidateRoot,
341
+ expectedRuntimeSha: candidateRuntimeSha,
342
+ expectedTransactionId: transactionId,
343
+ existingTransaction,
344
+ run: {
345
+ id: String(run.id),
346
+ repository: repoInfo.fullName,
347
+ headRepository: run.head_repository?.full_name || "",
348
+ status: run.status,
349
+ conclusion: run.conclusion,
350
+ event: run.event,
351
+ path: run.path,
352
+ name: run.name,
353
+ headSha: run.head_sha || "",
354
+ headBranch: run.head_branch || "",
355
+ pullRequestNumbers: (run.pull_requests || []).map((entry) => Number(entry.number)),
356
+ },
357
+ workflow: { path: workflow.path, name: workflow.name, state: workflow.state },
358
+ pullRequest: {
359
+ number: pullRequest.number,
360
+ merged: pullRequest.merged === true,
361
+ mergeSha: pullRequest.merge_commit_sha,
362
+ headRepository: pullRequest.head?.repo?.full_name || "",
363
+ baseRef: pullRequest.base?.ref || "",
364
+ authorAssociation: pullRequest.author_association || "",
365
+ headSha: pullRequest.head?.sha || "",
366
+ headRef: pullRequest.head?.ref || "",
367
+ },
368
+ ancestry: { status: compare.status, mergeIsAncestor: ["ahead", "identical"].includes(compare.status) },
369
+ passport,
370
+ buildSummary,
371
+ controllerReceipts,
372
+ platformManifests,
373
+ productPayloadManifests,
374
+ artifacts: downloads.map((download) => download.record),
375
+ currentToolingSha: runtimeSha,
376
+ recoveryRunId,
377
+ });
378
+ const sealed = createSealedBundle({
379
+ downloads,
380
+ bundleRoot,
381
+ repository: repoInfo.fullName,
382
+ passport,
383
+ runtimeSha,
384
+ publishPackageMain,
385
+ releasePatterns,
386
+ });
387
+ const recoveryReceiptPath = path.join(resolvedOutput, "recovery-receipt.json");
388
+ const sealedManifestPath = path.join(resolvedOutput, "sealed-bundle.json");
389
+ const requiredArtifactsPath = path.join(resolvedOutput, "publish-required-artifacts.json");
390
+ fs.writeFileSync(recoveryReceiptPath, `${JSON.stringify(recovery.receipt, null, 2)}\n`);
391
+ fs.writeFileSync(sealedManifestPath, `${JSON.stringify(sealed.manifest, null, 2)}\n`);
392
+ const publishRequiredArtifacts = generatePublishRequiredArtifacts({
393
+ kind: "npm",
394
+ tarballPaths: sealed.npmArtifacts.map((entry) => entry.file.absolutePath),
395
+ mainPackage: publishPackageMain,
396
+ });
397
+ fs.writeFileSync(requiredArtifactsPath, `${JSON.stringify(publishRequiredArtifacts, null, 2)}\n`);
398
+ const tarballs = sealed.npmArtifacts.map((entry) => outputPath(entry.file.absolutePath));
399
+ return {
400
+ enabled: true,
401
+ action: "reused",
402
+ repository: repoInfo.fullName,
403
+ run: { id: runId, url: run.html_url || "", name: run.name },
404
+ artifacts: { passport: selected.passport.name, summary: selected.summary.name, payloads: chosen.map((artifact) => artifact.name), sourceSha: selected.sourceSha },
405
+ version: candidateVersion,
406
+ candidateRoot: recovery.receipt.recovered.candidateRoot,
407
+ artifactRoot: recovery.receipt.recovered.artifactRoot,
408
+ receipt: recovery.receipt,
409
+ publishRequiredArtifacts,
410
+ paths: {
411
+ passport: outputPath(initialDownloads[0].files.find((file) => path.basename(file.path) === "release-candidate-passport.json").absolutePath),
412
+ buildSummary: outputPath(initialDownloads[1].files.find((file) => path.basename(file.path) === "build-summary.json").absolutePath),
413
+ payloads: outputPath(path.join(bundleRoot, "artifacts")),
414
+ platformManifests: downloads.flatMap((download) => download.files.filter((file) => path.basename(file.path) === "manifest.json").map((file) => outputPath(file.absolutePath))),
415
+ npmTarballs: tarballs,
416
+ releaseAssets: sealed.manifest.releaseAssets.map((asset) => outputPath(path.join(bundleRoot, asset.path))),
417
+ publishRequiredArtifacts: outputPath(requiredArtifactsPath),
418
+ sealedBundleRoot: outputPath(bundleRoot),
419
+ sealedBundleManifest: outputPath(sealedManifestPath),
420
+ recoveryReceipt: outputPath(recoveryReceiptPath),
421
+ },
422
+ };
423
+ } finally {
424
+ fs.rmSync(archiveDir, { recursive: true, force: true });
425
+ }
426
+ }
427
+
428
+ export async function resumeFromCandidateRunCli() {
429
+ try {
430
+ const result = await resumeFromCandidateRun({
431
+ repository: requiredEnv("BUILDCHAIN_RESUME_CANDIDATE_REPOSITORY"),
432
+ targetRepository: env("GITHUB_REPOSITORY"),
433
+ candidateRunId: requiredEnv("BUILDCHAIN_RESUME_CANDIDATE_RUN_ID"),
434
+ expectedWorkflowFile: requiredEnv("BUILDCHAIN_RESUME_EXPECTED_WORKFLOW_FILE"),
435
+ expectedWorkflowName: requiredEnv("BUILDCHAIN_RESUME_EXPECTED_WORKFLOW_NAME"),
436
+ channel: requiredEnv("BUILDCHAIN_RESUME_CHANNEL"),
437
+ targetRef: requiredEnv("BUILDCHAIN_RESUME_TARGET_REF"),
438
+ targetSha: requiredEnv("BUILDCHAIN_RESUME_TARGET_SHA"),
439
+ expectedSourceTree: env("BUILDCHAIN_RESUME_EXPECTED_SOURCE_TREE"),
440
+ expectedCandidateRoot: env("BUILDCHAIN_RESUME_EXPECTED_CANDIDATE_ROOT"),
441
+ candidateRuntimeSha: requiredEnv("BUILDCHAIN_RESUME_EXPECTED_CANDIDATE_RUNTIME_SHA"),
442
+ runtimeSha: requiredEnv("BUILDCHAIN_RESUME_RUNTIME_SHA"),
443
+ transactionId: env("BUILDCHAIN_RESUME_TRANSACTION_ID"),
444
+ artifactName: env("BUILDCHAIN_ARTIFACT_NAME"),
445
+ artifactPatterns: env("BUILDCHAIN_ARTIFACT_PATTERNS"),
446
+ releasePatterns: env("BUILDCHAIN_GITHUB_RELEASE_PAYLOAD_PATTERNS"),
447
+ requiredArtifactCount: env("BUILDCHAIN_REQUIRED_ARTIFACT_COUNT", "0"),
448
+ publishPackageMain: env("BUILDCHAIN_PUBLISH_PACKAGE_MAIN"),
449
+ outputDir: env("BUILDCHAIN_RC_OUTPUT_DIR", ".buildchain/release-candidate-recovery"),
450
+ });
451
+ writeGitHubOutputs({
452
+ "promote-only-release-candidate": "true",
453
+ "release-candidate-action": result.action,
454
+ "release-candidate-passport-path": result.paths.passport,
455
+ "release-candidate-build-summary-path": result.paths.buildSummary,
456
+ "release-candidate-version": result.version,
457
+ "release-candidate-source-sha": result.artifacts.sourceSha,
458
+ "release-candidate-artifact": result.artifacts.passport,
459
+ "release-candidate-build-summary-artifact": result.artifacts.summary,
460
+ "release-candidate-payload-artifacts": result.artifacts.payloads.join(","),
461
+ "release-candidate-payload-dir": result.paths.payloads,
462
+ "release-candidate-platform-manifest-paths": result.paths.platformManifests.join(","),
463
+ "release-candidate-npm-tarball-paths": result.paths.npmTarballs.join(","),
464
+ "release-candidate-github-release-artifact-paths": result.paths.releaseAssets.join("\n"),
465
+ "publish-required-artifacts-json": JSON.stringify(result.publishRequiredArtifacts),
466
+ "publish-required-artifacts-path": result.paths.publishRequiredArtifacts,
467
+ "release-candidate-run-id": result.run.id,
468
+ "release-candidate-run-url": result.run.url,
469
+ "release-candidate-recovery-receipt-path": result.paths.recoveryReceipt,
470
+ "release-candidate-recovery-root": result.receipt.root,
471
+ "release-candidate-root": result.candidateRoot,
472
+ "release-candidate-artifact-root": result.artifactRoot,
473
+ "publish-sealed-bundle-root": result.paths.sealedBundleRoot,
474
+ "publish-sealed-bundle-manifest": result.paths.sealedBundleManifest,
475
+ "release-candidate-diagnosis": `Reused sealed candidate run ${result.run.id}; product build stages skipped`,
476
+ });
477
+ console.log(JSON.stringify(result, null, 2));
478
+ return result;
479
+ } catch (error) {
480
+ const failure = recoveryFailure(error);
481
+ writeGitHubOutputs({
482
+ "release-candidate-action": "rejected",
483
+ "release-candidate-recovery-error-code": failure.code,
484
+ "release-candidate-recovery-next-action": failure.nextAction,
485
+ "release-candidate-diagnosis": `${failure.code}: ${failure.reason}; next: ${failure.nextAction}`,
486
+ });
487
+ throw Object.assign(error, { recoveryFailure: failure });
488
+ }
489
+ }
490
+
491
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
492
+ resumeFromCandidateRunCli().catch((error) => {
493
+ const failure = error.recoveryFailure || recoveryFailure(error);
494
+ console.error(`candidate recovery rejected [${failure.code}]: ${failure.reason}`);
495
+ console.error(`next action: ${failure.nextAction}`);
496
+ process.exitCode = 1;
497
+ });
498
+ }
@@ -71,6 +71,7 @@ export function routeOfflineRunners({
71
71
  return {
72
72
  ...platform,
73
73
  runner: fallbackRunner,
74
+ githubHosted: true,
74
75
  };
75
76
  });
76
77
  return {