@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.
- package/actions/promote-buildchain-ref/README.md +5 -1
- package/contracts/release-candidate-recovery-v1.schema.json +1 -0
- package/dist/site/buildchain-contract.json +23 -18
- package/dist/site/buildchain-site.json +15 -15
- package/dist/site/controller-registry.json +6 -2
- package/dist/site/kfd-claims.json +10 -5
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +3 -3
- package/dist/site/node-api-registry.json +77 -13
- package/dist/site/page-registry.json +8 -8
- package/dist/site/public-surface-audit.json +10 -5
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +7 -7
- package/dist/site/workflow-registry.json +8 -3
- package/docs/node-api-reference.md +22 -20
- package/docs/release-candidate.md +26 -1
- package/docs/release-governance.md +6 -0
- package/package.json +1 -1
- package/packages/core/publication-sealed-bundle.js +10 -1
- package/packages/core/release-candidate-recovery.js +89 -1
- package/scripts/audit-publication-control-plane.mjs +12 -4
- package/scripts/check-inventory.mjs +10 -1
- package/scripts/generate-channel-promotion-workflow.mjs +30 -14
- package/scripts/release-candidate-resolver.mjs +32 -6
- package/scripts/resume-from-candidate-run.mjs +127 -56
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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=\${
|
|
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
|
-
|
|
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-${
|
|
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
|
|
364
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
|
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
|
|
243
|
-
|
|
244
|
-
contract: PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
|
|
302
|
+
const candidate = createRecoveredPublicationCandidate({
|
|
303
|
+
allFiles,
|
|
245
304
|
repository,
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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:
|
|
314
|
+
releaseAssetPaths: npmReleaseAssets.map((file) => file.path),
|
|
260
315
|
});
|
|
261
|
-
return {
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
414
|
+
const publication = createRecoveredPublication({
|
|
355
415
|
downloads,
|
|
356
416
|
bundleRoot,
|
|
357
417
|
repository: repoInfo.fullName,
|
|
358
418
|
passport,
|
|
359
|
-
|
|
419
|
+
candidateRuntimeSha,
|
|
420
|
+
publishArtifactKind,
|
|
360
421
|
publishPackageMain,
|
|
361
422
|
releasePatterns,
|
|
423
|
+
platformManifests: platformManifestEvidence.manifests,
|
|
362
424
|
});
|
|
363
|
-
const candidateVersion =
|
|
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(
|
|
421
|
-
const publishRequiredArtifacts =
|
|
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 =
|
|
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:
|
|
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
|
});
|