@kungfu-tech/buildchain 2.14.3-alpha.0 → 2.14.3-alpha.10

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 (30) hide show
  1. package/dist/site/buildchain-contract.json +132 -26
  2. package/dist/site/buildchain-site.json +16 -16
  3. package/dist/site/capability-registry.json +1 -1
  4. package/dist/site/controller-registry.json +48 -4
  5. package/dist/site/kfd-claims.json +111 -8
  6. package/dist/site/kfd-upstream-aggregate.json +1 -1
  7. package/dist/site/manual-registry.json +4 -4
  8. package/dist/site/node-api-registry.json +6 -6
  9. package/dist/site/page-registry.json +8 -8
  10. package/dist/site/public-surface-audit.json +124 -6
  11. package/dist/site/publication-authority-registry.json +26 -1
  12. package/dist/site/publication-registry.json +4 -4
  13. package/dist/site/site-manifest.json +8 -8
  14. package/dist/site/workflow-registry.json +124 -3
  15. package/docs/migration-inventory.md +14 -0
  16. package/docs/release-candidate.md +3 -0
  17. package/docs/release-governance.md +5 -4
  18. package/docs/reusable-build-surface.md +36 -1
  19. package/package.json +1 -1
  20. package/packages/core/buildchain-contract.js +41 -0
  21. package/packages/core/buildchain-kfd-claims.js +4 -0
  22. package/packages/core/buildchain-publication-authority.js +1 -0
  23. package/packages/core/controller-evidence.js +1 -1
  24. package/packages/core/publication-control-plane-audit.js +2 -0
  25. package/packages/core/release-passport.js +39 -0
  26. package/scripts/audit-publication-control-plane.mjs +6 -1
  27. package/scripts/check-inventory.mjs +39 -2
  28. package/scripts/generate-channel-promotion-workflow.mjs +413 -0
  29. package/scripts/promotion-channel-router.mjs +125 -0
  30. package/scripts/stable-candidate-patrol.mjs +55 -6
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
+ const sourcePath = path.join(root, ".github/workflows/.release-candidate-promote.yml");
9
+ const targetPath = path.join(root, ".github/workflows/release-candidate-promote.yml");
10
+ const routingPath = path.join(root, ".buildchain/promotion-shell-routing.json");
11
+ const internalInputs = new Set([
12
+ "promotion-router-ref",
13
+ "promotion-router-sha",
14
+ "promotion-shell-ref",
15
+ "promotion-shell-sha",
16
+ "promotion-runtime-ref",
17
+ "promotion-runtime-sha",
18
+ "promotion-contract-lock-path",
19
+ "promotion-contract-lock-digest",
20
+ "promotion-publication-channel",
21
+ "promotion-target-ref",
22
+ "promotion-override-used",
23
+ ]);
24
+
25
+ function blockBetween(source, start, end) {
26
+ const startIndex = source.indexOf(start);
27
+ const endIndex = source.indexOf(end, startIndex + start.length);
28
+ if (startIndex < 0 || endIndex < 0) throw new Error(`unable to locate workflow block: ${start.trim()}`);
29
+ return source.slice(startIndex + start.length, endIndex);
30
+ }
31
+
32
+ function entries(block, indent = 6) {
33
+ const pattern = new RegExp(`^ {${indent}}([a-z0-9-]+):$`, "gm");
34
+ const matches = [...block.matchAll(pattern)];
35
+ return matches.map((match, index) => ({
36
+ name: match[1],
37
+ source: block.slice(match.index, matches[index + 1]?.index ?? block.length).trimEnd(),
38
+ }));
39
+ }
40
+
41
+ function publicInputs(inputBlock) {
42
+ const retained = entries(inputBlock)
43
+ .filter((entry) => !internalInputs.has(entry.name))
44
+ .map((entry) => entry.name === "buildchain-contract-lock-path"
45
+ ? entry.source.replace(' default: ".buildchain/contract-lock.json"', ' default: ""')
46
+ : entry.source);
47
+ return [
48
+ " buildchain-channel:",
49
+ ' description: "Promotion workflow-shell/runtime channel: auto, alpha, or stable"',
50
+ ' default: "auto"',
51
+ " type: string",
52
+ " required: false",
53
+ " buildchain-alpha-contract-lock-path:",
54
+ ' description: "Consumer-owned contract lock selected for alpha promotion"',
55
+ ' default: ".buildchain/alpha-contract-lock.json"',
56
+ " type: string",
57
+ " required: false",
58
+ " buildchain-stable-contract-lock-path:",
59
+ ' description: "Consumer-owned contract lock selected for stable promotion"',
60
+ ' default: ".buildchain/contract-lock.json"',
61
+ " type: string",
62
+ " required: false",
63
+ ...retained,
64
+ ].join("\n");
65
+ }
66
+
67
+ function publicOutputs(outputBlock) {
68
+ const forwarded = entries(outputBlock).map((entry) => entry.source.replace(
69
+ /^ value:.*$/m,
70
+ ` value: \${{ jobs.alpha.outputs.${entry.name} || jobs.stable.outputs.${entry.name} }}`,
71
+ ));
72
+ const routed = [
73
+ ["buildchain-channel", "Selected promotion workflow-shell channel", "channel"],
74
+ ["promotion-router-ref", "Router workflow ref", "router-ref"],
75
+ ["promotion-router-sha", "Resolved router workflow SHA", "router-sha"],
76
+ ["promotion-shell-ref", "Selected advanced promotion workflow-shell ref", "shell-ref"],
77
+ ["promotion-shell-sha", "Resolved advanced promotion workflow-shell SHA", "shell-sha"],
78
+ ["promotion-runtime-ref", "Selected Buildchain runtime ref", "runtime-ref"],
79
+ ["promotion-runtime-sha", "Resolved Buildchain runtime SHA", "runtime-sha"],
80
+ ["promotion-contract-lock-path", "Selected consumer contract-lock path", "contract-lock-path"],
81
+ ["promotion-contract-lock-digest", "Selected consumer contract-lock sha256 digest", "contract-lock-digest"],
82
+ ["promotion-publication-channel", "Derived publication channel", "publication-channel"],
83
+ ["promotion-target-ref", "Derived promotion target ref", "target-ref"],
84
+ ["promotion-override-used", "Whether a trusted runtime override was used", "override-used"],
85
+ ].map(([name, description, output]) => [
86
+ ` ${name}:`,
87
+ ` description: "${description}"`,
88
+ ` value: \${{ jobs.resolve-promotion.outputs.${output} }}`,
89
+ ].join("\n"));
90
+ return [...routed, ...forwarded].join("\n");
91
+ }
92
+
93
+ function forwardedInputs(inputNames, { includeInternal = true } = {}) {
94
+ return inputNames
95
+ .filter((name) => includeInternal || !internalInputs.has(name))
96
+ .map((name) => {
97
+ const routed = {
98
+ "buildchain-ref": "runtime-ref",
99
+ "buildchain-contract-lock-path": "contract-lock-path",
100
+ channel: "publication-channel",
101
+ "target-ref": "target-ref",
102
+ "promotion-router-ref": "router-ref",
103
+ "promotion-router-sha": "router-sha",
104
+ "promotion-shell-ref": "shell-ref",
105
+ "promotion-shell-sha": "shell-sha",
106
+ "promotion-runtime-ref": "runtime-ref",
107
+ "promotion-runtime-sha": "runtime-sha",
108
+ "promotion-contract-lock-path": "contract-lock-path",
109
+ "promotion-contract-lock-digest": "contract-lock-digest",
110
+ "promotion-publication-channel": "publication-channel",
111
+ "promotion-target-ref": "target-ref",
112
+ "promotion-override-used": "override-used",
113
+ }[name];
114
+ if (name === "promotion-override-used") {
115
+ return ` ${name}: \${{ needs.resolve-promotion.outputs.override-used == 'true' }}`;
116
+ }
117
+ return routed
118
+ ? ` ${name}: \${{ needs.resolve-promotion.outputs.${routed} }}`
119
+ : ` ${name}: \${{ inputs.${name} }}`;
120
+ }).join("\n");
121
+ }
122
+
123
+ function validateWorkflowRoute(name, route, expectedLogicalRef) {
124
+ if (!route || typeof route !== "object") throw new Error(`promotion shell routing missing ${name} route`);
125
+ if (route.logicalRef !== expectedLogicalRef) {
126
+ throw new Error(`promotion shell ${name} logicalRef must be ${expectedLogicalRef}`);
127
+ }
128
+ if (!/^\.github\/workflows\/[.a-z0-9-]+\.ya?ml$/.test(route.workflowPath || "")) {
129
+ throw new Error(`promotion shell ${name} workflowPath must name a reusable workflow`);
130
+ }
131
+ if (!/^(?:v[0-9]+(?:-alpha)?|[0-9a-f]{40})$/.test(route.callRef || "")) {
132
+ throw new Error(`promotion shell ${name} callRef must be an official channel ref or exact SHA`);
133
+ }
134
+ if (typeof route.forwardInternalInputs !== "boolean") {
135
+ throw new Error(`promotion shell ${name} forwardInternalInputs must be boolean`);
136
+ }
137
+ return { ...route };
138
+ }
139
+
140
+ export function parsePromotionShellRouting(source, { major = 2 } = {}) {
141
+ const parsed = JSON.parse(source);
142
+ if (parsed.schemaVersion !== 1) throw new Error("promotion shell routing schemaVersion must be 1");
143
+ if (parsed.major !== major) throw new Error(`promotion shell routing major must be ${major}`);
144
+ return {
145
+ alpha: validateWorkflowRoute("alpha", parsed.alpha, `v${major}-alpha`),
146
+ stable: validateWorkflowRoute("stable", parsed.stable, `v${major}`),
147
+ };
148
+ }
149
+
150
+ export function generateChannelPromotionWorkflow(source, { major = 2, shellRouting } = {}) {
151
+ const routes = shellRouting || {
152
+ alpha: {
153
+ logicalRef: `v${major}-alpha`,
154
+ callRef: `v${major}-alpha`,
155
+ workflowPath: ".github/workflows/.release-candidate-promote.yml",
156
+ forwardInternalInputs: true,
157
+ },
158
+ stable: {
159
+ logicalRef: `v${major}`,
160
+ callRef: `v${major}`,
161
+ workflowPath: ".github/workflows/.release-candidate-promote.yml",
162
+ forwardInternalInputs: true,
163
+ },
164
+ };
165
+ const alphaRoute = validateWorkflowRoute("alpha", routes.alpha, `v${major}-alpha`);
166
+ const stableRoute = validateWorkflowRoute("stable", routes.stable, `v${major}`);
167
+ const inputs = blockBetween(source, " inputs:\n", " secrets:\n");
168
+ const secrets = blockBetween(source, " secrets:\n", " outputs:\n");
169
+ const outputs = blockBetween(source, " outputs:\n", "\nconcurrency:\n");
170
+ const inputNames = entries(inputs).map((entry) => entry.name);
171
+ for (const required of ["buildchain-ref", "buildchain-contract-lock-path", "channel", "target-ref", ...internalInputs]) {
172
+ if (!inputNames.includes(required)) throw new Error(`advanced promotion workflow missing input: ${required}`);
173
+ }
174
+ const alphaForwarded = forwardedInputs(inputNames, {
175
+ includeInternal: alphaRoute.forwardInternalInputs,
176
+ });
177
+ const stableForwarded = forwardedInputs(inputNames, {
178
+ includeInternal: stableRoute.forwardInternalInputs,
179
+ });
180
+ return `# Generated by scripts/generate-channel-promotion-workflow.mjs. Do not edit directly.
181
+ name: Release Candidate Promote
182
+
183
+ on:
184
+ workflow_call:
185
+ inputs:
186
+ ${publicInputs(inputs)}
187
+ secrets:
188
+ ${secrets.trimEnd()}
189
+ outputs:
190
+ ${publicOutputs(outputs)}
191
+
192
+ permissions:
193
+ actions: read
194
+ checks: write
195
+ contents: write
196
+ id-token: write
197
+ issues: write
198
+
199
+ jobs:
200
+ resolve-promotion:
201
+ name: Resolve promotion workflow shell and runtime
202
+ runs-on: ubuntu-24.04
203
+ permissions:
204
+ contents: read
205
+ outputs:
206
+ channel: \${{ steps.route.outputs.channel }}
207
+ publication-channel: \${{ steps.route.outputs.publication-channel }}
208
+ target-ref: \${{ steps.route.outputs.target-ref }}
209
+ router-ref: \${{ steps.router.outputs.ref }}
210
+ router-sha: \${{ steps.identities.outputs.router-sha }}
211
+ shell-ref: \${{ steps.route.outputs.shell-ref }}
212
+ shell-sha: \${{ steps.identities.outputs.shell-sha }}
213
+ runtime-ref: \${{ steps.route.outputs.runtime-ref }}
214
+ runtime-sha: \${{ steps.identities.outputs.runtime-sha }}
215
+ contract-lock-path: \${{ steps.lock.outputs.path }}
216
+ contract-lock-digest: \${{ steps.lock.outputs.digest }}
217
+ override-used: \${{ steps.route.outputs.override-used }}
218
+ selection-source: \${{ steps.route.outputs.selection-source }}
219
+ reason: \${{ steps.route.outputs.reason }}
220
+ steps:
221
+ - name: Resolve promotion router source
222
+ id: router
223
+ shell: bash
224
+ env:
225
+ BUILDCHAIN_ROUTER_WORKFLOW_REF: \${{ job.workflow_ref }}
226
+ run: |
227
+ set -euo pipefail
228
+ workflow_ref="\${BUILDCHAIN_ROUTER_WORKFLOW_REF}"
229
+ repository="\${workflow_ref%%/.github/workflows/*}"
230
+ ref="\${workflow_ref##*@}"
231
+ ref="\${ref#refs/heads/}"
232
+ ref="\${ref#refs/tags/}"
233
+ if [[ -z "\${repository}" || "\${repository}" = "\${workflow_ref}" || -z "\${ref}" ]]; then
234
+ echo "::error::Unable to resolve promotion router source from job.workflow_ref=\${workflow_ref}"
235
+ exit 1
236
+ fi
237
+ echo "repository=\${repository}" >> "\${GITHUB_OUTPUT}"
238
+ echo "ref=\${ref}" >> "\${GITHUB_OUTPUT}"
239
+
240
+ - name: Checkout promotion router
241
+ uses: actions/checkout@v7.0.0
242
+ with:
243
+ repository: \${{ steps.router.outputs.repository }}
244
+ ref: \${{ steps.router.outputs.ref }}
245
+ path: .buildchain/router
246
+ persist-credentials: false
247
+
248
+ - name: Resolve promotion channel
249
+ id: route
250
+ shell: bash
251
+ run: >-
252
+ node .buildchain/router/scripts/promotion-channel-router.mjs
253
+ --cwd .buildchain/router
254
+ --channel "\${{ inputs.buildchain-channel }}"
255
+ --buildchain-ref "\${{ inputs.buildchain-ref }}"
256
+ --publication-channel "\${{ inputs.channel }}"
257
+ --target-ref "\${{ inputs.target-ref || github.ref_name }}"
258
+ --router-ref "\${{ steps.router.outputs.ref }}"
259
+
260
+ - name: Authorize trusted runtime override
261
+ if: \${{ steps.route.outputs.override-used == 'true' }}
262
+ uses: actions/github-script@v8
263
+ with:
264
+ script: |
265
+ if (context.eventName !== "workflow_dispatch") {
266
+ throw new Error("promotion runtime override is only allowed for trusted workflow_dispatch runs");
267
+ }
268
+ const permission = await github.rest.repos.getCollaboratorPermissionLevel({
269
+ owner: context.repo.owner,
270
+ repo: context.repo.repo,
271
+ username: context.actor,
272
+ });
273
+ const level = permission.data.user?.permissions || permission.data.permission || "none";
274
+ if (!["write", "maintain", "admin"].includes(level)) {
275
+ throw new Error(\`promotion runtime override requires write, maintain, or admin permission; actor has \${level}\`);
276
+ }
277
+
278
+ - name: Checkout selected promotion workflow shell
279
+ uses: actions/checkout@v7.0.0
280
+ with:
281
+ repository: \${{ steps.router.outputs.repository }}
282
+ ref: \${{ steps.route.outputs.shell-ref }}
283
+ path: .buildchain/shell
284
+ persist-credentials: false
285
+
286
+ - name: Checkout selected Buildchain runtime
287
+ uses: actions/checkout@v7.0.0
288
+ with:
289
+ repository: \${{ steps.router.outputs.repository }}
290
+ ref: \${{ steps.route.outputs.runtime-ref }}
291
+ path: .buildchain/runtime
292
+ persist-credentials: false
293
+
294
+ - name: Checkout promotion source
295
+ uses: actions/checkout@v7.0.0
296
+ with:
297
+ ref: \${{ inputs.target-sha || github.sha }}
298
+ path: .buildchain/source
299
+ persist-credentials: false
300
+
301
+ - name: Select and verify consumer contract lock
302
+ id: lock
303
+ shell: bash
304
+ env:
305
+ EXPLICIT_PATH: \${{ inputs.buildchain-contract-lock-path }}
306
+ ALPHA_PATH: \${{ inputs.buildchain-alpha-contract-lock-path }}
307
+ STABLE_PATH: \${{ inputs.buildchain-stable-contract-lock-path }}
308
+ CHANNEL: \${{ steps.route.outputs.channel }}
309
+ run: |
310
+ set -euo pipefail
311
+ path="\${EXPLICIT_PATH}"
312
+ if [[ -z "\${path}" ]]; then
313
+ if [[ "\${CHANNEL}" = "alpha" ]]; then path="\${ALPHA_PATH}"; else path="\${STABLE_PATH}"; fi
314
+ fi
315
+ if [[ -z "\${path}" || ! -f ".buildchain/source/\${path}" ]]; then
316
+ echo "::error::Selected promotion contract lock is missing: \${path:-<empty>}"
317
+ exit 1
318
+ fi
319
+ digest="sha256:$(shasum -a 256 ".buildchain/source/\${path}" | awk '{print $1}')"
320
+ echo "path=\${path}" >> "\${GITHUB_OUTPUT}"
321
+ echo "digest=\${digest}" >> "\${GITHUB_OUTPUT}"
322
+
323
+ - name: Resolve immutable promotion identities
324
+ id: identities
325
+ shell: bash
326
+ env:
327
+ CHANNEL: \${{ steps.route.outputs.channel }}
328
+ SELECTED_SHELL_REF: \${{ steps.route.outputs.shell-ref }}
329
+ ALPHA_SHELL_REF: ${alphaRoute.logicalRef}
330
+ ALPHA_SHELL_CALL_REF: ${alphaRoute.callRef}
331
+ ALPHA_SHELL_WORKFLOW_PATH: ${alphaRoute.workflowPath}
332
+ STABLE_SHELL_REF: ${stableRoute.logicalRef}
333
+ STABLE_SHELL_CALL_REF: ${stableRoute.callRef}
334
+ STABLE_SHELL_WORKFLOW_PATH: ${stableRoute.workflowPath}
335
+ run: |
336
+ set -euo pipefail
337
+ if [[ "\${CHANNEL}" = "alpha" ]]; then
338
+ expected_ref="\${ALPHA_SHELL_REF}"
339
+ call_ref="\${ALPHA_SHELL_CALL_REF}"
340
+ workflow_path="\${ALPHA_SHELL_WORKFLOW_PATH}"
341
+ else
342
+ expected_ref="\${STABLE_SHELL_REF}"
343
+ call_ref="\${STABLE_SHELL_CALL_REF}"
344
+ workflow_path="\${STABLE_SHELL_WORKFLOW_PATH}"
345
+ fi
346
+ if [[ "\${SELECTED_SHELL_REF}" != "\${expected_ref}" ]]; then
347
+ echo "::error::Selected promotion shell ref \${SELECTED_SHELL_REF} does not match configured \${expected_ref}"
348
+ exit 1
349
+ fi
350
+ test -f ".buildchain/shell/\${workflow_path}"
351
+ shell_sha="$(git -C .buildchain/shell rev-parse HEAD)"
352
+ if [[ "\${call_ref}" =~ ^[0-9a-f]{40}$ && "\${shell_sha}" != "\${call_ref}" ]]; then
353
+ echo "::error::Pinned promotion shell \${call_ref} does not match selected \${shell_sha}"
354
+ exit 1
355
+ fi
356
+ {
357
+ echo "router-sha=$(git -C .buildchain/router rev-parse HEAD)"
358
+ echo "shell-sha=\${shell_sha}"
359
+ echo "runtime-sha=$(git -C .buildchain/runtime rev-parse HEAD)"
360
+ } >> "\${GITHUB_OUTPUT}"
361
+
362
+ alpha:
363
+ name: Promote with alpha workflow shell
364
+ needs: resolve-promotion
365
+ if: \${{ needs.resolve-promotion.outputs.channel == 'alpha' }}
366
+ uses: kungfu-systems/buildchain/${alphaRoute.workflowPath}@${alphaRoute.callRef}
367
+ permissions:
368
+ actions: read
369
+ checks: write
370
+ contents: write
371
+ id-token: write
372
+ issues: write
373
+ with:
374
+ ${alphaForwarded}
375
+ secrets: inherit
376
+
377
+ stable:
378
+ name: Promote with stable workflow shell
379
+ needs: resolve-promotion
380
+ if: \${{ needs.resolve-promotion.outputs.channel == 'stable' }}
381
+ uses: kungfu-systems/buildchain/${stableRoute.workflowPath}@${stableRoute.callRef}
382
+ permissions:
383
+ actions: read
384
+ checks: write
385
+ contents: write
386
+ id-token: write
387
+ issues: write
388
+ with:
389
+ ${stableForwarded}
390
+ secrets: inherit
391
+ `;
392
+ }
393
+
394
+ function main() {
395
+ const source = fs.readFileSync(sourcePath, "utf8");
396
+ const version = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
397
+ const major = Number(String(version).match(/^(\d+)\./)?.[1]);
398
+ if (!Number.isInteger(major)) throw new Error("package version must expose a numeric major");
399
+ const shellRouting = parsePromotionShellRouting(fs.readFileSync(routingPath, "utf8"), { major });
400
+ const generated = generateChannelPromotionWorkflow(source, { major, shellRouting });
401
+ if (process.argv.includes("--check")) {
402
+ const current = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : "";
403
+ if (current !== generated) {
404
+ console.error(".github/workflows/release-candidate-promote.yml is stale; run node scripts/generate-channel-promotion-workflow.mjs");
405
+ process.exitCode = 1;
406
+ }
407
+ return;
408
+ }
409
+ fs.writeFileSync(targetPath, generated);
410
+ }
411
+
412
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
413
+ if (isMain) main();
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { resolveBuildchainChannel } from "./buildchain-channel-router.mjs";
7
+
8
+ const TARGETS = [
9
+ { pattern: /^alpha\/v(\d+)\/v\d+\.\d+$/, publicationChannel: "alpha", shellChannel: "alpha" },
10
+ { pattern: /^release\/v(\d+)\/v\d+\.\d+$/, publicationChannel: "release", shellChannel: "stable" },
11
+ { pattern: /^(?:publish-gate\/major|major-gate)$/, publicationChannel: "major", shellChannel: "stable" },
12
+ ];
13
+
14
+ function normalized(value) {
15
+ return String(value ?? "").trim();
16
+ }
17
+
18
+ function targetIntent(targetRef, requestedPublicationChannel = "") {
19
+ const ref = normalized(targetRef).replace(/^refs\/heads\//, "");
20
+ const target = TARGETS.find((entry) => entry.pattern.test(ref));
21
+ if (!target) throw new Error(`unsupported promotion target ref: ${ref || "<empty>"}`);
22
+ const requested = normalized(requestedPublicationChannel).toLowerCase();
23
+ if (requested && requested !== target.publicationChannel) {
24
+ throw new Error(`promotion channel ${requested} does not match target ref ${ref} (${target.publicationChannel})`);
25
+ }
26
+ return {
27
+ targetRef: ref,
28
+ publicationChannel: target.publicationChannel,
29
+ shellChannel: target.shellChannel,
30
+ };
31
+ }
32
+
33
+ export function resolvePromotionChannel({
34
+ requestedChannel = "auto",
35
+ requestedRef = "",
36
+ publicationChannel = "",
37
+ targetRef = "",
38
+ routerRef = "",
39
+ packageVersion = "",
40
+ } = {}) {
41
+ const intent = targetIntent(targetRef, publicationChannel);
42
+ const selected = resolveBuildchainChannel({
43
+ requestedChannel,
44
+ requestedRef,
45
+ publishChannel: intent.publicationChannel,
46
+ eventName: "workflow_call",
47
+ routerRef,
48
+ packageVersion,
49
+ });
50
+ const overrideUsed = selected.channel === "override";
51
+ if (!overrideUsed && selected.channel !== intent.shellChannel) {
52
+ throw new Error(
53
+ `promotion target ${intent.targetRef} requires ${intent.shellChannel} shell/runtime, got ${selected.channel}`,
54
+ );
55
+ }
56
+ const shellRef = intent.shellChannel === "alpha" ? `v${selected.major}-alpha` : `v${selected.major}`;
57
+ return {
58
+ targetRef: intent.targetRef,
59
+ publicationChannel: intent.publicationChannel,
60
+ channel: intent.shellChannel,
61
+ major: selected.major,
62
+ shellRef,
63
+ runtimeRef: selected.buildchainRef,
64
+ overrideUsed,
65
+ selectionSource: selected.selectionSource,
66
+ reason: selected.reason,
67
+ };
68
+ }
69
+
70
+ function parseArgs(argv) {
71
+ const values = {};
72
+ for (let index = 0; index < argv.length; index += 1) {
73
+ const token = argv[index];
74
+ if (!token.startsWith("--")) throw new Error(`unexpected argument: ${token}`);
75
+ const value = argv[index + 1];
76
+ if (value === undefined || value.startsWith("--")) throw new Error(`missing value for ${token}`);
77
+ values[token.slice(2)] = value;
78
+ index += 1;
79
+ }
80
+ return values;
81
+ }
82
+
83
+ function readPackageVersion(cwd) {
84
+ return JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")).version;
85
+ }
86
+
87
+ function writeOutputs(file, result) {
88
+ const outputs = {
89
+ "target-ref": result.targetRef,
90
+ "publication-channel": result.publicationChannel,
91
+ channel: result.channel,
92
+ major: String(result.major),
93
+ "shell-ref": result.shellRef,
94
+ "runtime-ref": result.runtimeRef,
95
+ "override-used": String(result.overrideUsed),
96
+ "selection-source": result.selectionSource,
97
+ reason: result.reason,
98
+ };
99
+ fs.appendFileSync(file, Object.entries(outputs).map(([key, value]) => `${key}=${value}\n`).join(""));
100
+ }
101
+
102
+ function main() {
103
+ const args = parseArgs(process.argv.slice(2));
104
+ const cwd = path.resolve(args.cwd || process.cwd());
105
+ const result = resolvePromotionChannel({
106
+ requestedChannel: args.channel,
107
+ requestedRef: args["buildchain-ref"],
108
+ publicationChannel: args["publication-channel"],
109
+ targetRef: args["target-ref"],
110
+ routerRef: args["router-ref"],
111
+ packageVersion: readPackageVersion(cwd),
112
+ });
113
+ if (process.env.GITHUB_OUTPUT) writeOutputs(process.env.GITHUB_OUTPUT, result);
114
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
115
+ }
116
+
117
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
118
+ if (isMain) {
119
+ try {
120
+ main();
121
+ } catch (error) {
122
+ console.error(`promotion-channel-router: ${error.message}`);
123
+ process.exitCode = 1;
124
+ }
125
+ }
@@ -223,7 +223,7 @@ export async function runStableCandidatePatrol(optionsInput = {}, clientInput) {
223
223
  `Buildchain Stable Candidate Patrol human release-now for ${selection.candidate.version}`,
224
224
  );
225
225
  }
226
- await client.ensureBranch(refs.sourceRef, selection.candidate.sha);
226
+ await client.ensureBranch(refs.sourceRef, selection.candidate.sha, refs.targetRef);
227
227
  const pullRequest = await client.ensurePromotionPullRequest({
228
228
  head: refs.sourceRef,
229
229
  base: refs.targetRef,
@@ -371,15 +371,64 @@ export function createGitHubStableCandidateClient({ repository: repositoryInput,
371
371
  },
372
372
  });
373
373
  },
374
- async ensureBranch(ref, candidateSha) {
374
+ async ensureBranch(ref, candidateSha, targetRef) {
375
375
  const current = await api(`/repos/${owner}/${repo}/git/ref/heads/${encodeRef(ref)}`, { allow404: true });
376
- if (!current) {
376
+ if (!targetRef) {
377
+ if (!current) {
378
+ return api(`/repos/${owner}/${repo}/git/refs`, { method: "POST", body: { ref: `refs/heads/${ref}`, sha: candidateSha } });
379
+ }
380
+ if (current.object.sha !== candidateSha) {
381
+ throw new Error(`source-lock branch ${ref} already points to ${current.object.sha}, not ${candidateSha}`);
382
+ }
383
+ return current;
384
+ }
385
+
386
+ const candidateCommit = await api(`/repos/${owner}/${repo}/git/commits/${candidateSha}`);
387
+ let sourceSha = current?.object?.sha || candidateSha;
388
+ if (sourceSha !== candidateSha) {
389
+ const sourceCommit = await api(`/repos/${owner}/${repo}/git/commits/${sourceSha}`);
390
+ if (sourceCommit.tree?.sha !== candidateCommit.tree?.sha) {
391
+ throw new Error(`source-lock branch ${ref} no longer preserves candidate ${candidateSha} tree`);
392
+ }
393
+ const candidateLineage = await api(`/repos/${owner}/${repo}/compare/${candidateSha}...${sourceSha}`);
394
+ if (!["ahead", "identical"].includes(candidateLineage.status)) {
395
+ throw new Error(`source-lock branch ${ref} no longer descends from candidate ${candidateSha}`);
396
+ }
397
+ }
398
+
399
+ const target = await api(`/repos/${owner}/${repo}/git/ref/heads/${encodeRef(targetRef)}`);
400
+ const targetSha = target.object?.sha || "";
401
+ const targetLineage = await api(`/repos/${owner}/${repo}/compare/${targetSha}...${sourceSha}`);
402
+ if (["ahead", "identical"].includes(targetLineage.status)) {
403
+ if (current) return current;
377
404
  return api(`/repos/${owner}/${repo}/git/refs`, { method: "POST", body: { ref: `refs/heads/${ref}`, sha: candidateSha } });
378
405
  }
379
- if (current.object.sha !== candidateSha) {
380
- throw new Error(`source-lock branch ${ref} already points to ${current.object.sha}, not ${candidateSha}`);
406
+
407
+ const identity = { name: "Buildchain Patrol", email: "buildchain-patrol@kungfu.link" };
408
+ const reconciliation = await api(`/repos/${owner}/${repo}/git/commits`, {
409
+ method: "POST",
410
+ body: {
411
+ message: [
412
+ "chore(buildchain): reconcile stable source-lock ancestry",
413
+ "",
414
+ `Preserve the exact candidate tree from ${candidateSha} while admitting ${targetRef} at ${targetSha}.`,
415
+ "",
416
+ "Signed-off-by: Buildchain Patrol <buildchain-patrol@kungfu.link>",
417
+ ].join("\n"),
418
+ tree: candidateCommit.tree.sha,
419
+ parents: [sourceSha, targetSha],
420
+ author: identity,
421
+ committer: identity,
422
+ },
423
+ });
424
+ sourceSha = reconciliation.sha;
425
+ if (!current) {
426
+ return api(`/repos/${owner}/${repo}/git/refs`, { method: "POST", body: { ref: `refs/heads/${ref}`, sha: sourceSha } });
381
427
  }
382
- return current;
428
+ return api(`/repos/${owner}/${repo}/git/refs/heads/${encodeRef(ref)}`, {
429
+ method: "PATCH",
430
+ body: { sha: sourceSha, force: false },
431
+ });
383
432
  },
384
433
  async ensurePromotionPullRequest({ head, base, title, body }) {
385
434
  const open = await api(`/repos/${owner}/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${head}`)}&base=${encodeURIComponent(base)}&per_page=20`);