@kungfu-tech/buildchain 2.14.3-alpha.0 → 2.14.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.
@@ -89,6 +89,7 @@ export function createBuildchainContractWorld({
89
89
  "publication-artifact",
90
90
  "paper-release",
91
91
  "release-candidate-promote",
92
+ ".release-candidate-promote",
92
93
  "release-propagation",
93
94
  ]);
94
95
  const hasCompleteControllerSource = [...controllerWorkflowIds]
@@ -173,11 +174,17 @@ export function createBuildchainContractWorld({
173
174
  requiredInputs: ["channel"],
174
175
  requiredOutputs: ["promoted-sha", "built-source-sha", "release-candidate-artifact"],
175
176
  breakingDefaults: {
177
+ channelDefault: "auto",
178
+ alphaShellDefault: `${majorLine}-alpha`,
179
+ stableShellDefault: majorLine,
176
180
  promoteOnlyReleaseCandidate: true,
177
181
  requiredStatusCheck: "check / check",
178
182
  },
179
183
  optionalInputs: [
184
+ "buildchain-channel",
180
185
  "buildchain-ref",
186
+ "buildchain-alpha-contract-lock-path",
187
+ "buildchain-stable-contract-lock-path",
181
188
  "release-candidate-workflow-file",
182
189
  "release-candidate-workflow-name",
183
190
  "publish-required-artifacts-json",
@@ -210,6 +217,9 @@ export function createBuildchainContractWorld({
210
217
  ],
211
218
  },
212
219
  guarantees: [
220
+ "one declarative promotion job selects matching alpha or stable workflow shell, runtime, and contract lock",
221
+ "router, shell, runtime, lock, publication channel, target ref, and override identities are bound before candidate download",
222
+ "train and exact-SHA runtime overrides remain limited to trusted workflow_dispatch actors",
213
223
  "promotion reuses PR-stage release-candidate artifacts",
214
224
  "promotion does not run the heavy native build matrix",
215
225
  "built source and promotion channel SHA are recorded separately",
@@ -218,6 +228,37 @@ export function createBuildchainContractWorld({
218
228
  "GitHub Release passport and evidence publication is delegated to promote-buildchain-ref after the semver release transaction completes",
219
229
  ],
220
230
  }),
231
+ surface(root, {
232
+ id: "advanced-release-candidate-promote",
233
+ kind: "workflow",
234
+ path: ".github/workflows/.release-candidate-promote.yml",
235
+ publicRef: `${pkg.repository ? "kungfu-systems/buildchain" : "buildchain"}/.github/workflows/.release-candidate-promote.yml@${majorLine}`,
236
+ requiredInputs: ["channel"],
237
+ requiredOutputs: ["promoted-sha", "built-source-sha", "release-candidate-artifact"],
238
+ breakingDefaults: {
239
+ promoteOnlyReleaseCandidate: true,
240
+ requiredStatusCheck: "check / check",
241
+ },
242
+ optionalInputs: [
243
+ "buildchain-ref",
244
+ "buildchain-contract-lock-path",
245
+ "promotion-router-ref",
246
+ "promotion-router-sha",
247
+ "promotion-shell-ref",
248
+ "promotion-shell-sha",
249
+ "promotion-runtime-ref",
250
+ "promotion-runtime-sha",
251
+ "promotion-contract-lock-path",
252
+ "promotion-contract-lock-digest",
253
+ "promotion-publication-channel",
254
+ "promotion-target-ref",
255
+ "promotion-override-used",
256
+ ],
257
+ guarantees: [
258
+ "advanced promotion verifies routed shell, runtime, lock, publication channel, and target bindings before candidate resolution",
259
+ "promotion reuses PR-stage release-candidate artifacts and does not run the heavy native build matrix",
260
+ ],
261
+ }),
221
262
  surface(root, {
222
263
  id: "web-surface",
223
264
  kind: "workflow",
@@ -82,6 +82,7 @@ const SCHEMA_AND_STANDARD_FILES = Object.freeze([
82
82
 
83
83
  const WORKFLOW_AND_ACTION_FILES = Object.freeze([
84
84
  ".github/workflows/.build.yml",
85
+ ".github/workflows/.release-candidate-promote.yml",
85
86
  ".github/workflows/release-candidate-promote.yml",
86
87
  ".github/workflows/buildchain-ref-promotion.yml",
87
88
  ".github/workflows/release-propagation.yml",
@@ -372,6 +373,7 @@ export function createBuildchainPublicClaimDefinitions() {
372
373
  "packages/core/buildchain-contract.js",
373
374
  "scripts/buildchain-contract-lock.mjs",
374
375
  ".github/workflows/.build.yml",
376
+ ".github/workflows/.release-candidate-promote.yml",
375
377
  ".github/workflows/release-candidate-promote.yml",
376
378
  "docs/reusable-build-surface.md",
377
379
  ],
@@ -387,6 +389,7 @@ export function createBuildchainPublicClaimDefinitions() {
387
389
  "actions/promote-buildchain-ref/index.js",
388
390
  "actions/promote-buildchain-ref/action.yml",
389
391
  "scripts/ensure-github-release.mjs",
392
+ ".github/workflows/.release-candidate-promote.yml",
390
393
  ".github/workflows/release-candidate-promote.yml",
391
394
  "docs/release-governance.md",
392
395
  ],
@@ -415,6 +418,7 @@ export function createBuildchainPublicClaimDefinitions() {
415
418
  sourcePaths: [
416
419
  "scripts/npm-publish-transaction.mjs",
417
420
  "actions/promote-buildchain-ref/index.js",
421
+ ".github/workflows/.release-candidate-promote.yml",
418
422
  ".github/workflows/release-candidate-promote.yml",
419
423
  "docs/publish-transaction.md",
420
424
  ],
@@ -5,6 +5,7 @@ import { createPublicationAuthorityRegistry } from "./publication-authority.js";
5
5
 
6
6
  const DESCRIPTORS = Object.freeze([
7
7
  [".github/workflows/.build.yml", "non-publication-oidc"],
8
+ [".github/workflows/.release-candidate-promote.yml", "product-publication", true, ["npm-publish", "github-release", "channel-ref"], "trusted-publishing", "none", "fixed", "caller-bound"],
8
9
  [".github/workflows/.publication-authority.yml", "evidence-publication"],
9
10
  [".github/workflows/.release-verify.yml", "governance-write"],
10
11
  [".github/workflows/.sam-release.yml", "retired-deny"],
@@ -72,7 +72,7 @@ const CONTROLLER_SPECS = [
72
72
  },
73
73
  {
74
74
  id: "release-candidate-promotion",
75
- workflowId: "release-candidate-promote",
75
+ workflowId: ".release-candidate-promote",
76
76
  version: 1,
77
77
  capabilities: ["promotion-preflight", "artifact-admission", "publication-authority", "publish-transaction", "release-passport"],
78
78
  stages: ["preflight", "admit-release-candidate", "publication-authority", "publish", "passport", "aggregate"],
@@ -926,6 +926,43 @@ function normalizeTransaction(value = undefined) {
926
926
  };
927
927
  }
928
928
 
929
+ function normalizePromotionRouting(value = undefined) {
930
+ if (value === undefined) return undefined;
931
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
932
+ throw new Error("release.promotionRouting must be a JSON object");
933
+ }
934
+ if (value.contract !== "buildchain.promotion-routing/v1") {
935
+ throw new Error("release.promotionRouting contract must be buildchain.promotion-routing/v1");
936
+ }
937
+ for (const [label, field] of [
938
+ ["router.ref", value.router?.ref],
939
+ ["router.sha", value.router?.sha],
940
+ ["shell.ref", value.shell?.ref],
941
+ ["shell.sha", value.shell?.sha],
942
+ ["runtime.requestedRef", value.runtime?.requestedRef],
943
+ ["runtime.resolvedSha", value.runtime?.resolvedSha],
944
+ ["contractLock.path", value.contractLock?.path],
945
+ ["contractLock.digest", value.contractLock?.digest],
946
+ ["publication.channel", value.publication?.channel],
947
+ ["publication.targetRef", value.publication?.targetRef],
948
+ ]) {
949
+ if (!String(field || "").trim()) throw new Error(`release.promotionRouting.${label} is required`);
950
+ }
951
+ for (const [label, sha] of [
952
+ ["router.sha", value.router.sha],
953
+ ["shell.sha", value.shell.sha],
954
+ ["runtime.resolvedSha", value.runtime.resolvedSha],
955
+ ]) {
956
+ if (!/^[0-9a-f]{40}$/i.test(String(sha))) {
957
+ throw new Error(`release.promotionRouting.${label} must be a 40-character Git SHA`);
958
+ }
959
+ }
960
+ if (!/^sha256:[0-9a-f]{64}$/i.test(String(value.contractLock.digest))) {
961
+ throw new Error("release.promotionRouting.contractLock.digest must be a sha256 digest");
962
+ }
963
+ return JSON.parse(JSON.stringify(value));
964
+ }
965
+
929
966
  export function createArtifactEvidence({ assets = [], repository = "", tag = "", sourceSha = "", workflow = {} } = {}) {
930
967
  const normalizedAssets = assets.map((asset, index) => normalizeAsset(asset, index));
931
968
  return {
@@ -1002,6 +1039,7 @@ export function createReleasePassport({
1002
1039
  const normalizedPackageSet = normalizePackageSet(packageSet, { packageName, packageVersion, publish });
1003
1040
  const normalizedTrustedPublishing = normalizeTrustedPublishing(trustedPublishing, { workflow, publish });
1004
1041
  const normalizedTransaction = normalizeTransaction(transaction);
1042
+ const normalizedPromotionRouting = normalizePromotionRouting(release.promotionRouting);
1005
1043
  const normalizedBuildSummary = buildSummary ? normalizeEvidenceDocument(buildSummary, "buildSummary") : undefined;
1006
1044
  const normalizedBuildFacts = (buildFacts || [])
1007
1045
  .map((fact, index) => normalizeEvidenceDocument(fact, `buildFacts[${index}]`))
@@ -1150,6 +1188,7 @@ export function createReleasePassport({
1150
1188
  ...(anchorManifest ? { anchorManifest } : {}),
1151
1189
  ...(normalizedTrustedPublishing ? { trustedPublishing: normalizedTrustedPublishing } : {}),
1152
1190
  ...(normalizedTransaction ? { transaction: normalizedTransaction } : {}),
1191
+ ...(normalizedPromotionRouting ? { promotionRouting: normalizedPromotionRouting } : {}),
1153
1192
  ...(normalizedBuildSummary ? { buildSummary: normalizedBuildSummary } : {}),
1154
1193
  ...(normalizedBuildFacts.length > 0 ? { buildFacts: normalizedBuildFacts } : {}),
1155
1194
  ...(normalizedPlatformArtifactManifests.length > 0 ? { platformArtifactManifests: normalizedPlatformArtifactManifests } : {}),
@@ -6,6 +6,7 @@ import {
6
6
  } from "../packages/core/public-surface-audit.js";
7
7
  import { evaluateBuildchainContractLock } from "../packages/core/buildchain-contract.js";
8
8
  import { generateChannelBuildWorkflow } from "./generate-channel-build-workflow.mjs";
9
+ import { generateChannelPromotionWorkflow } from "./generate-channel-promotion-workflow.mjs";
9
10
 
10
11
  const root = process.cwd();
11
12
  const sharedActionTsupConfig = fs.readFileSync(path.join(root, "scripts/tsup-action.config.mjs"), "utf8");
@@ -44,6 +45,8 @@ const requiredPaths = [
44
45
  "scripts/release-line-dry-run.mjs",
45
46
  "scripts/buildchain-channel-router.mjs",
46
47
  "scripts/generate-channel-build-workflow.mjs",
48
+ "scripts/promotion-channel-router.mjs",
49
+ "scripts/generate-channel-promotion-workflow.mjs",
47
50
  "scripts/build-standalone-binary.mjs",
48
51
  "scripts/create-release-bundle.mjs",
49
52
  "scripts/ensure-github-release.mjs",
@@ -81,6 +84,7 @@ const requiredPaths = [
81
84
  ".github/workflows/buildchain-patrol-monthly.yml",
82
85
  ".github/workflows/buildchain-alpha-self-dogfood.yml",
83
86
  ".github/workflows/release-candidate-promote.yml",
87
+ ".github/workflows/.release-candidate-promote.yml",
84
88
  ".github/workflows/release-propagation.yml",
85
89
  ".github/workflows/npm-publish.yml",
86
90
  ".github/workflows/paper-release.yml",
@@ -174,6 +178,30 @@ const channelBuildWorkflow = fs.readFileSync(
174
178
  if (channelBuildWorkflow !== generateChannelBuildWorkflow(reusableBuildWorkflow)) {
175
179
  throw new Error("generated channel build workflow is stale");
176
180
  }
181
+ const advancedPromotionWorkflow = fs.readFileSync(
182
+ path.join(root, ".github/workflows/.release-candidate-promote.yml"),
183
+ "utf8",
184
+ );
185
+ const channelPromotionWorkflow = fs.readFileSync(
186
+ path.join(root, ".github/workflows/release-candidate-promote.yml"),
187
+ "utf8",
188
+ );
189
+ if (channelPromotionWorkflow !== generateChannelPromotionWorkflow(advancedPromotionWorkflow, {
190
+ major: Number(selfDogfoodMajor),
191
+ })) {
192
+ throw new Error("generated channel promotion workflow is stale");
193
+ }
194
+ for (const requiredSnippet of [
195
+ "buildchain-channel:",
196
+ `/.github/workflows/.release-candidate-promote.yml@v${selfDogfoodMajor}-alpha`,
197
+ `/.github/workflows/.release-candidate-promote.yml@v${selfDogfoodMajor}`,
198
+ "promotion-contract-lock-digest:",
199
+ "promotion runtime override is only allowed for trusted workflow_dispatch runs",
200
+ ]) {
201
+ if (!channelPromotionWorkflow.includes(requiredSnippet)) {
202
+ throw new Error(`channel promotion workflow missing routing contract: ${requiredSnippet}`);
203
+ }
204
+ }
177
205
  for (const requiredSnippet of [
178
206
  "buildchain-channel:",
179
207
  "uses: ./.github/workflows/.build.yml",
@@ -814,7 +842,7 @@ for (const forbiddenSnippet of [
814
842
  for (const requiredSnippet of [
815
843
  "id-token: write",
816
844
  "actions: read",
817
- "uses: ./.github/workflows/release-candidate-promote.yml",
845
+ "uses: ./.github/workflows/.release-candidate-promote.yml",
818
846
  "github.event.workflow_run.event == 'push'",
819
847
  "!startsWith(github.event.workflow_run.display_title, 'chore(release): prepare v')",
820
848
  "!startsWith(github.event.workflow_run.display_title, 'chore(release): release v')",
@@ -828,7 +856,7 @@ for (const requiredSnippet of [
828
856
  throw new Error(`buildchain ref promotion workflow missing npm transaction snippet: ${requiredSnippet}`);
829
857
  }
830
858
  }
831
- const releaseCandidatePromoteWorkflow = fs.readFileSync(path.join(root, ".github/workflows/release-candidate-promote.yml"), "utf8");
859
+ const releaseCandidatePromoteWorkflow = fs.readFileSync(path.join(root, ".github/workflows/.release-candidate-promote.yml"), "utf8");
832
860
  for (const requiredSnippet of [
833
861
  "release-passport-kfd-1-witness-jsons:",
834
862
  "release-passport-kfd-1-witness-jsons: ${{ inputs.release-passport-kfd-1-witness-jsons }}",
@@ -0,0 +1,331 @@
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 internalInputs = new Set([
11
+ "promotion-router-ref",
12
+ "promotion-router-sha",
13
+ "promotion-shell-ref",
14
+ "promotion-shell-sha",
15
+ "promotion-runtime-ref",
16
+ "promotion-runtime-sha",
17
+ "promotion-contract-lock-path",
18
+ "promotion-contract-lock-digest",
19
+ "promotion-publication-channel",
20
+ "promotion-target-ref",
21
+ "promotion-override-used",
22
+ ]);
23
+
24
+ function blockBetween(source, start, end) {
25
+ const startIndex = source.indexOf(start);
26
+ const endIndex = source.indexOf(end, startIndex + start.length);
27
+ if (startIndex < 0 || endIndex < 0) throw new Error(`unable to locate workflow block: ${start.trim()}`);
28
+ return source.slice(startIndex + start.length, endIndex);
29
+ }
30
+
31
+ function entries(block, indent = 6) {
32
+ const pattern = new RegExp(`^ {${indent}}([a-z0-9-]+):$`, "gm");
33
+ const matches = [...block.matchAll(pattern)];
34
+ return matches.map((match, index) => ({
35
+ name: match[1],
36
+ source: block.slice(match.index, matches[index + 1]?.index ?? block.length).trimEnd(),
37
+ }));
38
+ }
39
+
40
+ function publicInputs(inputBlock) {
41
+ const retained = entries(inputBlock)
42
+ .filter((entry) => !internalInputs.has(entry.name))
43
+ .map((entry) => entry.name === "buildchain-contract-lock-path"
44
+ ? entry.source.replace(' default: ".buildchain/contract-lock.json"', ' default: ""')
45
+ : entry.source);
46
+ return [
47
+ " buildchain-channel:",
48
+ ' description: "Promotion workflow-shell/runtime channel: auto, alpha, or stable"',
49
+ ' default: "auto"',
50
+ " type: string",
51
+ " required: false",
52
+ " buildchain-alpha-contract-lock-path:",
53
+ ' description: "Consumer-owned contract lock selected for alpha promotion"',
54
+ ' default: ".buildchain/alpha-contract-lock.json"',
55
+ " type: string",
56
+ " required: false",
57
+ " buildchain-stable-contract-lock-path:",
58
+ ' description: "Consumer-owned contract lock selected for stable promotion"',
59
+ ' default: ".buildchain/contract-lock.json"',
60
+ " type: string",
61
+ " required: false",
62
+ ...retained,
63
+ ].join("\n");
64
+ }
65
+
66
+ function publicOutputs(outputBlock) {
67
+ const forwarded = entries(outputBlock).map((entry) => entry.source.replace(
68
+ /^ value:.*$/m,
69
+ ` value: \${{ jobs.alpha.outputs.${entry.name} || jobs.stable.outputs.${entry.name} }}`,
70
+ ));
71
+ const routed = [
72
+ ["buildchain-channel", "Selected promotion workflow-shell channel", "channel"],
73
+ ["promotion-router-ref", "Router workflow ref", "router-ref"],
74
+ ["promotion-router-sha", "Resolved router workflow SHA", "router-sha"],
75
+ ["promotion-shell-ref", "Selected advanced promotion workflow-shell ref", "shell-ref"],
76
+ ["promotion-shell-sha", "Resolved advanced promotion workflow-shell SHA", "shell-sha"],
77
+ ["promotion-runtime-ref", "Selected Buildchain runtime ref", "runtime-ref"],
78
+ ["promotion-runtime-sha", "Resolved Buildchain runtime SHA", "runtime-sha"],
79
+ ["promotion-contract-lock-path", "Selected consumer contract-lock path", "contract-lock-path"],
80
+ ["promotion-contract-lock-digest", "Selected consumer contract-lock sha256 digest", "contract-lock-digest"],
81
+ ["promotion-publication-channel", "Derived publication channel", "publication-channel"],
82
+ ["promotion-target-ref", "Derived promotion target ref", "target-ref"],
83
+ ["promotion-override-used", "Whether a trusted runtime override was used", "override-used"],
84
+ ].map(([name, description, output]) => [
85
+ ` ${name}:`,
86
+ ` description: "${description}"`,
87
+ ` value: \${{ jobs.resolve-promotion.outputs.${output} }}`,
88
+ ].join("\n"));
89
+ return [...routed, ...forwarded].join("\n");
90
+ }
91
+
92
+ function forwardedInputs(inputNames) {
93
+ return inputNames.map((name) => {
94
+ const routed = {
95
+ "buildchain-ref": "runtime-ref",
96
+ "buildchain-contract-lock-path": "contract-lock-path",
97
+ channel: "publication-channel",
98
+ "target-ref": "target-ref",
99
+ "promotion-router-ref": "router-ref",
100
+ "promotion-router-sha": "router-sha",
101
+ "promotion-shell-ref": "shell-ref",
102
+ "promotion-shell-sha": "shell-sha",
103
+ "promotion-runtime-ref": "runtime-ref",
104
+ "promotion-runtime-sha": "runtime-sha",
105
+ "promotion-contract-lock-path": "contract-lock-path",
106
+ "promotion-contract-lock-digest": "contract-lock-digest",
107
+ "promotion-publication-channel": "publication-channel",
108
+ "promotion-target-ref": "target-ref",
109
+ "promotion-override-used": "override-used",
110
+ }[name];
111
+ return routed
112
+ ? ` ${name}: \${{ needs.resolve-promotion.outputs.${routed} }}`
113
+ : ` ${name}: \${{ inputs.${name} }}`;
114
+ }).join("\n");
115
+ }
116
+
117
+ export function generateChannelPromotionWorkflow(source, { major = 2 } = {}) {
118
+ const inputs = blockBetween(source, " inputs:\n", " secrets:\n");
119
+ const secrets = blockBetween(source, " secrets:\n", " outputs:\n");
120
+ const outputs = blockBetween(source, " outputs:\n", "\nconcurrency:\n");
121
+ const inputNames = entries(inputs).map((entry) => entry.name);
122
+ for (const required of ["buildchain-ref", "buildchain-contract-lock-path", "channel", "target-ref", ...internalInputs]) {
123
+ if (!inputNames.includes(required)) throw new Error(`advanced promotion workflow missing input: ${required}`);
124
+ }
125
+ const forwarded = forwardedInputs(inputNames);
126
+ return `# Generated by scripts/generate-channel-promotion-workflow.mjs. Do not edit directly.
127
+ name: Release Candidate Promote
128
+
129
+ on:
130
+ workflow_call:
131
+ inputs:
132
+ ${publicInputs(inputs)}
133
+ secrets:
134
+ ${secrets.trimEnd()}
135
+ outputs:
136
+ ${publicOutputs(outputs)}
137
+
138
+ permissions:
139
+ actions: read
140
+ checks: write
141
+ contents: write
142
+ id-token: write
143
+ issues: write
144
+
145
+ jobs:
146
+ resolve-promotion:
147
+ name: Resolve promotion workflow shell and runtime
148
+ runs-on: ubuntu-24.04
149
+ permissions:
150
+ contents: read
151
+ outputs:
152
+ channel: \${{ steps.route.outputs.channel }}
153
+ publication-channel: \${{ steps.route.outputs.publication-channel }}
154
+ target-ref: \${{ steps.route.outputs.target-ref }}
155
+ router-ref: \${{ steps.router.outputs.ref }}
156
+ router-sha: \${{ steps.identities.outputs.router-sha }}
157
+ shell-ref: \${{ steps.route.outputs.shell-ref }}
158
+ shell-sha: \${{ steps.identities.outputs.shell-sha }}
159
+ runtime-ref: \${{ steps.route.outputs.runtime-ref }}
160
+ runtime-sha: \${{ steps.identities.outputs.runtime-sha }}
161
+ contract-lock-path: \${{ steps.lock.outputs.path }}
162
+ contract-lock-digest: \${{ steps.lock.outputs.digest }}
163
+ override-used: \${{ steps.route.outputs.override-used }}
164
+ selection-source: \${{ steps.route.outputs.selection-source }}
165
+ reason: \${{ steps.route.outputs.reason }}
166
+ steps:
167
+ - name: Resolve promotion router source
168
+ id: router
169
+ shell: bash
170
+ env:
171
+ BUILDCHAIN_ROUTER_WORKFLOW_REF: \${{ job.workflow_ref }}
172
+ run: |
173
+ set -euo pipefail
174
+ workflow_ref="\${BUILDCHAIN_ROUTER_WORKFLOW_REF}"
175
+ repository="\${workflow_ref%%/.github/workflows/*}"
176
+ ref="\${workflow_ref##*@}"
177
+ ref="\${ref#refs/heads/}"
178
+ ref="\${ref#refs/tags/}"
179
+ if [[ -z "\${repository}" || "\${repository}" = "\${workflow_ref}" || -z "\${ref}" ]]; then
180
+ echo "::error::Unable to resolve promotion router source from job.workflow_ref=\${workflow_ref}"
181
+ exit 1
182
+ fi
183
+ echo "repository=\${repository}" >> "\${GITHUB_OUTPUT}"
184
+ echo "ref=\${ref}" >> "\${GITHUB_OUTPUT}"
185
+
186
+ - name: Checkout promotion router
187
+ uses: actions/checkout@v7.0.0
188
+ with:
189
+ repository: \${{ steps.router.outputs.repository }}
190
+ ref: \${{ steps.router.outputs.ref }}
191
+ path: .buildchain/router
192
+ persist-credentials: false
193
+
194
+ - name: Resolve promotion channel
195
+ id: route
196
+ shell: bash
197
+ run: >-
198
+ node .buildchain/router/scripts/promotion-channel-router.mjs
199
+ --cwd .buildchain/router
200
+ --channel "\${{ inputs.buildchain-channel }}"
201
+ --buildchain-ref "\${{ inputs.buildchain-ref }}"
202
+ --publication-channel "\${{ inputs.channel }}"
203
+ --target-ref "\${{ inputs.target-ref || github.ref_name }}"
204
+ --router-ref "\${{ steps.router.outputs.ref }}"
205
+
206
+ - name: Authorize trusted runtime override
207
+ if: \${{ steps.route.outputs.override-used == 'true' }}
208
+ uses: actions/github-script@v8
209
+ with:
210
+ script: |
211
+ if (context.eventName !== "workflow_dispatch") {
212
+ throw new Error("promotion runtime override is only allowed for trusted workflow_dispatch runs");
213
+ }
214
+ const permission = await github.rest.repos.getCollaboratorPermissionLevel({
215
+ owner: context.repo.owner,
216
+ repo: context.repo.repo,
217
+ username: context.actor,
218
+ });
219
+ const level = permission.data.user?.permissions || permission.data.permission || "none";
220
+ if (!["write", "maintain", "admin"].includes(level)) {
221
+ throw new Error(\`promotion runtime override requires write, maintain, or admin permission; actor has \${level}\`);
222
+ }
223
+
224
+ - name: Checkout selected promotion workflow shell
225
+ uses: actions/checkout@v7.0.0
226
+ with:
227
+ repository: \${{ steps.router.outputs.repository }}
228
+ ref: \${{ steps.route.outputs.shell-ref }}
229
+ path: .buildchain/shell
230
+ persist-credentials: false
231
+
232
+ - name: Checkout selected Buildchain runtime
233
+ uses: actions/checkout@v7.0.0
234
+ with:
235
+ repository: \${{ steps.router.outputs.repository }}
236
+ ref: \${{ steps.route.outputs.runtime-ref }}
237
+ path: .buildchain/runtime
238
+ persist-credentials: false
239
+
240
+ - name: Checkout promotion source
241
+ uses: actions/checkout@v7.0.0
242
+ with:
243
+ ref: \${{ inputs.target-sha || github.sha }}
244
+ path: .buildchain/source
245
+ persist-credentials: false
246
+
247
+ - name: Select and verify consumer contract lock
248
+ id: lock
249
+ shell: bash
250
+ env:
251
+ EXPLICIT_PATH: \${{ inputs.buildchain-contract-lock-path }}
252
+ ALPHA_PATH: \${{ inputs.buildchain-alpha-contract-lock-path }}
253
+ STABLE_PATH: \${{ inputs.buildchain-stable-contract-lock-path }}
254
+ CHANNEL: \${{ steps.route.outputs.channel }}
255
+ run: |
256
+ set -euo pipefail
257
+ path="\${EXPLICIT_PATH}"
258
+ if [[ -z "\${path}" ]]; then
259
+ if [[ "\${CHANNEL}" = "alpha" ]]; then path="\${ALPHA_PATH}"; else path="\${STABLE_PATH}"; fi
260
+ fi
261
+ if [[ -z "\${path}" || ! -f ".buildchain/source/\${path}" ]]; then
262
+ echo "::error::Selected promotion contract lock is missing: \${path:-<empty>}"
263
+ exit 1
264
+ fi
265
+ digest="sha256:$(shasum -a 256 ".buildchain/source/\${path}" | awk '{print $1}')"
266
+ echo "path=\${path}" >> "\${GITHUB_OUTPUT}"
267
+ echo "digest=\${digest}" >> "\${GITHUB_OUTPUT}"
268
+
269
+ - name: Resolve immutable promotion identities
270
+ id: identities
271
+ shell: bash
272
+ run: |
273
+ set -euo pipefail
274
+ test -f .buildchain/shell/.github/workflows/.release-candidate-promote.yml
275
+ {
276
+ echo "router-sha=$(git -C .buildchain/router rev-parse HEAD)"
277
+ echo "shell-sha=$(git -C .buildchain/shell rev-parse HEAD)"
278
+ echo "runtime-sha=$(git -C .buildchain/runtime rev-parse HEAD)"
279
+ } >> "\${GITHUB_OUTPUT}"
280
+
281
+ alpha:
282
+ name: Promote with alpha workflow shell
283
+ needs: resolve-promotion
284
+ if: \${{ needs.resolve-promotion.outputs.channel == 'alpha' }}
285
+ uses: kungfu-systems/buildchain/.github/workflows/.release-candidate-promote.yml@v${major}-alpha
286
+ permissions:
287
+ actions: read
288
+ checks: write
289
+ contents: write
290
+ id-token: write
291
+ issues: write
292
+ with:
293
+ ${forwarded}
294
+ secrets: inherit
295
+
296
+ stable:
297
+ name: Promote with stable workflow shell
298
+ needs: resolve-promotion
299
+ if: \${{ needs.resolve-promotion.outputs.channel == 'stable' }}
300
+ uses: kungfu-systems/buildchain/.github/workflows/.release-candidate-promote.yml@v${major}
301
+ permissions:
302
+ actions: read
303
+ checks: write
304
+ contents: write
305
+ id-token: write
306
+ issues: write
307
+ with:
308
+ ${forwarded}
309
+ secrets: inherit
310
+ `;
311
+ }
312
+
313
+ function main() {
314
+ const source = fs.readFileSync(sourcePath, "utf8");
315
+ const version = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
316
+ const major = Number(String(version).match(/^(\d+)\./)?.[1]);
317
+ if (!Number.isInteger(major)) throw new Error("package version must expose a numeric major");
318
+ const generated = generateChannelPromotionWorkflow(source, { major });
319
+ if (process.argv.includes("--check")) {
320
+ const current = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : "";
321
+ if (current !== generated) {
322
+ console.error(".github/workflows/release-candidate-promote.yml is stale; run node scripts/generate-channel-promotion-workflow.mjs");
323
+ process.exitCode = 1;
324
+ }
325
+ return;
326
+ }
327
+ fs.writeFileSync(targetPath, generated);
328
+ }
329
+
330
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
331
+ if (isMain) main();