@kungfu-tech/buildchain 3.0.6-alpha.6 → 3.0.6

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.
@@ -9,13 +9,19 @@ import {
9
9
  matchesGithubDeploymentPolicy,
10
10
  } from "../packages/core/publication-control-plane-audit.js";
11
11
 
12
+ const GITHUB_JSON_MAX_BUFFER = 16 * 1024 * 1024;
13
+
12
14
  function flag(name, fallback = "") {
13
15
  const index = process.argv.indexOf(`--${name}`);
14
16
  return index === -1 ? fallback : String(process.argv[index + 1] || "");
15
17
  }
16
18
 
17
19
  function commandJson(command, args, label) {
18
- const result = spawnSync(command, args, { encoding: "utf8", timeout: 60_000 });
20
+ const result = spawnSync(command, args, {
21
+ encoding: "utf8",
22
+ timeout: 60_000,
23
+ maxBuffer: GITHUB_JSON_MAX_BUFFER,
24
+ });
19
25
  if (result.status !== 0) {
20
26
  const category = /401|E401|unauthorized/i.test(result.stderr) ? "unauthorized" : "unavailable";
21
27
  throw new Error(`${label} is ${category}; publication control-plane audit fails closed`);
@@ -35,6 +41,7 @@ function githubJsonOptional(apiPath, label, fallback) {
35
41
  const result = spawnSync("gh", ["api", apiPath, "-H", "Accept: application/vnd.github+json"], {
36
42
  encoding: "utf8",
37
43
  timeout: 60_000,
44
+ maxBuffer: GITHUB_JSON_MAX_BUFFER,
38
45
  });
39
46
  if (result.status !== 0) {
40
47
  if (/404|not found/i.test(`${result.stdout}\n${result.stderr}`)) return fallback;
@@ -52,6 +59,7 @@ function githubJsonReadLimited(apiPath, label, fallback) {
52
59
  const result = spawnSync("gh", ["api", apiPath, "-H", "Accept: application/vnd.github+json"], {
53
60
  encoding: "utf8",
54
61
  timeout: 60_000,
62
+ maxBuffer: GITHUB_JSON_MAX_BUFFER,
55
63
  });
56
64
  if (result.status !== 0) {
57
65
  if (/401|403|404|unauthorized|forbidden|not found/i.test(`${result.stdout}\n${result.stderr}`)) return fallback;
@@ -355,9 +363,6 @@ function main() {
355
363
  review.user?.login !== mergedPullRequest?.user?.login &&
356
364
  String(review.commit_id || "").toLowerCase() === pullRequestHeadSha
357
365
  );
358
- const checkRuns = /^[0-9a-f]{40}$/.test(pullRequestHeadSha)
359
- ? githubJson(`repos/${repository}/commits/${pullRequestHeadSha}/check-runs?per_page=100`, "merged pull-request head check runs")
360
- : { check_runs: [] };
361
366
  const requiredStatusCheckPolicy = branchState.protection?.required_status_checks || {};
362
367
  const requiredStatusChecks = [...new Set([
363
368
  ...(requiredStatusCheckPolicy.contexts || []),
@@ -372,6 +377,9 @@ function main() {
372
377
  const resolvedRequiredStatusCheck = exactRequiredStatusCheck ||
373
378
  (prefixedRequiredStatusChecks.length === 1 ? prefixedRequiredStatusChecks[0] : requiredStatusCheck);
374
379
  const requiredStatusCheckMatchCount = exactRequiredStatusCheck ? 1 : prefixedRequiredStatusChecks.length;
380
+ const checkRuns = /^[0-9a-f]{40}$/.test(pullRequestHeadSha)
381
+ ? githubJson(`repos/${repository}/commits/${pullRequestHeadSha}/check-runs?check_name=${encodeURIComponent(resolvedRequiredStatusCheck)}&filter=latest&per_page=100`, "merged pull-request required check runs")
382
+ : { check_runs: [] };
375
383
  const requiredCheckSource = (requiredStatusCheckPolicy.checks || []).find((entry) =>
376
384
  entry.context === resolvedRequiredStatusCheck
377
385
  );
@@ -276,16 +276,25 @@ if (channelPromotionWorkflow !== generateChannelPromotionWorkflow(advancedPromot
276
276
  }
277
277
  for (const requiredSnippet of [
278
278
  "buildchain-channel:",
279
- `/.github/workflows/.release-candidate-promote.yml@v${selfDogfoodMajor}-alpha`,
279
+ `/${promotionShellRouting.alpha.workflowPath}@${promotionShellRouting.alpha.callRef}`,
280
280
  `/${promotionShellRouting.stable.workflowPath}@${promotionShellRouting.stable.callRef}`,
281
281
  `STABLE_SHELL_REF: v${selfDogfoodMajor}`,
282
282
  "promotion-contract-lock-digest:",
283
283
  "authorize-promotion-runtime-override.cjs",
284
+ "BUILDCHAIN_ROUTER_REPOSITORY: ${{ inputs.buildchain-repository }}",
285
+ "BUILDCHAIN_RESUME_RUNTIME_SHA: ${{ inputs.resume-buildchain-runtime-sha }}",
286
+ "git ls-remote",
287
+ "Recovery router ref does not match resume-buildchain-runtime-sha",
284
288
  ]) {
285
289
  if (!channelPromotionWorkflow.includes(requiredSnippet)) {
286
290
  throw new Error(`channel promotion workflow missing routing contract: ${requiredSnippet}`);
287
291
  }
288
292
  }
293
+ for (const forbiddenSnippet of ["job.workflow_repository", "job.workflow_sha"]) {
294
+ if (channelPromotionWorkflow.includes(forbiddenSnippet)) {
295
+ throw new Error(`channel promotion workflow uses unsupported GitHub context: ${forbiddenSnippet}`);
296
+ }
297
+ }
289
298
  const promotionOverrideAuthorization = fs.readFileSync(
290
299
  path.join(root, "scripts/authorize-promotion-runtime-override.cjs"),
291
300
  "utf8",
@@ -130,8 +130,8 @@ function validateWorkflowRoute(name, route, expectedLogicalRef) {
130
130
  if (!/^\.github\/workflows\/[.a-z0-9-]+\.ya?ml$/.test(route.workflowPath || "")) {
131
131
  throw new Error(`promotion shell ${name} workflowPath must name a reusable workflow`);
132
132
  }
133
- if (!/^(?:v[0-9]+(?:-alpha)?|[0-9a-f]{40})$/.test(route.callRef || "")) {
134
- throw new Error(`promotion shell ${name} callRef must be an official channel ref or exact SHA`);
133
+ if (!/^(?:v[0-9]+(?:-alpha)?|train\/[A-Za-z0-9._+\/-]+|[0-9a-f]{40})$/.test(route.callRef || "")) {
134
+ throw new Error(`promotion shell ${name} callRef must be an official channel ref, trusted train, or exact SHA`);
135
135
  }
136
136
  if (typeof route.forwardInternalInputs !== "boolean") {
137
137
  throw new Error(`promotion shell ${name} forwardInternalInputs must be boolean`);
@@ -240,29 +240,45 @@ jobs:
240
240
  id: router
241
241
  shell: bash
242
242
  env:
243
- BUILDCHAIN_ROUTER_WORKFLOW_REF: \${{ job.workflow_ref }}
244
- BUILDCHAIN_ROUTER_WORKFLOW_REPOSITORY: \${{ job.workflow_repository }}
245
- BUILDCHAIN_ROUTER_WORKFLOW_SHA: \${{ job.workflow_sha }}
243
+ BUILDCHAIN_ROUTER_REPOSITORY: \${{ inputs.buildchain-repository }}
244
+ BUILDCHAIN_ROUTER_REF: \${{ inputs.buildchain-ref }}
245
+ BUILDCHAIN_RESUME_RUN_ID: \${{ inputs.resume-candidate-run-id }}
246
+ BUILDCHAIN_RESUME_RUNTIME_SHA: \${{ inputs.resume-buildchain-runtime-sha }}
246
247
  run: |
247
248
  set -euo pipefail
248
- workflow_ref="\${BUILDCHAIN_ROUTER_WORKFLOW_REF}"
249
- repository="\${BUILDCHAIN_ROUTER_WORKFLOW_REPOSITORY}"
250
- ref="\${workflow_ref##*@}"
249
+ repository="\${BUILDCHAIN_ROUTER_REPOSITORY}"
250
+ ref="\${BUILDCHAIN_ROUTER_REF}"
251
251
  ref="\${ref#refs/heads/}"
252
252
  ref="\${ref#refs/tags/}"
253
- parsed_repository="\${workflow_ref%%/.github/workflows/*}"
254
- if [[ -z "\${repository}" || "\${repository}" != "\${parsed_repository}" || -z "\${ref}" ]]; then
255
- echo "::error::Unable to resolve promotion router source from job.workflow_ref=\${workflow_ref}"
253
+ if [[ ! "\${repository}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ || -z "\${ref}" || "\${ref}" = /* || "\${ref}" = *..* ]]; then
254
+ echo "::error::Unable to resolve promotion router source from repository/ref inputs"
256
255
  exit 1
257
256
  fi
258
- if [[ ! "\${BUILDCHAIN_ROUTER_WORKFLOW_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
259
- echo "::error::job.workflow_sha is not an exact commit SHA"
257
+
258
+ remote_url="https://github.com/\${repository}.git"
259
+ refs="$(git ls-remote "\${remote_url}" "refs/heads/\${ref}" "refs/tags/\${ref}" "refs/tags/\${ref}^{}")"
260
+ head_sha="$(printf '%s\\n' "\${refs}" | awk -v name="refs/heads/\${ref}" '$2 == name { print tolower($1) }')"
261
+ tag_sha="$(printf '%s\\n' "\${refs}" | awk -v peeled="refs/tags/\${ref}^{}" -v name="refs/tags/\${ref}" '$2 == peeled { print tolower($1); found=1 } $2 == name && !found { fallback=tolower($1) } END { if (!found && fallback != "") print fallback }')"
262
+ if [[ -n "\${head_sha}" && -n "\${tag_sha}" && "\${head_sha}" != "\${tag_sha}" ]]; then
263
+ echo "::error::Promotion router ref is ambiguous between branch and tag"
264
+ exit 1
265
+ fi
266
+ sha="\${head_sha:-\${tag_sha}}"
267
+ if [[ ! "\${sha}" =~ ^[0-9a-f]{40}$ ]]; then
268
+ echo "::error::Promotion router ref did not resolve to one exact commit SHA"
260
269
  exit 1
261
270
  fi
271
+ if [[ -n "\${BUILDCHAIN_RESUME_RUN_ID}" ]]; then
272
+ expected_sha="\${BUILDCHAIN_RESUME_RUNTIME_SHA,,}"
273
+ if [[ ! "\${expected_sha}" =~ ^[0-9a-f]{40}$ || "\${sha}" != "\${expected_sha}" ]]; then
274
+ echo "::error::Recovery router ref does not match resume-buildchain-runtime-sha"
275
+ exit 1
276
+ fi
277
+ fi
262
278
  {
263
279
  echo "repository=\${repository}"
264
280
  echo "ref=\${ref}"
265
- echo "sha=\${BUILDCHAIN_ROUTER_WORKFLOW_SHA,,}"
281
+ echo "sha=\${sha}"
266
282
  } >> "\${GITHUB_OUTPUT}"
267
283
 
268
284
  - name: Checkout promotion router
@@ -4,6 +4,8 @@ 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";
8
10
  import { createResolvedPublicationSealedBundle } from "./publication-candidate-sealer.mjs";
9
11
  import { writeGitHubOutputs } from "./build-contract-core.mjs";
@@ -38,6 +40,9 @@ function assertSha(value, label = "sha") {
38
40
  return sha;
39
41
  }
40
42
 
43
+ export const releaseCandidateRuntimeSha = (passport) =>
44
+ assertSha(passport?.buildchain?.sha, "release candidate Passport Buildchain runtime SHA").toLowerCase();
45
+
41
46
  function githubHeaders(token) {
42
47
  const headers = {
43
48
  accept: "application/vnd.github+json",
@@ -77,10 +82,32 @@ export async function githubDownload({ apiUrl, token, path: requestPath, outputP
77
82
  }
78
83
  throw new Error(`GitHub artifact download ${requestPath} failed with ${response.status}${detail ? `: ${detail}` : ""}`);
79
84
  }
80
- 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
+ }
81
95
  return outputPath;
82
96
  }
83
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
+
84
111
  export function selectMergedChannelPullRequest({ pullRequests = [], targetRef, repository }) {
85
112
  const normalizedTarget = normalizeBranch(targetRef);
86
113
  const candidates = pullRequests.filter((pr) => {
@@ -256,7 +283,7 @@ function packageNameFromArtifactPath(filePath) {
256
283
  }
257
284
 
258
285
  function npmIntegrity(filePath) {
259
- return `sha512-${crypto.createHash("sha512").update(fs.readFileSync(filePath)).digest("base64")}`;
286
+ return `sha512-${digestFileSync(filePath, "sha512", "base64")}`;
260
287
  }
261
288
 
262
289
  function readNpmPackageJsonFromTarball(tarballPath) {
@@ -360,9 +387,8 @@ export function selectReleaseCandidateArtifacts({ artifacts = [], artifactName =
360
387
  }
361
388
 
362
389
  export function verifyArtifactArchive({ artifact, archivePath } = {}) {
363
- const bytes = fs.readFileSync(archivePath);
364
- const size = bytes.length;
365
- const digest = `sha256:${crypto.createHash("sha256").update(bytes).digest("hex")}`;
390
+ const size = fs.statSync(archivePath).size;
391
+ const digest = `sha256:${digestFileSync(archivePath, "sha256", "hex")}`;
366
392
  if (!artifact || artifact.expired === true) {
367
393
  throw new Error(`candidate artifact is missing or expired: ${artifact?.name || "<unknown>"}`);
368
394
  }
@@ -676,7 +702,7 @@ export async function resolveReleaseCandidateArtifacts({
676
702
  repository: repoInfo.fullName,
677
703
  sourceSha: passport.source?.headSha,
678
704
  sourceTreeSha: passport.source?.treeHash,
679
- runtimeSha: runtimeSha || sha,
705
+ runtimeSha: releaseCandidateRuntimeSha(passport),
680
706
  releaseCandidateRoot: passport.candidateHash,
681
707
  npmArtifacts: npmTarballPaths.map((tarballPath) => ({
682
708
  path: tarballPath,
@@ -19,6 +19,7 @@ import {
19
19
  } from "./release-candidate-resolver.mjs";
20
20
  import {
21
21
  recoveryFailure,
22
+ validateRecoveryTargetRef,
22
23
  verifyReleaseCandidateRecovery,
23
24
  } from "../packages/core/release-candidate-recovery.js";
24
25
  import {
@@ -58,7 +59,16 @@ function safeName(value) {
58
59
  }
59
60
 
60
61
  function sha256File(filePath) {
61
- return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex")}`;
62
+ const hash = crypto.createHash("sha256");
63
+ const descriptor = fs.openSync(filePath, "r");
64
+ const chunk = Buffer.allocUnsafe(8 * 1024 * 1024);
65
+ try {
66
+ let bytesRead = 0;
67
+ while ((bytesRead = fs.readSync(descriptor, chunk, 0, chunk.length, null)) > 0) hash.update(chunk.subarray(0, bytesRead));
68
+ } finally {
69
+ fs.closeSync(descriptor);
70
+ }
71
+ return `sha256:${hash.digest("hex")}`;
62
72
  }
63
73
 
64
74
  function collectFiles(root) {
@@ -123,7 +133,11 @@ async function downloadArtifact({ artifact, repoInfo, apiUrl, token, archiveDir,
123
133
  path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${artifact.id}/zip`,
124
134
  });
125
135
  const archive = verifyArtifactArchive({ artifact, archivePath });
126
- unzip(archivePath, artifactRoot);
136
+ try {
137
+ unzip(archivePath, artifactRoot);
138
+ } finally {
139
+ fs.rmSync(archivePath, { force: true });
140
+ }
127
141
  const files = collectFiles(artifactRoot);
128
142
  return {
129
143
  artifact,
@@ -164,9 +178,11 @@ function candidateArtifactNames({ passport, selected, artifacts, artifactPattern
164
178
  return names;
165
179
  }
166
180
 
167
- function normalizePlatformManifests(downloads, passport) {
181
+ export function normalizePlatformManifests(downloads, passport) {
168
182
  const manifests = [];
169
183
  const evidenceByArtifact = new Map();
184
+ const platformById = new Map((passport.platformMatrix || []).map((entry) => [String(entry.platformId || ""), entry]));
185
+ const seenPlatformIds = new Set();
170
186
  function addEvidence(artifactName, files) {
171
187
  if (!artifactName) return;
172
188
  const evidenceFiles = evidenceByArtifact.get(artifactName) || new Map();
@@ -182,10 +198,15 @@ function normalizePlatformManifests(downloads, passport) {
182
198
  for (const download of downloads) {
183
199
  if (String(download.artifact.name).includes("-manifest-")) for (const file of download.files.filter((entry) => path.basename(entry.path) === "manifest.json")) {
184
200
  const manifest = JSON.parse(fs.readFileSync(file.absolutePath, "utf8"));
185
- if (!manifest.artifactName) {
186
- const platformId = String(manifest.platform?.id || manifest.platformId || "");
187
- manifest.artifactName = (passport.platformMatrix || []).find((entry) => entry.platformId === platformId)?.artifactName || "";
201
+ const platformId = String(manifest.platform?.id || manifest.platformId || "");
202
+ const expectedPlatform = platformById.get(platformId);
203
+ if (!expectedPlatform) continue;
204
+ if (seenPlatformIds.has(platformId)) throw new Error(`candidate recovery found duplicate platform manifest for ${platformId}`);
205
+ if (manifest.artifactName && manifest.artifactName !== expectedPlatform.artifactName) {
206
+ throw new Error(`candidate recovery platform manifest ${platformId} names unexpected artifact ${manifest.artifactName}`);
188
207
  }
208
+ manifest.artifactName = expectedPlatform.artifactName;
209
+ seenPlatformIds.add(platformId);
189
210
  manifests.push(manifest);
190
211
  addEvidence(manifest.artifactName, download.record.files);
191
212
  }
@@ -223,42 +244,87 @@ function normalizeProductPayloadManifests(downloads) {
223
244
  .map((file) => JSON.parse(fs.readFileSync(file.absolutePath, "utf8"))));
224
245
  }
225
246
 
226
- function createSealedBundle({ downloads, bundleRoot, repository, passport, runtimeSha, publishPackageMain, releasePatterns }) {
247
+ export function createRecoveredPublicationCandidate({
248
+ allFiles,
249
+ repository,
250
+ passport,
251
+ candidateRuntimeSha,
252
+ }) {
253
+ if (passport.buildchain?.sha !== candidateRuntimeSha) {
254
+ throw new Error(
255
+ `recovered publication candidate runtime mismatch: passport=${passport.buildchain?.sha || "<empty>"} expected=${candidateRuntimeSha || "<empty>"}`,
256
+ );
257
+ }
258
+ const payload = {
259
+ schemaVersion: 1,
260
+ contract: PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
261
+ repository,
262
+ sourceSha: passport.source.headSha,
263
+ sourceTreeSha: passport.source.treeHash,
264
+ runtimeSha: candidateRuntimeSha,
265
+ releaseCandidateRoot: `sha256:${passport.candidateHash}`,
266
+ files: allFiles.map(({ path: filePath, size, sha256 }) => ({ path: filePath, size, sha256 })),
267
+ };
268
+ return { ...payload, candidateDigest: publicationArtifactCandidateDigest(payload) };
269
+ }
270
+
271
+ export function createRecoveredPublication({ downloads, bundleRoot, repository, passport, candidateRuntimeSha, publishArtifactKind, publishPackageMain, releasePatterns, platformManifests }) {
227
272
  const allFiles = downloads.flatMap((download) => download.files.map((file) => ({
228
273
  path: path.relative(bundleRoot, file.absolutePath).split(path.sep).join("/"),
229
274
  size: file.size,
230
275
  sha256: file.sha256.replace(/^sha256:/, ""),
231
276
  absolutePath: file.absolutePath,
232
277
  }))).sort((left, right) => left.path.localeCompare(right.path));
278
+ const kind = String(publishArtifactKind || "npm");
279
+ const releaseMatchers = splitPatterns(releasePatterns).map(patternMatcher);
280
+ const releaseAssets = allFiles.filter((file) => releaseMatchers.some((matcher) => matcher.test(path.basename(file.path))));
281
+ if (kind !== "npm") {
282
+ createRecoveredPublicationCandidate({ allFiles, repository, passport, candidateRuntimeSha });
283
+ const version = String(passport.target?.version || "").trim();
284
+ if (!version) throw new Error("candidate recovery requires a passport publication version");
285
+ return {
286
+ manifest: undefined,
287
+ npmArtifacts: [],
288
+ allFiles,
289
+ releaseAssets,
290
+ version,
291
+ publishRequiredArtifacts: generatePublishRequiredArtifacts({ manifests: platformManifests, version, kind }),
292
+ };
293
+ }
233
294
  const tarballs = allFiles.filter((file) => file.path.toLowerCase().endsWith(".tgz"));
234
295
  if (tarballs.length === 0) throw new Error("candidate recovery for npm publication requires at least one exact .tgz payload artifact");
235
296
  const npmArtifacts = tarballs.map((file) => ({ file, metadata: readNpmPackageArtifact({ tarballPath: file.absolutePath, mainPackage: publishPackageMain }) }));
236
297
  const main = npmArtifacts.find((entry) => entry.metadata.role === "main") || (npmArtifacts.length === 1 ? npmArtifacts[0] : undefined);
237
298
  if (!main) throw new Error("candidate npm payload set has no unique main package tarball");
238
- const releaseMatchers = splitPatterns(releasePatterns).map(patternMatcher);
239
- const releaseAssets = allFiles.filter((file) => releaseMatchers.length
299
+ const npmReleaseAssets = allFiles.filter((file) => releaseMatchers.length
240
300
  ? releaseMatchers.some((matcher) => matcher.test(path.basename(file.path)))
241
301
  : file.path.toLowerCase().endsWith(".tgz"));
242
- const payload = {
243
- schemaVersion: 1,
244
- contract: PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
302
+ const candidate = createRecoveredPublicationCandidate({
303
+ allFiles,
245
304
  repository,
246
- sourceSha: passport.source.headSha,
247
- sourceTreeSha: passport.source.treeHash,
248
- runtimeSha,
249
- releaseCandidateRoot: `sha256:${passport.candidateHash}`,
250
- files: allFiles.map(({ path: filePath, size, sha256 }) => ({ path: filePath, size, sha256 })),
251
- };
252
- const candidate = { ...payload, candidateDigest: publicationArtifactCandidateDigest(payload) };
305
+ passport,
306
+ candidateRuntimeSha,
307
+ });
253
308
  const manifest = createPublicationSealedBundle({
254
309
  candidate,
255
310
  packageName: main.metadata.name,
256
311
  packageVersion: main.metadata.ref,
257
312
  npmTarballPath: main.file.path,
258
313
  npmIntegrity: main.metadata.integrity,
259
- releaseAssetPaths: releaseAssets.map((file) => file.path),
314
+ releaseAssetPaths: npmReleaseAssets.map((file) => file.path),
260
315
  });
261
- return { manifest, npmArtifacts, allFiles };
316
+ return {
317
+ manifest,
318
+ npmArtifacts,
319
+ allFiles,
320
+ releaseAssets: npmReleaseAssets,
321
+ version: manifest.npm.version,
322
+ publishRequiredArtifacts: generatePublishRequiredArtifacts({
323
+ kind: "npm",
324
+ tarballPaths: npmArtifacts.map((entry) => entry.file.absolutePath),
325
+ mainPackage: publishPackageMain,
326
+ }),
327
+ };
262
328
  }
263
329
 
264
330
  async function recoverCandidateEvidence({
@@ -301,30 +367,24 @@ async function recoverCandidateEvidence({
301
367
  };
302
368
  }
303
369
 
370
+ async function resolveTargetAdvance({ observedTargetSha, targetSha, transactionId, existingTransaction, repoInfo, apiUrl, token, fetchImpl }) {
371
+ if (observedTargetSha === targetSha || !transactionId || existingTransaction?.id !== transactionId) return undefined;
372
+ const comparison = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/compare/${targetSha}...${observedTargetSha}` });
373
+ return { status: comparison.status, mergeIsAncestor: ["ahead", "identical"].includes(comparison.status) };
374
+ }
375
+
304
376
  export async function resumeFromCandidateRun({
305
- repository,
306
- targetRepository = repository,
307
- candidateRunId,
308
- expectedWorkflowFile,
309
- expectedWorkflowName,
310
- channel,
311
- targetRef,
312
- targetSha,
313
- expectedSourceTree = "",
314
- expectedCandidateRoot = "",
315
- candidateRuntimeSha,
316
- runtimeSha,
317
- transactionId = "",
318
- artifactName = "",
319
- artifactPatterns = "",
320
- releasePatterns = "",
377
+ repository, targetRepository = repository, candidateRunId,
378
+ expectedWorkflowFile, expectedWorkflowName,
379
+ channel, targetRef, targetSha,
380
+ expectedSourceTree = "", expectedCandidateRoot = "",
381
+ candidateRuntimeSha, runtimeSha,
382
+ transactionId = "", artifactName = "", artifactPatterns = "", releasePatterns = "",
321
383
  requiredArtifactCount = 0,
322
- publishPackageMain = "",
384
+ publishArtifactKind = "npm", publishPackageMain = "",
323
385
  outputDir = ".buildchain/release-candidate-recovery",
324
- token = env("GITHUB_TOKEN"),
325
- apiUrl = env("GITHUB_API_URL", "https://api.github.com"),
326
- recoveryRunId = env("GITHUB_RUN_ID"),
327
- fetchImpl = globalThis.fetch,
386
+ token = env("GITHUB_TOKEN"), apiUrl = env("GITHUB_API_URL", "https://api.github.com"),
387
+ recoveryRunId = env("GITHUB_RUN_ID"), fetchImpl = globalThis.fetch,
328
388
  } = {}) {
329
389
  const repoInfo = splitRepository(repository);
330
390
  const runId = String(candidateRunId || "").trim();
@@ -346,22 +406,35 @@ export async function resumeFromCandidateRun({
346
406
  const pullRequest = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}` });
347
407
  const targetCommit = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/git/commits/${targetSha}` });
348
408
  const targetRefState = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/git/ref/heads/${targetRef.replace(/^refs\/heads\//, "")}` });
349
- if (targetRefState.object?.sha !== targetSha) throw new Error(`target ref ${targetRef} no longer points at ${targetSha}`);
409
+ const observedTargetSha = String(targetRefState.object?.sha || "");
350
410
  const compare = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/compare/${pullRequest.merge_commit_sha}...${targetSha}` });
351
411
  const platformManifestEvidence = normalizePlatformManifests(downloads, passport);
352
412
  const controllerReceipts = normalizeControllerReceipts(downloads, passport);
353
413
  const productPayloadManifests = normalizeProductPayloadManifests(downloads);
354
- const sealed = createSealedBundle({
414
+ const publication = createRecoveredPublication({
355
415
  downloads,
356
416
  bundleRoot,
357
417
  repository: repoInfo.fullName,
358
418
  passport,
359
- runtimeSha,
419
+ candidateRuntimeSha,
420
+ publishArtifactKind,
360
421
  publishPackageMain,
361
422
  releasePatterns,
423
+ platformManifests: platformManifestEvidence.manifests,
362
424
  });
363
- const candidateVersion = sealed.manifest.npm.version;
425
+ const candidateVersion = publication.version;
364
426
  const existingTransaction = await readExistingTransaction({ repoInfo, apiUrl, token, fetchImpl, version: candidateVersion });
427
+ const targetAdvance = await resolveTargetAdvance({
428
+ observedTargetSha, targetSha, transactionId, existingTransaction,
429
+ repoInfo, apiUrl, token, fetchImpl,
430
+ });
431
+ validateRecoveryTargetRef({
432
+ targetSha,
433
+ observedTargetSha,
434
+ expectedTransactionId: transactionId,
435
+ existingTransaction,
436
+ ancestry: targetAdvance,
437
+ });
365
438
  const recovery = verifyReleaseCandidateRecovery({
366
439
  candidateRepository: repoInfo.fullName,
367
440
  targetRepository,
@@ -371,6 +444,7 @@ export async function resumeFromCandidateRun({
371
444
  channel,
372
445
  targetRef,
373
446
  targetSha,
447
+ targetRefSha: observedTargetSha,
374
448
  targetTree: targetCommit.tree?.sha,
375
449
  expectedSourceTree,
376
450
  expectedCandidateRoot,
@@ -417,14 +491,10 @@ export async function resumeFromCandidateRun({
417
491
  const sealedManifestPath = path.join(resolvedOutput, "sealed-bundle.json");
418
492
  const requiredArtifactsPath = path.join(resolvedOutput, "publish-required-artifacts.json");
419
493
  fs.writeFileSync(recoveryReceiptPath, `${JSON.stringify(recovery.receipt, null, 2)}\n`);
420
- fs.writeFileSync(sealedManifestPath, `${JSON.stringify(sealed.manifest, null, 2)}\n`);
421
- const publishRequiredArtifacts = generatePublishRequiredArtifacts({
422
- kind: "npm",
423
- tarballPaths: sealed.npmArtifacts.map((entry) => entry.file.absolutePath),
424
- mainPackage: publishPackageMain,
425
- });
494
+ if (publication.manifest) fs.writeFileSync(sealedManifestPath, `${JSON.stringify(publication.manifest, null, 2)}\n`);
495
+ const publishRequiredArtifacts = publication.publishRequiredArtifacts;
426
496
  fs.writeFileSync(requiredArtifactsPath, `${JSON.stringify(publishRequiredArtifacts, null, 2)}\n`);
427
- const tarballs = sealed.npmArtifacts.map((entry) => outputPath(entry.file.absolutePath));
497
+ const tarballs = publication.npmArtifacts.map((entry) => outputPath(entry.file.absolutePath));
428
498
  return {
429
499
  enabled: true,
430
500
  action: "reused",
@@ -442,10 +512,10 @@ export async function resumeFromCandidateRun({
442
512
  payloads: outputPath(path.join(bundleRoot, "artifacts")),
443
513
  platformManifests: downloads.flatMap((download) => download.files.filter((file) => path.basename(file.path) === "manifest.json").map((file) => outputPath(file.absolutePath))),
444
514
  npmTarballs: tarballs,
445
- releaseAssets: sealed.manifest.releaseAssets.map((asset) => outputPath(path.join(bundleRoot, asset.path))),
515
+ releaseAssets: publication.releaseAssets.map((asset) => outputPath(asset.absolutePath)),
446
516
  publishRequiredArtifacts: outputPath(requiredArtifactsPath),
447
- sealedBundleRoot: outputPath(bundleRoot),
448
- sealedBundleManifest: outputPath(sealedManifestPath),
517
+ sealedBundleRoot: publication.manifest ? outputPath(bundleRoot) : "",
518
+ sealedBundleManifest: publication.manifest ? outputPath(sealedManifestPath) : "",
449
519
  recoveryReceipt: outputPath(recoveryReceiptPath),
450
520
  },
451
521
  };
@@ -474,6 +544,7 @@ export async function resumeFromCandidateRunCli() {
474
544
  artifactPatterns: env("BUILDCHAIN_ARTIFACT_PATTERNS"),
475
545
  releasePatterns: env("BUILDCHAIN_GITHUB_RELEASE_PAYLOAD_PATTERNS"),
476
546
  requiredArtifactCount: env("BUILDCHAIN_REQUIRED_ARTIFACT_COUNT", "0"),
547
+ publishArtifactKind: env("BUILDCHAIN_PUBLISH_ARTIFACT_KIND", "npm"),
477
548
  publishPackageMain: env("BUILDCHAIN_PUBLISH_PACKAGE_MAIN"),
478
549
  outputDir: env("BUILDCHAIN_RC_OUTPUT_DIR", ".buildchain/release-candidate-recovery"),
479
550
  });