@kungfu-tech/buildchain 3.0.3-alpha.0 → 3.0.3-alpha.2

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 (115) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +32 -1
  4. package/actions/promote-buildchain-ref/README.md +90 -19
  5. package/actions/report-buildchain-issue/README.md +19 -1
  6. package/actions/run-lifecycle/README.md +5 -0
  7. package/actions/validate-config/README.md +19 -1
  8. package/bin/buildchain.mjs +41 -241
  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 +0 -45
  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 +161 -42
  19. package/dist/site/buildchain-site.json +180 -84
  20. package/dist/site/capability-registry.json +6 -6
  21. package/dist/site/cli-registry.json +932 -98
  22. package/dist/site/controller-registry.json +97 -5
  23. package/dist/site/kfd-claims.json +316 -23
  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 +371 -56
  27. package/dist/site/page-registry.json +155 -59
  28. package/dist/site/public-surface-audit.json +286 -24
  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 +570 -79
  33. package/docs/MAP.md +22 -4
  34. package/docs/auditable-demo.md +35 -12
  35. package/docs/aws-us-elastic-runner-burst-plane.md +381 -0
  36. package/docs/cli.md +143 -12
  37. package/docs/consumer-issue-reporting.md +19 -1
  38. package/docs/github-governance-authority.md +2 -2
  39. package/docs/lifecycle-protocol.md +20 -2
  40. package/docs/migration-inventory.md +24 -5
  41. package/docs/ownership.md +19 -1
  42. package/docs/publication-artifacts.md +185 -18
  43. package/docs/publication-authority.md +4 -4
  44. package/docs/publish-transaction.md +78 -31
  45. package/docs/release-candidate.md +24 -0
  46. package/docs/release-flow.md +50 -32
  47. package/docs/release-governance.md +31 -31
  48. package/docs/release-passport.md +4 -0
  49. package/docs/release-propagation.md +57 -12
  50. package/docs/reusable-build-surface.md +95 -27
  51. package/docs/runtime-train-validation.md +20 -2
  52. package/docs/stable-candidate-patrol.md +6 -6
  53. package/docs/toolkit-observability.md +22 -0
  54. package/docs/versioning.md +6 -4
  55. package/docs/web-surface-deployments.md +62 -0
  56. package/package.json +7 -5
  57. package/packages/core/README.md +61 -4
  58. package/packages/core/buildchain-contract.js +8 -4
  59. package/packages/core/cache-evidence.js +1 -0
  60. package/packages/core/diagnostics.js +105 -30
  61. package/packages/core/github-governance-authority.js +10 -25
  62. package/packages/core/index.js +40 -0
  63. package/packages/core/paper-fleet.js +260 -0
  64. package/packages/core/paper-repository.js +210 -0
  65. package/packages/core/paper-work.js +365 -0
  66. package/packages/core/paper.js +3180 -0
  67. package/packages/core/public-surface-audit.js +26 -75
  68. package/packages/core/public-surface-cli.js +117 -0
  69. package/packages/core/publication-artifact.js +1 -6
  70. package/packages/core/publication-authority.js +1 -0
  71. package/packages/core/publication-reproducibility.js +947 -0
  72. package/packages/core/publication-sealed-bundle.js +190 -0
  73. package/packages/core/publish-transaction.js +147 -17
  74. package/packages/core/release-candidate.js +234 -0
  75. package/packages/core/release-passport.js +483 -201
  76. package/packages/core/release-propagation.js +175 -1
  77. package/scripts/auditable-demo.mjs +119 -5
  78. package/scripts/aws-codebuild-toolchain.mjs +285 -0
  79. package/scripts/aws-macos-jit-core.mjs +378 -0
  80. package/scripts/aws-macos-jit.mjs +123 -0
  81. package/scripts/aws-runner-burst-core.mjs +248 -0
  82. package/scripts/aws-runner-burst.mjs +79 -0
  83. package/scripts/aws-windows-jit-core.mjs +374 -0
  84. package/scripts/aws-windows-jit.mjs +121 -0
  85. package/scripts/build-contract-core.mjs +69 -6
  86. package/scripts/build-standalone-binary.mjs +23 -0
  87. package/scripts/buildchain-cli-help.mjs +256 -0
  88. package/scripts/buildchain-contract-lock.mjs +1 -1
  89. package/scripts/check-internal-architecture.mjs +69 -2
  90. package/scripts/check-inventory.mjs +69 -2
  91. package/scripts/check-javascript-syntax.mjs +31 -0
  92. package/scripts/check-maintainability.mjs +371 -0
  93. package/scripts/compiler-cache-evidence.mjs +221 -0
  94. package/scripts/dev-alpha-candidate-patrol.mjs +268 -43
  95. package/scripts/finalize-native-artifact-signing-result.mjs +75 -4
  96. package/scripts/generate-channel-promotion-workflow.mjs +3 -12
  97. package/scripts/generate-release-candidate-passport.mjs +4 -0
  98. package/scripts/generate-site-bundle.mjs +68 -188
  99. package/scripts/import-artifact-signing-results.mjs +64 -5
  100. package/scripts/inspect-artifact-signing-requests.mjs +7 -0
  101. package/scripts/maintainability-metrics.mjs +339 -0
  102. package/scripts/npm-publish-transaction.mjs +57 -6
  103. package/scripts/paper-work-fleet-cli.mjs +563 -0
  104. package/scripts/paper.mjs +631 -0
  105. package/scripts/promotion-channel-router.mjs +16 -4
  106. package/scripts/publication-reproducibility.mjs +62 -0
  107. package/scripts/release-propagation.mjs +39 -0
  108. package/scripts/resolve-build-contract.mjs +6 -0
  109. package/scripts/route-offline-runners.mjs +236 -0
  110. package/scripts/run-lifecycle-core.mjs +62 -1
  111. package/scripts/runtime-ref-core.mjs +23 -2
  112. package/scripts/seal-artifact-signing-requests.mjs +13 -6
  113. package/scripts/site-capability-metadata.mjs +202 -0
  114. package/scripts/stable-candidate-qualification.mjs +42 -0
  115. package/scripts/web-surface-core.mjs +272 -86
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { pathToFileURL } from "node:url";
7
+ import {
8
+ analyzeJavaScript,
9
+ collectMaintainabilityMetrics,
10
+ isHandMaintainedSource,
11
+ } from "./maintainability-metrics.mjs";
12
+
13
+ function readJson(root, file) {
14
+ return JSON.parse(fs.readFileSync(path.join(root, file), "utf8"));
15
+ }
16
+
17
+ function gitOutput(root, args) {
18
+ return execFileSync("git", args, { cwd: root, encoding: "utf8" });
19
+ }
20
+
21
+ function revisionAvailable(root, revision) {
22
+ try {
23
+ execFileSync("git", ["cat-file", "-e", `${revision}^{commit}`], {
24
+ cwd: root,
25
+ stdio: "ignore",
26
+ });
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function ensureRevisionAvailable(root, revision) {
34
+ if (revisionAvailable(root, revision)) return false;
35
+ try {
36
+ gitOutput(root, ["fetch", "--no-tags", "--depth=1", "origin", revision]);
37
+ } catch (error) {
38
+ const detail = String(error?.stderr || error?.message || error).trim();
39
+ throw new Error(
40
+ `maintainability revision ${revision} is unavailable and could not be fetched from origin${detail ? `: ${detail}` : ""}`,
41
+ );
42
+ }
43
+ if (!revisionAvailable(root, revision)) {
44
+ throw new Error(
45
+ `maintainability revision ${revision} is unavailable after a successful origin fetch`,
46
+ );
47
+ }
48
+ return true;
49
+ }
50
+
51
+ function ensureMaintainabilityRevisionsAvailable(
52
+ root,
53
+ { baselineRevision, enforcementRevision },
54
+ ) {
55
+ return Object.fromEntries(
56
+ [...new Set([baselineRevision, enforcementRevision])].map((revision) => [
57
+ revision,
58
+ ensureRevisionAvailable(root, revision),
59
+ ]),
60
+ );
61
+ }
62
+
63
+ function sourceMetricsAtRevision(root, revision) {
64
+ const files = gitOutput(root, ["ls-tree", "-r", "--name-only", revision])
65
+ .split("\n")
66
+ .filter(isHandMaintainedSource)
67
+ .sort();
68
+ return Object.fromEntries(
69
+ files.map((file) => [
70
+ file,
71
+ analyzeJavaScript(file, gitOutput(root, ["show", `${revision}:${file}`])),
72
+ ]),
73
+ );
74
+ }
75
+
76
+ function maximumFunction(metrics, field) {
77
+ return Math.max(
78
+ 0,
79
+ ...(metrics?.functions || []).map((entry) => entry[field]),
80
+ );
81
+ }
82
+
83
+ function selectedFunction(metrics, file, name) {
84
+ return metrics.files[file]?.functions.find((entry) => entry.name === name);
85
+ }
86
+
87
+ function readJsonAtRevision(root, revision, file) {
88
+ return JSON.parse(gitOutput(root, ["show", `${revision}:${file}`]));
89
+ }
90
+
91
+ function publicSurfaceContract(entry, kind) {
92
+ if (kind === "cli") return { id: entry.id, usage: entry.usage };
93
+ if (kind === "node")
94
+ return {
95
+ export: entry.export,
96
+ specifier: entry.specifier,
97
+ target: entry.target,
98
+ };
99
+ return {
100
+ id: entry.id,
101
+ path: entry.path,
102
+ reusable: entry.reusable,
103
+ inputs: entry.inputs || [],
104
+ secrets: entry.secrets || [],
105
+ outputs: entry.outputs || [],
106
+ };
107
+ }
108
+
109
+ function evaluatePublicSurface({ root, revision, policy }) {
110
+ const issues = [];
111
+ const lifecycleFields = policy.publicSurfacePolicy.requiredLifecycleFields;
112
+ const capabilityGroups = new Set(
113
+ readJson(root, "dist/site/capability-registry.json").groups.map(
114
+ (entry) => entry.id,
115
+ ),
116
+ );
117
+ const definitions = [
118
+ {
119
+ file: "dist/site/cli-registry.json",
120
+ collection: "commands",
121
+ kind: "cli",
122
+ key: "id",
123
+ },
124
+ {
125
+ file: "dist/site/node-api-registry.json",
126
+ collection: "exports",
127
+ kind: "node",
128
+ key: "export",
129
+ },
130
+ {
131
+ file: "dist/site/workflow-registry.json",
132
+ collection: "workflows",
133
+ kind: "workflow",
134
+ key: "id",
135
+ },
136
+ {
137
+ file: "dist/site/workflow-registry.json",
138
+ collection: "actions",
139
+ kind: "action",
140
+ key: "id",
141
+ },
142
+ ];
143
+ for (const definition of definitions) {
144
+ const current =
145
+ readJson(root, definition.file)[definition.collection] || [];
146
+ const baseline =
147
+ readJsonAtRevision(root, revision, definition.file)[
148
+ definition.collection
149
+ ] || [];
150
+ const baselineByKey = new Map(
151
+ baseline.map((entry) => [entry[definition.key], entry]),
152
+ );
153
+ for (const entry of current) {
154
+ const label = `${definition.kind}:${entry[definition.key]}`;
155
+ for (const field of lifecycleFields) {
156
+ if (
157
+ !Object.prototype.hasOwnProperty.call(entry, field) ||
158
+ typeof entry[field] !== "string"
159
+ ) {
160
+ issues.push(`${label}: lifecycle field ${field} is missing`);
161
+ }
162
+ }
163
+ if (!capabilityGroups.has(entry.capabilityGroup)) {
164
+ issues.push(
165
+ `${label}: capability group ${entry.capabilityGroup || "<empty>"} is not registered`,
166
+ );
167
+ }
168
+ const previous = baselineByKey.get(entry[definition.key]);
169
+ if (
170
+ previous &&
171
+ JSON.stringify(publicSurfaceContract(entry, definition.kind)) !==
172
+ JSON.stringify(publicSurfaceContract(previous, definition.kind))
173
+ ) {
174
+ issues.push(
175
+ `${label}: existing public contract drifted from ${revision}`,
176
+ );
177
+ }
178
+ if (!previous && !entry.nonDuplicationRationale) {
179
+ issues.push(
180
+ `${label}: new public surface requires a non-duplication rationale`,
181
+ );
182
+ }
183
+ }
184
+ }
185
+ return issues;
186
+ }
187
+
188
+ function evaluateAddedFunctionBudgets({
189
+ file,
190
+ metrics,
191
+ baseline,
192
+ policy,
193
+ budgets,
194
+ }) {
195
+ const issues = [];
196
+ const baselineFunctionNames = new Set(
197
+ (baseline.functions || [])
198
+ .map((entry) => entry.name)
199
+ .filter((name) => !name.startsWith("<anonymous@")),
200
+ );
201
+ for (const entry of metrics.functions) {
202
+ if (
203
+ entry.name.startsWith("<anonymous@") ||
204
+ baselineFunctionNames.has(entry.name)
205
+ ) {
206
+ continue;
207
+ }
208
+ const key = `${file}#${entry.name}`;
209
+ const approval = policy.approvedExtractedDebt?.[key];
210
+ if (approval && !String(approval.rationale || "").trim()) {
211
+ issues.push(`${key}: approved extracted debt requires a rationale`);
212
+ }
213
+ const allowedLines = approval?.maxLines ?? budgets.newFunctionLines;
214
+ const allowedComplexity =
215
+ approval?.maxComplexity ?? budgets.newFunctionComplexity;
216
+ if (entry.lines > allowedLines) {
217
+ issues.push(
218
+ `${file}:${entry.start} ${entry.name} has ${entry.lines} lines; new-function budget is ${allowedLines}`,
219
+ );
220
+ }
221
+ if (entry.complexity > allowedComplexity) {
222
+ issues.push(
223
+ `${file}:${entry.start} ${entry.name} has complexity ${entry.complexity}; new-function budget is ${allowedComplexity}`,
224
+ );
225
+ }
226
+ }
227
+ return issues;
228
+ }
229
+
230
+ function evaluateMaintainability({ current, baselineFiles, policy }) {
231
+ const issues = [];
232
+ const budgets = policy.sourceBudgets;
233
+ for (const [file, metrics] of Object.entries(current.files)) {
234
+ const baseline = baselineFiles[file];
235
+ if (!baseline) {
236
+ if (metrics.lines > budgets.newFileLines) {
237
+ issues.push(
238
+ `${file}: new file has ${metrics.lines} lines; budget is ${budgets.newFileLines}`,
239
+ );
240
+ }
241
+ for (const entry of metrics.functions) {
242
+ if (entry.lines > budgets.newFunctionLines) {
243
+ issues.push(
244
+ `${file}:${entry.start} ${entry.name} has ${entry.lines} lines; new-function budget is ${budgets.newFunctionLines}`,
245
+ );
246
+ }
247
+ if (entry.complexity > budgets.newFunctionComplexity) {
248
+ issues.push(
249
+ `${file}:${entry.start} ${entry.name} has complexity ${entry.complexity}; new-function budget is ${budgets.newFunctionComplexity}`,
250
+ );
251
+ }
252
+ }
253
+ continue;
254
+ }
255
+ const transition = policy.approvedExistingDebtTransitions?.[file];
256
+ if (transition && !String(transition.rationale || "").trim()) {
257
+ issues.push(`${file}: approved debt transition requires a rationale`);
258
+ }
259
+ const allowedLines =
260
+ transition?.maxLines ?? Math.max(baseline.lines, budgets.newFileLines);
261
+ const allowedFunctionLines =
262
+ transition?.maxFunctionLines ??
263
+ Math.max(maximumFunction(baseline, "lines"), budgets.newFunctionLines);
264
+ const allowedFunctionComplexity =
265
+ transition?.maxFunctionComplexity ??
266
+ Math.max(
267
+ maximumFunction(baseline, "complexity"),
268
+ budgets.newFunctionComplexity,
269
+ );
270
+ if (metrics.lines > allowedLines) {
271
+ issues.push(
272
+ `${file}: file debt widened from ${baseline.lines} to ${metrics.lines} lines`,
273
+ );
274
+ }
275
+ const maxLines = maximumFunction(metrics, "lines");
276
+ if (maxLines > allowedFunctionLines) {
277
+ issues.push(
278
+ `${file}: maximum function length widened beyond ${allowedFunctionLines} to ${maxLines}`,
279
+ );
280
+ }
281
+ const maxComplexity = maximumFunction(metrics, "complexity");
282
+ if (maxComplexity > allowedFunctionComplexity) {
283
+ issues.push(
284
+ `${file}: maximum function complexity widened beyond ${allowedFunctionComplexity} to ${maxComplexity}`,
285
+ );
286
+ }
287
+ issues.push(
288
+ ...evaluateAddedFunctionBudgets({
289
+ file,
290
+ metrics,
291
+ baseline,
292
+ policy,
293
+ budgets,
294
+ }),
295
+ );
296
+ }
297
+
298
+ for (const [name, budget] of Object.entries(policy.selectedFunctionBudgets)) {
299
+ const entry = selectedFunction(current, budget.file, name);
300
+ if (!entry) {
301
+ issues.push(`${budget.file}: selected function ${name} is missing`);
302
+ continue;
303
+ }
304
+ if (entry.lines > budget.lines) {
305
+ issues.push(
306
+ `${budget.file}: ${name} has ${entry.lines} lines; terminal budget is ${budget.lines}`,
307
+ );
308
+ }
309
+ if (entry.complexity > budget.complexity) {
310
+ issues.push(
311
+ `${budget.file}: ${name} has complexity ${entry.complexity}; terminal budget is ${budget.complexity}`,
312
+ );
313
+ }
314
+ }
315
+ return issues;
316
+ }
317
+
318
+ function checkMaintainability({ root = process.cwd() } = {}) {
319
+ const policy = readJson(root, "architecture/maintainability-policy.json");
320
+ const baseline = readJson(root, policy.baseline);
321
+ const enforcementRevision = policy.enforcementRevision || baseline.revision;
322
+ const hydratedRevisions = ensureMaintainabilityRevisionsAvailable(root, {
323
+ baselineRevision: baseline.revision,
324
+ enforcementRevision,
325
+ });
326
+ const current = collectMaintainabilityMetrics({ root });
327
+ const baselineFiles = sourceMetricsAtRevision(root, enforcementRevision);
328
+ const issues = evaluateMaintainability({ current, baselineFiles, policy });
329
+ issues.push(
330
+ ...evaluatePublicSurface({ root, revision: enforcementRevision, policy }),
331
+ );
332
+ if (issues.length > 0) {
333
+ throw new Error(`maintainability check failed:\n- ${issues.join("\n- ")}`);
334
+ }
335
+ return {
336
+ schemaVersion: 1,
337
+ baselineRevision: baseline.revision,
338
+ enforcementRevision,
339
+ hydratedBaselineRevision: hydratedRevisions[baseline.revision],
340
+ hydratedEnforcementRevision: hydratedRevisions[enforcementRevision],
341
+ trackedFiles: current.repository.trackedFiles,
342
+ sourceFiles: current.repository.handMaintainedSourceFiles,
343
+ publicSurface: current.publicSurface,
344
+ hotspots: current.hotspots,
345
+ };
346
+ }
347
+
348
+ if (
349
+ process.argv[1] &&
350
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
351
+ ) {
352
+ try {
353
+ const report = checkMaintainability();
354
+ console.log(
355
+ `maintainability check passed: ${report.sourceFiles} source files against ${report.enforcementRevision} ` +
356
+ `(audit baseline ${report.baselineRevision})`,
357
+ );
358
+ } catch (error) {
359
+ console.error(error instanceof Error ? error.message : String(error));
360
+ process.exitCode = 1;
361
+ }
362
+ }
363
+
364
+ export {
365
+ checkMaintainability,
366
+ ensureMaintainabilityRevisionsAvailable,
367
+ ensureRevisionAvailable,
368
+ evaluateMaintainability,
369
+ evaluatePublicSurface,
370
+ sourceMetricsAtRevision,
371
+ };
@@ -0,0 +1,221 @@
1
+ import crypto from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ export const BUILDCHAIN_COMPILER_CACHE_PREPARATION_CONTRACT =
8
+ "kungfu-buildchain-compiler-cache-preparation";
9
+
10
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
11
+
12
+ function stableValue(value) {
13
+ if (Array.isArray(value)) return value.map(stableValue);
14
+ if (value && typeof value === "object") {
15
+ return Object.fromEntries(
16
+ Object.keys(value)
17
+ .sort()
18
+ .map((key) => [key, stableValue(value[key])]),
19
+ );
20
+ }
21
+ return value;
22
+ }
23
+
24
+ function digest(value) {
25
+ return `sha256:${crypto
26
+ .createHash("sha256")
27
+ .update(JSON.stringify(stableValue(value)))
28
+ .digest("hex")}`;
29
+ }
30
+
31
+ function cleanText(value) {
32
+ return String(value || "").trim();
33
+ }
34
+
35
+ function optionalDigest(value, label) {
36
+ const candidate = cleanText(value);
37
+ if (!candidate) return "";
38
+ if (!DIGEST_RE.test(candidate)) {
39
+ throw new Error(`${label} must be a sha256 digest`);
40
+ }
41
+ return candidate;
42
+ }
43
+
44
+ function defaultRunCommand(command, args, options = {}) {
45
+ return spawnSync(command, args, {
46
+ cwd: options.cwd,
47
+ env: options.env,
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ windowsHide: true,
51
+ });
52
+ }
53
+
54
+ function assertCommandSucceeded(result, label) {
55
+ if (result?.error) {
56
+ throw new Error(`${label} failed: ${result.error.code || result.error.message}`);
57
+ }
58
+ if (result?.status !== 0) {
59
+ const detail = cleanText(result?.stderr || result?.stdout);
60
+ throw new Error(`${label} exited ${result?.status ?? "without status"}${detail ? `: ${detail}` : ""}`);
61
+ }
62
+ }
63
+
64
+ function appendGithubEnv(filePath, values) {
65
+ if (!filePath) return;
66
+ for (const [name, value] of Object.entries(values)) {
67
+ if (/[\r\n\0]/.test(String(value))) {
68
+ throw new Error(`${name} contains control characters`);
69
+ }
70
+ }
71
+ fs.appendFileSync(
72
+ filePath,
73
+ `${Object.entries(values)
74
+ .map(([name, value]) => `${name}=${value}`)
75
+ .join("\n")}\n`,
76
+ );
77
+ }
78
+
79
+ function readToolEvidence({ cwd, env, expectedRoot }) {
80
+ const configured = cleanText(
81
+ env.BUILDCHAIN_COMPILER_CACHE_TOOL_EVIDENCE_PATH,
82
+ );
83
+ if (!configured) return undefined;
84
+ const filePath = path.resolve(cwd, configured);
85
+ const relative = path.relative(path.resolve(cwd), filePath);
86
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
87
+ throw new Error(
88
+ "BUILDCHAIN_COMPILER_CACHE_TOOL_EVIDENCE_PATH must remain inside the workflow workspace",
89
+ );
90
+ }
91
+ const evidence = JSON.parse(fs.readFileSync(filePath, "utf8"));
92
+ if (!DIGEST_RE.test(evidence?.root || "")) {
93
+ throw new Error("compiler-cache tool evidence root is invalid");
94
+ }
95
+ if (expectedRoot && evidence.root !== expectedRoot) {
96
+ throw new Error(
97
+ "compiler-cache tool evidence does not match BUILDCHAIN_COMPILER_CACHE_TOOL_ROOT",
98
+ );
99
+ }
100
+ return evidence;
101
+ }
102
+
103
+ export function prepareCompilerCacheEvidence({
104
+ cwd = process.cwd(),
105
+ env = process.env,
106
+ runCommand = defaultRunCommand,
107
+ now = () => new Date(),
108
+ } = {}) {
109
+ const provider = cleanText(env.BUILDCHAIN_COMPILER_CACHE_PROVIDER || "none").toLowerCase();
110
+ const required = cleanText(env.BUILDCHAIN_COMPILER_CACHE_REQUIRED).toLowerCase() === "true";
111
+ if (provider !== "sccache") {
112
+ if (required) {
113
+ throw new Error(`required compiler cache provider must be sccache, received ${provider || "empty"}`);
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ const outputPath = path.resolve(
119
+ cwd,
120
+ cleanText(env.BUILDCHAIN_COMPILER_CACHE_PREPARATION_PATH) ||
121
+ ".buildchain/diagnostics/compiler-cache-preparation.json",
122
+ );
123
+ const versionResult = runCommand("sccache", ["--version"], { cwd, env });
124
+ assertCommandSucceeded(versionResult, "sccache version probe");
125
+ const version = cleanText(versionResult.stdout).split(/\r?\n/, 1)[0];
126
+ if (!version) {
127
+ throw new Error("sccache version probe returned empty output");
128
+ }
129
+
130
+ const resetResult = runCommand("sccache", ["--zero-stats"], { cwd, env });
131
+ assertCommandSucceeded(resetResult, "sccache current-run stats reset");
132
+
133
+ const compilerCacheToolRoot = optionalDigest(
134
+ env.BUILDCHAIN_COMPILER_CACHE_TOOL_ROOT,
135
+ "BUILDCHAIN_COMPILER_CACHE_TOOL_ROOT",
136
+ );
137
+ const toolEvidence = readToolEvidence({
138
+ cwd,
139
+ env,
140
+ expectedRoot: compilerCacheToolRoot,
141
+ });
142
+ const bindings = Object.fromEntries(
143
+ [
144
+ ["sourceCommit", cleanText(env.BUILDCHAIN_SOURCE_SHA || env.GITHUB_SHA)],
145
+ ["sourceTree", cleanText(env.BUILDCHAIN_SOURCE_TREE_SHA)],
146
+ ["runtimeCommit", cleanText(env.BUILDCHAIN_RUNTIME_SHA)],
147
+ ["dependencyLockRoot", cleanText(env.BUILDCHAIN_DEPENDENCY_LOCK_ROOT)],
148
+ ["toolchainRoot", cleanText(env.BUILDCHAIN_TOOLCHAIN_ROOT)],
149
+ ["policyRoot", cleanText(env.BUILDCHAIN_CACHE_POLICY_ROOT)],
150
+ ["cacheProfileRoot", optionalDigest(env.SHIFU_CACHE_PROFILE_DIGEST, "SHIFU_CACHE_PROFILE_DIGEST")],
151
+ ["compilerCacheToolRoot", compilerCacheToolRoot],
152
+ ].filter(([, value]) => value),
153
+ );
154
+ if (!bindings.sourceCommit) {
155
+ throw new Error("BUILDCHAIN_SOURCE_SHA is required for compiler-cache preparation evidence");
156
+ }
157
+
158
+ const body = {
159
+ schemaVersion: 1,
160
+ contract: BUILDCHAIN_COMPILER_CACHE_PREPARATION_CONTRACT,
161
+ generatedAt: now().toISOString(),
162
+ provider,
163
+ required,
164
+ status: "prepared",
165
+ platform: {
166
+ id: cleanText(env.BUILDCHAIN_PLATFORM_ID) || "unknown",
167
+ os: cleanText(env.RUNNER_OS) || process.platform,
168
+ arch: cleanText(env.RUNNER_ARCH) || process.arch,
169
+ },
170
+ tool: {
171
+ command: "sccache",
172
+ version,
173
+ ...(bindings.compilerCacheToolRoot
174
+ ? { evidenceRoot: bindings.compilerCacheToolRoot }
175
+ : {}),
176
+ ...(toolEvidence ? { evidence: toolEvidence } : {}),
177
+ },
178
+ action: {
179
+ statsReset: true,
180
+ command: ["sccache", "--zero-stats"],
181
+ },
182
+ bindings,
183
+ };
184
+ const receipt = {
185
+ ...body,
186
+ root: digest(body),
187
+ };
188
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
189
+ fs.writeFileSync(outputPath, `${JSON.stringify(receipt, null, 2)}\n`);
190
+ appendGithubEnv(env.GITHUB_ENV, {
191
+ BUILDCHAIN_COMPILER_CACHE_ACTIVE_PROVIDER: provider,
192
+ BUILDCHAIN_COMPILER_CACHE_PREPARATION_PATH: outputPath,
193
+ BUILDCHAIN_COMPILER_CACHE_PREPARATION_ROOT: receipt.root,
194
+ });
195
+ return receipt;
196
+ }
197
+
198
+ function main() {
199
+ const command = process.argv[2] || "";
200
+ if (command !== "prepare") {
201
+ throw new Error("usage: node scripts/compiler-cache-evidence.mjs prepare");
202
+ }
203
+ const receipt = prepareCompilerCacheEvidence();
204
+ if (receipt) {
205
+ console.log(`compiler_cache_provider=${receipt.provider}`);
206
+ console.log(`compiler_cache_preparation_root=${receipt.root}`);
207
+ } else {
208
+ console.log("compiler_cache_provider=none");
209
+ }
210
+ }
211
+
212
+ const isMain = process.argv[1] &&
213
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
214
+ if (isMain) {
215
+ try {
216
+ main();
217
+ } catch (error) {
218
+ console.error(error.message);
219
+ process.exitCode = 1;
220
+ }
221
+ }