@kungfu-tech/buildchain 3.0.0 → 3.0.1-alpha.1
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 +3 -0
- package/dist/site/buildchain-contract.json +101 -26
- package/dist/site/buildchain-site.json +93 -17
- package/dist/site/capability-registry.json +6 -5
- package/dist/site/controller-registry.json +23 -3
- package/dist/site/kfd-claims.json +92 -11
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +17 -3
- package/dist/site/node-api-registry.json +19 -6
- package/dist/site/page-registry.json +77 -9
- package/dist/site/public-surface-audit.json +61 -9
- package/dist/site/publication-authority-registry.json +19 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +1 -0
- package/dist/site/site-manifest.json +15 -7
- package/dist/site/workflow-registry.json +52 -5
- package/docs/MAP.md +1 -0
- package/docs/auditable-demo.md +155 -0
- package/docs/github-governance-authority.md +2 -2
- package/docs/release-activation-transaction.md +38 -0
- package/docs/versioning.md +1 -0
- package/package.json +2 -1
- package/packages/core/buildchain-config.js +14 -0
- package/packages/core/buildchain-contract.js +45 -0
- package/packages/core/buildchain-kfd-claims.js +1 -0
- package/packages/core/buildchain-publication-authority.js +1 -0
- package/packages/core/github-governance-authority.js +3 -3
- package/packages/core/index.js +14 -0
- package/packages/core/release-activation-transaction.js +421 -0
- package/scripts/auditable-demo.mjs +576 -0
- package/scripts/check-inventory.mjs +4 -0
- package/scripts/generate-site-bundle.mjs +5 -0
- package/scripts/installer-publication.mjs +15 -5
- package/scripts/publication-commit-evidence.mjs +298 -12
- package/scripts/resolve-artifact-coordinates.mjs +174 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { pathToFileURL } from "node:url";
|
|
6
7
|
|
|
7
8
|
const SCHEMA = "kungfu-buildchain-publication-commit-evidence/v1";
|
|
9
|
+
const INSTALLER_BUNDLE_SCHEMA = "kungfu.installer-publication-bundle/v1";
|
|
8
10
|
|
|
9
11
|
function requiredString(value, label) {
|
|
10
12
|
if (typeof value !== "string" || value.trim() === "") {
|
|
@@ -52,14 +54,194 @@ function publicHttps(value, label) {
|
|
|
52
54
|
return normalized;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
function canonical(value) {
|
|
58
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
59
|
+
if (value && typeof value === "object") {
|
|
60
|
+
return Object.fromEntries(
|
|
61
|
+
Object.entries(value)
|
|
62
|
+
.filter(([, item]) => item !== undefined)
|
|
63
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
64
|
+
.map(([key, item]) => [key, canonical(item)]),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function digest(bytes) {
|
|
71
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function semanticRoot(value) {
|
|
75
|
+
return digest(Buffer.from(JSON.stringify(canonical(value))));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function expectedContentType(assetPath) {
|
|
79
|
+
if (assetPath.endsWith(".json")) return "application/json; charset=utf-8";
|
|
80
|
+
if (assetPath.endsWith(".sh")) return "text/x-shellscript; charset=utf-8";
|
|
81
|
+
if (assetPath.endsWith(".ps1")) return "text/plain; charset=utf-8";
|
|
82
|
+
throw new Error(`installer bundle asset type is unsupported: ${assetPath}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateInstallerBundle(evidence, expected) {
|
|
86
|
+
const bundle = evidence.publication?.installerBundle;
|
|
87
|
+
if (!bundle) return null;
|
|
88
|
+
if (bundle.schema !== INSTALLER_BUNDLE_SCHEMA) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`installer bundle schema must be ${INSTALLER_BUNDLE_SCHEMA}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const bundleRoot = sha256Root(
|
|
94
|
+
bundle.bundleRoot,
|
|
95
|
+
"installerBundle.bundleRoot",
|
|
96
|
+
);
|
|
97
|
+
if (
|
|
98
|
+
bundleRoot !== evidence.publication.payloadRoot ||
|
|
99
|
+
bundleRoot !== evidence.readback?.payloadRoot
|
|
100
|
+
) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
"installer bundle root must be the publication payload root",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
exactSha(bundle.sourceCommit, "installerBundle.sourceCommit") !==
|
|
107
|
+
expected.sourceSha ||
|
|
108
|
+
!["alpha", "stable"].includes(bundle.channel)
|
|
109
|
+
) {
|
|
110
|
+
throw new Error("installer bundle release identity mismatch");
|
|
111
|
+
}
|
|
112
|
+
sha256Root(bundle.channelPayloadRoot, "installerBundle.channelPayloadRoot");
|
|
113
|
+
sha256Root(bundle.channelFileDigest, "installerBundle.channelFileDigest");
|
|
114
|
+
sha256Root(
|
|
115
|
+
bundle.releasePassport?.root,
|
|
116
|
+
"installerBundle.releasePassport.root",
|
|
117
|
+
);
|
|
118
|
+
sha256Root(bundle.manifestDigest, "installerBundle.manifestDigest");
|
|
119
|
+
if (
|
|
120
|
+
evidence.readback?.manifestDigest !== bundle.manifestDigest ||
|
|
121
|
+
!Array.isArray(bundle.assets) ||
|
|
122
|
+
bundle.assets.length !== 7
|
|
123
|
+
) {
|
|
124
|
+
throw new Error("installer bundle read-back or asset set is incomplete");
|
|
125
|
+
}
|
|
126
|
+
const paths = new Set();
|
|
127
|
+
const topLevel = new Set([
|
|
128
|
+
"installer-publication.json",
|
|
129
|
+
"channel-index.json",
|
|
130
|
+
"trusted-keys.json",
|
|
131
|
+
"install.sh",
|
|
132
|
+
"install.ps1",
|
|
133
|
+
]);
|
|
134
|
+
const expectedRoles = new Map([
|
|
135
|
+
["installer-publication.json", "publication-manifest"],
|
|
136
|
+
["channel-index.json", "signed-channel-index"],
|
|
137
|
+
["trusted-keys.json", "public-trust-anchors"],
|
|
138
|
+
["install.sh", "friendly-installer"],
|
|
139
|
+
["install.ps1", "friendly-installer"],
|
|
140
|
+
]);
|
|
141
|
+
const immutableDirectories = new Set();
|
|
142
|
+
let immutableShell = 0;
|
|
143
|
+
let immutablePowerShell = 0;
|
|
144
|
+
const releaseBaseUrl =
|
|
145
|
+
`https://github.com/kungfu-systems/kungfu/releases/download/` +
|
|
146
|
+
expected.releaseTag;
|
|
147
|
+
for (const asset of bundle.assets) {
|
|
148
|
+
const assetPath = requiredString(
|
|
149
|
+
asset.path,
|
|
150
|
+
"installer bundle asset path",
|
|
151
|
+
).replaceAll("\\", "/");
|
|
152
|
+
if (
|
|
153
|
+
assetPath.startsWith("/") ||
|
|
154
|
+
assetPath.endsWith("/") ||
|
|
155
|
+
assetPath.split("/").some((part) => part === "" || part === "..") ||
|
|
156
|
+
paths.has(assetPath)
|
|
157
|
+
) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`unsafe or duplicate installer bundle asset: ${assetPath}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
paths.add(assetPath);
|
|
163
|
+
topLevel.delete(assetPath);
|
|
164
|
+
if (assetPath.includes("/") && assetPath.endsWith("/install.sh")) {
|
|
165
|
+
immutableShell += 1;
|
|
166
|
+
immutableDirectories.add(path.posix.dirname(assetPath));
|
|
167
|
+
}
|
|
168
|
+
if (assetPath.includes("/") && assetPath.endsWith("/install.ps1")) {
|
|
169
|
+
immutablePowerShell += 1;
|
|
170
|
+
immutableDirectories.add(path.posix.dirname(assetPath));
|
|
171
|
+
}
|
|
172
|
+
if (!Number.isSafeInteger(asset.size) || asset.size < 1) {
|
|
173
|
+
throw new Error(`installer bundle asset size is invalid: ${assetPath}`);
|
|
174
|
+
}
|
|
175
|
+
sha256Root(asset.digest, `installer bundle asset digest: ${assetPath}`);
|
|
176
|
+
const releaseAsset = requiredString(
|
|
177
|
+
asset.releaseAsset,
|
|
178
|
+
`installer bundle release asset: ${assetPath}`,
|
|
179
|
+
);
|
|
180
|
+
if (
|
|
181
|
+
asset.contentType !== expectedContentType(assetPath) ||
|
|
182
|
+
publicHttps(
|
|
183
|
+
asset.releaseUrl,
|
|
184
|
+
`installer bundle asset URL: ${assetPath}`,
|
|
185
|
+
) !== `${releaseBaseUrl}/${releaseAsset}` ||
|
|
186
|
+
!/^kungfu-[a-z0-9.-]+$/.test(releaseAsset)
|
|
187
|
+
) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`installer bundle asset transport metadata is invalid: ${assetPath}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const expectedRole = assetPath.includes("/")
|
|
193
|
+
? "immutable-installer"
|
|
194
|
+
: expectedRoles.get(assetPath);
|
|
195
|
+
if (asset.role !== expectedRole) {
|
|
196
|
+
throw new Error(`installer bundle asset role is invalid: ${assetPath}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const immutableDirectory = [...immutableDirectories][0] || "";
|
|
200
|
+
const immutableParts = immutableDirectory.split("/");
|
|
201
|
+
if (
|
|
202
|
+
topLevel.size !== 0 ||
|
|
203
|
+
immutableShell !== 1 ||
|
|
204
|
+
immutablePowerShell !== 1 ||
|
|
205
|
+
immutableDirectories.size !== 1 ||
|
|
206
|
+
immutableParts.length !== 5 ||
|
|
207
|
+
immutableParts[0] !== "installers" ||
|
|
208
|
+
immutableParts[1] !== "v1" ||
|
|
209
|
+
immutableParts[2] !== bundle.channel ||
|
|
210
|
+
immutableParts[3] !== expected.version ||
|
|
211
|
+
!/^[a-f0-9]{64}$/.test(immutableParts[4])
|
|
212
|
+
) {
|
|
213
|
+
throw new Error("installer bundle asset topology is incomplete");
|
|
214
|
+
}
|
|
215
|
+
if (
|
|
216
|
+
bundle.cachePolicy?.friendly !== "public,max-age=300,must-revalidate" ||
|
|
217
|
+
bundle.cachePolicy?.immutable !== "public,max-age=31536000,immutable"
|
|
218
|
+
) {
|
|
219
|
+
throw new Error("installer bundle cache policy is invalid");
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
evidence.siteHandoff?.state !== "deferred-to-site-owned-consumer" ||
|
|
223
|
+
evidence.siteHandoff?.productionAvailable !== false ||
|
|
224
|
+
evidence.siteHandoff?.requiredBundleRoot !== bundleRoot
|
|
225
|
+
) {
|
|
226
|
+
throw new Error("installer bundle site handoff must remain deferred");
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
schema: bundle.schema,
|
|
230
|
+
bundleRoot,
|
|
231
|
+
manifestDigest: bundle.manifestDigest,
|
|
232
|
+
channel: bundle.channel,
|
|
233
|
+
channelPayloadRoot: bundle.channelPayloadRoot,
|
|
234
|
+
channelFileDigest: bundle.channelFileDigest,
|
|
235
|
+
releasePassport: bundle.releasePassport,
|
|
236
|
+
cachePolicy: bundle.cachePolicy,
|
|
237
|
+
immutablePath: immutableDirectory,
|
|
238
|
+
assets: bundle.assets,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
55
242
|
export function validatePublicationCommitEvidence(
|
|
56
243
|
evidence,
|
|
57
|
-
{
|
|
58
|
-
version,
|
|
59
|
-
sourceSha,
|
|
60
|
-
releaseSha,
|
|
61
|
-
releaseTag,
|
|
62
|
-
} = {},
|
|
244
|
+
{ version, sourceSha, releaseSha, releaseTag } = {},
|
|
63
245
|
) {
|
|
64
246
|
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
65
247
|
throw new Error("publication commit evidence must be an object");
|
|
@@ -107,6 +289,7 @@ export function validatePublicationCommitEvidence(
|
|
|
107
289
|
"publication recovery must preserve or explicitly declare no previous authority",
|
|
108
290
|
);
|
|
109
291
|
}
|
|
292
|
+
const installerBundle = validateInstallerBundle(evidence, expected);
|
|
110
293
|
return {
|
|
111
294
|
schema: SCHEMA,
|
|
112
295
|
status: "passed",
|
|
@@ -117,10 +300,109 @@ export function validatePublicationCommitEvidence(
|
|
|
117
300
|
previousAuthority,
|
|
118
301
|
rollbackReference,
|
|
119
302
|
},
|
|
303
|
+
...(installerBundle ? { installerBundle } : {}),
|
|
120
304
|
};
|
|
121
305
|
}
|
|
122
306
|
|
|
123
|
-
function
|
|
307
|
+
export async function verifyInstallerBundleReadback(
|
|
308
|
+
result,
|
|
309
|
+
fetchImpl = globalThis.fetch,
|
|
310
|
+
) {
|
|
311
|
+
const bundle = result.installerBundle;
|
|
312
|
+
if (!bundle) return null;
|
|
313
|
+
if (typeof fetchImpl !== "function") {
|
|
314
|
+
throw new Error("installer bundle read-back requires fetch");
|
|
315
|
+
}
|
|
316
|
+
const manifestResponse = await fetchImpl(result.publicUrl, {
|
|
317
|
+
redirect: "manual",
|
|
318
|
+
cache: "no-store",
|
|
319
|
+
});
|
|
320
|
+
if (manifestResponse.status !== 200) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`installer bundle manifest read-back failed: HTTP ${manifestResponse.status}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const manifestBytes = Buffer.from(await manifestResponse.arrayBuffer());
|
|
326
|
+
if (digest(manifestBytes) !== bundle.manifestDigest) {
|
|
327
|
+
throw new Error("installer bundle manifest digest mismatch");
|
|
328
|
+
}
|
|
329
|
+
const manifest = JSON.parse(manifestBytes);
|
|
330
|
+
const unsigned = Object.fromEntries(
|
|
331
|
+
Object.entries(manifest).filter(([key]) => key !== "bundleRoot"),
|
|
332
|
+
);
|
|
333
|
+
if (
|
|
334
|
+
manifest.schema !== INSTALLER_BUNDLE_SCHEMA ||
|
|
335
|
+
manifest.bundleRoot !== bundle.bundleRoot ||
|
|
336
|
+
semanticRoot(unsigned) !== bundle.bundleRoot ||
|
|
337
|
+
manifest.package?.name !== "@kungfu-tech/site" ||
|
|
338
|
+
typeof manifest.package?.version !== "string" ||
|
|
339
|
+
manifest.identity?.sourceCommit !== result.identity.sourceSha ||
|
|
340
|
+
manifest.identity?.releaseSha !== result.identity.releaseSha ||
|
|
341
|
+
manifest.identity?.releaseTag !== result.identity.releaseTag ||
|
|
342
|
+
manifest.identity?.version !== result.identity.version ||
|
|
343
|
+
manifest.identity?.channel !== bundle.channel ||
|
|
344
|
+
manifest.identity?.channelPayloadRoot !== bundle.channelPayloadRoot ||
|
|
345
|
+
manifest.identity?.channelFileDigest !== bundle.channelFileDigest ||
|
|
346
|
+
manifest.identity?.releasePassport?.root !== bundle.releasePassport.root ||
|
|
347
|
+
manifest.distribution?.repository !== "kungfu-systems/kungfu" ||
|
|
348
|
+
manifest.routes?.immutablePath !== bundle.immutablePath ||
|
|
349
|
+
manifest.routes?.friendly?.["install.sh"] !==
|
|
350
|
+
"https://kungfu.tech/install.sh" ||
|
|
351
|
+
manifest.routes?.friendly?.["install.ps1"] !==
|
|
352
|
+
"https://kungfu.tech/install.ps1" ||
|
|
353
|
+
JSON.stringify(canonical(manifest.cachePolicy)) !==
|
|
354
|
+
JSON.stringify(canonical(bundle.cachePolicy)) ||
|
|
355
|
+
JSON.stringify(canonical(manifest.assets)) !==
|
|
356
|
+
JSON.stringify(canonical(bundle.assets)) ||
|
|
357
|
+
`${manifest.distribution?.releaseBaseUrl}/` +
|
|
358
|
+
manifest.distribution?.manifestAsset !==
|
|
359
|
+
result.publicUrl
|
|
360
|
+
) {
|
|
361
|
+
throw new Error("installer bundle manifest root mismatch");
|
|
362
|
+
}
|
|
363
|
+
const observations = [];
|
|
364
|
+
const byUrl = new Map();
|
|
365
|
+
for (const asset of bundle.assets) {
|
|
366
|
+
let observation = byUrl.get(asset.releaseUrl);
|
|
367
|
+
if (!observation) {
|
|
368
|
+
const response = await fetchImpl(asset.releaseUrl, {
|
|
369
|
+
redirect: "manual",
|
|
370
|
+
cache: "no-store",
|
|
371
|
+
});
|
|
372
|
+
if (response.status !== 200) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`installer bundle asset read-back failed: HTTP ${response.status}`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
378
|
+
observation = {
|
|
379
|
+
releaseUrl: asset.releaseUrl,
|
|
380
|
+
size: bytes.length,
|
|
381
|
+
digest: digest(bytes),
|
|
382
|
+
};
|
|
383
|
+
byUrl.set(asset.releaseUrl, observation);
|
|
384
|
+
}
|
|
385
|
+
if (
|
|
386
|
+
observation.size !== asset.size ||
|
|
387
|
+
observation.digest !== asset.digest
|
|
388
|
+
) {
|
|
389
|
+
throw new Error(`installer bundle asset drifted: ${asset.path}`);
|
|
390
|
+
}
|
|
391
|
+
observations.push({ path: asset.path, ...observation });
|
|
392
|
+
}
|
|
393
|
+
const seal = {
|
|
394
|
+
schema: "kungfu-buildchain-installer-publication-bundle-seal/v1",
|
|
395
|
+
bundleRoot: bundle.bundleRoot,
|
|
396
|
+
manifestDigest: bundle.manifestDigest,
|
|
397
|
+
sourceCommit: result.identity.sourceSha,
|
|
398
|
+
releaseTag: result.identity.releaseTag,
|
|
399
|
+
releasePassport: bundle.releasePassport,
|
|
400
|
+
observations,
|
|
401
|
+
};
|
|
402
|
+
return { ...seal, sealRoot: semanticRoot(seal) };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function main(args) {
|
|
124
406
|
const options = {};
|
|
125
407
|
for (let index = 0; index < args.length; index += 1) {
|
|
126
408
|
const value = args[index];
|
|
@@ -138,21 +420,25 @@ function main(args) {
|
|
|
138
420
|
JSON.parse(fs.readFileSync(evidencePath, "utf8")),
|
|
139
421
|
options,
|
|
140
422
|
);
|
|
141
|
-
|
|
423
|
+
const installerBundleSeal = await verifyInstallerBundleReadback(result);
|
|
424
|
+
process.stdout.write(
|
|
425
|
+
`${JSON.stringify({
|
|
426
|
+
...result,
|
|
427
|
+
...(installerBundleSeal ? { installerBundleSeal } : {}),
|
|
428
|
+
})}\n`,
|
|
429
|
+
);
|
|
142
430
|
}
|
|
143
431
|
|
|
144
432
|
if (
|
|
145
433
|
process.argv[1] &&
|
|
146
434
|
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
|
147
435
|
) {
|
|
148
|
-
|
|
149
|
-
main(process.argv.slice(2));
|
|
150
|
-
} catch (error) {
|
|
436
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
151
437
|
console.error(
|
|
152
438
|
`publication commit evidence failed: ${
|
|
153
439
|
error instanceof Error ? error.message : String(error)
|
|
154
440
|
}`,
|
|
155
441
|
);
|
|
156
442
|
process.exit(1);
|
|
157
|
-
}
|
|
443
|
+
});
|
|
158
444
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
resolveArtifactContract,
|
|
8
|
+
writeGitHubOutputs,
|
|
9
|
+
} from "./build-contract-core.mjs";
|
|
10
|
+
|
|
11
|
+
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
12
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
13
|
+
|
|
14
|
+
function required(value, label) {
|
|
15
|
+
const normalized = String(value || "").trim();
|
|
16
|
+
if (!normalized) {
|
|
17
|
+
throw new Error(`${label} is required`);
|
|
18
|
+
}
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseArray(value, label) {
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(value);
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(`${label} must be valid JSON`);
|
|
28
|
+
}
|
|
29
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
30
|
+
throw new Error(`${label} must be a non-empty JSON array`);
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveArtifactCoordinates({
|
|
36
|
+
artifacts,
|
|
37
|
+
platforms,
|
|
38
|
+
artifactName,
|
|
39
|
+
artifactNameTemplate,
|
|
40
|
+
sourceSha,
|
|
41
|
+
sourceRef = "",
|
|
42
|
+
repository,
|
|
43
|
+
runId,
|
|
44
|
+
runAttempt,
|
|
45
|
+
serverUrl = "https://github.com",
|
|
46
|
+
}) {
|
|
47
|
+
if (!Array.isArray(artifacts)) {
|
|
48
|
+
throw new Error("artifacts must be an array");
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(platforms) || platforms.length === 0) {
|
|
51
|
+
throw new Error("platforms must be a non-empty array");
|
|
52
|
+
}
|
|
53
|
+
if (!SHA_PATTERN.test(sourceSha)) {
|
|
54
|
+
throw new Error("source SHA must be an exact lowercase commit SHA");
|
|
55
|
+
}
|
|
56
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
|
|
57
|
+
throw new Error("repository must be an owner/name coordinate");
|
|
58
|
+
}
|
|
59
|
+
if (!/^[1-9][0-9]*$/.test(String(runId))) {
|
|
60
|
+
throw new Error("run id must be a positive integer");
|
|
61
|
+
}
|
|
62
|
+
if (!/^[1-9][0-9]*$/.test(String(runAttempt))) {
|
|
63
|
+
throw new Error("run attempt must be a positive integer");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const coordinates = platforms.map((platform) => {
|
|
67
|
+
const id = required(platform?.id, "platform id");
|
|
68
|
+
const name = required(platform?.name, `platform ${id} name`);
|
|
69
|
+
const contract = resolveArtifactContract({
|
|
70
|
+
artifactName,
|
|
71
|
+
artifactNameTemplate,
|
|
72
|
+
platformId: id,
|
|
73
|
+
platformName: name,
|
|
74
|
+
sha: sourceSha,
|
|
75
|
+
ref: sourceRef,
|
|
76
|
+
runId: String(runId),
|
|
77
|
+
runAttempt: String(runAttempt),
|
|
78
|
+
});
|
|
79
|
+
const matches = artifacts.filter(
|
|
80
|
+
(artifact) =>
|
|
81
|
+
artifact?.name === contract.artifactName && !artifact.expired,
|
|
82
|
+
);
|
|
83
|
+
if (matches.length !== 1) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`expected exactly one live artifact named ${contract.artifactName}, found ${matches.length}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const artifact = matches[0];
|
|
89
|
+
if (!/^[1-9][0-9]*$/.test(String(artifact.id || ""))) {
|
|
90
|
+
throw new Error(`artifact ${contract.artifactName} has no exact id`);
|
|
91
|
+
}
|
|
92
|
+
if (!DIGEST_PATTERN.test(String(artifact.digest || ""))) {
|
|
93
|
+
throw new Error(`artifact ${contract.artifactName} has no exact digest`);
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
!artifact.expires_at ||
|
|
97
|
+
!Number.isFinite(Date.parse(artifact.expires_at))
|
|
98
|
+
) {
|
|
99
|
+
throw new Error(`artifact ${contract.artifactName} has no exact expiry`);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
platformId: id,
|
|
103
|
+
id: String(artifact.id),
|
|
104
|
+
name: artifact.name,
|
|
105
|
+
digest: artifact.digest,
|
|
106
|
+
url: `${serverUrl}/${repository}/actions/runs/${runId}/artifacts/${artifact.id}`,
|
|
107
|
+
expiresAt: new Date(artifact.expires_at).toISOString(),
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
coordinates.sort((left, right) =>
|
|
112
|
+
left.platformId.localeCompare(right.platformId),
|
|
113
|
+
);
|
|
114
|
+
return {
|
|
115
|
+
schema: "buildchain.github-artifact-coordinate-set/v1",
|
|
116
|
+
repository,
|
|
117
|
+
runId: String(runId),
|
|
118
|
+
runAttempt: String(runAttempt),
|
|
119
|
+
sourceSha,
|
|
120
|
+
artifacts: coordinates,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveArtifactCoordinatesCli(env = process.env) {
|
|
125
|
+
const artifactListPath = required(
|
|
126
|
+
env.BUILDCHAIN_ARTIFACT_LIST_PATH,
|
|
127
|
+
"BUILDCHAIN_ARTIFACT_LIST_PATH",
|
|
128
|
+
);
|
|
129
|
+
const outputPath = required(
|
|
130
|
+
env.BUILDCHAIN_ARTIFACT_COORDINATES_PATH,
|
|
131
|
+
"BUILDCHAIN_ARTIFACT_COORDINATES_PATH",
|
|
132
|
+
);
|
|
133
|
+
const artifacts = parseArray(
|
|
134
|
+
fs.readFileSync(artifactListPath, "utf8"),
|
|
135
|
+
"artifact list",
|
|
136
|
+
);
|
|
137
|
+
const platforms = parseArray(
|
|
138
|
+
env.BUILDCHAIN_PLATFORMS_JSON || "",
|
|
139
|
+
"platforms-json",
|
|
140
|
+
);
|
|
141
|
+
const result = resolveArtifactCoordinates({
|
|
142
|
+
artifacts,
|
|
143
|
+
platforms,
|
|
144
|
+
artifactName: env.BUILDCHAIN_ARTIFACT_NAME,
|
|
145
|
+
artifactNameTemplate: env.BUILDCHAIN_ARTIFACT_NAME_TEMPLATE,
|
|
146
|
+
sourceSha: env.BUILDCHAIN_SOURCE_SHA,
|
|
147
|
+
sourceRef: env.GITHUB_REF,
|
|
148
|
+
repository: env.GITHUB_REPOSITORY,
|
|
149
|
+
runId: env.GITHUB_RUN_ID,
|
|
150
|
+
runAttempt: env.GITHUB_RUN_ATTEMPT,
|
|
151
|
+
serverUrl: env.GITHUB_SERVER_URL,
|
|
152
|
+
});
|
|
153
|
+
const pretty = `${JSON.stringify(result, null, 2)}\n`;
|
|
154
|
+
const compact = JSON.stringify(result);
|
|
155
|
+
fs.writeFileSync(outputPath, pretty);
|
|
156
|
+
writeGitHubOutputs({
|
|
157
|
+
"artifact-coordinates-json": compact,
|
|
158
|
+
"artifact-coordinates-path": outputPath,
|
|
159
|
+
});
|
|
160
|
+
process.stdout.write(pretty);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (
|
|
165
|
+
process.argv[1] &&
|
|
166
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
167
|
+
) {
|
|
168
|
+
try {
|
|
169
|
+
resolveArtifactCoordinatesCli();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error(`resolve-artifact-coordinates: ${error.message}`);
|
|
172
|
+
process.exitCode = 1;
|
|
173
|
+
}
|
|
174
|
+
}
|