@kungfu-tech/buildchain 2.14.18 → 2.14.19-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.
@@ -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 main(args) {
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
- process.stdout.write(`${JSON.stringify(result)}\n`);
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
- try {
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
  }
@@ -402,7 +402,9 @@ export async function resolveReleaseCandidateArtifacts({
402
402
  const repoInfo = splitRepository(repository);
403
403
  const sha = assertSha(targetSha, "targetSha");
404
404
  const normalizedTarget = normalizeBranch(targetRef);
405
- if (!/^(alpha|release)\/v\d+\/v\d+\.\d+$/.test(normalizedTarget)) {
405
+ const releaseCandidateTarget = /^(alpha|release)\/v\d+\/v\d+\.\d+$/.test(normalizedTarget);
406
+ const majorGateTarget = normalizedTarget === "publish-gate/major" || normalizedTarget === "major-gate";
407
+ if (!releaseCandidateTarget && !majorGateTarget) {
406
408
  return {
407
409
  enabled: false,
408
410
  reason: `target ref ${normalizedTarget || "(empty)"} does not require release-candidate promotion`,
@@ -414,14 +416,49 @@ export async function resolveReleaseCandidateArtifacts({
414
416
  fetchImpl,
415
417
  path: `/repos/${repoInfo.owner}/${repoInfo.repo}/commits/${sha}/pulls`,
416
418
  });
417
- const pullRequest = selectMergedChannelPullRequest({
419
+ const channelPullRequest = selectMergedChannelPullRequest({
418
420
  pullRequests: Array.isArray(pulls) ? pulls : [],
419
421
  targetRef: normalizedTarget,
420
422
  repository: repoInfo.fullName,
421
423
  });
422
- if (!pullRequest) {
424
+ if (!channelPullRequest) {
423
425
  throw new Error(`no same-repository merged channel PR found for ${sha} into ${normalizedTarget}`);
424
426
  }
427
+ let pullRequest = channelPullRequest;
428
+ if (majorGateTarget) {
429
+ const releaseRef = normalizeBranch(channelPullRequest.head?.ref || "");
430
+ if (!/^release\/v\d+\/v\d+\.\d+$/.test(releaseRef)) {
431
+ throw new Error(
432
+ `major gate ${normalizedTarget} must be merged from a release/vN/vN.M head, got ${releaseRef || "<empty>"}`,
433
+ );
434
+ }
435
+ const releaseSha = assertSha(channelPullRequest.head?.sha, "major gate release head SHA");
436
+ const releasePulls = await githubJson({
437
+ apiUrl,
438
+ token,
439
+ fetchImpl,
440
+ path: `/repos/${repoInfo.owner}/${repoInfo.repo}/commits/${releaseSha}/pulls`,
441
+ });
442
+ pullRequest = selectMergedChannelPullRequest({
443
+ pullRequests: Array.isArray(releasePulls) ? releasePulls : [],
444
+ targetRef: releaseRef,
445
+ repository: repoInfo.fullName,
446
+ });
447
+ if (!pullRequest) {
448
+ throw new Error(
449
+ `no same-repository merged release-candidate PR found for major gate release head ${releaseSha} into ${releaseRef}`,
450
+ );
451
+ }
452
+ const releaseMergeSha = assertSha(
453
+ pullRequest.merge_commit_sha || pullRequest.mergeCommit?.oid,
454
+ "release-candidate PR merge SHA",
455
+ );
456
+ if (releaseMergeSha !== releaseSha) {
457
+ throw new Error(
458
+ `major gate release head ${releaseSha} does not equal release-candidate PR #${pullRequest.number} merge ${releaseMergeSha}`,
459
+ );
460
+ }
461
+ }
425
462
  const timeoutMs = Number(waitSeconds) * 1000;
426
463
  const intervalMs = Number(pollIntervalMs);
427
464
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
@@ -501,6 +538,16 @@ export async function resolveReleaseCandidateArtifacts({
501
538
  headRef: pullRequest.head?.ref || "",
502
539
  baseRef: pullRequest.base?.ref || "",
503
540
  },
541
+ ...(majorGateTarget
542
+ ? {
543
+ promotionPullRequest: {
544
+ number: channelPullRequest.number,
545
+ url: channelPullRequest.html_url || channelPullRequest.url || "",
546
+ headRef: channelPullRequest.head?.ref || "",
547
+ baseRef: channelPullRequest.base?.ref || "",
548
+ },
549
+ }
550
+ : {}),
504
551
  run: {
505
552
  id: String(run.id || ""),
506
553
  url: run.html_url || run.url || "",