@kungfu-tech/buildchain 2.14.3-alpha.1 → 2.14.3-alpha.11

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.
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
8
  const sourcePath = path.join(root, ".github/workflows/.release-candidate-promote.yml");
9
9
  const targetPath = path.join(root, ".github/workflows/release-candidate-promote.yml");
10
+ const routingPath = path.join(root, ".buildchain/promotion-shell-routing.json");
10
11
  const internalInputs = new Set([
11
12
  "promotion-router-ref",
12
13
  "promotion-router-sha",
@@ -89,32 +90,80 @@ function publicOutputs(outputBlock) {
89
90
  return [...routed, ...forwarded].join("\n");
90
91
  }
91
92
 
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");
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");
115
121
  }
116
122
 
117
- export function generateChannelPromotionWorkflow(source, { major = 2 } = {}) {
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}`);
118
167
  const inputs = blockBetween(source, " inputs:\n", " secrets:\n");
119
168
  const secrets = blockBetween(source, " secrets:\n", " outputs:\n");
120
169
  const outputs = blockBetween(source, " outputs:\n", "\nconcurrency:\n");
@@ -122,7 +171,12 @@ export function generateChannelPromotionWorkflow(source, { major = 2 } = {}) {
122
171
  for (const required of ["buildchain-ref", "buildchain-contract-lock-path", "channel", "target-ref", ...internalInputs]) {
123
172
  if (!inputNames.includes(required)) throw new Error(`advanced promotion workflow missing input: ${required}`);
124
173
  }
125
- const forwarded = forwardedInputs(inputNames);
174
+ const alphaForwarded = forwardedInputs(inputNames, {
175
+ includeInternal: alphaRoute.forwardInternalInputs,
176
+ });
177
+ const stableForwarded = forwardedInputs(inputNames, {
178
+ includeInternal: stableRoute.forwardInternalInputs,
179
+ });
126
180
  return `# Generated by scripts/generate-channel-promotion-workflow.mjs. Do not edit directly.
127
181
  name: Release Candidate Promote
128
182
 
@@ -269,12 +323,39 @@ jobs:
269
323
  - name: Resolve immutable promotion identities
270
324
  id: identities
271
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}
272
335
  run: |
273
336
  set -euo pipefail
274
- test -f .buildchain/shell/.github/workflows/.release-candidate-promote.yml
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
275
356
  {
276
357
  echo "router-sha=$(git -C .buildchain/router rev-parse HEAD)"
277
- echo "shell-sha=$(git -C .buildchain/shell rev-parse HEAD)"
358
+ echo "shell-sha=\${shell_sha}"
278
359
  echo "runtime-sha=$(git -C .buildchain/runtime rev-parse HEAD)"
279
360
  } >> "\${GITHUB_OUTPUT}"
280
361
 
@@ -282,7 +363,7 @@ jobs:
282
363
  name: Promote with alpha workflow shell
283
364
  needs: resolve-promotion
284
365
  if: \${{ needs.resolve-promotion.outputs.channel == 'alpha' }}
285
- uses: kungfu-systems/buildchain/.github/workflows/.release-candidate-promote.yml@v${major}-alpha
366
+ uses: kungfu-systems/buildchain/${alphaRoute.workflowPath}@${alphaRoute.callRef}
286
367
  permissions:
287
368
  actions: read
288
369
  checks: write
@@ -290,14 +371,14 @@ jobs:
290
371
  id-token: write
291
372
  issues: write
292
373
  with:
293
- ${forwarded}
374
+ ${alphaForwarded}
294
375
  secrets: inherit
295
376
 
296
377
  stable:
297
378
  name: Promote with stable workflow shell
298
379
  needs: resolve-promotion
299
380
  if: \${{ needs.resolve-promotion.outputs.channel == 'stable' }}
300
- uses: kungfu-systems/buildchain/.github/workflows/.release-candidate-promote.yml@v${major}
381
+ uses: kungfu-systems/buildchain/${stableRoute.workflowPath}@${stableRoute.callRef}
301
382
  permissions:
302
383
  actions: read
303
384
  checks: write
@@ -305,7 +386,7 @@ ${forwarded}
305
386
  id-token: write
306
387
  issues: write
307
388
  with:
308
- ${forwarded}
389
+ ${stableForwarded}
309
390
  secrets: inherit
310
391
  `;
311
392
  }
@@ -315,7 +396,8 @@ function main() {
315
396
  const version = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
316
397
  const major = Number(String(version).match(/^(\d+)\./)?.[1]);
317
398
  if (!Number.isInteger(major)) throw new Error("package version must expose a numeric major");
318
- const generated = generateChannelPromotionWorkflow(source, { major });
399
+ const shellRouting = parsePromotionShellRouting(fs.readFileSync(routingPath, "utf8"), { major });
400
+ const generated = generateChannelPromotionWorkflow(source, { major, shellRouting });
319
401
  if (process.argv.includes("--check")) {
320
402
  const current = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : "";
321
403
  if (current !== generated) {
@@ -23,12 +23,10 @@ function targetIntent(targetRef, requestedPublicationChannel = "") {
23
23
  if (requested && requested !== target.publicationChannel) {
24
24
  throw new Error(`promotion channel ${requested} does not match target ref ${ref} (${target.publicationChannel})`);
25
25
  }
26
- const match = ref.match(target.pattern);
27
26
  return {
28
27
  targetRef: ref,
29
28
  publicationChannel: target.publicationChannel,
30
29
  shellChannel: target.shellChannel,
31
- targetMajor: match?.[1] ? Number(match[1]) : undefined,
32
30
  };
33
31
  }
34
32
 
@@ -55,11 +53,6 @@ export function resolvePromotionChannel({
55
53
  `promotion target ${intent.targetRef} requires ${intent.shellChannel} shell/runtime, got ${selected.channel}`,
56
54
  );
57
55
  }
58
- if (Number.isInteger(intent.targetMajor) && selected.major !== intent.targetMajor) {
59
- throw new Error(
60
- `promotion target ${intent.targetRef} requires Buildchain v${intent.targetMajor}, got v${selected.major}`,
61
- );
62
- }
63
56
  const shellRef = intent.shellChannel === "alpha" ? `v${selected.major}-alpha` : `v${selected.major}`;
64
57
  return {
65
58
  targetRef: intent.targetRef,
@@ -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`);