@kungfu-tech/buildchain 3.0.6-alpha.2 → 3.0.6-alpha.4
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 +103 -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 +406 -0
- package/scripts/check-inventory.mjs +12 -2
- package/scripts/release-candidate-resolver.mjs +35 -3
- package/scripts/resume-from-candidate-run.mjs +527 -0
- package/scripts/site-capability-metadata.mjs +1 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createControllerReceiptReference,
|
|
3
|
+
validateControllerReceipt,
|
|
4
|
+
} from "./controller-evidence.js";
|
|
5
|
+
import {
|
|
6
|
+
sha256Json,
|
|
7
|
+
validateReleaseCandidatePassport,
|
|
8
|
+
} from "./release-candidate.js";
|
|
9
|
+
import { releaseTransactionId } from "./publish-transaction.js";
|
|
10
|
+
|
|
11
|
+
export const RELEASE_CANDIDATE_RECOVERY_CONTRACT =
|
|
12
|
+
"kungfu-buildchain-release-candidate-recovery/v1";
|
|
13
|
+
|
|
14
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
15
|
+
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
16
|
+
const TRUSTED_ASSOCIATIONS = new Set(["COLLABORATOR", "MEMBER", "OWNER"]);
|
|
17
|
+
|
|
18
|
+
export class ReleaseCandidateRecoveryError extends Error {
|
|
19
|
+
constructor(code, message, nextAction) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "ReleaseCandidateRecoveryError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.nextAction = nextAction;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fail(code, message, nextAction) {
|
|
28
|
+
throw new ReleaseCandidateRecoveryError(code, message, nextAction);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function required(value, label, code = "invalid-recovery-input") {
|
|
32
|
+
const normalized = String(value || "").trim();
|
|
33
|
+
if (!normalized) fail(code, `${label} is required`, "Correct the recovery dispatch inputs and run a fresh recovery event.");
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function exactSha(value, label) {
|
|
38
|
+
const normalized = required(value, label).toLowerCase();
|
|
39
|
+
if (!SHA_PATTERN.test(normalized)) {
|
|
40
|
+
fail("invalid-recovery-input", `${label} must be a 40-character Git SHA`, "Supply the exact immutable Git SHA.");
|
|
41
|
+
}
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function contentRoot(value, label) {
|
|
46
|
+
const normalized = required(value, label).toLowerCase();
|
|
47
|
+
if (!SHA256_PATTERN.test(normalized)) {
|
|
48
|
+
fail("invalid-recovery-input", `${label} must be a sha256 content root`, "Supply the exact sha256 root from the sealed candidate evidence.");
|
|
49
|
+
}
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeArtifact(record, index) {
|
|
54
|
+
const name = required(record?.name, `artifacts[${index}].name`);
|
|
55
|
+
if (record?.expired === true) {
|
|
56
|
+
fail("artifact-expired", `candidate artifact is expired: ${name}`, "Create a new candidate explicitly; recovery never falls back to rebuilding.");
|
|
57
|
+
}
|
|
58
|
+
if (record?.missing === true) {
|
|
59
|
+
fail("artifact-missing", `candidate artifact is missing: ${name}`, "Restore the immutable artifact if possible or explicitly create a new candidate.");
|
|
60
|
+
}
|
|
61
|
+
const size = Number(record?.size);
|
|
62
|
+
const downloadedSize = Number(record?.downloadedSize);
|
|
63
|
+
if (!Number.isSafeInteger(size) || size < 0 || !Number.isSafeInteger(downloadedSize) || downloadedSize < 0) {
|
|
64
|
+
fail("artifact-metadata-invalid", `candidate artifact size is invalid: ${name}`, "Inspect the candidate run artifact metadata and retry with a complete artifact set.");
|
|
65
|
+
}
|
|
66
|
+
const digest = contentRoot(record?.digest, `artifacts[${index}].digest`);
|
|
67
|
+
const downloadedDigest = contentRoot(record?.downloadedDigest, `artifacts[${index}].downloadedDigest`);
|
|
68
|
+
if (size !== downloadedSize || digest !== downloadedDigest) {
|
|
69
|
+
fail("artifact-digest-mismatch", `candidate artifact archive differs from GitHub metadata: ${name}`, "Do not publish; preserve the run and investigate artifact corruption or replacement.");
|
|
70
|
+
}
|
|
71
|
+
const files = (record?.files || []).map((file, fileIndex) => {
|
|
72
|
+
const fileSize = Number(file?.size);
|
|
73
|
+
if (!Number.isSafeInteger(fileSize) || fileSize < 0) {
|
|
74
|
+
fail("artifact-manifest-mismatch", `${name} file ${fileIndex} has an invalid size`, "Inspect the platform manifest and product payload bytes.");
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
path: required(file?.path, `${name}.files[${fileIndex}].path`),
|
|
78
|
+
size: fileSize,
|
|
79
|
+
sha256: contentRoot(file?.sha256, `${name}.files[${fileIndex}].sha256`),
|
|
80
|
+
};
|
|
81
|
+
}).sort((left, right) => left.path.localeCompare(right.path));
|
|
82
|
+
if (new Set(files.map((file) => file.path)).size !== files.length) {
|
|
83
|
+
fail("artifact-manifest-mismatch", `candidate artifact contains duplicate file paths: ${name}`, "Regenerate a valid candidate explicitly.");
|
|
84
|
+
}
|
|
85
|
+
return { name, size, digest, files, kind: String(record?.kind || "payload") };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assertEqual(actual, expected, code, label, nextAction) {
|
|
89
|
+
if (actual !== expected) fail(code, `${label} mismatch: expected ${expected}, got ${actual || "<empty>"}`, nextAction);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizedRef(value) {
|
|
93
|
+
return String(value || "").replace(/^refs\/heads\//, "").trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sameArtifactFile(left, right) {
|
|
97
|
+
return Boolean(left && right && left.size === right.size && left.sha256 === right.sha256);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validatePlatformPayloads(passport, artifacts, platformManifests, platformManifestEvidence) {
|
|
101
|
+
const byName = new Map(artifacts.map((artifact) => [artifact.name, artifact]));
|
|
102
|
+
const manifestsByArtifact = new Map((platformManifests || []).map((manifest) => [manifest.artifactName, manifest]));
|
|
103
|
+
const evidenceByArtifact = new Map((platformManifestEvidence || []).map((evidence) => [evidence.artifactName, evidence]));
|
|
104
|
+
if (manifestsByArtifact.size !== (passport.platformMatrix || []).length) {
|
|
105
|
+
fail("platform-matrix-mismatch", "platform manifest count differs from the Passport platform matrix", "Restore every exact platform manifest from the candidate run.");
|
|
106
|
+
}
|
|
107
|
+
for (const platform of passport.platformMatrix || []) {
|
|
108
|
+
const payload = byName.get(platform.artifactName);
|
|
109
|
+
if (!payload) {
|
|
110
|
+
fail("artifact-missing", `platform payload artifact is missing: ${platform.artifactName}`, "Restore the exact candidate artifact or explicitly create a new candidate.");
|
|
111
|
+
}
|
|
112
|
+
const passportFiles = (platform.artifacts || []).map((file) => ({
|
|
113
|
+
path: String(file.path || file.name || ""),
|
|
114
|
+
size: Number(file.size),
|
|
115
|
+
sha256: `sha256:${String(file.sha256 || "").replace(/^sha256:/, "")}`,
|
|
116
|
+
})).sort((left, right) => left.path.localeCompare(right.path));
|
|
117
|
+
const manifest = manifestsByArtifact.get(platform.artifactName);
|
|
118
|
+
if (!manifest) {
|
|
119
|
+
fail("artifact-missing", `platform manifest is missing for ${platform.artifactName}`, "Restore the exact platform manifest artifact.");
|
|
120
|
+
}
|
|
121
|
+
const manifestFiles = (manifest.files || []).map((file) => ({
|
|
122
|
+
path: String(file.path || file.name || ""),
|
|
123
|
+
size: Number(file.size ?? file.bytes),
|
|
124
|
+
sha256: `sha256:${String(file.sha256 || "").replace(/^sha256:/, "")}`,
|
|
125
|
+
})).sort((left, right) => left.path.localeCompare(right.path));
|
|
126
|
+
const platformId = required(platform.platformId, `${platform.artifactName}.platformId`);
|
|
127
|
+
const buildchainEvidencePrefix = `.buildchain/artifacts/${platformId}/`;
|
|
128
|
+
const manifestByPath = new Map(manifestFiles.map((file) => [file.path, file]));
|
|
129
|
+
const evidenceFiles = new Map((evidenceByArtifact.get(platform.artifactName)?.files || []).map((file) => [file.path, file]));
|
|
130
|
+
const payloadPaths = new Set(payload.files.map((file) => file.path));
|
|
131
|
+
for (const manifestFile of manifestFiles) {
|
|
132
|
+
if (!payloadPaths.has(manifestFile.path)) {
|
|
133
|
+
fail("artifact-manifest-mismatch", `platform payload omits a manifest-declared file: ${platform.artifactName}/${manifestFile.path}`, "Do not publish; preserve the mismatched manifest and payload evidence.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const payloadFile of payload.files) {
|
|
137
|
+
const manifestFile = manifestByPath.get(payloadFile.path);
|
|
138
|
+
if (sameArtifactFile(payloadFile, manifestFile)) continue;
|
|
139
|
+
const evidencePath = payloadFile.path.startsWith(buildchainEvidencePrefix)
|
|
140
|
+
? payloadFile.path.slice(buildchainEvidencePrefix.length)
|
|
141
|
+
: "";
|
|
142
|
+
const evidenceFile = evidencePath ? evidenceFiles.get(evidencePath) : undefined;
|
|
143
|
+
if (!sameArtifactFile(payloadFile, evidenceFile)) {
|
|
144
|
+
fail("artifact-manifest-mismatch", `platform payload contains unbound bytes or evidence drift: ${platform.artifactName}/${payloadFile.path}`, "Do not publish; preserve the platform payload, manifest, and independently uploaded evidence.");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (passportFiles.length > 0 && JSON.stringify(manifestFiles) !== JSON.stringify(passportFiles)) {
|
|
148
|
+
fail("artifact-manifest-mismatch", `platform manifest differs from Passport inventory: ${platform.artifactName}`, "Do not publish; preserve the mismatched manifest and Passport evidence.");
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
Number(platform.summary?.fileCount) !== manifestFiles.length ||
|
|
152
|
+
Number(platform.summary?.totalBytes) !== manifestFiles.reduce((total, file) => total + file.size, 0)
|
|
153
|
+
) {
|
|
154
|
+
fail("platform-matrix-mismatch", `platform summary differs from manifest inventory: ${platform.artifactName}`, "Use the exact summary, manifest, and payload uploaded by the successful candidate run.");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function validateProductPayloads({ passport, artifacts, productPayloadManifests, candidateRoot, buildSummaryRoot, runtimeSha }) {
|
|
160
|
+
const byName = new Map(artifacts.map((artifact) => [artifact.name, artifact]));
|
|
161
|
+
for (const [index, manifest] of (productPayloadManifests || []).entries()) {
|
|
162
|
+
if (manifest?.contract !== "kungfu-buildchain-product-payload-manifest/v1") {
|
|
163
|
+
fail("artifact-manifest-mismatch", `product payload manifest ${index} has an invalid contract`, "Use the exact Buildchain-produced product payload manifest.");
|
|
164
|
+
}
|
|
165
|
+
const { root: _root, ...rootInput } = manifest;
|
|
166
|
+
assertEqual(manifest.root, `sha256:${sha256Json(rootInput)}`, "artifact-manifest-mismatch", "product payload manifest root", "Use an untampered product payload manifest from the candidate run.");
|
|
167
|
+
assertEqual(manifest.candidateRoot, candidateRoot, "candidate-root-mismatch", "product payload candidate root", "Use the product payload sealed for this exact candidate.");
|
|
168
|
+
assertEqual(manifest.buildSummaryRoot, buildSummaryRoot, "build-summary-root-mismatch", "product payload build summary root", "Use the payload produced from the exact candidate summary.");
|
|
169
|
+
assertEqual(manifest.source?.tree, passport.source?.treeHash, "source-tree-mismatch", "product payload source tree", "Use payload bytes produced from the exact candidate tree.");
|
|
170
|
+
assertEqual(manifest.runtimeSha, runtimeSha, "runtime-mismatch", "product payload candidate runtime", "Use payload evidence created by the candidate runtime.");
|
|
171
|
+
const artifact = byName.get(manifest.artifactName);
|
|
172
|
+
if (!artifact) fail("artifact-missing", `product payload artifact is missing: ${manifest.artifactName}`, "Restore the exact product payload artifact.");
|
|
173
|
+
const manifestName = String(manifest.manifestPath || "product-payload-manifest.json");
|
|
174
|
+
const payloadFiles = artifact.files.filter((file) => file.path !== manifestName);
|
|
175
|
+
const expectedFiles = (manifest.files || []).map((file) => ({
|
|
176
|
+
path: String(file.path || ""),
|
|
177
|
+
size: Number(file.size),
|
|
178
|
+
sha256: String(file.sha256 || ""),
|
|
179
|
+
})).sort((left, right) => left.path.localeCompare(right.path));
|
|
180
|
+
if (JSON.stringify(payloadFiles) !== JSON.stringify(expectedFiles)) {
|
|
181
|
+
fail("artifact-manifest-mismatch", `product payload bytes differ from their manifest: ${manifest.artifactName}`, "Do not publish; preserve the manifest and uploaded product bytes.");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateCandidateProvenance({
|
|
187
|
+
candidateRepository,
|
|
188
|
+
targetRepository,
|
|
189
|
+
expectedRunId,
|
|
190
|
+
expectedWorkflowFile,
|
|
191
|
+
expectedWorkflowName,
|
|
192
|
+
targetRef,
|
|
193
|
+
run,
|
|
194
|
+
workflow,
|
|
195
|
+
pullRequest,
|
|
196
|
+
ancestry,
|
|
197
|
+
}) {
|
|
198
|
+
const repository = required(candidateRepository, "candidateRepository");
|
|
199
|
+
assertEqual(repository, required(targetRepository, "targetRepository"), "repository-mismatch", "candidate repository", "Dispatch recovery in the repository that created the candidate.");
|
|
200
|
+
assertEqual(String(run?.id || ""), required(expectedRunId, "expectedRunId"), "run-mismatch", "candidate run ID", "Select the exact successful candidate run.");
|
|
201
|
+
assertEqual(run?.repository, repository, "repository-mismatch", "run repository", "Use a candidate run from the target repository.");
|
|
202
|
+
assertEqual(run?.headRepository, repository, "provenance-insufficient", "run head repository", "Fork candidates are not recoverable; create a same-repository candidate.");
|
|
203
|
+
if (run?.status !== "completed" || run?.conclusion !== "success" || run?.event !== "pull_request") {
|
|
204
|
+
fail("untrusted-build-run", "candidate run must be a successful completed pull_request build", "Choose a successful allowed Build workflow run.");
|
|
205
|
+
}
|
|
206
|
+
const workflowFile = required(expectedWorkflowFile, "expectedWorkflowFile").replace(/^\.github\/workflows\//, "");
|
|
207
|
+
const actualWorkflowPath = String(workflow?.path || run?.path || "").split("@")[0].replace(/^\.github\/workflows\//, "");
|
|
208
|
+
assertEqual(actualWorkflowPath, workflowFile, "workflow-mismatch", "candidate workflow file", "Select a run from the documented trusted Build workflow.");
|
|
209
|
+
assertEqual(String(workflow?.name || run?.name || ""), required(expectedWorkflowName, "expectedWorkflowName"), "workflow-mismatch", "candidate workflow name", "Select a run from the documented trusted Build workflow.");
|
|
210
|
+
if (workflow?.state && workflow.state !== "active") fail("untrusted-build-run", `candidate workflow is not active: ${workflow.state}`, "Restore or select an allowed active Build workflow.");
|
|
211
|
+
if (!TRUSTED_ASSOCIATIONS.has(String(pullRequest?.authorAssociation || ""))) fail("permission-evidence-insufficient", "candidate PR author lacks trusted repository association evidence", "Have a repository member create or approve a same-repository candidate PR.");
|
|
212
|
+
assertEqual(pullRequest?.headRepository, repository, "provenance-insufficient", "candidate PR head repository", "Fork candidates are outside the recovery permission boundary.");
|
|
213
|
+
if (!pullRequest?.merged) fail("pr-identity-invalid", "candidate PR is not merged", "Use a merged channel candidate PR.");
|
|
214
|
+
assertEqual(normalizedRef(pullRequest?.baseRef), normalizedRef(targetRef), "target-mismatch", "candidate PR target ref", "Use the original release channel target.");
|
|
215
|
+
const boundByNumber = Array.isArray(run?.pullRequestNumbers) && run.pullRequestNumbers.includes(Number(pullRequest?.number));
|
|
216
|
+
const boundByHead = run?.headSha === pullRequest?.headSha && normalizedRef(run?.headBranch) === normalizedRef(pullRequest?.headRef);
|
|
217
|
+
if (!boundByNumber && !boundByHead) fail("pr-identity-invalid", "candidate run is not bound to the merged candidate PR", "Select the exact PR-stage Build run.");
|
|
218
|
+
if (!ancestry?.mergeIsAncestor && ancestry?.status !== "identical") fail("ancestry-invalid", "candidate merge is not an ancestor of the promotion SHA", "Promote a descendant of the verified merged candidate identity.");
|
|
219
|
+
return { repository, workflowFile };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function validateCandidateIdentity({
|
|
223
|
+
repository,
|
|
224
|
+
channel,
|
|
225
|
+
targetSha,
|
|
226
|
+
targetTree,
|
|
227
|
+
expectedSourceTree,
|
|
228
|
+
expectedRuntimeSha,
|
|
229
|
+
currentToolingSha,
|
|
230
|
+
passport,
|
|
231
|
+
buildSummary,
|
|
232
|
+
}) {
|
|
233
|
+
const sha = exactSha(targetSha, "targetSha");
|
|
234
|
+
const tree = exactSha(targetTree, "targetTree");
|
|
235
|
+
const runtimeSha = exactSha(expectedRuntimeSha, "expectedRuntimeSha");
|
|
236
|
+
const toolingSha = exactSha(currentToolingSha, "currentToolingSha");
|
|
237
|
+
const validation = validateReleaseCandidatePassport({ passport, repository, targetChannel: channel, buildSummary });
|
|
238
|
+
if (!validation.ok) fail("passport-invalid", `Release Candidate Passport validation failed: ${validation.errors.join("; ")}`, "Preserve the candidate evidence and explicitly create a new candidate after fixing the producer.");
|
|
239
|
+
const expectedPassportHash = sha256Json({
|
|
240
|
+
repository: passport.repository,
|
|
241
|
+
target: passport.target,
|
|
242
|
+
source: passport.source,
|
|
243
|
+
platformMatrix: passport.platformMatrix,
|
|
244
|
+
buildchain: passport.buildchain,
|
|
245
|
+
...(passport.gateProfileEvidence ? { gateProfileEvidence: passport.gateProfileEvidence } : {}),
|
|
246
|
+
...(passport.familyEvidence ? { familyEvidence: passport.familyEvidence } : {}),
|
|
247
|
+
...(passport.controllerReceipts ? { controllerReceipts: passport.controllerReceipts } : {}),
|
|
248
|
+
});
|
|
249
|
+
if (passport.candidateHash !== expectedPassportHash) fail("candidate-root-mismatch", "Release Candidate Passport candidate hash does not match its content", "Preserve the run evidence and explicitly create a new candidate after fixing the producer.");
|
|
250
|
+
assertEqual(passport.source?.treeHash, tree, "source-tree-mismatch", "promotion Git tree", "Select a promotion SHA with the exact candidate Git tree or create a new candidate explicitly.");
|
|
251
|
+
if (expectedSourceTree) assertEqual(passport.source?.treeHash, exactSha(expectedSourceTree, "expectedSourceTree"), "source-tree-mismatch", "expected source tree", "Correct the expected tree or create a new candidate.");
|
|
252
|
+
assertEqual(passport.buildchain?.sha, runtimeSha, "runtime-mismatch", "candidate Buildchain runtime SHA", "Run recovery with the exact trusted Buildchain runtime recorded by the candidate.");
|
|
253
|
+
return { sha, tree, runtimeSha, toolingSha };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function validateRecoveryTransaction({ existingTransaction, expectedTransactionId, repository, version, passport, sha, targetRef, candidateRoot }) {
|
|
257
|
+
const actualTransactionId = String(existingTransaction?.id || "");
|
|
258
|
+
if (expectedTransactionId && !actualTransactionId) fail("transaction-identity-conflict", `expected transaction ${expectedTransactionId} does not exist`, "Remove the stale transaction identity only if no durable transaction was ever sealed; otherwise preserve evidence and enter repair_required.");
|
|
259
|
+
if (expectedTransactionId && expectedTransactionId !== actualTransactionId) fail("transaction-identity-conflict", `existing transaction ${actualTransactionId} conflicts with expected ${expectedTransactionId}`, "Enter repair_required and inspect the durable transaction before any retry.");
|
|
260
|
+
if (existingTransaction) {
|
|
261
|
+
const expectedIdentity = releaseTransactionId({ repository, version, sourceSha: sha, targetRef: normalizedRef(targetRef) });
|
|
262
|
+
assertEqual(existingTransaction.id, expectedIdentity, "transaction-identity-conflict", "durable transaction identity", "Enter repair_required; the durable transaction does not belong to this exact publication target.");
|
|
263
|
+
assertEqual(existingTransaction.repository, repository, "transaction-identity-conflict", "durable transaction repository", "Enter repair_required; never cross repository transaction boundaries.");
|
|
264
|
+
assertEqual(normalizedRef(existingTransaction.target_ref), normalizedRef(targetRef), "transaction-identity-conflict", "durable transaction target ref", "Enter repair_required; never retarget a sealed transaction.");
|
|
265
|
+
assertEqual(existingTransaction.source_sha, sha, "transaction-identity-conflict", "durable transaction source SHA", "Resume with the transaction's exact promotion SHA or enter repair_required.");
|
|
266
|
+
assertEqual(existingTransaction.version, version, "transaction-identity-conflict", "durable transaction version", "Enter repair_required; never change a sealed publication version.");
|
|
267
|
+
assertEqual(existingTransaction.channel, passport.target.channel, "transaction-identity-conflict", "durable transaction channel", "Enter repair_required; never move a transaction between channels.");
|
|
268
|
+
}
|
|
269
|
+
if (existingTransaction?.candidateRoot && existingTransaction.candidateRoot !== candidateRoot) fail("transaction-identity-conflict", "existing transaction is sealed to a different candidate root", "Enter repair_required; never reuse the conflicting transaction.");
|
|
270
|
+
return actualTransactionId;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function verifyReleaseCandidateRecovery({
|
|
274
|
+
candidateRepository,
|
|
275
|
+
targetRepository,
|
|
276
|
+
expectedRunId,
|
|
277
|
+
expectedWorkflowFile,
|
|
278
|
+
expectedWorkflowName,
|
|
279
|
+
channel,
|
|
280
|
+
targetRef,
|
|
281
|
+
targetSha,
|
|
282
|
+
targetTree,
|
|
283
|
+
expectedSourceTree = "",
|
|
284
|
+
expectedCandidateRoot = "",
|
|
285
|
+
expectedRuntimeSha,
|
|
286
|
+
expectedTransactionId = "",
|
|
287
|
+
existingTransaction = undefined,
|
|
288
|
+
run,
|
|
289
|
+
workflow,
|
|
290
|
+
pullRequest,
|
|
291
|
+
ancestry,
|
|
292
|
+
passport,
|
|
293
|
+
buildSummary,
|
|
294
|
+
controllerReceipts = [],
|
|
295
|
+
platformManifests = [],
|
|
296
|
+
platformManifestEvidence = [],
|
|
297
|
+
productPayloadManifests = [],
|
|
298
|
+
artifacts = [],
|
|
299
|
+
publicationVersion = "",
|
|
300
|
+
currentToolingSha,
|
|
301
|
+
recoveryRunId = "",
|
|
302
|
+
createdAt = new Date().toISOString(),
|
|
303
|
+
} = {}) {
|
|
304
|
+
const { repository, workflowFile } = validateCandidateProvenance({
|
|
305
|
+
candidateRepository, targetRepository, expectedRunId, expectedWorkflowFile,
|
|
306
|
+
expectedWorkflowName, targetRef, run, workflow, pullRequest, ancestry,
|
|
307
|
+
});
|
|
308
|
+
const { sha, tree, runtimeSha, toolingSha } = validateCandidateIdentity({
|
|
309
|
+
repository, channel, targetSha, targetTree, expectedSourceTree,
|
|
310
|
+
expectedRuntimeSha, currentToolingSha, passport, buildSummary,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const recoveredArtifacts = artifacts.map(normalizeArtifact).sort((left, right) => left.name.localeCompare(right.name));
|
|
314
|
+
if (recoveredArtifacts.length === 0) fail("artifact-missing", "candidate artifact set is empty", "Select a retained run with the complete candidate artifact set.");
|
|
315
|
+
if (new Set(recoveredArtifacts.map((artifact) => artifact.name)).size !== recoveredArtifacts.length) {
|
|
316
|
+
fail("artifact-count-mismatch", "candidate artifact names are not unique", "Inspect the candidate run and remove ambiguity by creating a new candidate.");
|
|
317
|
+
}
|
|
318
|
+
validatePlatformPayloads(passport, recoveredArtifacts, platformManifests, platformManifestEvidence);
|
|
319
|
+
|
|
320
|
+
const references = new Map((passport.controllerReceipts || []).map((reference) => [reference.controllerId, reference]));
|
|
321
|
+
for (const receipt of controllerReceipts) {
|
|
322
|
+
const receiptValidation = validateControllerReceipt(receipt, {
|
|
323
|
+
expectedSourceSha: passport.source?.headSha,
|
|
324
|
+
expectedRuntimeSha: runtimeSha,
|
|
325
|
+
});
|
|
326
|
+
if (!receiptValidation.ok || !receiptValidation.qualifying) {
|
|
327
|
+
fail("controller-receipt-invalid", `controller receipt is not qualifying: ${receiptValidation.issues.join("; ")}`, "Use a successful candidate with complete controller evidence.");
|
|
328
|
+
}
|
|
329
|
+
const actualReference = createControllerReceiptReference(receipt);
|
|
330
|
+
const expectedReference = references.get(actualReference.controllerId);
|
|
331
|
+
if (!expectedReference || sha256Json(actualReference) !== sha256Json(expectedReference)) {
|
|
332
|
+
fail("controller-receipt-mismatch", `controller receipt does not match Passport reference: ${actualReference.controllerId}`, "Use the exact controller receipt artifact uploaded by the candidate run.");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (references.size !== controllerReceipts.length) {
|
|
336
|
+
fail("controller-receipt-missing", "not every Passport controller receipt was recovered", "Restore every referenced controller receipt artifact before recovery.");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const candidateRoot = `sha256:${passport.candidateHash}`;
|
|
340
|
+
if (expectedCandidateRoot) assertEqual(candidateRoot, contentRoot(expectedCandidateRoot, "expectedCandidateRoot"), "candidate-root-mismatch", "candidate root", "Use the exact candidate root recorded by the original run.");
|
|
341
|
+
const artifactInventory = recoveredArtifacts.map(({ name, size, digest, files }) => ({ name, size, digest, files }));
|
|
342
|
+
const artifactRoot = `sha256:${sha256Json(artifactInventory)}`;
|
|
343
|
+
const artifactArchiveRoot = `sha256:${sha256Json(recoveredArtifacts.map(({ name, size, digest }) => ({ name, size, digest })))}`;
|
|
344
|
+
validateProductPayloads({
|
|
345
|
+
passport,
|
|
346
|
+
artifacts: recoveredArtifacts,
|
|
347
|
+
productPayloadManifests,
|
|
348
|
+
candidateRoot,
|
|
349
|
+
buildSummaryRoot: `sha256:${passport.diagnostics.buildSummaryHash}`,
|
|
350
|
+
runtimeSha,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const version = required(publicationVersion || passport.target?.version, "publicationVersion");
|
|
354
|
+
const actualTransactionId = validateRecoveryTransaction({
|
|
355
|
+
existingTransaction, expectedTransactionId, repository, version, passport, sha, targetRef, candidateRoot,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const receipt = {
|
|
359
|
+
schemaVersion: 1,
|
|
360
|
+
contract: RELEASE_CANDIDATE_RECOVERY_CONTRACT,
|
|
361
|
+
action: "reused",
|
|
362
|
+
createdAt,
|
|
363
|
+
repository,
|
|
364
|
+
recoveryRunId: String(recoveryRunId || ""),
|
|
365
|
+
originalCandidate: {
|
|
366
|
+
runId: String(run.id),
|
|
367
|
+
workflowFile,
|
|
368
|
+
workflowName: String(workflow?.name || run?.name || ""),
|
|
369
|
+
pullRequest: Number(pullRequest.number),
|
|
370
|
+
sourceSha: passport.source.headSha,
|
|
371
|
+
mergeSha: String(pullRequest.mergeSha || ""),
|
|
372
|
+
tree: passport.source.treeHash,
|
|
373
|
+
},
|
|
374
|
+
target: { channel: passport.target.channel, ref: normalizedRef(targetRef), sha, tree, version },
|
|
375
|
+
recovered: {
|
|
376
|
+
candidateRoot,
|
|
377
|
+
buildSummaryRoot: `sha256:${passport.diagnostics.buildSummaryHash}`,
|
|
378
|
+
artifactRoot,
|
|
379
|
+
artifactArchiveRoot,
|
|
380
|
+
artifactCount: recoveredArtifacts.length,
|
|
381
|
+
artifacts: recoveredArtifacts.map(({ name, size, digest }) => ({ name, size, digest })),
|
|
382
|
+
},
|
|
383
|
+
skippedBuildStages: ["install", "build", "verify", "platform-matrix"],
|
|
384
|
+
payloadBytes: "unchanged",
|
|
385
|
+
buildchainToolingSha: toolingSha,
|
|
386
|
+
transaction: {
|
|
387
|
+
identity: expectedTransactionId || actualTransactionId || "",
|
|
388
|
+
state: String(existingTransaction?.state || "absent"),
|
|
389
|
+
publicationState: String(existingTransaction?.publication_state || existingTransaction?.state || "absent"),
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
receipt.root = `sha256:${sha256Json(receipt)}`;
|
|
393
|
+
return { receipt, artifacts: recoveredArtifacts };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function recoveryFailure(error) {
|
|
397
|
+
if (error instanceof ReleaseCandidateRecoveryError) {
|
|
398
|
+
return { ok: false, code: error.code, reason: error.message, nextAction: error.nextAction };
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
code: "recovery-internal-error",
|
|
403
|
+
reason: String(error?.message || error),
|
|
404
|
+
nextAction: "Preserve the run logs and candidate evidence; do not rebuild automatically.",
|
|
405
|
+
};
|
|
406
|
+
}
|
|
@@ -74,6 +74,7 @@ const requiredPaths = [
|
|
|
74
74
|
"docs/shifu-gate-profiles.md",
|
|
75
75
|
"docs/auditable-demo.md",
|
|
76
76
|
"contracts/auditable-demo-scenario-v1.schema.json",
|
|
77
|
+
"contracts/release-candidate-recovery-v1.schema.json",
|
|
77
78
|
"contracts/auditable-demo-media-profiles-v1.json",
|
|
78
79
|
"contracts/evidence/auditable-demo-web-delivery-v1.json",
|
|
79
80
|
"contracts/evidence/auditable-demo-responsive-web-delivery-v1.json",
|
|
@@ -117,6 +118,7 @@ const requiredPaths = [
|
|
|
117
118
|
"scripts/publication-commit-evidence.mjs",
|
|
118
119
|
"scripts/publication-reproducibility.mjs",
|
|
119
120
|
"scripts/release-candidate-resolver.mjs",
|
|
121
|
+
"scripts/resume-from-candidate-run.mjs",
|
|
120
122
|
"scripts/buildchain-patrol.mjs",
|
|
121
123
|
"scripts/observed-evidence.mjs",
|
|
122
124
|
"scripts/workflow-friction-report.mjs",
|
|
@@ -134,6 +136,7 @@ const requiredPaths = [
|
|
|
134
136
|
".github/actionlint.yaml",
|
|
135
137
|
".github/workflows/self-hosted-runner-smoke.yml",
|
|
136
138
|
".github/workflows/buildchain-ref-promotion.yml",
|
|
139
|
+
".github/workflows/buildchain-candidate-recovery-dogfood-failure.yml",
|
|
137
140
|
".github/workflows/release-line-bootstrap.yml",
|
|
138
141
|
".github/workflows/release-governance-reconcile.yml",
|
|
139
142
|
".github/workflows/dev-pr-auto-merge.yml",
|
|
@@ -984,10 +987,12 @@ for (const requiredSnippet of [
|
|
|
984
987
|
"github.event.workflow_run.event == 'push'",
|
|
985
988
|
"!startsWith(github.event.workflow_run.display_title, 'chore(release): prepare v')",
|
|
986
989
|
"!startsWith(github.event.workflow_run.display_title, 'chore(release): release v')",
|
|
987
|
-
"
|
|
990
|
+
"resume-candidate-run-id:",
|
|
991
|
+
"resume-expected-source-tree:",
|
|
992
|
+
"resume-buildchain-runtime-sha:",
|
|
988
993
|
"github-release: true",
|
|
989
994
|
"release-passport-buildchain-self-kfd: true",
|
|
990
|
-
|
|
995
|
+
'artifact-patterns: "buildchain-package-*"',
|
|
991
996
|
"release-passport-impact-json: .buildchain/release-impact.json",
|
|
992
997
|
]) {
|
|
993
998
|
if (!buildchainRefPromotionWorkflow.includes(requiredSnippet)) {
|
|
@@ -1041,6 +1046,11 @@ for (const requiredSnippet of [
|
|
|
1041
1046
|
"BUILDCHAIN_STABLE_RELEASE_POLICY: .buildchain/stable-release-policy.json",
|
|
1042
1047
|
"Consumer has no binary-distribution.yml; standalone binary dispatch is not applicable.",
|
|
1043
1048
|
"gh workflow view binary-distribution.yml",
|
|
1049
|
+
"resume-candidate-run-id:",
|
|
1050
|
+
"node .buildchain/runtime/scripts/resume-from-candidate-run.mjs",
|
|
1051
|
+
"publish-sealed-bundle-root: ${{ steps.rc.outputs.publish-sealed-bundle-root }}",
|
|
1052
|
+
"BUILDCHAIN_EXPECTED_TRANSACTION_ID: ${{ inputs.resume-transaction-id }}",
|
|
1053
|
+
"if: ${{ inputs.resume-candidate-run-id == '' }}",
|
|
1044
1054
|
]) {
|
|
1045
1055
|
if (!releaseCandidatePromoteWorkflow.includes(requiredSnippet)) {
|
|
1046
1056
|
throw new Error(`release candidate promote workflow missing KFD gate pass-through: ${requiredSnippet}`);
|
|
@@ -49,7 +49,7 @@ function githubHeaders(token) {
|
|
|
49
49
|
return headers;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fetchImpl = globalThis.fetch }) {
|
|
52
|
+
export async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fetchImpl = globalThis.fetch, allowNotFound = false }) {
|
|
53
53
|
if (typeof fetchImpl !== "function") {
|
|
54
54
|
throw new Error("fetch is required to resolve release candidate artifacts");
|
|
55
55
|
}
|
|
@@ -57,13 +57,14 @@ async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fe
|
|
|
57
57
|
const response = await fetchImpl(url, { method, headers: githubHeaders(token) });
|
|
58
58
|
const text = await response.text();
|
|
59
59
|
const body = text ? JSON.parse(text) : {};
|
|
60
|
+
if (allowNotFound && response.status === 404) return undefined;
|
|
60
61
|
if (!response.ok) {
|
|
61
62
|
throw new Error(`GitHub API ${method} ${requestPath} failed with ${response.status}: ${body.message || text}`);
|
|
62
63
|
}
|
|
63
64
|
return body;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fetchImpl = globalThis.fetch }) {
|
|
67
|
+
export async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fetchImpl = globalThis.fetch }) {
|
|
67
68
|
const url = `${String(apiUrl || "https://api.github.com").replace(/\/+$/, "")}${requestPath}`;
|
|
68
69
|
const response = await fetchImpl(url, { headers: githubHeaders(token) });
|
|
69
70
|
if (!response.ok) {
|
|
@@ -357,7 +358,35 @@ export function selectReleaseCandidateArtifacts({ artifacts = [], artifactName =
|
|
|
357
358
|
return { passport, summary: summaries[0], prefix, sourceSha: sha };
|
|
358
359
|
}
|
|
359
360
|
|
|
360
|
-
function
|
|
361
|
+
export function verifyArtifactArchive({ artifact, archivePath } = {}) {
|
|
362
|
+
const bytes = fs.readFileSync(archivePath);
|
|
363
|
+
const size = bytes.length;
|
|
364
|
+
const digest = `sha256:${crypto.createHash("sha256").update(bytes).digest("hex")}`;
|
|
365
|
+
if (!artifact || artifact.expired === true) {
|
|
366
|
+
throw new Error(`candidate artifact is missing or expired: ${artifact?.name || "<unknown>"}`);
|
|
367
|
+
}
|
|
368
|
+
if (Number(artifact.size_in_bytes) !== size) {
|
|
369
|
+
throw new Error(`candidate artifact size mismatch for ${artifact.name}: expected ${artifact.size_in_bytes}, got ${size}`);
|
|
370
|
+
}
|
|
371
|
+
if (!/^sha256:[0-9a-f]{64}$/i.test(String(artifact.digest || ""))) {
|
|
372
|
+
throw new Error(`candidate artifact ${artifact.name} has no trusted sha256 digest metadata`);
|
|
373
|
+
}
|
|
374
|
+
if (String(artifact.digest).toLowerCase() !== digest) {
|
|
375
|
+
throw new Error(`candidate artifact digest mismatch for ${artifact.name}: expected ${artifact.digest}, got ${digest}`);
|
|
376
|
+
}
|
|
377
|
+
return { size, digest };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function unzip(zipPath, outputDir) {
|
|
381
|
+
const entries = execFileSync("unzip", ["-Z1", zipPath], { encoding: "utf8" })
|
|
382
|
+
.split(/\r?\n/)
|
|
383
|
+
.filter(Boolean);
|
|
384
|
+
for (const entry of entries) {
|
|
385
|
+
const normalized = entry.replaceAll("\\", "/");
|
|
386
|
+
if (normalized.startsWith("/") || normalized.split("/").some((part) => part === "..")) {
|
|
387
|
+
throw new Error(`candidate artifact contains an unsafe zip entry: ${entry}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
361
390
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
362
391
|
execFileSync("unzip", ["-q", "-o", zipPath, "-d", outputDir], { stdio: "inherit" });
|
|
363
392
|
}
|
|
@@ -579,6 +608,7 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
579
608
|
outputPath: passportZip,
|
|
580
609
|
path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${selected.passport.id}/zip`,
|
|
581
610
|
});
|
|
611
|
+
verifyArtifactArchive({ artifact: selected.passport, archivePath: passportZip });
|
|
582
612
|
await githubDownload({
|
|
583
613
|
apiUrl,
|
|
584
614
|
token,
|
|
@@ -586,6 +616,7 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
586
616
|
outputPath: summaryZip,
|
|
587
617
|
path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${selected.summary.id}/zip`,
|
|
588
618
|
});
|
|
619
|
+
verifyArtifactArchive({ artifact: selected.summary, archivePath: summaryZip });
|
|
589
620
|
for (const artifact of payloadArtifacts) {
|
|
590
621
|
const safeName = String(artifact.name || `artifact-${artifact.id}`).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
591
622
|
const payloadZip = path.join(tempDir, `${safeName}.zip`);
|
|
@@ -596,6 +627,7 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
596
627
|
outputPath: payloadZip,
|
|
597
628
|
path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${artifact.id}/zip`,
|
|
598
629
|
});
|
|
630
|
+
verifyArtifactArchive({ artifact, archivePath: payloadZip });
|
|
599
631
|
unzip(payloadZip, path.join(payloadDir, safeName));
|
|
600
632
|
}
|
|
601
633
|
unzip(passportZip, passportDir);
|