@kungfu-tech/buildchain 3.0.2 → 3.0.3-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.
Files changed (127) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +27 -1
  4. package/actions/promote-buildchain-ref/README.md +90 -19
  5. package/actions/report-buildchain-issue/README.md +20 -2
  6. package/actions/run-lifecycle/README.md +5 -0
  7. package/actions/validate-config/README.md +19 -1
  8. package/bin/buildchain.mjs +56 -2
  9. package/bin/internal/command-registry.mjs +82 -0
  10. package/bin/internal/trust-release-cli.mjs +1 -0
  11. package/contracts/auditable-demo-media-profiles-v1.json +116 -1
  12. package/contracts/buildchain-v2-residuals-v1.json +146 -0
  13. package/contracts/evidence/auditable-demo-responsive-web-delivery-v1.json +143 -0
  14. package/contracts/evidence/auditable-demo-web-delivery-v1.json +7 -3
  15. package/contracts/fixtures/auditable-demo-responsive-web-delivery-v1/complete-transcript.txt +2 -0
  16. package/contracts/fixtures/auditable-demo-responsive-web-delivery-v1/public-projection.json +16 -0
  17. package/contracts/fixtures/auditable-demo-responsive-web-delivery-v1/scene.json +12 -0
  18. package/dist/site/buildchain-contract.json +188 -46
  19. package/dist/site/buildchain-site.json +181 -90
  20. package/dist/site/capability-registry.json +5 -5
  21. package/dist/site/cli-registry.json +860 -98
  22. package/dist/site/controller-registry.json +118 -5
  23. package/dist/site/kfd-claims.json +233 -19
  24. package/dist/site/kfd-upstream-aggregate.json +1 -1
  25. package/dist/site/manual-registry.json +20 -20
  26. package/dist/site/node-api-registry.json +374 -59
  27. package/dist/site/page-registry.json +156 -65
  28. package/dist/site/public-surface-audit.json +231 -22
  29. package/dist/site/publication-registry.json +4 -4
  30. package/dist/site/release-provenance.json +3 -0
  31. package/dist/site/site-manifest.json +24 -24
  32. package/dist/site/workflow-registry.json +575 -79
  33. package/docs/MAP.md +22 -4
  34. package/docs/auditable-demo.md +54 -13
  35. package/docs/aws-us-elastic-runner-burst-plane.md +381 -0
  36. package/docs/cli.md +119 -12
  37. package/docs/consumer-issue-reporting.md +20 -2
  38. package/docs/dev-alpha-candidate-patrol.md +24 -10
  39. package/docs/github-governance-authority.md +24 -31
  40. package/docs/lifecycle-protocol.md +20 -2
  41. package/docs/migration-inventory.md +24 -5
  42. package/docs/observed-evidence-patrol.md +28 -12
  43. package/docs/ownership.md +19 -1
  44. package/docs/publication-artifacts.md +158 -18
  45. package/docs/publication-authority.md +4 -4
  46. package/docs/publish-transaction.md +78 -31
  47. package/docs/release-candidate.md +24 -0
  48. package/docs/release-flow.md +50 -32
  49. package/docs/release-governance.md +39 -32
  50. package/docs/release-passport.md +4 -0
  51. package/docs/release-propagation.md +57 -12
  52. package/docs/reusable-build-surface.md +218 -90
  53. package/docs/runtime-train-validation.md +20 -2
  54. package/docs/shifu-gate-profiles.md +7 -1
  55. package/docs/stable-candidate-patrol.md +6 -6
  56. package/docs/toolkit-observability.md +22 -0
  57. package/docs/versioning.md +46 -24
  58. package/docs/web-surface-deployments.md +82 -1
  59. package/package.json +7 -5
  60. package/packages/core/README.md +60 -4
  61. package/packages/core/artifact-signing.js +1 -0
  62. package/packages/core/buildchain-contract.js +9 -4
  63. package/packages/core/cache-evidence.js +1 -0
  64. package/packages/core/controller-evidence.js +2 -0
  65. package/packages/core/diagnostics.js +105 -30
  66. package/packages/core/github-governance-authority.js +25 -17
  67. package/packages/core/index.js +40 -0
  68. package/packages/core/paper.js +3226 -0
  69. package/packages/core/public-surface-audit.js +3 -0
  70. package/packages/core/publication-artifact.js +1 -6
  71. package/packages/core/publication-authority.js +1 -0
  72. package/packages/core/publication-control-plane-audit.js +28 -0
  73. package/packages/core/publication-reproducibility.js +947 -0
  74. package/packages/core/publication-sealed-bundle.js +190 -0
  75. package/packages/core/publish-transaction.js +147 -17
  76. package/packages/core/release-candidate.js +234 -0
  77. package/packages/core/release-passport.js +519 -228
  78. package/packages/core/release-propagation.js +175 -1
  79. package/packages/core/stable-release-gate.js +4 -1
  80. package/scripts/artifact-signing-delegation.mjs +268 -0
  81. package/scripts/audit-github-governance.mjs +32 -13
  82. package/scripts/audit-publication-control-plane.mjs +17 -8
  83. package/scripts/auditable-demo.mjs +266 -7
  84. package/scripts/aws-codebuild-toolchain.mjs +285 -0
  85. package/scripts/aws-macos-jit-core.mjs +378 -0
  86. package/scripts/aws-macos-jit.mjs +123 -0
  87. package/scripts/aws-runner-burst-core.mjs +248 -0
  88. package/scripts/aws-runner-burst.mjs +79 -0
  89. package/scripts/aws-windows-jit-core.mjs +374 -0
  90. package/scripts/aws-windows-jit.mjs +121 -0
  91. package/scripts/build-contract-core.mjs +69 -6
  92. package/scripts/build-standalone-binary.mjs +23 -0
  93. package/scripts/buildchain-contract-lock.mjs +1 -1
  94. package/scripts/buildchain-patrol.mjs +1 -1
  95. package/scripts/check-internal-architecture.mjs +69 -2
  96. package/scripts/check-inventory.mjs +70 -2
  97. package/scripts/check-javascript-syntax.mjs +31 -0
  98. package/scripts/check-maintainability.mjs +371 -0
  99. package/scripts/compiler-cache-evidence.mjs +221 -0
  100. package/scripts/dev-alpha-candidate-patrol.mjs +288 -43
  101. package/scripts/dispatch-artifact-signing-authority.mjs +7 -1
  102. package/scripts/finalize-native-artifact-signing-result.mjs +185 -31
  103. package/scripts/gate-profile-core.mjs +6 -1
  104. package/scripts/generate-channel-promotion-workflow.mjs +3 -12
  105. package/scripts/generate-release-candidate-passport.mjs +4 -0
  106. package/scripts/generate-site-bundle.mjs +84 -18
  107. package/scripts/import-artifact-signing-results.mjs +64 -5
  108. package/scripts/inspect-artifact-signing-requests.mjs +52 -14
  109. package/scripts/maintainability-metrics.mjs +339 -0
  110. package/scripts/npm-publish-transaction.mjs +57 -6
  111. package/scripts/observed-evidence.mjs +151 -33
  112. package/scripts/paper.mjs +645 -0
  113. package/scripts/promotion-channel-router.mjs +16 -4
  114. package/scripts/publication-reproducibility.mjs +62 -0
  115. package/scripts/reconcile-github-governance.mjs +8 -1
  116. package/scripts/release-propagation.mjs +39 -0
  117. package/scripts/resolve-artifact-signing-upload-route.mjs +55 -0
  118. package/scripts/resolve-build-contract.mjs +6 -0
  119. package/scripts/route-offline-runners.mjs +236 -0
  120. package/scripts/run-candidate-body-prefix-renderer.mjs +187 -0
  121. package/scripts/run-lifecycle-core.mjs +62 -1
  122. package/scripts/runtime-ref-core.mjs +36 -4
  123. package/scripts/seal-artifact-signing-requests.mjs +64 -12
  124. package/scripts/stable-candidate-qualification.mjs +50 -0
  125. package/scripts/verify-artifact-signing-results.mjs +26 -1
  126. package/scripts/web-surface-core.mjs +272 -86
  127. package/scripts/web-surface-production-decision.mjs +19 -3
@@ -0,0 +1,947 @@
1
+ import crypto from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import zlib from "node:zlib";
7
+
8
+ import { loadBuildchainConfig } from "./buildchain-config.js";
9
+ import { writePublicationArtifact } from "./publication-artifact.js";
10
+ import { preparePublicationNpmPackage } from "./publication-package.js";
11
+
12
+ export const PUBLICATION_REPRODUCIBILITY_RECEIPT_CONTRACT =
13
+ "kungfu-buildchain-publication-reproducibility-receipt";
14
+
15
+ const DEFAULT_OUTPUT = ".buildchain/publication/reproducibility-receipt.json";
16
+ const DEFAULT_REGISTRY_INPUT_DIR = ".buildchain/publication/registry-inputs";
17
+ const DEFAULT_REGISTRY_HYDRATION =
18
+ ".buildchain/publication/registry-hydration.json";
19
+ const DEFAULT_PACKAGE_DIR = ".buildchain/publication/npm-package";
20
+ const DEFAULT_PACKAGE_TARBALL_DIR = ".buildchain/publication/npm-tarball";
21
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
22
+
23
+ function toPosix(value) {
24
+ return String(value || "")
25
+ .split(path.sep)
26
+ .join("/");
27
+ }
28
+
29
+ function stableValue(value) {
30
+ if (Array.isArray(value)) {
31
+ return value.map(stableValue);
32
+ }
33
+ if (!value || typeof value !== "object") {
34
+ return value;
35
+ }
36
+ return Object.fromEntries(
37
+ Object.entries(value)
38
+ .filter(([, entry]) => entry !== undefined)
39
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
40
+ .map(([key, entry]) => [key, stableValue(entry)]),
41
+ );
42
+ }
43
+
44
+ function stableJson(value) {
45
+ return `${JSON.stringify(stableValue(value), null, 2)}\n`;
46
+ }
47
+
48
+ function sha256Buffer(value) {
49
+ return crypto.createHash("sha256").update(value).digest("hex");
50
+ }
51
+
52
+ function sha256File(filePath) {
53
+ return sha256Buffer(fs.readFileSync(filePath));
54
+ }
55
+
56
+ function digestValue(value) {
57
+ return `sha256:${sha256Buffer(Buffer.from(stableJson(value)))}`;
58
+ }
59
+
60
+ function run(
61
+ command,
62
+ args,
63
+ { cwd, env = process.env, label = [command, ...args].join(" ") } = {},
64
+ ) {
65
+ const result = spawnSync(command, args, {
66
+ cwd,
67
+ env,
68
+ encoding: "utf8",
69
+ stdio: ["ignore", "pipe", "pipe"],
70
+ });
71
+ if (result.error) {
72
+ throw new Error(`${label} could not start: ${result.error.message}`);
73
+ }
74
+ if (result.status !== 0) {
75
+ const detail = [result.stdout, result.stderr]
76
+ .filter(Boolean)
77
+ .join("\n")
78
+ .trim();
79
+ throw new Error(
80
+ `${label} failed with exit ${result.status}${detail ? `\n${detail}` : ""}`,
81
+ );
82
+ }
83
+ return String(result.stdout || "").trim();
84
+ }
85
+
86
+ function git(cwd, args, fallback) {
87
+ try {
88
+ return run("git", args, { cwd, label: `git ${args.join(" ")}` });
89
+ } catch (error) {
90
+ if (fallback !== undefined) {
91
+ return fallback;
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ function assertSafeRelativePath(relPath, label) {
98
+ const normalized = path.posix
99
+ .normalize(toPosix(relPath))
100
+ .replace(/^\.\/+/, "");
101
+ if (
102
+ !normalized ||
103
+ normalized === "." ||
104
+ normalized.startsWith("../") ||
105
+ path.posix.isAbsolute(normalized)
106
+ ) {
107
+ throw new Error(`${label} must be a safe repository-relative path`);
108
+ }
109
+ return normalized;
110
+ }
111
+
112
+ function fileFact(root, relPath) {
113
+ const normalized = assertSafeRelativePath(relPath, "artifact path");
114
+ const filePath = path.resolve(root, normalized);
115
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
116
+ throw new Error(`reproducibility output is missing: ${normalized}`);
117
+ }
118
+ return {
119
+ path: normalized,
120
+ bytes: fs.statSync(filePath).size,
121
+ sha256: sha256File(filePath),
122
+ };
123
+ }
124
+
125
+ function pdfMetadataFact(filePath) {
126
+ const text = fs.readFileSync(filePath).toString("latin1");
127
+ const values = (pattern) =>
128
+ [...text.matchAll(pattern)].map((match) => match[1]);
129
+ const ids = [...text.matchAll(/\/ID\s*\[\s*<([^>]*)>\s*<([^>]*)>\s*\]/g)].map(
130
+ (match) => [match[1], match[2]],
131
+ );
132
+ const metadata = {
133
+ creationDates: values(/\/CreationDate\s*\(([^)]*)\)/g),
134
+ modificationDates: values(/\/ModDate\s*\(([^)]*)\)/g),
135
+ documentIds: ids,
136
+ };
137
+ return {
138
+ ...metadata,
139
+ metadataRoot: digestValue(metadata),
140
+ };
141
+ }
142
+
143
+ function publicationArtifactFact(root, relPath) {
144
+ const fact = fileFact(root, relPath);
145
+ if (!fact.path.toLowerCase().endsWith(".pdf")) {
146
+ return fact;
147
+ }
148
+ return {
149
+ ...fact,
150
+ pdfMetadata: pdfMetadataFact(path.resolve(root, fact.path)),
151
+ };
152
+ }
153
+
154
+ function tarText(buffer, start, length) {
155
+ return buffer
156
+ .subarray(start, start + length)
157
+ .toString("utf8")
158
+ .replace(/\0.*$/s, "")
159
+ .trim();
160
+ }
161
+
162
+ function tarOctal(buffer, start, length) {
163
+ const value = tarText(buffer, start, length).replace(/\s/g, "");
164
+ return value ? Number.parseInt(value, 8) : 0;
165
+ }
166
+
167
+ function inspectSourceArchive(filePath, sourceDateEpoch) {
168
+ const tar = zlib.gunzipSync(fs.readFileSync(filePath));
169
+ const entries = [];
170
+ let offset = 0;
171
+ while (offset + 512 <= tar.length) {
172
+ const header = tar.subarray(offset, offset + 512);
173
+ if (header.every((byte) => byte === 0)) {
174
+ break;
175
+ }
176
+ const name = tarText(header, 0, 100);
177
+ const prefix = tarText(header, 345, 155);
178
+ const size = tarOctal(header, 124, 12);
179
+ const entry = {
180
+ path: toPosix(prefix ? `${prefix}/${name}` : name),
181
+ mode: tarOctal(header, 100, 8),
182
+ uid: tarOctal(header, 108, 8),
183
+ gid: tarOctal(header, 116, 8),
184
+ size,
185
+ mtime: tarOctal(header, 136, 12),
186
+ type: tarText(header, 156, 1) || "0",
187
+ };
188
+ entries.push(entry);
189
+ offset += 512 + Math.ceil(size / 512) * 512;
190
+ }
191
+ const archiveHeaders = entries.filter(
192
+ (entry) => entry.type === "g" || entry.path === "pax_global_header",
193
+ );
194
+ const contentEntries = entries.filter(
195
+ (entry) => entry.type !== "g" && entry.path !== "pax_global_header",
196
+ );
197
+ const paths = contentEntries.map((entry) => entry.path);
198
+ const expectedPaths = [...paths].sort();
199
+ const mtimes = [...new Set(entries.map((entry) => entry.mtime))];
200
+ const uids = [...new Set(entries.map((entry) => entry.uid))];
201
+ const gids = [...new Set(entries.map((entry) => entry.gid))];
202
+ const orderingNormalized =
203
+ JSON.stringify(paths) === JSON.stringify(expectedPaths);
204
+ const orderingDifferenceIndex = orderingNormalized
205
+ ? -1
206
+ : paths.findIndex((entry, index) => entry !== expectedPaths[index]);
207
+ const timestampsNormalized =
208
+ mtimes.length === 1 && String(mtimes[0]) === String(sourceDateEpoch);
209
+ const ownersNormalized =
210
+ uids.length === 1 && uids[0] === 0 && gids.length === 1 && gids[0] === 0;
211
+ const normalized =
212
+ entries.length > 0 &&
213
+ archiveHeaders.every(
214
+ (entry, index) => index === 0 && entry.path === "pax_global_header",
215
+ ) &&
216
+ orderingNormalized &&
217
+ timestampsNormalized &&
218
+ ownersNormalized;
219
+ return {
220
+ normalized,
221
+ orderingNormalized,
222
+ orderingDifference:
223
+ orderingDifferenceIndex === -1
224
+ ? null
225
+ : {
226
+ index: orderingDifferenceIndex,
227
+ actual: paths[orderingDifferenceIndex],
228
+ expected: expectedPaths[orderingDifferenceIndex],
229
+ },
230
+ timestampsNormalized,
231
+ ownersNormalized,
232
+ ordering: "optional-pax-header-then-lexicographic",
233
+ mtimePolicy: "source-date-epoch",
234
+ ownerPolicy: "root-zero",
235
+ entryCount: entries.length,
236
+ entries,
237
+ metadataRoot: digestValue(entries),
238
+ };
239
+ }
240
+
241
+ function listFileFacts(root, relPath) {
242
+ const normalized = assertSafeRelativePath(relPath, "directory path");
243
+ const target = path.resolve(root, normalized);
244
+ if (!fs.existsSync(target)) {
245
+ return [];
246
+ }
247
+ if (fs.statSync(target).isFile()) {
248
+ return [fileFact(root, normalized)];
249
+ }
250
+ return fs
251
+ .readdirSync(target, { withFileTypes: true })
252
+ .sort((left, right) =>
253
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
254
+ )
255
+ .flatMap((entry) => {
256
+ const child = path.posix.join(normalized, entry.name);
257
+ return entry.isDirectory()
258
+ ? listFileFacts(root, child)
259
+ : entry.isFile()
260
+ ? [fileFact(root, child)]
261
+ : [];
262
+ });
263
+ }
264
+
265
+ function ensureParent(filePath) {
266
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
267
+ }
268
+
269
+ function copyRelative(sourceRoot, targetRoot, relPath) {
270
+ const normalized = assertSafeRelativePath(relPath, "overlay path");
271
+ const source = path.resolve(sourceRoot, normalized);
272
+ if (!fs.existsSync(source)) {
273
+ return false;
274
+ }
275
+ const target = path.resolve(targetRoot, normalized);
276
+ ensureParent(target);
277
+ fs.cpSync(source, target, {
278
+ recursive: fs.statSync(source).isDirectory(),
279
+ force: true,
280
+ });
281
+ return true;
282
+ }
283
+
284
+ function sourceFacts(cwd, requestedSha = "") {
285
+ const canonicalCwd = fs.realpathSync(cwd);
286
+ const repositoryRoot = fs.realpathSync(
287
+ git(canonicalCwd, ["rev-parse", "--show-toplevel"]),
288
+ );
289
+ const projectPath =
290
+ toPosix(path.relative(repositoryRoot, canonicalCwd)) || ".";
291
+ const head = git(repositoryRoot, ["rev-parse", "HEAD"]);
292
+ const sha = git(repositoryRoot, [
293
+ "rev-parse",
294
+ `${requestedSha || head}^{commit}`,
295
+ ]);
296
+ if (sha !== head) {
297
+ throw new Error(
298
+ `reproducibility source must be the checked-out HEAD: requested ${sha}, HEAD is ${head}`,
299
+ );
300
+ }
301
+ const trackedStatus = git(repositoryRoot, [
302
+ "status",
303
+ "--porcelain",
304
+ "--untracked-files=no",
305
+ ]);
306
+ if (trackedStatus) {
307
+ throw new Error(
308
+ "reproducibility source has tracked working-tree changes; commit the exact admitted source before building",
309
+ );
310
+ }
311
+ const sourceDateEpoch = git(repositoryRoot, [
312
+ "show",
313
+ "-s",
314
+ "--format=%ct",
315
+ sha,
316
+ ]);
317
+ if (!/^\d+$/.test(sourceDateEpoch)) {
318
+ throw new Error(
319
+ "reproducibility source commit has no valid commit timestamp",
320
+ );
321
+ }
322
+ return {
323
+ repository:
324
+ git(repositoryRoot, ["config", "--get", "remote.origin.url"], "") ||
325
+ "local-git-source",
326
+ repositoryRoot,
327
+ projectPath,
328
+ sha,
329
+ treeSha: git(repositoryRoot, ["rev-parse", `${sha}^{tree}`]),
330
+ sourceDateEpoch,
331
+ };
332
+ }
333
+
334
+ function toolchainFacts(config) {
335
+ const publication = config.publication || {};
336
+ const declared = publication.toolchain || {};
337
+ const type = String(
338
+ process.env.BUILDCHAIN_PUBLICATION_TOOLCHAIN_TYPE ||
339
+ declared.type ||
340
+ "custom-command",
341
+ ).trim();
342
+ const facts = {
343
+ type,
344
+ image: String(
345
+ process.env.BUILDCHAIN_PUBLICATION_TOOLCHAIN_IMAGE ||
346
+ declared.image ||
347
+ "",
348
+ ).trim(),
349
+ digest: String(
350
+ process.env.BUILDCHAIN_PUBLICATION_TOOLCHAIN_DIGEST ||
351
+ declared.digest ||
352
+ "",
353
+ ).trim(),
354
+ command: String(
355
+ process.env.BUILDCHAIN_PUBLICATION_TOOLCHAIN_COMMAND ||
356
+ declared.command ||
357
+ "",
358
+ ).trim(),
359
+ };
360
+ if (!["latex-docker", "custom-command"].includes(facts.type)) {
361
+ throw new Error(
362
+ `unsupported publication reproducibility toolchain: ${facts.type}`,
363
+ );
364
+ }
365
+ if (!facts.command) {
366
+ throw new Error("publication reproducibility requires a toolchain command");
367
+ }
368
+ if (facts.type === "latex-docker") {
369
+ if (!facts.image) {
370
+ throw new Error(
371
+ "latex-docker publication reproducibility requires a toolchain image",
372
+ );
373
+ }
374
+ if (!/^sha256:[0-9a-f]{64}$/i.test(facts.digest)) {
375
+ throw new Error(
376
+ "latex-docker publication reproducibility requires an exact sha256 image digest",
377
+ );
378
+ }
379
+ }
380
+ const machineVerifiable =
381
+ facts.type === "latex-docker" &&
382
+ Boolean(facts.image && facts.digest && facts.command);
383
+ return {
384
+ ...facts,
385
+ imageRef:
386
+ facts.type === "latex-docker" ? `${facts.image}@${facts.digest}` : "",
387
+ machineVerifiable,
388
+ identityRoot: digestValue({
389
+ type: facts.type,
390
+ image: facts.image,
391
+ digest: facts.digest,
392
+ command: facts.command,
393
+ }),
394
+ };
395
+ }
396
+
397
+ function cloneSource({ cwd, cloneRoot, source, overlayPaths }) {
398
+ run(
399
+ "git",
400
+ [
401
+ "clone",
402
+ "--quiet",
403
+ "--no-hardlinks",
404
+ "--no-checkout",
405
+ source.repositoryRoot,
406
+ cloneRoot,
407
+ ],
408
+ {
409
+ cwd: path.dirname(cloneRoot),
410
+ label: "independent local git clone",
411
+ },
412
+ );
413
+ git(cloneRoot, ["checkout", "--quiet", "--detach", source.sha]);
414
+ if (source.repository !== "local-git-source") {
415
+ git(cloneRoot, ["remote", "set-url", "origin", source.repository]);
416
+ }
417
+ const projectRoot =
418
+ source.projectPath === "."
419
+ ? cloneRoot
420
+ : path.resolve(cloneRoot, source.projectPath);
421
+ if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) {
422
+ throw new Error(
423
+ `publication project path is missing from the source commit: ${source.projectPath}`,
424
+ );
425
+ }
426
+ for (const relPath of overlayPaths) {
427
+ copyRelative(cwd, projectRoot, relPath);
428
+ }
429
+ return projectRoot;
430
+ }
431
+
432
+ function isolatedEnvironment(buildRoot, source, toolchain) {
433
+ const home = path.join(
434
+ buildRoot,
435
+ ".buildchain",
436
+ "reproducibility-runtime",
437
+ "home",
438
+ );
439
+ const cache = path.join(
440
+ buildRoot,
441
+ ".buildchain",
442
+ "reproducibility-runtime",
443
+ "npm-cache",
444
+ );
445
+ fs.mkdirSync(home, { recursive: true });
446
+ fs.mkdirSync(cache, { recursive: true });
447
+ return {
448
+ ...process.env,
449
+ HOME: home,
450
+ XDG_CACHE_HOME: path.join(home, ".cache"),
451
+ npm_config_cache: cache,
452
+ npm_config_ignore_scripts: "true",
453
+ SOURCE_DATE_EPOCH: source.sourceDateEpoch,
454
+ TZ: "UTC",
455
+ LANG: "C.UTF-8",
456
+ LC_ALL: "C.UTF-8",
457
+ BUILDCHAIN_PUBLICATION_TOOLCHAIN_TYPE: toolchain.type,
458
+ BUILDCHAIN_PUBLICATION_TOOLCHAIN_IMAGE: toolchain.image,
459
+ BUILDCHAIN_PUBLICATION_TOOLCHAIN_DIGEST: toolchain.digest,
460
+ BUILDCHAIN_PUBLICATION_TOOLCHAIN_COMMAND: toolchain.command,
461
+ };
462
+ }
463
+
464
+ function executeToolchain({ buildRoot, source, toolchain }) {
465
+ const env = isolatedEnvironment(buildRoot, source, toolchain);
466
+ if (toolchain.type === "latex-docker") {
467
+ const dockerArgs = [
468
+ "run",
469
+ "--rm",
470
+ "--network=none",
471
+ "--env",
472
+ `SOURCE_DATE_EPOCH=${source.sourceDateEpoch}`,
473
+ "--env",
474
+ "TZ=UTC",
475
+ "--env",
476
+ "LANG=C.UTF-8",
477
+ "--env",
478
+ "LC_ALL=C.UTF-8",
479
+ "--env",
480
+ "HOME=/tmp",
481
+ "--volume",
482
+ `${buildRoot}:/workspace`,
483
+ "--workdir",
484
+ "/workspace",
485
+ toolchain.imageRef,
486
+ "bash",
487
+ "-lc",
488
+ toolchain.command,
489
+ ];
490
+ run("docker", dockerArgs, {
491
+ cwd: buildRoot,
492
+ env,
493
+ label: `pinned publication toolchain ${toolchain.imageRef}`,
494
+ });
495
+ return;
496
+ }
497
+ run("bash", ["-lc", toolchain.command], {
498
+ cwd: buildRoot,
499
+ env,
500
+ label: "custom publication toolchain command",
501
+ });
502
+ }
503
+
504
+ function registryInputs(buildRoot) {
505
+ const inputDir = path.resolve(buildRoot, DEFAULT_REGISTRY_INPUT_DIR);
506
+ if (!fs.existsSync(inputDir)) {
507
+ return [];
508
+ }
509
+ return fs
510
+ .readdirSync(inputDir)
511
+ .filter((entry) => entry.endsWith(".json"))
512
+ .sort()
513
+ .map((entry) => path.join(inputDir, entry));
514
+ }
515
+
516
+ function withPublicationEnvironment(source, toolchain, callback) {
517
+ const updates = {
518
+ SOURCE_DATE_EPOCH: source.sourceDateEpoch,
519
+ TZ: "UTC",
520
+ LANG: "C.UTF-8",
521
+ LC_ALL: "C.UTF-8",
522
+ BUILDCHAIN_PUBLICATION_TOOLCHAIN_TYPE: toolchain.type,
523
+ BUILDCHAIN_PUBLICATION_TOOLCHAIN_IMAGE: toolchain.image,
524
+ BUILDCHAIN_PUBLICATION_TOOLCHAIN_DIGEST: toolchain.digest,
525
+ BUILDCHAIN_PUBLICATION_TOOLCHAIN_COMMAND: toolchain.command,
526
+ };
527
+ const previous = Object.fromEntries(
528
+ Object.keys(updates).map((key) => [key, process.env[key]]),
529
+ );
530
+ Object.assign(process.env, updates);
531
+ try {
532
+ return callback();
533
+ } finally {
534
+ for (const [key, value] of Object.entries(previous)) {
535
+ if (value === undefined) {
536
+ delete process.env[key];
537
+ } else {
538
+ process.env[key] = value;
539
+ }
540
+ }
541
+ }
542
+ }
543
+
544
+ function packPublication({ buildRoot, packageDir, source, toolchain }) {
545
+ const packDir = path.join(
546
+ buildRoot,
547
+ ".buildchain",
548
+ "reproducibility-runtime",
549
+ "pack",
550
+ );
551
+ fs.mkdirSync(packDir, { recursive: true });
552
+ const output = run(
553
+ "npm",
554
+ [
555
+ "pack",
556
+ "--json",
557
+ "--ignore-scripts",
558
+ "--pack-destination",
559
+ packDir,
560
+ `--registry=${DEFAULT_REGISTRY}`,
561
+ ],
562
+ {
563
+ cwd: packageDir,
564
+ env: isolatedEnvironment(buildRoot, source, toolchain),
565
+ label: "npm pack reproducibility candidate",
566
+ },
567
+ );
568
+ const parsed = JSON.parse(output);
569
+ const result = Array.isArray(parsed) ? parsed[0] : parsed;
570
+ if (!result?.filename || !result?.integrity || !result?.shasum) {
571
+ throw new Error(
572
+ "npm pack reproducibility candidate did not report filename, integrity, and shasum",
573
+ );
574
+ }
575
+ const tarballPath = path.resolve(packDir, result.filename);
576
+ if (!fs.existsSync(tarballPath)) {
577
+ throw new Error(
578
+ `npm pack reproducibility candidate is missing: ${result.filename}`,
579
+ );
580
+ }
581
+ return {
582
+ name: result.name,
583
+ version: result.version,
584
+ filename: result.filename,
585
+ integrity: result.integrity,
586
+ shasum: result.shasum,
587
+ bytes: fs.statSync(tarballPath).size,
588
+ sha256: sha256File(tarballPath),
589
+ tarballPath,
590
+ };
591
+ }
592
+
593
+ function comparableEntry(kind, fact) {
594
+ return {
595
+ key: `${kind}:${fact.path}`,
596
+ kind,
597
+ path: fact.path,
598
+ bytes: fact.bytes,
599
+ sha256: fact.sha256,
600
+ };
601
+ }
602
+
603
+ function buildOnce({
604
+ buildId,
605
+ cloneRoot,
606
+ cwd,
607
+ source,
608
+ toolchain,
609
+ overlayPaths,
610
+ packageName,
611
+ }) {
612
+ const buildRoot = cloneSource({
613
+ cwd,
614
+ cloneRoot,
615
+ source,
616
+ overlayPaths,
617
+ });
618
+ executeToolchain({ buildRoot, source, toolchain });
619
+ const generatedAt = new Date(
620
+ Number(source.sourceDateEpoch) * 1000,
621
+ ).toISOString();
622
+ const publication = withPublicationEnvironment(source, toolchain, () =>
623
+ writePublicationArtifact({
624
+ cwd: buildRoot,
625
+ sourceSha: source.sha,
626
+ generatedAt,
627
+ registryInputs: registryInputs(buildRoot),
628
+ }),
629
+ );
630
+ const packageManifest = preparePublicationNpmPackage({
631
+ cwd: buildRoot,
632
+ outputDir: DEFAULT_PACKAGE_DIR,
633
+ packageName,
634
+ });
635
+ const packageDir = path.resolve(buildRoot, packageManifest.outputDir);
636
+ const npmPackage = packPublication({
637
+ buildRoot,
638
+ packageDir,
639
+ source,
640
+ toolchain,
641
+ });
642
+ const artifactFacts = publication.manifest.artifacts.map((entry) =>
643
+ publicationArtifactFact(buildRoot, entry.path),
644
+ );
645
+ const publicationFacts = [
646
+ fileFact(buildRoot, publication.manifestPath),
647
+ fileFact(buildRoot, publication.passportPath),
648
+ ...(publication.registryPath
649
+ ? [fileFact(buildRoot, publication.registryPath)]
650
+ : []),
651
+ ];
652
+ const sourceBundle = publication.manifest.source.sourceBundle
653
+ ? fileFact(buildRoot, publication.manifest.source.sourceBundle.path)
654
+ : undefined;
655
+ const sourceBundleArchive = sourceBundle
656
+ ? inspectSourceArchive(
657
+ path.resolve(buildRoot, sourceBundle.path),
658
+ source.sourceDateEpoch,
659
+ )
660
+ : undefined;
661
+ if (sourceBundleArchive && !sourceBundleArchive.normalized) {
662
+ throw new Error(
663
+ `source bundle archive metadata is not normalized: ordering=${sourceBundleArchive.orderingNormalized}, timestamps=${sourceBundleArchive.timestampsNormalized}, owners=${sourceBundleArchive.ownersNormalized}, orderingDifference=${JSON.stringify(sourceBundleArchive.orderingDifference)}`,
664
+ );
665
+ }
666
+ const packageFiles = listFileFacts(buildRoot, packageManifest.outputDir);
667
+ const comparable = [
668
+ ...artifactFacts.map((fact) => comparableEntry("artifact", fact)),
669
+ ...(sourceBundle ? [comparableEntry("source-bundle", sourceBundle)] : []),
670
+ ...publicationFacts.map((fact) =>
671
+ comparableEntry("publication-evidence", fact),
672
+ ),
673
+ ...packageFiles.map((fact) =>
674
+ comparableEntry("npm-package-file", {
675
+ ...fact,
676
+ path: toPosix(path.relative(packageManifest.outputDir, fact.path)),
677
+ }),
678
+ ),
679
+ {
680
+ key: `npm-tarball:${npmPackage.filename}`,
681
+ kind: "npm-tarball",
682
+ path: npmPackage.filename,
683
+ bytes: npmPackage.bytes,
684
+ sha256: npmPackage.sha256,
685
+ integrity: npmPackage.integrity,
686
+ shasum: npmPackage.shasum,
687
+ },
688
+ ];
689
+ return {
690
+ buildId,
691
+ workspace: {
692
+ class: "independent-local-git-clone",
693
+ cache: "isolated-per-build",
694
+ network:
695
+ toolchain.type === "latex-docker"
696
+ ? "disabled-during-build"
697
+ : "caller-command-boundary",
698
+ },
699
+ artifacts: artifactFacts,
700
+ sourceBundle,
701
+ sourceBundleArchive,
702
+ publication: {
703
+ manifestPath: publication.manifestPath,
704
+ passportPath: publication.passportPath,
705
+ registryPath: publication.registryPath || "",
706
+ },
707
+ publicationEvidence: publicationFacts,
708
+ npmPackage: {
709
+ name: npmPackage.name,
710
+ version: npmPackage.version,
711
+ filename: npmPackage.filename,
712
+ integrity: npmPackage.integrity,
713
+ shasum: npmPackage.shasum,
714
+ bytes: npmPackage.bytes,
715
+ sha256: npmPackage.sha256,
716
+ },
717
+ packageFiles,
718
+ comparable,
719
+ outputSetRoot: digestValue(comparable),
720
+ promotion: {
721
+ artifactPaths: artifactFacts.map((entry) => entry.path),
722
+ publicationPath: ".buildchain/publication",
723
+ npmTarball: {
724
+ sourcePath: toPosix(path.relative(buildRoot, npmPackage.tarballPath)),
725
+ targetPath: path.posix.join(DEFAULT_PACKAGE_TARBALL_DIR, npmPackage.filename),
726
+ },
727
+ },
728
+ };
729
+ }
730
+
731
+ function firstDifference(leftBuild, rightBuild) {
732
+ const left = new Map(leftBuild.comparable.map((entry) => [entry.key, entry]));
733
+ const right = new Map(
734
+ rightBuild.comparable.map((entry) => [entry.key, entry]),
735
+ );
736
+ const keys = [...new Set([...left.keys(), ...right.keys()])].sort();
737
+ for (const key of keys) {
738
+ const leftEntry = left.get(key);
739
+ const rightEntry = right.get(key);
740
+ if (!leftEntry || !rightEntry) {
741
+ return {
742
+ key,
743
+ kind: leftEntry?.kind || rightEntry?.kind || "unknown",
744
+ path: leftEntry?.path || rightEntry?.path || "",
745
+ reason: leftEntry
746
+ ? "missing-from-second-build"
747
+ : "missing-from-first-build",
748
+ firstBuild: leftEntry || null,
749
+ secondBuild: rightEntry || null,
750
+ };
751
+ }
752
+ for (const field of ["bytes", "sha256", "integrity", "shasum"]) {
753
+ if ((leftEntry[field] || "") !== (rightEntry[field] || "")) {
754
+ return {
755
+ key,
756
+ kind: leftEntry.kind,
757
+ path: leftEntry.path,
758
+ field,
759
+ reason: "field-mismatch",
760
+ firstBuild: leftEntry[field] ?? null,
761
+ secondBuild: rightEntry[field] ?? null,
762
+ };
763
+ }
764
+ }
765
+ }
766
+ return null;
767
+ }
768
+
769
+ function receipt({ source, toolchain, builds, difference, error }) {
770
+ const { repositoryRoot: _repositoryRoot, ...receiptSource } = source;
771
+ const comparisonPassed = builds.length === 2 && !difference && !error;
772
+ const issues = [];
773
+ if (error) {
774
+ issues.push({
775
+ code: "clean-build-failed",
776
+ message: error.message,
777
+ });
778
+ }
779
+ if (difference) {
780
+ issues.push({
781
+ code: "publication-bytes-differ",
782
+ message: `first difference ${difference.key}${
783
+ difference.field ? ` at ${difference.field}` : ""
784
+ }`,
785
+ });
786
+ }
787
+ if (!toolchain.machineVerifiable) {
788
+ issues.push({
789
+ code: "toolchain-not-machine-verifiable",
790
+ message:
791
+ "custom-command builds may diagnose byte drift but cannot qualify publication admission; use a digest-pinned latex-docker toolchain",
792
+ });
793
+ }
794
+ const payload = {
795
+ schemaVersion: 1,
796
+ contract: PUBLICATION_REPRODUCIBILITY_RECEIPT_CONTRACT,
797
+ status: comparisonPassed ? "passed" : "failed",
798
+ qualifying: comparisonPassed && toolchain.machineVerifiable,
799
+ source: receiptSource,
800
+ toolchain,
801
+ policy: {
802
+ independentBuilds: 2,
803
+ sourceCheckout: "independent-local-git-clone",
804
+ sourceTimestamp: "git-commit-time",
805
+ timezone: "UTC",
806
+ locale: "C.UTF-8",
807
+ npmCache: "isolated-per-build",
808
+ npmScripts: "disabled",
809
+ artifactComparison: "exact-bytes",
810
+ admission: "fail-closed-before-alpha-or-release",
811
+ },
812
+ builds: builds.map(
813
+ ({ comparable: _comparable, promotion: _promotion, ...entry }) => entry,
814
+ ),
815
+ comparison: {
816
+ status: comparisonPassed ? "identical" : "different",
817
+ firstDifference: difference || null,
818
+ },
819
+ issues,
820
+ };
821
+ return {
822
+ ...payload,
823
+ receiptDigest: digestValue(payload),
824
+ };
825
+ }
826
+
827
+ function writeReceipt(cwd, output, value) {
828
+ const normalized = assertSafeRelativePath(
829
+ output || DEFAULT_OUTPUT,
830
+ "reproducibility receipt output",
831
+ );
832
+ const target = path.resolve(cwd, normalized);
833
+ ensureParent(target);
834
+ fs.writeFileSync(target, stableJson(value));
835
+ return normalized;
836
+ }
837
+
838
+ function promoteBuild(cwd, buildRoot, build) {
839
+ for (const relPath of build.promotion.artifactPaths) {
840
+ const source = path.resolve(buildRoot, relPath);
841
+ const target = path.resolve(cwd, relPath);
842
+ ensureParent(target);
843
+ fs.copyFileSync(source, target);
844
+ }
845
+ const publicationPath = assertSafeRelativePath(
846
+ build.promotion.publicationPath,
847
+ "publication promotion path",
848
+ );
849
+ const sourcePublication = path.resolve(buildRoot, publicationPath);
850
+ const targetPublication = path.resolve(cwd, publicationPath);
851
+ fs.rmSync(targetPublication, { recursive: true, force: true });
852
+ ensureParent(targetPublication);
853
+ fs.cpSync(sourcePublication, targetPublication, {
854
+ recursive: true,
855
+ force: true,
856
+ });
857
+ const npmTarballSource = path.resolve(
858
+ buildRoot,
859
+ assertSafeRelativePath(build.promotion.npmTarball.sourcePath, "npm tarball promotion source"),
860
+ );
861
+ const npmTarballTarget = path.resolve(
862
+ cwd,
863
+ assertSafeRelativePath(build.promotion.npmTarball.targetPath, "npm tarball promotion target"),
864
+ );
865
+ ensureParent(npmTarballTarget);
866
+ fs.copyFileSync(npmTarballSource, npmTarballTarget);
867
+ }
868
+
869
+ export function verifyPublicationReproducibility({
870
+ cwd = process.cwd(),
871
+ sourceSha = "",
872
+ output = DEFAULT_OUTPUT,
873
+ promote = false,
874
+ keepWorkspaces = false,
875
+ pullToolchain = true,
876
+ packageName = "",
877
+ allowUnpinnedToolchain = false,
878
+ overlayPaths = [DEFAULT_REGISTRY_INPUT_DIR, DEFAULT_REGISTRY_HYDRATION],
879
+ } = {}) {
880
+ const resolvedCwd = path.resolve(cwd);
881
+ const loaded = loadBuildchainConfig(resolvedCwd);
882
+ if (loaded.config.project?.type !== "publication-artifact") {
883
+ throw new Error(
884
+ 'publication reproducibility requires project.type = "publication-artifact"',
885
+ );
886
+ }
887
+ const source = sourceFacts(resolvedCwd, sourceSha);
888
+ const toolchain = toolchainFacts(loaded.config);
889
+ if (toolchain.type === "latex-docker" && pullToolchain) {
890
+ run("docker", ["pull", toolchain.imageRef], {
891
+ cwd: resolvedCwd,
892
+ label: `pull pinned publication toolchain ${toolchain.imageRef}`,
893
+ });
894
+ }
895
+ const tempRoot = fs.mkdtempSync(
896
+ path.join(os.tmpdir(), "buildchain-publication-reproducibility-"),
897
+ );
898
+ const builds = [];
899
+ let error;
900
+ try {
901
+ for (let index = 0; index < 2; index += 1) {
902
+ const cloneRoot = path.join(tempRoot, `build-${index + 1}`);
903
+ try {
904
+ builds.push(
905
+ buildOnce({
906
+ buildId: `build-${index + 1}`,
907
+ cloneRoot,
908
+ cwd: resolvedCwd,
909
+ source,
910
+ toolchain,
911
+ overlayPaths,
912
+ packageName,
913
+ }),
914
+ );
915
+ } catch (buildError) {
916
+ error = buildError;
917
+ break;
918
+ }
919
+ }
920
+ const difference =
921
+ builds.length === 2 ? firstDifference(builds[0], builds[1]) : null;
922
+ const result = receipt({
923
+ source,
924
+ toolchain,
925
+ builds,
926
+ difference,
927
+ error,
928
+ });
929
+ if (
930
+ promote &&
931
+ result.status === "passed" &&
932
+ (result.qualifying || allowUnpinnedToolchain)
933
+ ) {
934
+ const firstProjectRoot =
935
+ source.projectPath === "."
936
+ ? path.join(tempRoot, "build-1")
937
+ : path.join(tempRoot, "build-1", source.projectPath);
938
+ promoteBuild(resolvedCwd, firstProjectRoot, builds[0]);
939
+ }
940
+ result.outputPath = writeReceipt(resolvedCwd, output, result);
941
+ return result;
942
+ } finally {
943
+ if (!keepWorkspaces) {
944
+ fs.rmSync(tempRoot, { recursive: true, force: true });
945
+ }
946
+ }
947
+ }