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

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 (52) hide show
  1. package/actions/report-buildchain-issue/README.md +1 -1
  2. package/contracts/buildchain-v2-residuals-v1.json +191 -0
  3. package/dist/site/buildchain-contract.json +58 -35
  4. package/dist/site/buildchain-site.json +33 -33
  5. package/dist/site/controller-registry.json +24 -3
  6. package/dist/site/kfd-claims.json +11 -6
  7. package/dist/site/kfd-upstream-aggregate.json +1 -1
  8. package/dist/site/manual-registry.json +7 -7
  9. package/dist/site/node-api-registry.json +6 -6
  10. package/dist/site/page-registry.json +22 -22
  11. package/dist/site/public-surface-audit.json +10 -5
  12. package/dist/site/publication-registry.json +4 -4
  13. package/dist/site/site-manifest.json +11 -11
  14. package/dist/site/workflow-registry.json +8 -3
  15. package/docs/auditable-demo.md +19 -1
  16. package/docs/consumer-issue-reporting.md +1 -1
  17. package/docs/dev-alpha-candidate-patrol.md +24 -10
  18. package/docs/github-governance-authority.md +23 -30
  19. package/docs/observed-evidence-patrol.md +28 -12
  20. package/docs/release-governance.md +8 -1
  21. package/docs/reusable-build-surface.md +124 -64
  22. package/docs/shifu-gate-profiles.md +7 -1
  23. package/docs/versioning.md +41 -21
  24. package/docs/web-surface-deployments.md +20 -1
  25. package/package.json +1 -1
  26. package/packages/core/artifact-signing.js +1 -0
  27. package/packages/core/buildchain-contract.js +1 -0
  28. package/packages/core/controller-evidence.js +2 -0
  29. package/packages/core/github-governance-authority.js +25 -17
  30. package/packages/core/publication-control-plane-audit.js +28 -0
  31. package/packages/core/release-passport.js +36 -27
  32. package/packages/core/stable-release-gate.js +4 -1
  33. package/scripts/artifact-signing-delegation.mjs +268 -0
  34. package/scripts/audit-github-governance.mjs +32 -13
  35. package/scripts/audit-publication-control-plane.mjs +17 -8
  36. package/scripts/auditable-demo.mjs +147 -2
  37. package/scripts/buildchain-patrol.mjs +1 -1
  38. package/scripts/check-inventory.mjs +1 -0
  39. package/scripts/dev-alpha-candidate-patrol.mjs +20 -0
  40. package/scripts/dispatch-artifact-signing-authority.mjs +7 -1
  41. package/scripts/finalize-native-artifact-signing-result.mjs +114 -31
  42. package/scripts/gate-profile-core.mjs +6 -1
  43. package/scripts/inspect-artifact-signing-requests.mjs +45 -14
  44. package/scripts/observed-evidence.mjs +151 -33
  45. package/scripts/reconcile-github-governance.mjs +8 -1
  46. package/scripts/resolve-artifact-signing-upload-route.mjs +55 -0
  47. package/scripts/run-candidate-body-prefix-renderer.mjs +187 -0
  48. package/scripts/runtime-ref-core.mjs +13 -2
  49. package/scripts/seal-artifact-signing-requests.mjs +51 -6
  50. package/scripts/stable-candidate-qualification.mjs +8 -0
  51. package/scripts/verify-artifact-signing-results.mjs +26 -1
  52. package/scripts/web-surface-production-decision.mjs +19 -3
@@ -24,6 +24,40 @@ function assertHex(value, label) {
24
24
  if (!/^[0-9a-f]{64}$/.test(String(value || ""))) throw new Error(`${label} must be a lowercase sha256 hex digest`);
25
25
  }
26
26
 
27
+ function requiredHeader(value, label) {
28
+ const normalized = String(value || "").trim();
29
+ if (!normalized || /[\r\n]/.test(normalized)) throw new Error(`${label} must be a non-empty single-line value`);
30
+ return normalized;
31
+ }
32
+
33
+ function validatePublicationEntry({ entry, kind, root, snapshotId, requireSnapshotDocument = false, requireHeaders = false }) {
34
+ const source = safeRelative(entry.source, `${kind}.source`);
35
+ const key = safeRelative(entry.key, `${kind}.key`);
36
+ const file = path.resolve(root, source);
37
+ if (file !== root && !file.startsWith(`${root}${path.sep}`)) throw new Error(`${kind}.source escapes artifact root`);
38
+ if (!fs.statSync(file).isFile()) throw new Error(`${kind}.source is not a file: ${source}`);
39
+ assertHex(entry.sha256, `${kind}.sha256`);
40
+ const actual = sha256(file);
41
+ if (actual !== entry.sha256) throw new Error(`${kind}.sha256 does not match ${source}`);
42
+ if (requireSnapshotDocument) {
43
+ const document = readJson(file);
44
+ if (document.snapshotId !== snapshotId) throw new Error(`${kind} document snapshotId does not match manifest`);
45
+ }
46
+ return {
47
+ kind,
48
+ source,
49
+ key,
50
+ file,
51
+ sha256: actual,
52
+ contentType: requireHeaders
53
+ ? requiredHeader(entry.contentType, `${kind}.contentType`)
54
+ : String(entry.contentType || "application/json"),
55
+ cacheControl: requireHeaders
56
+ ? requiredHeader(entry.cacheControl, `${kind}.cacheControl`)
57
+ : String(entry.cacheControl || ""),
58
+ };
59
+ }
60
+
27
61
  export function validateObservedEvidenceBundle({ manifestPath, artifactRoot = path.dirname(manifestPath) }) {
28
62
  const resolvedManifest = path.resolve(manifestPath);
29
63
  const root = path.resolve(artifactRoot);
@@ -36,26 +70,39 @@ export function validateObservedEvidenceBundle({ manifestPath, artifactRoot = pa
36
70
  if (!Number.isFinite(Date.parse(manifest.snapshot?.observedAt || ""))) throw new Error("snapshot.observedAt must be ISO-8601");
37
71
  const immutable = manifest.publication?.immutable || {};
38
72
  const latest = manifest.publication?.latest || {};
39
- const entries = [["immutable", immutable], ["latest", latest]].map(([kind, entry]) => {
40
- const source = safeRelative(entry.source, `${kind}.source`);
41
- const key = safeRelative(entry.key, `${kind}.key`);
42
- const file = path.resolve(root, source);
43
- if (file !== root && !file.startsWith(`${root}${path.sep}`)) throw new Error(`${kind}.source escapes artifact root`);
44
- if (!fs.statSync(file).isFile()) throw new Error(`${kind}.source is not a file: ${source}`);
45
- assertHex(entry.sha256, `${kind}.sha256`);
46
- const actual = sha256(file);
47
- if (actual !== entry.sha256) throw new Error(`${kind}.sha256 does not match ${source}`);
48
- const document = readJson(file);
49
- if (document.snapshotId !== snapshotId) throw new Error(`${kind} document snapshotId does not match manifest`);
50
- return { kind, source, key, file, sha256: actual, contentType: entry.contentType || "application/json" };
51
- });
52
- if (!entries[0].key.includes(`/${snapshotId}.json`)) throw new Error("immutable key must bind snapshot.id below a versioned path");
53
- if (entries[0].key === entries[1].key) throw new Error("immutable and latest keys must differ");
73
+ const immutableEntry = validatePublicationEntry({ entry: immutable, kind: "immutable", root, snapshotId, requireSnapshotDocument: true });
74
+ const latestEntry = validatePublicationEntry({ entry: latest, kind: "latest", root, snapshotId, requireSnapshotDocument: true });
75
+ const projectionInput = manifest.publication?.projections ?? [];
76
+ if (!Array.isArray(projectionInput) || projectionInput.length > 16) {
77
+ throw new Error("publication.projections must be an array with at most 16 entries");
78
+ }
79
+ const projections = projectionInput.map((entry, index) => validatePublicationEntry({
80
+ entry,
81
+ kind: `projection[${index}]`,
82
+ root,
83
+ snapshotId,
84
+ requireHeaders: true,
85
+ }));
86
+ const entries = [immutableEntry, ...projections, latestEntry];
87
+ if (!immutableEntry.key.includes(`/${snapshotId}.json`)) throw new Error("immutable key must bind snapshot.id below a versioned path");
88
+ if (new Set(entries.map((entry) => entry.key)).size !== entries.length) {
89
+ throw new Error("immutable, projection, and latest keys must be unique");
90
+ }
54
91
  const invalidationPaths = [...new Set(manifest.publication?.invalidationPaths || [])];
55
92
  if (invalidationPaths.some((entry) => typeof entry !== "string" || !entry.startsWith("/") || entry.includes(".."))) {
56
93
  throw new Error("invalidation paths must be absolute viewer paths without traversal");
57
94
  }
58
- return { manifest, manifestPath: resolvedManifest, artifactRoot: root, snapshotId, entries, invalidationPaths };
95
+ return {
96
+ manifest,
97
+ manifestPath: resolvedManifest,
98
+ artifactRoot: root,
99
+ snapshotId,
100
+ entries,
101
+ immutable: immutableEntry,
102
+ latest: latestEntry,
103
+ projections,
104
+ invalidationPaths,
105
+ };
59
106
  }
60
107
 
61
108
  function defaultRunner(args) {
@@ -79,6 +126,8 @@ function headEvidence(result) {
79
126
  snapshotId: String(value.Metadata?.["snapshot-id"] || ""),
80
127
  etag: String(value.ETag || "").replaceAll('"', ""),
81
128
  versionId: String(value.VersionId || ""),
129
+ contentType: String(value.ContentType || ""),
130
+ cacheControl: String(value.CacheControl || ""),
82
131
  };
83
132
  }
84
133
 
@@ -90,7 +139,7 @@ function putArgs({ bucket, entry, snapshotId, immutable }) {
90
139
  const args = [
91
140
  "s3api", "put-object", "--bucket", bucket, "--key", entry.key, "--body", entry.file,
92
141
  "--content-type", entry.contentType,
93
- "--cache-control", immutable ? "public,max-age=31536000,immutable" : "public,max-age=0,must-revalidate",
142
+ "--cache-control", entry.cacheControl || (immutable ? "public,max-age=31536000,immutable" : "public,max-age=0,must-revalidate"),
94
143
  "--checksum-sha256", base64Digest(entry.sha256),
95
144
  "--metadata", `snapshot-id=${snapshotId},sha256=${entry.sha256}`,
96
145
  ];
@@ -98,10 +147,39 @@ function putArgs({ bucket, entry, snapshotId, immutable }) {
98
147
  return args;
99
148
  }
100
149
 
150
+ function copySource(bucket, key, versionId) {
151
+ const encoded = `${encodeURIComponent(bucket)}/${key.split("/").map(encodeURIComponent).join("/")}`;
152
+ return `${encoded}?versionId=${encodeURIComponent(versionId)}`;
153
+ }
154
+
155
+ function rollbackMutableEntries({ commandRunner, bucket, applied }) {
156
+ const operations = [];
157
+ for (const { entry, previous } of [...applied].reverse()) {
158
+ const result = previous
159
+ ? commandRunner([
160
+ "s3api", "copy-object", "--bucket", bucket, "--key", entry.key,
161
+ "--copy-source", copySource(bucket, entry.key, previous.versionId),
162
+ "--metadata-directive", "COPY",
163
+ ])
164
+ : commandRunner(["s3api", "delete-object", "--bucket", bucket, "--key", entry.key]);
165
+ operations.push({ action: previous ? "restore-previous-version" : "remove-new-projection", key: entry.key, status: result.status });
166
+ if (result.status !== 0) {
167
+ throw new Error(`rollback failed for ${entry.key}: ${result.stderr || result.stdout}`);
168
+ }
169
+ }
170
+ return operations;
171
+ }
172
+
101
173
  function assertHeadMatches(head, entry, snapshotId, label) {
102
174
  if (!head || head.sha256 !== entry.sha256 || head.snapshotId !== snapshotId) {
103
175
  throw new Error(`${label} object does not match the admitted snapshot and sha256`);
104
176
  }
177
+ if (
178
+ entry.kind.startsWith("projection[") &&
179
+ (head.contentType !== entry.contentType || head.cacheControl !== entry.cacheControl)
180
+ ) {
181
+ throw new Error(`${label} object does not match the admitted content type and cache control`);
182
+ }
105
183
  }
106
184
 
107
185
  export function publishObservedEvidence(options, { commandRunner = defaultRunner } = {}) {
@@ -110,7 +188,7 @@ export function publishObservedEvidence(options, { commandRunner = defaultRunner
110
188
  if (!bucket) throw new Error("bucket is required");
111
189
  const distributionId = String(options.distributionId || "").trim();
112
190
  const dryRun = options.dryRun !== false;
113
- const [immutable, latest] = bundle.entries;
191
+ const { immutable, latest, projections } = bundle;
114
192
  const receipt = {
115
193
  schemaVersion: 1,
116
194
  contract: "kungfu-buildchain-observed-evidence-publication-receipt",
@@ -121,8 +199,17 @@ export function publishObservedEvidence(options, { commandRunner = defaultRunner
121
199
  bucket,
122
200
  distributionId,
123
201
  immutable: { key: immutable.key, sha256: immutable.sha256, status: "planned" },
202
+ projections: projections.map((entry) => ({
203
+ key: entry.key,
204
+ sha256: entry.sha256,
205
+ contentType: entry.contentType,
206
+ cacheControl: entry.cacheControl,
207
+ status: "planned",
208
+ })),
124
209
  latest: { key: latest.key, sha256: latest.sha256, status: "planned" },
210
+ previousProjections: [],
125
211
  previousLatest: null,
212
+ rollback: { status: "not-needed", operations: [] },
126
213
  invalidationPaths: bundle.invalidationPaths,
127
214
  operations: [],
128
215
  };
@@ -147,21 +234,52 @@ export function publishObservedEvidence(options, { commandRunner = defaultRunner
147
234
  assertHeadMatches(verifiedImmutable, immutable, bundle.snapshotId, "verified immutable");
148
235
  receipt.immutable.verification = verifiedImmutable;
149
236
 
150
- receipt.previousLatest = headEvidence(headObject(commandRunner, bucket, latest.key));
151
- const latestPut = commandRunner(putArgs({ bucket, entry: latest, snapshotId: bundle.snapshotId, immutable: false }));
152
- receipt.operations.push({ action: "advance-latest", status: latestPut.status });
153
- if (latestPut.status !== 0) throw new Error(`latest alias update failed: ${latestPut.stderr || latestPut.stdout}`);
154
- const verifiedLatest = headEvidence(headObject(commandRunner, bucket, latest.key));
155
- assertHeadMatches(verifiedLatest, latest, bundle.snapshotId, "verified latest");
156
- receipt.latest = { ...receipt.latest, status: "written", verification: verifiedLatest };
157
-
158
- if (distributionId && bundle.invalidationPaths.length > 0) {
159
- const invalidation = commandRunner([
160
- "cloudfront", "create-invalidation", "--distribution-id", distributionId,
161
- "--paths", ...bundle.invalidationPaths,
162
- ]);
163
- receipt.operations.push({ action: "invalidate-cdn", status: invalidation.status, result: jsonOutput(invalidation) });
164
- if (invalidation.status !== 0) throw new Error(`CloudFront invalidation failed: ${invalidation.stderr || invalidation.stdout}`);
237
+ const mutableEntries = [...projections, latest];
238
+ const previousByKey = new Map(mutableEntries.map((entry) => [entry.key, headEvidence(headObject(commandRunner, bucket, entry.key))]));
239
+ if (projections.length > 0) {
240
+ for (const [key, previous] of previousByKey) {
241
+ if (previous && !previous.versionId) {
242
+ throw new Error(`mutable projection transaction requires bucket versioning before replacing ${key}`);
243
+ }
244
+ }
245
+ }
246
+ receipt.previousProjections = projections.map((entry) => ({ key: entry.key, previous: previousByKey.get(entry.key) }));
247
+ receipt.previousLatest = previousByKey.get(latest.key);
248
+ const applied = [];
249
+ try {
250
+ for (const [index, projection] of projections.entries()) {
251
+ const put = commandRunner(putArgs({ bucket, entry: projection, snapshotId: bundle.snapshotId, immutable: false }));
252
+ receipt.operations.push({ action: "advance-projection", key: projection.key, status: put.status });
253
+ if (put.status !== 0) throw new Error(`projection update failed for ${projection.key}: ${put.stderr || put.stdout}`);
254
+ applied.push({ entry: projection, previous: previousByKey.get(projection.key) });
255
+ const verified = headEvidence(headObject(commandRunner, bucket, projection.key));
256
+ assertHeadMatches(verified, projection, bundle.snapshotId, `verified projection ${projection.key}`);
257
+ receipt.projections[index] = { ...receipt.projections[index], status: "written", verification: verified };
258
+ }
259
+
260
+ const latestPut = commandRunner(putArgs({ bucket, entry: latest, snapshotId: bundle.snapshotId, immutable: false }));
261
+ receipt.operations.push({ action: "advance-latest", status: latestPut.status });
262
+ if (latestPut.status !== 0) throw new Error(`latest alias update failed: ${latestPut.stderr || latestPut.stdout}`);
263
+ applied.push({ entry: latest, previous: previousByKey.get(latest.key) });
264
+ const verifiedLatest = headEvidence(headObject(commandRunner, bucket, latest.key));
265
+ assertHeadMatches(verifiedLatest, latest, bundle.snapshotId, "verified latest");
266
+ receipt.latest = { ...receipt.latest, status: "written", verification: verifiedLatest };
267
+
268
+ if (distributionId && bundle.invalidationPaths.length > 0) {
269
+ const invalidation = commandRunner([
270
+ "cloudfront", "create-invalidation", "--distribution-id", distributionId,
271
+ "--paths", ...bundle.invalidationPaths,
272
+ ]);
273
+ receipt.operations.push({ action: "invalidate-cdn", status: invalidation.status, result: jsonOutput(invalidation) });
274
+ if (invalidation.status !== 0) throw new Error(`CloudFront invalidation failed: ${invalidation.stderr || invalidation.stdout}`);
275
+ }
276
+ } catch (error) {
277
+ if (projections.length > 0 && applied.length > 0) {
278
+ receipt.rollback.status = "applying";
279
+ receipt.rollback.operations = rollbackMutableEntries({ commandRunner, bucket, applied });
280
+ receipt.rollback.status = "restored";
281
+ }
282
+ throw error;
165
283
  }
166
284
  receipt.status = "published";
167
285
  receipt.publishedAt = new Date().toISOString();
@@ -96,6 +96,11 @@ function writeJson(filePath, value) {
96
96
  fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`);
97
97
  }
98
98
 
99
+ export function githubApiFailureIsAbsence(method, output) {
100
+ return String(method || "GET").toUpperCase() === "GET" &&
101
+ /404|not found/i.test(String(output || ""));
102
+ }
103
+
99
104
  function githubApi(route, { method = "GET", body } = {}) {
100
105
  const args = [
101
106
  "api",
@@ -115,7 +120,9 @@ function githubApi(route, { method = "GET", body } = {}) {
115
120
  });
116
121
  const output = `${result.stdout || ""}\n${result.stderr || ""}`;
117
122
  if (result.status !== 0) {
118
- if (/404|not found/i.test(output)) return { exists: false, data: null };
123
+ if (githubApiFailureIsAbsence(method, output)) {
124
+ return { exists: false, data: null };
125
+ }
119
126
  throw new Error(`GitHub API ${method} ${route} failed closed`);
120
127
  }
121
128
  return {
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ function normalize(value, name) {
7
+ const normalized = String(value ?? "").trim();
8
+ if (/\r|\n|\0/.test(normalized)) {
9
+ throw new Error(`${name} must be a single-line NO_PROXY value`);
10
+ }
11
+ return normalized;
12
+ }
13
+
14
+ export function resolveArtifactSigningUploadRoute({
15
+ requestedNoProxy = "",
16
+ noProxy = "",
17
+ lowerNoProxy = "",
18
+ } = {}) {
19
+ const requested = normalize(requestedNoProxy, "artifact signing request upload NO_PROXY");
20
+ const currentUpper = normalize(noProxy, "NO_PROXY");
21
+ const currentLower = normalize(lowerNoProxy, "no_proxy");
22
+
23
+ return {
24
+ noProxy: requested || currentUpper || currentLower,
25
+ overrideApplied: requested !== "",
26
+ };
27
+ }
28
+
29
+ export function writeGitHubOutputs(route, outputPath = process.env.GITHUB_OUTPUT) {
30
+ if (!outputPath) return;
31
+ fs.appendFileSync(
32
+ outputPath,
33
+ [
34
+ `no-proxy=${route.noProxy}`,
35
+ `override-applied=${route.overrideApplied}`,
36
+ "",
37
+ ].join("\n"),
38
+ );
39
+ }
40
+
41
+ function main() {
42
+ const route = resolveArtifactSigningUploadRoute({
43
+ requestedNoProxy: process.env.BUILDCHAIN_SIGNING_REQUEST_UPLOAD_NO_PROXY,
44
+ noProxy: process.env.NO_PROXY,
45
+ lowerNoProxy: process.env.no_proxy,
46
+ });
47
+ writeGitHubOutputs(route);
48
+ process.stdout.write(
49
+ `Resolved artifact signing request upload route (override=${route.overrideApplied ? "yes" : "no"}).\n`,
50
+ );
51
+ }
52
+
53
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
54
+ main();
55
+ }
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import childProcess from "node:child_process";
5
+ import crypto from "node:crypto";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { TextDecoder } from "node:util";
9
+
10
+ const EXACT_SHA = /^[0-9a-f]{40}$/u;
11
+ const MANAGED_MARKER = "<!-- buildchain-dev-alpha-candidate-state";
12
+ const MAX_PREFIX_CHARACTERS = 32768;
13
+
14
+ function required(value, name) {
15
+ const normalized = String(value ?? "").trim();
16
+ if (!normalized) throw new Error(`${name} is required`);
17
+ return normalized;
18
+ }
19
+
20
+ export function resolveRendererPath(consumerRootValue, rendererValue) {
21
+ const consumerRoot = fs.realpathSync(
22
+ path.resolve(required(consumerRootValue, "consumerRoot")),
23
+ );
24
+ const renderer = required(rendererValue, "renderer");
25
+ if (path.isAbsolute(renderer))
26
+ throw new Error("renderer must be repository-relative");
27
+ const candidate = path.resolve(consumerRoot, renderer);
28
+ const candidateRelative = path.relative(consumerRoot, candidate);
29
+ if (
30
+ !candidateRelative ||
31
+ candidateRelative.startsWith("..") ||
32
+ path.isAbsolute(candidateRelative)
33
+ )
34
+ throw new Error("renderer must resolve inside the consumer checkout");
35
+ const absolute = fs.realpathSync(candidate);
36
+ const relative = path.relative(consumerRoot, absolute);
37
+ if (relative.startsWith("..") || path.isAbsolute(relative))
38
+ throw new Error("renderer must resolve inside the consumer checkout");
39
+ const stat = fs.statSync(absolute);
40
+ if (!stat.isFile())
41
+ throw new Error("renderer must resolve to a regular file");
42
+ return { consumerRoot, renderer: absolute };
43
+ }
44
+
45
+ function safeEnvironment(values, source = process.env) {
46
+ const retained = [
47
+ "CI",
48
+ "HOME",
49
+ "LANG",
50
+ "LC_ALL",
51
+ "PATH",
52
+ "RUNNER_ARCH",
53
+ "RUNNER_OS",
54
+ "TEMP",
55
+ "TMP",
56
+ "TMPDIR",
57
+ "TZ",
58
+ ];
59
+ return {
60
+ ...Object.fromEntries(
61
+ retained
62
+ .filter((key) => source[key] !== undefined)
63
+ .map((key) => [key, source[key]]),
64
+ ),
65
+ BUILDCHAIN_CHANNEL_PATROL_PR_BODY_PREFIX_OUTPUT: values.outputPath,
66
+ BUILDCHAIN_CHANNEL_PATROL_SELECTED_SHA: values.selectedSha,
67
+ BUILDCHAIN_CHANNEL_PATROL_SOURCE_BRANCH: values.sourceBranch,
68
+ BUILDCHAIN_CHANNEL_PATROL_TARGET_BRANCH: values.targetBranch,
69
+ GIT_TERMINAL_PROMPT: "0",
70
+ };
71
+ }
72
+
73
+ function checkedSpawn(command, args, options, label) {
74
+ const result = childProcess.spawnSync(command, args, {
75
+ ...options,
76
+ encoding: "utf8",
77
+ });
78
+ if (result.error) throw result.error;
79
+ if (result.status !== 0) {
80
+ const detail = String(result.stderr || result.stdout || "").trim();
81
+ throw new Error(`${label} failed${detail ? `: ${detail}` : ""}`);
82
+ }
83
+ return result;
84
+ }
85
+
86
+ function readPrefix(outputPath) {
87
+ const bytes = fs.readFileSync(outputPath);
88
+ let value;
89
+ try {
90
+ value = new TextDecoder("utf-8", { fatal: true }).decode(bytes).trim();
91
+ } catch {
92
+ throw new Error("renderer output must be valid UTF-8");
93
+ }
94
+ if (!value) throw new Error("renderer output must not be empty");
95
+ if (value.length > MAX_PREFIX_CHARACTERS)
96
+ throw new Error(
97
+ `renderer output exceeds ${MAX_PREFIX_CHARACTERS} characters`,
98
+ );
99
+ if (value.includes(MANAGED_MARKER))
100
+ throw new Error(
101
+ "renderer output must not contain the managed candidate state marker",
102
+ );
103
+ return value;
104
+ }
105
+
106
+ export function appendMultilineOutput(outputFile, name, value) {
107
+ let delimiter;
108
+ do {
109
+ delimiter = `buildchain_${crypto.randomUUID()}`;
110
+ } while (value.split(/\r?\n/u).includes(delimiter));
111
+ fs.appendFileSync(
112
+ outputFile,
113
+ `${name}<<${delimiter}\n${value}\n${delimiter}\n`,
114
+ );
115
+ }
116
+
117
+ export function runCandidateBodyPrefixRenderer(options = {}) {
118
+ const { consumerRoot, renderer } = resolveRendererPath(
119
+ options.consumerRoot,
120
+ options.renderer,
121
+ );
122
+ const selectedSha = required(options.selectedSha, "selectedSha");
123
+ if (!EXACT_SHA.test(selectedSha))
124
+ throw new Error("selectedSha must be an exact 40-character commit SHA");
125
+ const sourceBranch = required(options.sourceBranch, "sourceBranch");
126
+ const targetBranch = required(options.targetBranch, "targetBranch");
127
+ const outputPath = path.resolve(required(options.outputPath, "outputPath"));
128
+ const githubOutput = required(options.githubOutput, "githubOutput");
129
+
130
+ const head = checkedSpawn(
131
+ "git",
132
+ ["rev-parse", "HEAD"],
133
+ {
134
+ cwd: consumerRoot,
135
+ env: safeEnvironment({
136
+ outputPath,
137
+ selectedSha,
138
+ sourceBranch,
139
+ targetBranch,
140
+ }),
141
+ },
142
+ "consumer HEAD resolution",
143
+ ).stdout.trim();
144
+ if (head !== selectedSha)
145
+ throw new Error(
146
+ `consumer checkout HEAD ${head} does not match selected SHA ${selectedSha}`,
147
+ );
148
+
149
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
150
+ fs.rmSync(outputPath, { force: true });
151
+ const env = safeEnvironment({
152
+ outputPath,
153
+ selectedSha,
154
+ sourceBranch,
155
+ targetBranch,
156
+ });
157
+ checkedSpawn(
158
+ process.execPath,
159
+ [renderer],
160
+ { cwd: consumerRoot, env },
161
+ "PR body prefix renderer",
162
+ );
163
+ const value = readPrefix(outputPath);
164
+ appendMultilineOutput(githubOutput, "pull-request-body-prefix", value);
165
+ return value;
166
+ }
167
+
168
+ function main() {
169
+ runCandidateBodyPrefixRenderer({
170
+ consumerRoot: process.env.BUILDCHAIN_CHANNEL_PATROL_CONSUMER_ROOT,
171
+ renderer: process.env.BUILDCHAIN_CHANNEL_PATROL_PR_BODY_PREFIX_RENDERER,
172
+ outputPath: process.env.BUILDCHAIN_CHANNEL_PATROL_PR_BODY_PREFIX_OUTPUT,
173
+ selectedSha: process.env.BUILDCHAIN_CHANNEL_PATROL_SELECTED_SHA,
174
+ sourceBranch: process.env.BUILDCHAIN_CHANNEL_PATROL_SOURCE_BRANCH,
175
+ targetBranch: process.env.BUILDCHAIN_CHANNEL_PATROL_TARGET_BRANCH,
176
+ githubOutput: process.env.GITHUB_OUTPUT,
177
+ });
178
+ }
179
+
180
+ if (import.meta.url === `file://${process.argv[1]}`) {
181
+ try {
182
+ main();
183
+ } catch (error) {
184
+ console.error(error instanceof Error ? error.message : String(error));
185
+ process.exit(1);
186
+ }
187
+ }
@@ -3,7 +3,7 @@ const TRAIN_REF_RE = /^train\/v\d+\/v\d+\.\d+\/[A-Za-z0-9._/-]+$/;
3
3
  const AUTHORITY_REF_RE = /^authority\/v\d+\/v\d+\.\d+\/[A-Za-z0-9._/-]+$/;
4
4
  const OFFICIAL_CHANNEL_REF_RE = /^v\d+(?:\.\d+)?(?:-alpha)?$/;
5
5
 
6
- export function parseWorkflowShellRef(workflowRef = "", fallback = "v2", buildchainRepository = "kungfu-systems/buildchain") {
6
+ export function parseWorkflowShellRef(workflowRef = "", fallback = "v3", buildchainRepository = "kungfu-systems/buildchain") {
7
7
  const value = String(workflowRef || "");
8
8
  const expectedPrefix = `${buildchainRepository}/.github/workflows/`;
9
9
  if (!value.startsWith(expectedPrefix)) {
@@ -80,7 +80,7 @@ export function normalizeRequestedRuntimeRef(requestedRef = "") {
80
80
  export function resolveRuntimeSelection({
81
81
  requestedRef = "",
82
82
  workflowRef = "",
83
- defaultStableRef = "v2",
83
+ defaultStableRef = "v3",
84
84
  buildchainRepository = "kungfu-systems/buildchain",
85
85
  } = {}) {
86
86
  const requested = String(requestedRef || "").trim();
@@ -114,9 +114,11 @@ export function resolveRuntimeSelection({
114
114
  export function validateRuntimeOverrideTrust({
115
115
  requestedRef = "",
116
116
  eventName = "",
117
+ eventAction = "",
117
118
  actorPermission = "",
118
119
  sameRepositoryPullRequest = false,
119
120
  pullRequestHeadSha = "",
121
+ workflowShellSha = "",
120
122
  } = {}) {
121
123
  if (!String(requestedRef || "").trim()) {
122
124
  return { ok: true, decision: "stable-default" };
@@ -126,6 +128,15 @@ export function validateRuntimeOverrideTrust({
126
128
  }
127
129
  const normalizedRequested = String(requestedRef || "").trim().toLowerCase();
128
130
  const normalizedHeadSha = String(pullRequestHeadSha || "").trim().toLowerCase();
131
+ const normalizedWorkflowShellSha = String(workflowShellSha || "").trim().toLowerCase();
132
+ if (
133
+ eventName === "pull_request" &&
134
+ eventAction === "closed" &&
135
+ EXACT_SHA_RE.test(normalizedRequested) &&
136
+ normalizedRequested === normalizedWorkflowShellSha
137
+ ) {
138
+ return { ok: true, decision: "closed-release-pr-shell-runtime" };
139
+ }
129
140
  if (
130
141
  eventName === "pull_request" &&
131
142
  sameRepositoryPullRequest === true &&
@@ -52,6 +52,31 @@ function assertInside(root, target, label) {
52
52
  }
53
53
  }
54
54
 
55
+ function containsPath(root, target) {
56
+ const relative = path.relative(root, target);
57
+ return (
58
+ !relative || (!relative.startsWith("..") && !path.isAbsolute(relative))
59
+ );
60
+ }
61
+
62
+ function resetGeneratedOutputRoot({
63
+ workspace,
64
+ cwd,
65
+ outputRoot,
66
+ protectedPaths = [],
67
+ }) {
68
+ const protectedRoots = [workspace, cwd, ...protectedPaths];
69
+ for (const protectedPath of protectedRoots) {
70
+ if (containsPath(outputRoot, protectedPath)) {
71
+ throw new Error(
72
+ "signing request output must not contain the workspace, working directory, manifest, or a declared artifact",
73
+ );
74
+ }
75
+ }
76
+ fs.rmSync(outputRoot, { recursive: true, force: true });
77
+ fs.mkdirSync(outputRoot, { recursive: true });
78
+ }
79
+
55
80
  function walkSubject(subjectRoot) {
56
81
  const entries = [];
57
82
  function visit(current) {
@@ -255,8 +280,12 @@ export function sealArtifactSigningRequests({
255
280
  );
256
281
  const resolvedOutputRoot = path.resolve(resolvedWorkspace, outputRoot);
257
282
  assertInside(resolvedWorkspace, resolvedOutputRoot, "signing request output");
258
- fs.mkdirSync(resolvedOutputRoot, { recursive: true });
259
283
  if (selected.length === 0) {
284
+ resetGeneratedOutputRoot({
285
+ workspace: resolvedWorkspace,
286
+ cwd: resolvedCwd,
287
+ outputRoot: resolvedOutputRoot,
288
+ });
260
289
  const index = { schemaVersion: 1, contract: INDEX_CONTRACT, requests: [] };
261
290
  const indexPath = path.join(resolvedOutputRoot, "index.json");
262
291
  fs.writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`);
@@ -274,8 +303,7 @@ export function sealArtifactSigningRequests({
274
303
  assertInside(resolvedWorkspace, resolvedManifest, "artifact manifest");
275
304
  const manifest = JSON.parse(fs.readFileSync(resolvedManifest, "utf8"));
276
305
  const platform = normalizePlatform(manifest.platform?.os || process.platform);
277
- const requests = [];
278
- for (const declaration of selected) {
306
+ const prepared = selected.map((declaration) => {
279
307
  const subjectPath = path.resolve(resolvedCwd, declaration.path);
280
308
  assertInside(
281
309
  resolvedWorkspace,
@@ -288,7 +316,7 @@ export function sealArtifactSigningRequests({
288
316
  );
289
317
  const realSubject = fs.realpathSync(subjectPath);
290
318
  assertInside(
291
- resolvedWorkspace,
319
+ fs.realpathSync(resolvedWorkspace),
292
320
  realSubject,
293
321
  `signing artifact ${declaration.id}`,
294
322
  );
@@ -299,8 +327,25 @@ export function sealArtifactSigningRequests({
299
327
  subjectPath,
300
328
  descriptor,
301
329
  });
302
- const id = safeId(declaration.id);
303
- const kind = inferKind(subjectPath, declaration.kind);
330
+ return {
331
+ declaration,
332
+ subjectPath,
333
+ descriptor,
334
+ id: safeId(declaration.id),
335
+ kind: inferKind(subjectPath, declaration.kind),
336
+ };
337
+ });
338
+ resetGeneratedOutputRoot({
339
+ workspace: resolvedWorkspace,
340
+ cwd: resolvedCwd,
341
+ outputRoot: resolvedOutputRoot,
342
+ protectedPaths: [
343
+ resolvedManifest,
344
+ ...prepared.map((entry) => entry.subjectPath),
345
+ ],
346
+ });
347
+ const requests = [];
348
+ for (const { declaration, subjectPath, descriptor, id, kind } of prepared) {
304
349
  const transport = archiveSubject({
305
350
  subjectPath,
306
351
  outputRoot: resolvedOutputRoot,