@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,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const ROOT_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
9
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/u;
|
|
10
|
+
|
|
11
|
+
function canonical(value) {
|
|
12
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
13
|
+
if (value && typeof value === "object") {
|
|
14
|
+
return Object.fromEntries(
|
|
15
|
+
Object.entries(value)
|
|
16
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
17
|
+
.map(([key, item]) => [key, canonical(item)]),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function digest(value) {
|
|
24
|
+
return `sha256:${crypto
|
|
25
|
+
.createHash("sha256")
|
|
26
|
+
.update(JSON.stringify(canonical(value)))
|
|
27
|
+
.digest("hex")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function withoutRoot(value, field = "evidenceRoot") {
|
|
31
|
+
const body = structuredClone(value);
|
|
32
|
+
Reflect.deleteProperty(body, field);
|
|
33
|
+
return body;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requireIso(value, label) {
|
|
37
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
|
|
38
|
+
throw new Error(`${label} must be an ISO timestamp`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function verifyEvidenceHeader(
|
|
43
|
+
evidence,
|
|
44
|
+
{ lifecycleStage, sourceSha, sourceTree, platformId },
|
|
45
|
+
) {
|
|
46
|
+
if (evidence?.schema !== "kungfu.lifecycle-substage-evidence/v1") {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`unsupported lifecycle substage evidence schema: ${evidence?.schema || "missing"}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (!ROOT_PATTERN.test(evidence.evidenceRoot || "")) {
|
|
52
|
+
throw new Error("lifecycle substage evidence root is invalid");
|
|
53
|
+
}
|
|
54
|
+
if (evidence.evidenceRoot !== digest(withoutRoot(evidence))) {
|
|
55
|
+
throw new Error("lifecycle substage evidence root mismatch");
|
|
56
|
+
}
|
|
57
|
+
if (lifecycleStage && evidence.lifecycleStage !== lifecycleStage) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`lifecycle stage mismatch: expected ${lifecycleStage}, got ${evidence.lifecycleStage}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
!SHA_PATTERN.test(evidence.source?.sha || "") ||
|
|
64
|
+
!SHA_PATTERN.test(evidence.source?.tree || "")
|
|
65
|
+
) {
|
|
66
|
+
throw new Error("lifecycle substage source identity is invalid");
|
|
67
|
+
}
|
|
68
|
+
if (sourceSha && evidence.source.sha !== sourceSha) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`lifecycle substage source SHA mismatch: expected ${sourceSha}, got ${evidence.source.sha}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (sourceTree && evidence.source.tree !== sourceTree) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`lifecycle substage source tree mismatch: expected ${sourceTree}, got ${evidence.source.tree}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (platformId && evidence.platform?.id !== platformId) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`lifecycle substage platform mismatch: expected ${platformId}, got ${evidence.platform?.id}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (!["passed", "failed"].includes(evidence.conclusion)) {
|
|
84
|
+
throw new Error("lifecycle substage conclusion must be passed or failed");
|
|
85
|
+
}
|
|
86
|
+
requireIso(evidence.startedAt, "lifecycle substage startedAt");
|
|
87
|
+
requireIso(evidence.completedAt, "lifecycle substage completedAt");
|
|
88
|
+
if (!Array.isArray(evidence.substages) || evidence.substages.length === 0) {
|
|
89
|
+
throw new Error("lifecycle substage evidence has no substages");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function verifySubstage(substage, index, names) {
|
|
94
|
+
if (typeof substage.stage !== "string" || !substage.stage) {
|
|
95
|
+
throw new Error(`substage ${index} has no name`);
|
|
96
|
+
}
|
|
97
|
+
if (names.has(substage.stage)) {
|
|
98
|
+
throw new Error(`duplicate lifecycle substage: ${substage.stage}`);
|
|
99
|
+
}
|
|
100
|
+
names.add(substage.stage);
|
|
101
|
+
requireIso(substage.startedAt, `${substage.stage}.startedAt`);
|
|
102
|
+
requireIso(substage.completedAt, `${substage.stage}.completedAt`);
|
|
103
|
+
if (
|
|
104
|
+
!Number.isFinite(substage.durationSeconds) ||
|
|
105
|
+
substage.durationSeconds < 0
|
|
106
|
+
) {
|
|
107
|
+
throw new Error(`${substage.stage}.durationSeconds is invalid`);
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
!Number.isInteger(substage.status) ||
|
|
111
|
+
!["passed", "failed"].includes(substage.conclusion)
|
|
112
|
+
) {
|
|
113
|
+
throw new Error(`${substage.stage} result is invalid`);
|
|
114
|
+
}
|
|
115
|
+
if ((substage.status === 0) !== (substage.conclusion === "passed")) {
|
|
116
|
+
throw new Error(`${substage.stage} status and conclusion disagree`);
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
!["platform-native", "exact-source-reuse"].includes(substage.executionMode)
|
|
120
|
+
) {
|
|
121
|
+
throw new Error(`${substage.stage}.executionMode is invalid`);
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
!ROOT_PATTERN.test(substage.evidenceRoot || "") ||
|
|
125
|
+
substage.evidenceRoot !== digest(withoutRoot(substage))
|
|
126
|
+
) {
|
|
127
|
+
throw new Error(`${substage.stage} evidence root mismatch`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function verifyAggregate(evidence) {
|
|
132
|
+
const failed = evidence.substages.some((substage) => substage.status !== 0);
|
|
133
|
+
const expectedFailureReason = failed
|
|
134
|
+
? "substage-failed"
|
|
135
|
+
: evidence.conclusion === "failed"
|
|
136
|
+
? "budget-exceeded"
|
|
137
|
+
: undefined;
|
|
138
|
+
if (
|
|
139
|
+
(failed && evidence.conclusion !== "failed") ||
|
|
140
|
+
(!failed && evidence.conclusion === "passed" && evidence.failureReason) ||
|
|
141
|
+
evidence.failureReason !== expectedFailureReason
|
|
142
|
+
) {
|
|
143
|
+
throw new Error("lifecycle substage aggregate conclusion is inconsistent");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function verifyLifecycleSubstageEvidence(
|
|
148
|
+
value,
|
|
149
|
+
{
|
|
150
|
+
lifecycleStage = "",
|
|
151
|
+
sourceSha = "",
|
|
152
|
+
sourceTree = "",
|
|
153
|
+
platformId = "",
|
|
154
|
+
} = {},
|
|
155
|
+
) {
|
|
156
|
+
const evidence = value?.substageEvidence || value;
|
|
157
|
+
verifyEvidenceHeader(evidence, {
|
|
158
|
+
lifecycleStage,
|
|
159
|
+
sourceSha,
|
|
160
|
+
sourceTree,
|
|
161
|
+
platformId,
|
|
162
|
+
});
|
|
163
|
+
const names = new Set();
|
|
164
|
+
for (const [index, substage] of evidence.substages.entries()) {
|
|
165
|
+
verifySubstage(substage, index, names);
|
|
166
|
+
}
|
|
167
|
+
verifyAggregate(evidence);
|
|
168
|
+
return structuredClone(evidence);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function lifecycleSubstageEvidenceContext({
|
|
172
|
+
substageEvidencePath = "",
|
|
173
|
+
cwd,
|
|
174
|
+
workspace,
|
|
175
|
+
diagnosticsDir,
|
|
176
|
+
lifecycleStage,
|
|
177
|
+
sourceSha = process.env.BUILDCHAIN_SOURCE_SHA || "",
|
|
178
|
+
sourceTree = process.env.BUILDCHAIN_SOURCE_TREE_SHA || "",
|
|
179
|
+
platformId,
|
|
180
|
+
}) {
|
|
181
|
+
if (!substageEvidencePath) {
|
|
182
|
+
return {
|
|
183
|
+
evidence: undefined,
|
|
184
|
+
observability: {},
|
|
185
|
+
lifecycle: {},
|
|
186
|
+
links: {},
|
|
187
|
+
sourcePath: "",
|
|
188
|
+
targetPath: "",
|
|
189
|
+
sidecar: {},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const sourcePath = path.resolve(cwd, substageEvidencePath);
|
|
193
|
+
const targetPath = path.join(diagnosticsDir, "verify-substages.json");
|
|
194
|
+
const relativePath = path
|
|
195
|
+
.relative(workspace, targetPath)
|
|
196
|
+
.split(path.sep)
|
|
197
|
+
.join("/");
|
|
198
|
+
const evidence = readLifecycleSubstageEvidence(sourcePath, {
|
|
199
|
+
lifecycleStage,
|
|
200
|
+
sourceSha,
|
|
201
|
+
sourceTree,
|
|
202
|
+
platformId,
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
evidence,
|
|
206
|
+
observability: {
|
|
207
|
+
substages: {
|
|
208
|
+
contract: evidence.schema,
|
|
209
|
+
evidenceRoot: evidence.evidenceRoot,
|
|
210
|
+
conclusion: evidence.conclusion,
|
|
211
|
+
path: relativePath,
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
lifecycle: { substageEvidence: evidence },
|
|
215
|
+
links: { lifecycleSubstages: relativePath },
|
|
216
|
+
sourcePath,
|
|
217
|
+
targetPath,
|
|
218
|
+
sidecar: {
|
|
219
|
+
kind: "lifecycle-substages",
|
|
220
|
+
filePath: targetPath,
|
|
221
|
+
required: true,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function readLifecycleSubstageEvidence(file, options = {}) {
|
|
227
|
+
if (!file) return undefined;
|
|
228
|
+
const absolute = path.resolve(file);
|
|
229
|
+
if (!fs.existsSync(absolute))
|
|
230
|
+
throw new Error(`lifecycle substage evidence file not found: ${file}`);
|
|
231
|
+
return verifyLifecycleSubstageEvidence(
|
|
232
|
+
JSON.parse(fs.readFileSync(absolute, "utf8")),
|
|
233
|
+
options,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parse(argv) {
|
|
238
|
+
const options = {};
|
|
239
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
240
|
+
const flag = argv[index];
|
|
241
|
+
if (!flag?.startsWith("--") || index + 1 >= argv.length)
|
|
242
|
+
throw new Error(`invalid option: ${flag || "missing"}`);
|
|
243
|
+
options[flag.slice(2)] = argv[index + 1];
|
|
244
|
+
}
|
|
245
|
+
return options;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function main(argv = process.argv.slice(2)) {
|
|
249
|
+
const options = parse(argv);
|
|
250
|
+
const evidence = readLifecycleSubstageEvidence(options.file, {
|
|
251
|
+
lifecycleStage: options.stage || "",
|
|
252
|
+
sourceSha: options["source-sha"] || "",
|
|
253
|
+
sourceTree: options["source-tree"] || "",
|
|
254
|
+
platformId: options["platform-id"] || "",
|
|
255
|
+
});
|
|
256
|
+
process.stdout.write(
|
|
257
|
+
`${JSON.stringify({ ok: true, evidenceRoot: evidence.evidenceRoot, conclusion: evidence.conclusion })}\n`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (
|
|
262
|
+
process.argv[1] &&
|
|
263
|
+
path.basename(process.argv[1]) === "lifecycle-substage-evidence.mjs" &&
|
|
264
|
+
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
|
|
265
|
+
) {
|
|
266
|
+
try {
|
|
267
|
+
main();
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error(
|
|
270
|
+
`[lifecycle-substages] ${error instanceof Error ? error.message : String(error)}`,
|
|
271
|
+
);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -4,6 +4,7 @@ import crypto from "node:crypto";
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { runGitFetchSync } from "./git-fetch-process-tree.mjs";
|
|
7
8
|
|
|
8
9
|
export const LOCKED_SOURCE_CHECKOUT_CONTRACT = "kungfu-buildchain-locked-source-checkout-cache";
|
|
9
10
|
export const ISOLATED_GIT_GLOBAL_CONFIG = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
@@ -111,9 +112,10 @@ function ensureCheckoutTarget(targetPath, workspace) {
|
|
|
111
112
|
|
|
112
113
|
function git(args, { cwd, env = {}, timeoutMs = 60000, stdio = ["ignore", "pipe", "pipe"] } = {}) {
|
|
113
114
|
try {
|
|
114
|
-
const
|
|
115
|
+
const commandEnv = { ...process.env, ...env };
|
|
116
|
+
const output = args[0] === "fetch" ? runGitFetchSync({ args, cwd, env: commandEnv, timeoutMs, stdio }) : execFileSync("git", args, {
|
|
115
117
|
cwd,
|
|
116
|
-
env:
|
|
118
|
+
env: commandEnv,
|
|
117
119
|
encoding: "utf8",
|
|
118
120
|
stdio,
|
|
119
121
|
timeout: timeoutMs,
|
|
@@ -233,7 +235,8 @@ function checkoutFetchedCommit(targetPath, sha, timeoutMs) {
|
|
|
233
235
|
function retryableGitFetchError(error) {
|
|
234
236
|
const code = String(error?.code || "").toUpperCase();
|
|
235
237
|
if (["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ENETUNREACH", "EPIPE"].includes(code)) return true;
|
|
236
|
-
|
|
238
|
+
const commandOutput = [error?.stderr, error?.stdout].filter(Boolean).map(String).join("\n").trim();
|
|
239
|
+
return /timed?\s*out|timeout|connection (?:reset|refused)|remote end hung up|early eof|rpc failed|http (?:429|5\d\d)|temporary failure|network is unreachable/i.test(commandOutput || String(error?.message || error || ""));
|
|
237
240
|
}
|
|
238
241
|
|
|
239
242
|
export function fetchSourceCommit({
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
|
|
7
|
+
publicationArtifactCandidateDigest,
|
|
8
|
+
} from "../packages/core/publication-artifact-candidate.js";
|
|
9
|
+
import { createPublicationSealedBundle } from "../packages/core/publication-sealed-bundle.js";
|
|
10
|
+
|
|
11
|
+
function requiredString(value, label) {
|
|
12
|
+
const normalized = String(value || "").trim();
|
|
13
|
+
if (!normalized) throw new Error(`${label} must be a non-empty string`);
|
|
14
|
+
return normalized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeSha256(value, label) {
|
|
18
|
+
const digest = requiredString(value, label).replace(/^sha256:/, "").toLowerCase();
|
|
19
|
+
if (!/^[0-9a-f]{64}$/.test(digest)) throw new Error(`${label} must be a sha256 digest`);
|
|
20
|
+
return digest;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertGitSha(value, label) {
|
|
24
|
+
const sha = requiredString(value, label);
|
|
25
|
+
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error(`${label} must be a 40-character Git SHA`);
|
|
26
|
+
return sha.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function collectCandidateFiles(root) {
|
|
30
|
+
const resolvedRoot = path.resolve(requiredString(root, "bundleRoot"));
|
|
31
|
+
const files = [];
|
|
32
|
+
const stack = fs.existsSync(resolvedRoot) ? [resolvedRoot] : [];
|
|
33
|
+
while (stack.length) {
|
|
34
|
+
const current = stack.pop();
|
|
35
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
36
|
+
const absolutePath = path.join(current, entry.name);
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
stack.push(absolutePath);
|
|
39
|
+
} else if (entry.isFile()) {
|
|
40
|
+
files.push({
|
|
41
|
+
path: path.relative(resolvedRoot, absolutePath).split(path.sep).join("/"),
|
|
42
|
+
absolutePath,
|
|
43
|
+
size: fs.statSync(absolutePath).size,
|
|
44
|
+
sha256: crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex"),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { resolvedRoot, files: files.sort((left, right) => left.path.localeCompare(right.path)) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createResolvedPublicationSealedBundle({
|
|
53
|
+
bundleRoot,
|
|
54
|
+
repository,
|
|
55
|
+
sourceSha,
|
|
56
|
+
sourceTreeSha,
|
|
57
|
+
runtimeSha,
|
|
58
|
+
releaseCandidateRoot,
|
|
59
|
+
npmArtifacts = [],
|
|
60
|
+
releaseAssetPaths = [],
|
|
61
|
+
} = {}) {
|
|
62
|
+
if (npmArtifacts.length === 0) return undefined;
|
|
63
|
+
const { resolvedRoot, files } = collectCandidateFiles(bundleRoot);
|
|
64
|
+
const byAbsolutePath = new Map(files.map((file) => [path.resolve(file.absolutePath), file]));
|
|
65
|
+
const resolvedNpmArtifacts = npmArtifacts.map((artifact, index) => {
|
|
66
|
+
const file = byAbsolutePath.get(path.resolve(requiredString(artifact.path, `npmArtifacts[${index}].path`)));
|
|
67
|
+
if (!file) throw new Error(`npmArtifacts[${index}].path is outside the sealed payload root`);
|
|
68
|
+
return { file, metadata: artifact };
|
|
69
|
+
});
|
|
70
|
+
const main = resolvedNpmArtifacts.find((entry) => entry.metadata.role === "main")
|
|
71
|
+
|| (resolvedNpmArtifacts.length === 1 ? resolvedNpmArtifacts[0] : undefined);
|
|
72
|
+
if (!main) throw new Error("candidate npm payload set has no unique main package tarball");
|
|
73
|
+
const selectedReleaseAssets = (releaseAssetPaths.length > 0
|
|
74
|
+
? releaseAssetPaths
|
|
75
|
+
: resolvedNpmArtifacts.map((entry) => entry.file.absolutePath))
|
|
76
|
+
.map((assetPath, index) => {
|
|
77
|
+
const file = byAbsolutePath.get(path.resolve(assetPath));
|
|
78
|
+
if (!file) throw new Error(`releaseAssetPaths[${index}] is outside the sealed payload root`);
|
|
79
|
+
return file;
|
|
80
|
+
});
|
|
81
|
+
const candidatePayload = {
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
contract: PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
|
|
84
|
+
repository: requiredString(repository, "repository"),
|
|
85
|
+
sourceSha: assertGitSha(sourceSha, "sourceSha"),
|
|
86
|
+
sourceTreeSha: assertGitSha(sourceTreeSha, "sourceTreeSha"),
|
|
87
|
+
runtimeSha: assertGitSha(runtimeSha, "runtimeSha"),
|
|
88
|
+
releaseCandidateRoot: `sha256:${normalizeSha256(releaseCandidateRoot, "releaseCandidateRoot")}`,
|
|
89
|
+
files: files.map(({ path: filePath, size, sha256 }) => ({ path: filePath, size, sha256 })),
|
|
90
|
+
};
|
|
91
|
+
const candidate = {
|
|
92
|
+
...candidatePayload,
|
|
93
|
+
candidateDigest: publicationArtifactCandidateDigest(candidatePayload),
|
|
94
|
+
};
|
|
95
|
+
const manifest = createPublicationSealedBundle({
|
|
96
|
+
candidate,
|
|
97
|
+
packageName: main.metadata.name,
|
|
98
|
+
packageVersion: main.metadata.ref,
|
|
99
|
+
npmTarballPath: main.file.path,
|
|
100
|
+
npmIntegrity: main.metadata.integrity,
|
|
101
|
+
releaseAssetPaths: selectedReleaseAssets.map((file) => file.path),
|
|
102
|
+
});
|
|
103
|
+
return { root: resolvedRoot, manifest, npmArtifacts: resolvedNpmArtifacts, files };
|
|
104
|
+
}
|
|
@@ -4,7 +4,10 @@ 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";
|
|
10
|
+
import { createResolvedPublicationSealedBundle } from "./publication-candidate-sealer.mjs";
|
|
8
11
|
import { writeGitHubOutputs } from "./build-contract-core.mjs";
|
|
9
12
|
|
|
10
13
|
const DEFAULT_WORKFLOW_FILE = "build-surface-fixture.yml";
|
|
@@ -37,6 +40,9 @@ function assertSha(value, label = "sha") {
|
|
|
37
40
|
return sha;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
export const releaseCandidateRuntimeSha = (passport) =>
|
|
44
|
+
assertSha(passport?.buildchain?.sha, "release candidate Passport Buildchain runtime SHA").toLowerCase();
|
|
45
|
+
|
|
40
46
|
function githubHeaders(token) {
|
|
41
47
|
const headers = {
|
|
42
48
|
accept: "application/vnd.github+json",
|
|
@@ -49,7 +55,7 @@ function githubHeaders(token) {
|
|
|
49
55
|
return headers;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fetchImpl = globalThis.fetch }) {
|
|
58
|
+
export async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fetchImpl = globalThis.fetch, allowNotFound = false }) {
|
|
53
59
|
if (typeof fetchImpl !== "function") {
|
|
54
60
|
throw new Error("fetch is required to resolve release candidate artifacts");
|
|
55
61
|
}
|
|
@@ -57,13 +63,14 @@ async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fe
|
|
|
57
63
|
const response = await fetchImpl(url, { method, headers: githubHeaders(token) });
|
|
58
64
|
const text = await response.text();
|
|
59
65
|
const body = text ? JSON.parse(text) : {};
|
|
66
|
+
if (allowNotFound && response.status === 404) return undefined;
|
|
60
67
|
if (!response.ok) {
|
|
61
68
|
throw new Error(`GitHub API ${method} ${requestPath} failed with ${response.status}: ${body.message || text}`);
|
|
62
69
|
}
|
|
63
70
|
return body;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fetchImpl = globalThis.fetch }) {
|
|
73
|
+
export async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fetchImpl = globalThis.fetch }) {
|
|
67
74
|
const url = `${String(apiUrl || "https://api.github.com").replace(/\/+$/, "")}${requestPath}`;
|
|
68
75
|
const response = await fetchImpl(url, { headers: githubHeaders(token) });
|
|
69
76
|
if (!response.ok) {
|
|
@@ -75,10 +82,32 @@ async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fe
|
|
|
75
82
|
}
|
|
76
83
|
throw new Error(`GitHub artifact download ${requestPath} failed with ${response.status}${detail ? `: ${detail}` : ""}`);
|
|
77
84
|
}
|
|
78
|
-
|
|
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
|
+
}
|
|
79
95
|
return outputPath;
|
|
80
96
|
}
|
|
81
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
|
+
|
|
82
111
|
export function selectMergedChannelPullRequest({ pullRequests = [], targetRef, repository }) {
|
|
83
112
|
const normalizedTarget = normalizeBranch(targetRef);
|
|
84
113
|
const candidates = pullRequests.filter((pr) => {
|
|
@@ -254,7 +283,7 @@ function packageNameFromArtifactPath(filePath) {
|
|
|
254
283
|
}
|
|
255
284
|
|
|
256
285
|
function npmIntegrity(filePath) {
|
|
257
|
-
return `sha512-${
|
|
286
|
+
return `sha512-${digestFileSync(filePath, "sha512", "base64")}`;
|
|
258
287
|
}
|
|
259
288
|
|
|
260
289
|
function readNpmPackageJsonFromTarball(tarballPath) {
|
|
@@ -357,7 +386,34 @@ export function selectReleaseCandidateArtifacts({ artifacts = [], artifactName =
|
|
|
357
386
|
return { passport, summary: summaries[0], prefix, sourceSha: sha };
|
|
358
387
|
}
|
|
359
388
|
|
|
360
|
-
function
|
|
389
|
+
export function verifyArtifactArchive({ artifact, archivePath } = {}) {
|
|
390
|
+
const size = fs.statSync(archivePath).size;
|
|
391
|
+
const digest = `sha256:${digestFileSync(archivePath, "sha256", "hex")}`;
|
|
392
|
+
if (!artifact || artifact.expired === true) {
|
|
393
|
+
throw new Error(`candidate artifact is missing or expired: ${artifact?.name || "<unknown>"}`);
|
|
394
|
+
}
|
|
395
|
+
if (Number(artifact.size_in_bytes) !== size) {
|
|
396
|
+
throw new Error(`candidate artifact size mismatch for ${artifact.name}: expected ${artifact.size_in_bytes}, got ${size}`);
|
|
397
|
+
}
|
|
398
|
+
if (!/^sha256:[0-9a-f]{64}$/i.test(String(artifact.digest || ""))) {
|
|
399
|
+
throw new Error(`candidate artifact ${artifact.name} has no trusted sha256 digest metadata`);
|
|
400
|
+
}
|
|
401
|
+
if (String(artifact.digest).toLowerCase() !== digest) {
|
|
402
|
+
throw new Error(`candidate artifact digest mismatch for ${artifact.name}: expected ${artifact.digest}, got ${digest}`);
|
|
403
|
+
}
|
|
404
|
+
return { size, digest };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function unzip(zipPath, outputDir) {
|
|
408
|
+
const entries = execFileSync("unzip", ["-Z1", zipPath], { encoding: "utf8" })
|
|
409
|
+
.split(/\r?\n/)
|
|
410
|
+
.filter(Boolean);
|
|
411
|
+
for (const entry of entries) {
|
|
412
|
+
const normalized = entry.replaceAll("\\", "/");
|
|
413
|
+
if (normalized.startsWith("/") || normalized.split("/").some((part) => part === "..")) {
|
|
414
|
+
throw new Error(`candidate artifact contains an unsafe zip entry: ${entry}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
361
417
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
362
418
|
execFileSync("unzip", ["-q", "-o", zipPath, "-d", outputDir], { stdio: "inherit" });
|
|
363
419
|
}
|
|
@@ -392,6 +448,7 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
392
448
|
requiredArtifactCount = 0,
|
|
393
449
|
publishArtifactKind = "npm",
|
|
394
450
|
publishPackageMain = "",
|
|
451
|
+
runtimeSha = "",
|
|
395
452
|
outputDir = ".buildchain/release-candidate",
|
|
396
453
|
fetchImpl = globalThis.fetch,
|
|
397
454
|
download = true,
|
|
@@ -579,6 +636,7 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
579
636
|
outputPath: passportZip,
|
|
580
637
|
path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${selected.passport.id}/zip`,
|
|
581
638
|
});
|
|
639
|
+
verifyArtifactArchive({ artifact: selected.passport, archivePath: passportZip });
|
|
582
640
|
await githubDownload({
|
|
583
641
|
apiUrl,
|
|
584
642
|
token,
|
|
@@ -586,6 +644,7 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
586
644
|
outputPath: summaryZip,
|
|
587
645
|
path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${selected.summary.id}/zip`,
|
|
588
646
|
});
|
|
647
|
+
verifyArtifactArchive({ artifact: selected.summary, archivePath: summaryZip });
|
|
589
648
|
for (const artifact of payloadArtifacts) {
|
|
590
649
|
const safeName = String(artifact.name || `artifact-${artifact.id}`).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
591
650
|
const payloadZip = path.join(tempDir, `${safeName}.zip`);
|
|
@@ -596,6 +655,7 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
596
655
|
outputPath: payloadZip,
|
|
597
656
|
path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${artifact.id}/zip`,
|
|
598
657
|
});
|
|
658
|
+
verifyArtifactArchive({ artifact, archivePath: payloadZip });
|
|
599
659
|
unzip(payloadZip, path.join(payloadDir, safeName));
|
|
600
660
|
}
|
|
601
661
|
unzip(passportZip, passportDir);
|
|
@@ -636,6 +696,27 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
636
696
|
});
|
|
637
697
|
const requiredArtifactsPath = path.join(resolvedOutput, "publish-required-artifacts.json");
|
|
638
698
|
fs.writeFileSync(requiredArtifactsPath, `${JSON.stringify(generatedRequiredArtifacts, null, 2)}\n`);
|
|
699
|
+
const sealedBundle = publishArtifactKind === "npm"
|
|
700
|
+
? createResolvedPublicationSealedBundle({
|
|
701
|
+
bundleRoot: payloadDir,
|
|
702
|
+
repository: repoInfo.fullName,
|
|
703
|
+
sourceSha: passport.source?.headSha,
|
|
704
|
+
sourceTreeSha: passport.source?.treeHash,
|
|
705
|
+
runtimeSha: releaseCandidateRuntimeSha(passport),
|
|
706
|
+
releaseCandidateRoot: passport.candidateHash,
|
|
707
|
+
npmArtifacts: npmTarballPaths.map((tarballPath) => ({
|
|
708
|
+
path: tarballPath,
|
|
709
|
+
...readNpmPackageArtifact({ tarballPath, mainPackage: publishPackageMain }),
|
|
710
|
+
})),
|
|
711
|
+
releaseAssetPaths,
|
|
712
|
+
})
|
|
713
|
+
: undefined;
|
|
714
|
+
const sealedBundleManifestPath = sealedBundle
|
|
715
|
+
? path.join(resolvedOutput, "sealed-bundle.json")
|
|
716
|
+
: "";
|
|
717
|
+
if (sealedBundleManifestPath) {
|
|
718
|
+
fs.writeFileSync(sealedBundleManifestPath, `${JSON.stringify(sealedBundle.manifest, null, 2)}\n`);
|
|
719
|
+
}
|
|
639
720
|
return {
|
|
640
721
|
...result,
|
|
641
722
|
paths: {
|
|
@@ -647,6 +728,8 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
647
728
|
npmTarballs: npmTarballPaths.map(outputPath),
|
|
648
729
|
releaseAssets: releaseAssetPaths.map(outputPath),
|
|
649
730
|
publishRequiredArtifacts: outputPath(requiredArtifactsPath),
|
|
731
|
+
sealedBundleRoot: sealedBundle ? outputPath(sealedBundle.root) : "",
|
|
732
|
+
sealedBundleManifest: sealedBundleManifestPath ? outputPath(sealedBundleManifestPath) : "",
|
|
650
733
|
},
|
|
651
734
|
version: passport.target?.version || "",
|
|
652
735
|
candidateHash: passport.candidateHash || "",
|
|
@@ -673,6 +756,7 @@ export async function resolveReleaseCandidateArtifactsCli() {
|
|
|
673
756
|
requiredArtifactCount: env("BUILDCHAIN_REQUIRED_ARTIFACT_COUNT", "0"),
|
|
674
757
|
publishArtifactKind: env("BUILDCHAIN_PUBLISH_ARTIFACT_KIND", "npm"),
|
|
675
758
|
publishPackageMain: env("BUILDCHAIN_PUBLISH_PACKAGE_MAIN"),
|
|
759
|
+
runtimeSha: env("BUILDCHAIN_CURRENT_RUNTIME_SHA", env("BUILDCHAIN_RUNTIME_SHA", env("BUILDCHAIN_TARGET_SHA"))),
|
|
676
760
|
outputDir: env("BUILDCHAIN_RC_OUTPUT_DIR", ".buildchain/release-candidate"),
|
|
677
761
|
download: releaseCandidateDownloadEnabled(env("BUILDCHAIN_RC_DOWNLOAD", "true")),
|
|
678
762
|
waitSeconds: env("BUILDCHAIN_RC_WAIT_SECONDS", "600"),
|
|
@@ -703,6 +787,8 @@ export async function resolveReleaseCandidateArtifactsCli() {
|
|
|
703
787
|
).join("\n"),
|
|
704
788
|
"publish-required-artifacts-json": JSON.stringify(result.publishRequiredArtifacts || []),
|
|
705
789
|
"publish-required-artifacts-path": result.paths?.publishRequiredArtifacts || "",
|
|
790
|
+
"publish-sealed-bundle-root": result.paths?.sealedBundleRoot || "",
|
|
791
|
+
"publish-sealed-bundle-manifest": result.paths?.sealedBundleManifest || "",
|
|
706
792
|
"release-candidate-run-id": result.run?.id || "",
|
|
707
793
|
"release-candidate-run-url": result.run?.url || "",
|
|
708
794
|
"release-candidate-pr": result.pullRequest?.number ? String(result.pullRequest.number) : "",
|
|
@@ -26,6 +26,15 @@ function resolveArtifactTransferMode(env = process.env) {
|
|
|
26
26
|
oidcAudience: "",
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
if (env.INPUT_RELAY_REQUIRED === "false") {
|
|
30
|
+
return {
|
|
31
|
+
mode: "github-artifacts",
|
|
32
|
+
s3Bucket: "",
|
|
33
|
+
s3Region: "",
|
|
34
|
+
s3Prefix: "",
|
|
35
|
+
oidcAudience: "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
29
38
|
const s3Bucket = firstValue(env.INPUT_S3_BUCKET, env.VAR_S3_BUCKET);
|
|
30
39
|
const s3Region = firstValue(env.INPUT_S3_REGION, env.VAR_S3_REGION);
|
|
31
40
|
const s3Prefix = firstValue(
|
|
@@ -41,6 +41,13 @@ export function resolveBuildContractCli() {
|
|
|
41
41
|
"native-platform-count": String(resolved.nativePlatformCount),
|
|
42
42
|
"container-platforms-json": resolved.containerPlatformsJson,
|
|
43
43
|
"container-platform-count": String(resolved.containerPlatformCount),
|
|
44
|
+
"github-hosted-platforms-json": resolved.githubHostedPlatformsJson,
|
|
45
|
+
"github-hosted-platform-ids-json": resolved.githubHostedPlatformIdsJson,
|
|
46
|
+
"github-hosted-platform-count": String(
|
|
47
|
+
resolved.githubHostedPlatformCount,
|
|
48
|
+
),
|
|
49
|
+
"relay-platforms-json": resolved.relayPlatformsJson,
|
|
50
|
+
"relay-platform-count": String(resolved.relayPlatformCount),
|
|
44
51
|
"linux-container-enabled": String(resolved.linuxContainer.enabled),
|
|
45
52
|
"linux-container-preset": resolved.linuxContainer.preset,
|
|
46
53
|
"linux-container-image": resolved.linuxContainer.image,
|