@kungfu-tech/buildchain 3.0.6-alpha.2 → 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.
- package/contracts/release-candidate-recovery-v1.schema.json +102 -0
- package/dist/site/buildchain-contract.json +100 -25
- package/dist/site/buildchain-site.json +23 -13
- package/dist/site/capability-registry.json +2 -2
- package/dist/site/controller-registry.json +38 -2
- package/dist/site/kfd-claims.json +52 -8
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +3 -3
- package/dist/site/node-api-registry.json +153 -13
- package/dist/site/page-registry.json +16 -6
- package/dist/site/public-surface-audit.json +42 -7
- package/dist/site/publication-authority-registry.json +6 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +1 -0
- package/dist/site/site-manifest.json +7 -7
- package/dist/site/workflow-registry.json +49 -4
- package/docs/node-api-reference.md +42 -23
- package/docs/publish-transaction.md +10 -1
- package/docs/release-candidate.md +87 -10
- package/package.json +2 -1
- package/packages/core/buildchain-contract.js +30 -2
- package/packages/core/index.js +7 -0
- package/packages/core/release-candidate-recovery.js +386 -0
- package/scripts/check-inventory.mjs +12 -2
- package/scripts/release-candidate-resolver.mjs +35 -3
- package/scripts/resume-from-candidate-run.mjs +498 -0
- package/scripts/site-capability-metadata.mjs +1 -0
|
@@ -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
|
+
}
|
|
@@ -193,6 +193,7 @@ export function nodeApiMeta(exportName) {
|
|
|
193
193
|
"./kfd-agent-hub": { group: "kfd-trust", summary: "Declarative Agent Hub adapter inspection, fixed-suite execution, exact KFD cut locking, and agent explanation APIs." },
|
|
194
194
|
"./release-passport-contract": { group: "release-passport-trust", summary: "Standalone release passport JSON Schema, ownership/check manifest, and structural validation APIs." },
|
|
195
195
|
"./release-candidate": { group: "reusable-build", summary: "PR-stage release-candidate artifact, passport, and promote-only resolver APIs." },
|
|
196
|
+
"./release-candidate-recovery": { group: "release-passport-trust", summary: "Fail-closed verification and immutable receipts for reusing a successful sealed candidate run without rebuilding product payloads." },
|
|
196
197
|
"./stable-candidate-ledger": { group: "governance-versioning", summary: "Immutable alpha candidate ledger, qualification, revocation, selection, and exact stable source-lock APIs." },
|
|
197
198
|
"./release-propagation": { group: "site-and-propagation", summary: "Release propagation graph, plan, and exact upstream lock APIs." },
|
|
198
199
|
"./release-activation-transaction": { group: "release-passport-trust", summary: "Ordered cross-repository activation, exact receipt-set binding, retry, abort, rollback, and shadow-rehearsal APIs." },
|