@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.
- package/dist/site/buildchain-contract.json +132 -26
- package/dist/site/buildchain-site.json +16 -16
- package/dist/site/capability-registry.json +1 -1
- package/dist/site/controller-registry.json +48 -4
- package/dist/site/kfd-claims.json +111 -8
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +4 -4
- package/dist/site/node-api-registry.json +6 -6
- package/dist/site/page-registry.json +8 -8
- package/dist/site/public-surface-audit.json +124 -6
- package/dist/site/publication-authority-registry.json +26 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +8 -8
- package/dist/site/workflow-registry.json +124 -3
- package/docs/migration-inventory.md +14 -0
- package/docs/release-candidate.md +3 -0
- package/docs/release-governance.md +5 -4
- package/docs/reusable-build-surface.md +36 -1
- package/package.json +1 -1
- package/packages/core/buildchain-contract.js +41 -0
- package/packages/core/buildchain-kfd-claims.js +4 -0
- package/packages/core/buildchain-publication-authority.js +1 -0
- package/packages/core/controller-evidence.js +1 -1
- package/packages/core/publication-control-plane-audit.js +2 -0
- package/packages/core/release-passport.js +39 -0
- package/scripts/audit-publication-control-plane.mjs +6 -1
- package/scripts/check-inventory.mjs +39 -2
- package/scripts/generate-channel-promotion-workflow.mjs +413 -0
- package/scripts/promotion-channel-router.mjs +125 -0
- 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 (!
|
|
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
|
-
|
|
380
|
-
|
|
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
|
|
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`);
|