@kungfu-tech/buildchain 3.0.6-alpha.0 → 3.0.6-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/actions/promote-buildchain-ref/README.md +13 -1
- package/bin/buildchain.mjs +13 -1
- package/contracts/auditable-demo-scenario-v1.schema.json +52 -0
- package/contracts/release-candidate-recovery-v1.schema.json +104 -0
- package/dist/site/buildchain-contract.json +134 -34
- package/dist/site/buildchain-site.json +178 -47
- package/dist/site/capability-registry.json +5 -5
- package/dist/site/cli-registry.json +40 -4
- package/dist/site/controller-registry.json +60 -4
- package/dist/site/kfd-claims.json +190 -20
- package/dist/site/kfd-upstream-aggregate.json +9 -9
- package/dist/site/manual-registry.json +10 -10
- package/dist/site/node-api-registry.json +1295 -80
- package/dist/site/page-registry.json +164 -33
- package/dist/site/public-surface-audit.json +428 -21
- package/dist/site/publication-authority-registry.json +66 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +2 -0
- package/dist/site/site-manifest.json +14 -14
- package/dist/site/workflow-registry.json +203 -15
- package/docs/MAP.md +2 -0
- package/docs/auditable-demo.md +58 -11
- package/docs/aws-us-elastic-runner-burst-plane.md +114 -80
- package/docs/cli-reference.md +154 -0
- package/docs/dev-alpha-candidate-patrol.md +13 -5
- package/docs/dev-delivery-warrant.md +158 -0
- package/docs/node-api-reference.md +105 -44
- package/docs/publication-authority.md +11 -0
- package/docs/publish-transaction.md +10 -1
- package/docs/release-candidate.md +140 -12
- package/docs/release-governance.md +66 -1
- package/docs/reusable-build-surface.md +11 -1
- package/docs/shifu-gate-profiles.md +12 -1
- package/docs/versioning.md +2 -0
- package/package.json +5 -2
- package/packages/core/buildchain-contract.js +30 -2
- package/packages/core/buildchain-publication-authority.js +3 -1
- package/packages/core/channel-candidate.js +2 -21
- package/packages/core/channel-promotion-baseline.js +199 -0
- package/packages/core/dev-delivery-candidate-identity.js +94 -0
- package/packages/core/dev-delivery-common.js +73 -0
- package/packages/core/dev-delivery-proof.js +252 -0
- package/packages/core/dev-delivery-warrant-cancellation.js +94 -0
- package/packages/core/dev-delivery-warrant-settlement.js +73 -0
- package/packages/core/dev-delivery-warrant.js +591 -0
- package/packages/core/index.js +7 -0
- package/packages/core/publication-sealed-bundle.js +10 -1
- package/packages/core/release-candidate-recovery.js +539 -0
- package/scripts/audit-publication-control-plane.mjs +12 -4
- package/scripts/auditable-demo-bundle-verification.mjs +148 -0
- package/scripts/auditable-demo-platform.mjs +86 -50
- package/scripts/auditable-demo-presentation.mjs +83 -0
- package/scripts/auditable-demo-renditions.mjs +264 -0
- package/scripts/auditable-demo.mjs +24 -30
- package/scripts/aws-windows-jit-campaign-core.mjs +7 -8
- package/scripts/aws-windows-jit-controller.mjs +1 -0
- package/scripts/aws-windows-jit-core.mjs +1 -1
- package/scripts/build-contract-core.mjs +58 -3
- package/scripts/buildchain-cli-help.mjs +8 -0
- package/scripts/buildchain-patrol.mjs +9 -0
- package/scripts/check-inventory.mjs +25 -3
- package/scripts/dev-alpha-candidate-patrol.mjs +45 -48
- package/scripts/dev-delivery-proof.mjs +193 -0
- package/scripts/dev-delivery-warrant.mjs +426 -0
- package/scripts/dev-pr-auto-merge.mjs +497 -55
- package/scripts/dev-pr-delivery-warrant.mjs +209 -0
- package/scripts/dispatch-artifact-signing-authority.mjs +2 -4
- package/scripts/gate-profile-core.mjs +24 -0
- package/scripts/generate-channel-promotion-workflow.mjs +30 -14
- package/scripts/generate-site-bundle.mjs +2 -2
- package/scripts/git-fetch-process-tree.mjs +142 -0
- package/scripts/lifecycle-substage-evidence.mjs +274 -0
- package/scripts/locked-source-checkout.mjs +6 -3
- package/scripts/publication-candidate-sealer.mjs +104 -0
- package/scripts/release-candidate-resolver.mjs +91 -5
- package/scripts/resolve-artifact-transfer-mode.mjs +9 -0
- package/scripts/resolve-build-contract.mjs +7 -0
- package/scripts/resume-from-candidate-run.mjs +598 -0
- package/scripts/route-offline-runners.mjs +1 -0
- package/scripts/run-lifecycle-core.mjs +9 -9
- package/scripts/shifu-gate-profile.mjs +10 -16
- package/scripts/site-capability-metadata.mjs +15 -0
|
@@ -0,0 +1,598 @@
|
|
|
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
|
+
validateRecoveryTargetRef,
|
|
23
|
+
verifyReleaseCandidateRecovery,
|
|
24
|
+
} from "../packages/core/release-candidate-recovery.js";
|
|
25
|
+
import {
|
|
26
|
+
PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
|
|
27
|
+
publicationArtifactCandidateDigest,
|
|
28
|
+
} from "../packages/core/publication-artifact-candidate.js";
|
|
29
|
+
import { createPublicationSealedBundle } from "../packages/core/publication-sealed-bundle.js";
|
|
30
|
+
import { releaseTransactionStateRef } from "../packages/core/publish-transaction.js";
|
|
31
|
+
|
|
32
|
+
function env(name, fallback = "") {
|
|
33
|
+
return process.env[name] || fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requiredEnv(name) {
|
|
37
|
+
const value = env(name).trim();
|
|
38
|
+
if (!value) throw new Error(`${name} is required for candidate recovery`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function splitRepository(repository) {
|
|
43
|
+
const match = String(repository || "").trim().match(/^([^/\s]+)\/([^/\s]+)$/);
|
|
44
|
+
if (!match) throw new Error(`candidate repository must be owner/repo, got ${repository || "<empty>"}`);
|
|
45
|
+
return { owner: match[1], repo: match[2], fullName: `${match[1]}/${match[2]}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function splitPatterns(value = "") {
|
|
49
|
+
return String(value || "").split(/\r?\n|,/).map((entry) => entry.trim()).filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function patternMatcher(pattern) {
|
|
53
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
|
|
54
|
+
return new RegExp(`^${escaped}$`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeName(value) {
|
|
58
|
+
return String(value || "artifact").replace(/[^A-Za-z0-9._-]/g, "_");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sha256File(filePath) {
|
|
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")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function collectFiles(root) {
|
|
75
|
+
const resolvedRoot = path.resolve(root);
|
|
76
|
+
const files = [];
|
|
77
|
+
const pending = [resolvedRoot];
|
|
78
|
+
while (pending.length) {
|
|
79
|
+
const current = pending.pop();
|
|
80
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
81
|
+
const fullPath = path.join(current, entry.name);
|
|
82
|
+
if (entry.isDirectory()) pending.push(fullPath);
|
|
83
|
+
else if (entry.isFile()) files.push({
|
|
84
|
+
path: path.relative(resolvedRoot, fullPath).split(path.sep).join("/"),
|
|
85
|
+
size: fs.statSync(fullPath).size,
|
|
86
|
+
sha256: sha256File(fullPath),
|
|
87
|
+
absolutePath: fullPath,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findFiles(root, predicate) {
|
|
95
|
+
return collectFiles(root).filter((file) => predicate(file.path, file.absolutePath));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readOnlyJson(files, label) {
|
|
99
|
+
if (files.length !== 1) throw new Error(`expected exactly one ${label}, found ${files.length}`);
|
|
100
|
+
return JSON.parse(fs.readFileSync(files[0].absolutePath, "utf8"));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function readExistingTransaction({ repoInfo, apiUrl, token, fetchImpl, version }) {
|
|
104
|
+
const stateRef = releaseTransactionStateRef(version);
|
|
105
|
+
const response = await githubJson({
|
|
106
|
+
apiUrl,
|
|
107
|
+
token,
|
|
108
|
+
fetchImpl,
|
|
109
|
+
allowNotFound: true,
|
|
110
|
+
path: `/repos/${repoInfo.owner}/${repoInfo.repo}/contents/state.json?ref=${encodeURIComponent(stateRef)}`,
|
|
111
|
+
});
|
|
112
|
+
if (!response) return undefined;
|
|
113
|
+
if (response.type !== "file" || response.encoding !== "base64" || !response.content) {
|
|
114
|
+
throw new Error(`durable transaction ${stateRef} did not expose a base64 state.json file`);
|
|
115
|
+
}
|
|
116
|
+
return JSON.parse(Buffer.from(String(response.content).replace(/\s/g, ""), "base64").toString("utf8"));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function outputPath(filePath) {
|
|
120
|
+
const relative = path.relative(process.cwd(), filePath).split(path.sep).join("/");
|
|
121
|
+
return relative.startsWith("../") ? filePath : relative;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function downloadArtifact({ artifact, repoInfo, apiUrl, token, archiveDir, bundleRoot, fetchImpl }) {
|
|
125
|
+
const name = safeName(artifact.name);
|
|
126
|
+
const archivePath = path.join(archiveDir, `${name}.zip`);
|
|
127
|
+
const artifactRoot = path.join(bundleRoot, "artifacts", name);
|
|
128
|
+
await githubDownload({
|
|
129
|
+
apiUrl,
|
|
130
|
+
token,
|
|
131
|
+
fetchImpl,
|
|
132
|
+
outputPath: archivePath,
|
|
133
|
+
path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${artifact.id}/zip`,
|
|
134
|
+
});
|
|
135
|
+
const archive = verifyArtifactArchive({ artifact, archivePath });
|
|
136
|
+
try {
|
|
137
|
+
unzip(archivePath, artifactRoot);
|
|
138
|
+
} finally {
|
|
139
|
+
fs.rmSync(archivePath, { force: true });
|
|
140
|
+
}
|
|
141
|
+
const files = collectFiles(artifactRoot);
|
|
142
|
+
return {
|
|
143
|
+
artifact,
|
|
144
|
+
artifactRoot,
|
|
145
|
+
record: {
|
|
146
|
+
name: artifact.name,
|
|
147
|
+
kind: "candidate",
|
|
148
|
+
size: Number(artifact.size_in_bytes),
|
|
149
|
+
downloadedSize: archive.size,
|
|
150
|
+
digest: artifact.digest,
|
|
151
|
+
downloadedDigest: archive.digest,
|
|
152
|
+
expired: artifact.expired === true,
|
|
153
|
+
files: files.map(({ path: filePath, size, sha256 }) => ({ path: filePath, size, sha256 })),
|
|
154
|
+
},
|
|
155
|
+
files,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function candidateArtifactNames({ passport, selected, artifacts, artifactPatterns }) {
|
|
160
|
+
const names = new Set([selected.passport.name, selected.summary.name]);
|
|
161
|
+
for (const reference of passport.controllerReceipts || []) {
|
|
162
|
+
if (!reference.artifact) throw new Error(`Passport controller receipt ${reference.controllerId} has no artifact identity`);
|
|
163
|
+
names.add(reference.artifact);
|
|
164
|
+
}
|
|
165
|
+
for (const platform of passport.platformMatrix || []) {
|
|
166
|
+
names.add(platform.artifactName);
|
|
167
|
+
const diagnosticsArtifactName = `${selected.prefix}-diagnostics-${platform.platformId}-${selected.sourceSha}`;
|
|
168
|
+
if (artifacts.some((artifact) => artifact.name === diagnosticsArtifactName)) names.add(diagnosticsArtifactName);
|
|
169
|
+
}
|
|
170
|
+
const manifestPattern = new RegExp(`^${selected.prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-manifest-.+-${selected.sourceSha}$`);
|
|
171
|
+
for (const artifact of artifacts) {
|
|
172
|
+
if (manifestPattern.test(String(artifact.name || ""))) names.add(artifact.name);
|
|
173
|
+
}
|
|
174
|
+
const matchers = splitPatterns(artifactPatterns).map(patternMatcher);
|
|
175
|
+
for (const artifact of artifacts) {
|
|
176
|
+
if (matchers.some((matcher) => matcher.test(String(artifact.name || "")))) names.add(artifact.name);
|
|
177
|
+
}
|
|
178
|
+
return names;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function normalizePlatformManifests(downloads, passport) {
|
|
182
|
+
const manifests = [];
|
|
183
|
+
const evidenceByArtifact = new Map();
|
|
184
|
+
const platformById = new Map((passport.platformMatrix || []).map((entry) => [String(entry.platformId || ""), entry]));
|
|
185
|
+
const seenPlatformIds = new Set();
|
|
186
|
+
function addEvidence(artifactName, files) {
|
|
187
|
+
if (!artifactName) return;
|
|
188
|
+
const evidenceFiles = evidenceByArtifact.get(artifactName) || new Map();
|
|
189
|
+
for (const file of files) {
|
|
190
|
+
const existing = evidenceFiles.get(file.path);
|
|
191
|
+
if (existing && (existing.size !== file.size || existing.sha256 !== file.sha256)) {
|
|
192
|
+
throw new Error(`platform evidence disagrees for ${artifactName}/${file.path}`);
|
|
193
|
+
}
|
|
194
|
+
evidenceFiles.set(file.path, file);
|
|
195
|
+
}
|
|
196
|
+
evidenceByArtifact.set(artifactName, evidenceFiles);
|
|
197
|
+
}
|
|
198
|
+
for (const download of downloads) {
|
|
199
|
+
if (String(download.artifact.name).includes("-manifest-")) for (const file of download.files.filter((entry) => path.basename(entry.path) === "manifest.json")) {
|
|
200
|
+
const manifest = JSON.parse(fs.readFileSync(file.absolutePath, "utf8"));
|
|
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}`);
|
|
207
|
+
}
|
|
208
|
+
manifest.artifactName = expectedPlatform.artifactName;
|
|
209
|
+
seenPlatformIds.add(platformId);
|
|
210
|
+
manifests.push(manifest);
|
|
211
|
+
addEvidence(manifest.artifactName, download.record.files);
|
|
212
|
+
}
|
|
213
|
+
if (String(download.artifact.name).includes("-diagnostics-")) {
|
|
214
|
+
const diagnosticsFiles = download.files.filter((entry) => path.basename(entry.path) === "diagnostics.json");
|
|
215
|
+
if (diagnosticsFiles.length === 1) {
|
|
216
|
+
const diagnostics = JSON.parse(fs.readFileSync(diagnosticsFiles[0].absolutePath, "utf8"));
|
|
217
|
+
addEvidence(String(diagnostics.links?.artifactName || ""), download.record.files);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const evidence = [...evidenceByArtifact].map(([artifactName, files]) => ({
|
|
222
|
+
artifactName,
|
|
223
|
+
files: [...files.values()].sort((left, right) => left.path.localeCompare(right.path)),
|
|
224
|
+
}));
|
|
225
|
+
return { manifests, evidence };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizeControllerReceipts(downloads, passport) {
|
|
229
|
+
const artifactNames = new Set((passport.controllerReceipts || []).map((reference) => reference.artifact));
|
|
230
|
+
const receipts = [];
|
|
231
|
+
for (const download of downloads.filter((entry) => artifactNames.has(entry.artifact.name))) {
|
|
232
|
+
const candidates = download.files.filter((file) => file.path.endsWith(".json")).map((file) => {
|
|
233
|
+
try { return JSON.parse(fs.readFileSync(file.absolutePath, "utf8")); } catch { return undefined; }
|
|
234
|
+
}).filter((value) => value?.contract === "buildchain.controller-evidence/v1" && value?.kind === "receipt");
|
|
235
|
+
if (candidates.length !== 1) throw new Error(`controller artifact ${download.artifact.name} must contain exactly one controller receipt`);
|
|
236
|
+
receipts.push(candidates[0]);
|
|
237
|
+
}
|
|
238
|
+
return receipts;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function normalizeProductPayloadManifests(downloads) {
|
|
242
|
+
return downloads.flatMap((download) => download.files
|
|
243
|
+
.filter((file) => path.basename(file.path) === "product-payload-manifest.json")
|
|
244
|
+
.map((file) => JSON.parse(fs.readFileSync(file.absolutePath, "utf8"))));
|
|
245
|
+
}
|
|
246
|
+
|
|
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 }) {
|
|
272
|
+
const allFiles = downloads.flatMap((download) => download.files.map((file) => ({
|
|
273
|
+
path: path.relative(bundleRoot, file.absolutePath).split(path.sep).join("/"),
|
|
274
|
+
size: file.size,
|
|
275
|
+
sha256: file.sha256.replace(/^sha256:/, ""),
|
|
276
|
+
absolutePath: file.absolutePath,
|
|
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
|
+
}
|
|
294
|
+
const tarballs = allFiles.filter((file) => file.path.toLowerCase().endsWith(".tgz"));
|
|
295
|
+
if (tarballs.length === 0) throw new Error("candidate recovery for npm publication requires at least one exact .tgz payload artifact");
|
|
296
|
+
const npmArtifacts = tarballs.map((file) => ({ file, metadata: readNpmPackageArtifact({ tarballPath: file.absolutePath, mainPackage: publishPackageMain }) }));
|
|
297
|
+
const main = npmArtifacts.find((entry) => entry.metadata.role === "main") || (npmArtifacts.length === 1 ? npmArtifacts[0] : undefined);
|
|
298
|
+
if (!main) throw new Error("candidate npm payload set has no unique main package tarball");
|
|
299
|
+
const npmReleaseAssets = allFiles.filter((file) => releaseMatchers.length
|
|
300
|
+
? releaseMatchers.some((matcher) => matcher.test(path.basename(file.path)))
|
|
301
|
+
: file.path.toLowerCase().endsWith(".tgz"));
|
|
302
|
+
const candidate = createRecoveredPublicationCandidate({
|
|
303
|
+
allFiles,
|
|
304
|
+
repository,
|
|
305
|
+
passport,
|
|
306
|
+
candidateRuntimeSha,
|
|
307
|
+
});
|
|
308
|
+
const manifest = createPublicationSealedBundle({
|
|
309
|
+
candidate,
|
|
310
|
+
packageName: main.metadata.name,
|
|
311
|
+
packageVersion: main.metadata.ref,
|
|
312
|
+
npmTarballPath: main.file.path,
|
|
313
|
+
npmIntegrity: main.metadata.integrity,
|
|
314
|
+
releaseAssetPaths: npmReleaseAssets.map((file) => file.path),
|
|
315
|
+
});
|
|
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
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function recoverCandidateEvidence({
|
|
331
|
+
repoInfo,
|
|
332
|
+
runId,
|
|
333
|
+
artifactName,
|
|
334
|
+
artifactPatterns,
|
|
335
|
+
requiredArtifactCount,
|
|
336
|
+
outputDir,
|
|
337
|
+
apiUrl,
|
|
338
|
+
token,
|
|
339
|
+
fetchImpl,
|
|
340
|
+
archiveDir,
|
|
341
|
+
}) {
|
|
342
|
+
const run = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}` });
|
|
343
|
+
const workflow = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/workflows/${run.workflow_id}` });
|
|
344
|
+
const artifactResponse = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${runId}/artifacts?per_page=100` });
|
|
345
|
+
const artifacts = Array.isArray(artifactResponse.artifacts) ? artifactResponse.artifacts : [];
|
|
346
|
+
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");
|
|
347
|
+
const selected = selectReleaseCandidateArtifacts({ artifacts, artifactName });
|
|
348
|
+
const resolvedOutput = path.resolve(outputDir);
|
|
349
|
+
const bundleRoot = path.join(resolvedOutput, "sealed-candidate");
|
|
350
|
+
fs.mkdirSync(bundleRoot, { recursive: true });
|
|
351
|
+
const initialDownloads = [];
|
|
352
|
+
for (const artifact of [selected.passport, selected.summary]) initialDownloads.push(await downloadArtifact({ artifact, repoInfo, apiUrl, token, archiveDir, bundleRoot, fetchImpl }));
|
|
353
|
+
const passport = readOnlyJson(initialDownloads[0].files.filter((file) => path.basename(file.path) === "release-candidate-passport.json"), "release-candidate-passport.json");
|
|
354
|
+
const buildSummary = readOnlyJson(initialDownloads[1].files.filter((file) => path.basename(file.path) === "build-summary.json"), "build-summary.json");
|
|
355
|
+
const requiredNames = candidateArtifactNames({ passport, selected, artifacts, artifactPatterns });
|
|
356
|
+
const chosen = artifacts.filter((artifact) => requiredNames.has(artifact.name));
|
|
357
|
+
if (chosen.length !== requiredNames.size) {
|
|
358
|
+
const found = new Set(chosen.map((artifact) => artifact.name));
|
|
359
|
+
throw new Error(`candidate artifacts are missing: ${[...requiredNames].filter((name) => !found.has(name)).join(", ")}`);
|
|
360
|
+
}
|
|
361
|
+
if (Number(requiredArtifactCount || 0) > 0 && chosen.length < Number(requiredArtifactCount)) throw new Error(`candidate artifact count ${chosen.length} is below required ${requiredArtifactCount}`);
|
|
362
|
+
const downloads = [...initialDownloads];
|
|
363
|
+
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 }));
|
|
364
|
+
return {
|
|
365
|
+
run, workflow, selected, resolvedOutput, bundleRoot, initialDownloads,
|
|
366
|
+
passport, buildSummary, chosen, downloads,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
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
|
+
|
|
376
|
+
export async function resumeFromCandidateRun({
|
|
377
|
+
repository, targetRepository = repository, candidateRunId,
|
|
378
|
+
expectedWorkflowFile, expectedWorkflowName,
|
|
379
|
+
channel, targetRef, targetSha,
|
|
380
|
+
expectedSourceTree = "", expectedCandidateRoot = "",
|
|
381
|
+
candidateRuntimeSha, runtimeSha,
|
|
382
|
+
transactionId = "", artifactName = "", artifactPatterns = "", releasePatterns = "",
|
|
383
|
+
requiredArtifactCount = 0,
|
|
384
|
+
publishArtifactKind = "npm", publishPackageMain = "",
|
|
385
|
+
outputDir = ".buildchain/release-candidate-recovery",
|
|
386
|
+
token = env("GITHUB_TOKEN"), apiUrl = env("GITHUB_API_URL", "https://api.github.com"),
|
|
387
|
+
recoveryRunId = env("GITHUB_RUN_ID"), fetchImpl = globalThis.fetch,
|
|
388
|
+
} = {}) {
|
|
389
|
+
const repoInfo = splitRepository(repository);
|
|
390
|
+
const runId = String(candidateRunId || "").trim();
|
|
391
|
+
if (!/^\d+$/.test(runId)) throw new Error("candidate run ID must be numeric");
|
|
392
|
+
if (!String(expectedSourceTree || "").trim() && !String(expectedCandidateRoot || "").trim()) {
|
|
393
|
+
throw new Error("candidate recovery requires expectedSourceTree or expectedCandidateRoot");
|
|
394
|
+
}
|
|
395
|
+
const archiveDir = fs.mkdtempSync(path.join(os.tmpdir(), "buildchain-resume-"));
|
|
396
|
+
try {
|
|
397
|
+
const {
|
|
398
|
+
run, workflow, selected, resolvedOutput, bundleRoot, initialDownloads,
|
|
399
|
+
passport, buildSummary, chosen, downloads,
|
|
400
|
+
} = await recoverCandidateEvidence({
|
|
401
|
+
repoInfo, runId, artifactName, artifactPatterns, requiredArtifactCount,
|
|
402
|
+
outputDir, apiUrl, token, fetchImpl, archiveDir,
|
|
403
|
+
});
|
|
404
|
+
const prNumber = Number(passport.pullRequest?.number || 0);
|
|
405
|
+
if (!prNumber) throw new Error("Release Candidate Passport has no PR identity");
|
|
406
|
+
const pullRequest = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/pulls/${prNumber}` });
|
|
407
|
+
const targetCommit = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/git/commits/${targetSha}` });
|
|
408
|
+
const targetRefState = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/git/ref/heads/${targetRef.replace(/^refs\/heads\//, "")}` });
|
|
409
|
+
const observedTargetSha = String(targetRefState.object?.sha || "");
|
|
410
|
+
const compare = await githubJson({ apiUrl, token, fetchImpl, path: `/repos/${repoInfo.owner}/${repoInfo.repo}/compare/${pullRequest.merge_commit_sha}...${targetSha}` });
|
|
411
|
+
const platformManifestEvidence = normalizePlatformManifests(downloads, passport);
|
|
412
|
+
const controllerReceipts = normalizeControllerReceipts(downloads, passport);
|
|
413
|
+
const productPayloadManifests = normalizeProductPayloadManifests(downloads);
|
|
414
|
+
const publication = createRecoveredPublication({
|
|
415
|
+
downloads,
|
|
416
|
+
bundleRoot,
|
|
417
|
+
repository: repoInfo.fullName,
|
|
418
|
+
passport,
|
|
419
|
+
candidateRuntimeSha,
|
|
420
|
+
publishArtifactKind,
|
|
421
|
+
publishPackageMain,
|
|
422
|
+
releasePatterns,
|
|
423
|
+
platformManifests: platformManifestEvidence.manifests,
|
|
424
|
+
});
|
|
425
|
+
const candidateVersion = publication.version;
|
|
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
|
+
});
|
|
438
|
+
const recovery = verifyReleaseCandidateRecovery({
|
|
439
|
+
candidateRepository: repoInfo.fullName,
|
|
440
|
+
targetRepository,
|
|
441
|
+
expectedRunId: runId,
|
|
442
|
+
expectedWorkflowFile,
|
|
443
|
+
expectedWorkflowName,
|
|
444
|
+
channel,
|
|
445
|
+
targetRef,
|
|
446
|
+
targetSha,
|
|
447
|
+
targetRefSha: observedTargetSha,
|
|
448
|
+
targetTree: targetCommit.tree?.sha,
|
|
449
|
+
expectedSourceTree,
|
|
450
|
+
expectedCandidateRoot,
|
|
451
|
+
expectedRuntimeSha: candidateRuntimeSha,
|
|
452
|
+
expectedTransactionId: transactionId,
|
|
453
|
+
existingTransaction,
|
|
454
|
+
run: {
|
|
455
|
+
id: String(run.id),
|
|
456
|
+
repository: repoInfo.fullName,
|
|
457
|
+
headRepository: run.head_repository?.full_name || "",
|
|
458
|
+
status: run.status,
|
|
459
|
+
conclusion: run.conclusion,
|
|
460
|
+
event: run.event,
|
|
461
|
+
path: run.path,
|
|
462
|
+
name: run.name,
|
|
463
|
+
headSha: run.head_sha || "",
|
|
464
|
+
headBranch: run.head_branch || "",
|
|
465
|
+
pullRequestNumbers: (run.pull_requests || []).map((entry) => Number(entry.number)),
|
|
466
|
+
},
|
|
467
|
+
workflow: { path: workflow.path, name: workflow.name, state: workflow.state },
|
|
468
|
+
pullRequest: {
|
|
469
|
+
number: pullRequest.number,
|
|
470
|
+
merged: pullRequest.merged === true,
|
|
471
|
+
mergeSha: pullRequest.merge_commit_sha,
|
|
472
|
+
headRepository: pullRequest.head?.repo?.full_name || "",
|
|
473
|
+
baseRef: pullRequest.base?.ref || "",
|
|
474
|
+
authorAssociation: pullRequest.author_association || "",
|
|
475
|
+
headSha: pullRequest.head?.sha || "",
|
|
476
|
+
headRef: pullRequest.head?.ref || "",
|
|
477
|
+
},
|
|
478
|
+
ancestry: { status: compare.status, mergeIsAncestor: ["ahead", "identical"].includes(compare.status) },
|
|
479
|
+
passport,
|
|
480
|
+
buildSummary,
|
|
481
|
+
controllerReceipts,
|
|
482
|
+
platformManifests: platformManifestEvidence.manifests,
|
|
483
|
+
platformManifestEvidence: platformManifestEvidence.evidence,
|
|
484
|
+
productPayloadManifests,
|
|
485
|
+
artifacts: downloads.map((download) => download.record),
|
|
486
|
+
publicationVersion: candidateVersion,
|
|
487
|
+
currentToolingSha: runtimeSha,
|
|
488
|
+
recoveryRunId,
|
|
489
|
+
});
|
|
490
|
+
const recoveryReceiptPath = path.join(resolvedOutput, "recovery-receipt.json");
|
|
491
|
+
const sealedManifestPath = path.join(resolvedOutput, "sealed-bundle.json");
|
|
492
|
+
const requiredArtifactsPath = path.join(resolvedOutput, "publish-required-artifacts.json");
|
|
493
|
+
fs.writeFileSync(recoveryReceiptPath, `${JSON.stringify(recovery.receipt, null, 2)}\n`);
|
|
494
|
+
if (publication.manifest) fs.writeFileSync(sealedManifestPath, `${JSON.stringify(publication.manifest, null, 2)}\n`);
|
|
495
|
+
const publishRequiredArtifacts = publication.publishRequiredArtifacts;
|
|
496
|
+
fs.writeFileSync(requiredArtifactsPath, `${JSON.stringify(publishRequiredArtifacts, null, 2)}\n`);
|
|
497
|
+
const tarballs = publication.npmArtifacts.map((entry) => outputPath(entry.file.absolutePath));
|
|
498
|
+
return {
|
|
499
|
+
enabled: true,
|
|
500
|
+
action: "reused",
|
|
501
|
+
repository: repoInfo.fullName,
|
|
502
|
+
run: { id: runId, url: run.html_url || "", name: run.name },
|
|
503
|
+
artifacts: { passport: selected.passport.name, summary: selected.summary.name, payloads: chosen.map((artifact) => artifact.name), sourceSha: selected.sourceSha },
|
|
504
|
+
version: candidateVersion,
|
|
505
|
+
candidateRoot: recovery.receipt.recovered.candidateRoot,
|
|
506
|
+
artifactRoot: recovery.receipt.recovered.artifactRoot,
|
|
507
|
+
receipt: recovery.receipt,
|
|
508
|
+
publishRequiredArtifacts,
|
|
509
|
+
paths: {
|
|
510
|
+
passport: outputPath(initialDownloads[0].files.find((file) => path.basename(file.path) === "release-candidate-passport.json").absolutePath),
|
|
511
|
+
buildSummary: outputPath(initialDownloads[1].files.find((file) => path.basename(file.path) === "build-summary.json").absolutePath),
|
|
512
|
+
payloads: outputPath(path.join(bundleRoot, "artifacts")),
|
|
513
|
+
platformManifests: downloads.flatMap((download) => download.files.filter((file) => path.basename(file.path) === "manifest.json").map((file) => outputPath(file.absolutePath))),
|
|
514
|
+
npmTarballs: tarballs,
|
|
515
|
+
releaseAssets: publication.releaseAssets.map((asset) => outputPath(asset.absolutePath)),
|
|
516
|
+
publishRequiredArtifacts: outputPath(requiredArtifactsPath),
|
|
517
|
+
sealedBundleRoot: publication.manifest ? outputPath(bundleRoot) : "",
|
|
518
|
+
sealedBundleManifest: publication.manifest ? outputPath(sealedManifestPath) : "",
|
|
519
|
+
recoveryReceipt: outputPath(recoveryReceiptPath),
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
} finally {
|
|
523
|
+
fs.rmSync(archiveDir, { recursive: true, force: true });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export async function resumeFromCandidateRunCli() {
|
|
528
|
+
try {
|
|
529
|
+
const result = await resumeFromCandidateRun({
|
|
530
|
+
repository: requiredEnv("BUILDCHAIN_RESUME_CANDIDATE_REPOSITORY"),
|
|
531
|
+
targetRepository: env("GITHUB_REPOSITORY"),
|
|
532
|
+
candidateRunId: requiredEnv("BUILDCHAIN_RESUME_CANDIDATE_RUN_ID"),
|
|
533
|
+
expectedWorkflowFile: requiredEnv("BUILDCHAIN_RESUME_EXPECTED_WORKFLOW_FILE"),
|
|
534
|
+
expectedWorkflowName: requiredEnv("BUILDCHAIN_RESUME_EXPECTED_WORKFLOW_NAME"),
|
|
535
|
+
channel: requiredEnv("BUILDCHAIN_RESUME_CHANNEL"),
|
|
536
|
+
targetRef: requiredEnv("BUILDCHAIN_RESUME_TARGET_REF"),
|
|
537
|
+
targetSha: requiredEnv("BUILDCHAIN_RESUME_TARGET_SHA"),
|
|
538
|
+
expectedSourceTree: env("BUILDCHAIN_RESUME_EXPECTED_SOURCE_TREE"),
|
|
539
|
+
expectedCandidateRoot: env("BUILDCHAIN_RESUME_EXPECTED_CANDIDATE_ROOT"),
|
|
540
|
+
candidateRuntimeSha: requiredEnv("BUILDCHAIN_RESUME_EXPECTED_CANDIDATE_RUNTIME_SHA"),
|
|
541
|
+
runtimeSha: requiredEnv("BUILDCHAIN_RESUME_RUNTIME_SHA"),
|
|
542
|
+
transactionId: env("BUILDCHAIN_RESUME_TRANSACTION_ID"),
|
|
543
|
+
artifactName: env("BUILDCHAIN_ARTIFACT_NAME"),
|
|
544
|
+
artifactPatterns: env("BUILDCHAIN_ARTIFACT_PATTERNS"),
|
|
545
|
+
releasePatterns: env("BUILDCHAIN_GITHUB_RELEASE_PAYLOAD_PATTERNS"),
|
|
546
|
+
requiredArtifactCount: env("BUILDCHAIN_REQUIRED_ARTIFACT_COUNT", "0"),
|
|
547
|
+
publishArtifactKind: env("BUILDCHAIN_PUBLISH_ARTIFACT_KIND", "npm"),
|
|
548
|
+
publishPackageMain: env("BUILDCHAIN_PUBLISH_PACKAGE_MAIN"),
|
|
549
|
+
outputDir: env("BUILDCHAIN_RC_OUTPUT_DIR", ".buildchain/release-candidate-recovery"),
|
|
550
|
+
});
|
|
551
|
+
writeGitHubOutputs({
|
|
552
|
+
"promote-only-release-candidate": "true",
|
|
553
|
+
"release-candidate-action": result.action,
|
|
554
|
+
"release-candidate-passport-path": result.paths.passport,
|
|
555
|
+
"release-candidate-build-summary-path": result.paths.buildSummary,
|
|
556
|
+
"release-candidate-version": result.version,
|
|
557
|
+
"release-candidate-source-sha": result.artifacts.sourceSha,
|
|
558
|
+
"release-candidate-artifact": result.artifacts.passport,
|
|
559
|
+
"release-candidate-build-summary-artifact": result.artifacts.summary,
|
|
560
|
+
"release-candidate-payload-artifacts": result.artifacts.payloads.join(","),
|
|
561
|
+
"release-candidate-payload-dir": result.paths.payloads,
|
|
562
|
+
"release-candidate-platform-manifest-paths": result.paths.platformManifests.join(","),
|
|
563
|
+
"release-candidate-npm-tarball-paths": result.paths.npmTarballs.join(","),
|
|
564
|
+
"release-candidate-github-release-artifact-paths": result.paths.releaseAssets.join("\n"),
|
|
565
|
+
"publish-required-artifacts-json": JSON.stringify(result.publishRequiredArtifacts),
|
|
566
|
+
"publish-required-artifacts-path": result.paths.publishRequiredArtifacts,
|
|
567
|
+
"release-candidate-run-id": result.run.id,
|
|
568
|
+
"release-candidate-run-url": result.run.url,
|
|
569
|
+
"release-candidate-recovery-receipt-path": result.paths.recoveryReceipt,
|
|
570
|
+
"release-candidate-recovery-root": result.receipt.root,
|
|
571
|
+
"release-candidate-root": result.candidateRoot,
|
|
572
|
+
"release-candidate-artifact-root": result.artifactRoot,
|
|
573
|
+
"publish-sealed-bundle-root": result.paths.sealedBundleRoot,
|
|
574
|
+
"publish-sealed-bundle-manifest": result.paths.sealedBundleManifest,
|
|
575
|
+
"release-candidate-diagnosis": `Reused sealed candidate run ${result.run.id}; product build stages skipped`,
|
|
576
|
+
});
|
|
577
|
+
console.log(JSON.stringify(result, null, 2));
|
|
578
|
+
return result;
|
|
579
|
+
} catch (error) {
|
|
580
|
+
const failure = recoveryFailure(error);
|
|
581
|
+
writeGitHubOutputs({
|
|
582
|
+
"release-candidate-action": "rejected",
|
|
583
|
+
"release-candidate-recovery-error-code": failure.code,
|
|
584
|
+
"release-candidate-recovery-next-action": failure.nextAction,
|
|
585
|
+
"release-candidate-diagnosis": `${failure.code}: ${failure.reason}; next: ${failure.nextAction}`,
|
|
586
|
+
});
|
|
587
|
+
throw Object.assign(error, { recoveryFailure: failure });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
592
|
+
resumeFromCandidateRunCli().catch((error) => {
|
|
593
|
+
const failure = error.recoveryFailure || recoveryFailure(error);
|
|
594
|
+
console.error(`candidate recovery rejected [${failure.code}]: ${failure.reason}`);
|
|
595
|
+
console.error(`next action: ${failure.nextAction}`);
|
|
596
|
+
process.exitCode = 1;
|
|
597
|
+
});
|
|
598
|
+
}
|