@kungfu-tech/buildchain 4.0.0 → 4.0.1-alpha.2

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 (69) hide show
  1. package/AGENTS.md +13 -5
  2. package/actions/promote-buildchain-ref/README.md +11 -6
  3. package/architecture/internal-capabilities.json +11 -2
  4. package/architecture/maintainability-policy.json +149 -17
  5. package/architecture/v4-floating-consumer-policy.json +75 -0
  6. package/architecture/v4-floating-consumer-policy.md +32 -0
  7. package/architecture/v4-runtime-ref-resume-authority.json +64 -0
  8. package/architecture/v4-stage-capsule-qualification.json +2 -2
  9. package/bin/internal/trust-release-release-handlers.mjs +5 -0
  10. package/contracts/fixtures/v4-floating-consumer-policy-v1/cases.json +53 -0
  11. package/contracts/fixtures/v4-runtime-ref-resume-authority-v1/scenario.json +15 -0
  12. package/contracts/v4-floating-consumer-policy-receipt-v1.schema.json +105 -0
  13. package/contracts/v4-runtime-ref-resume-authority-v1.schema.json +235 -0
  14. package/dist/site/buildchain-contract.json +101 -31
  15. package/dist/site/buildchain-site.json +73 -27
  16. package/dist/site/capability-registry.json +3 -3
  17. package/dist/site/controller-registry.json +62 -6
  18. package/dist/site/kfd-claims.json +68 -13
  19. package/dist/site/kfd-upstream-aggregate.json +1 -1
  20. package/dist/site/manual-registry.json +6 -6
  21. package/dist/site/node-api-registry.json +799 -127
  22. package/dist/site/page-registry.json +63 -17
  23. package/dist/site/public-surface-audit.json +49 -17
  24. package/dist/site/publication-registry.json +4 -4
  25. package/dist/site/release-provenance.json +2 -0
  26. package/dist/site/site-manifest.json +10 -10
  27. package/dist/site/workflow-registry.json +46 -18
  28. package/docs/MAP.md +2 -0
  29. package/docs/dev-delivery-warrant.md +19 -1
  30. package/docs/lifecycle-protocol.md +41 -0
  31. package/docs/node-api-reference.md +207 -134
  32. package/docs/reusable-build-surface.md +41 -0
  33. package/docs/runtime-train-validation.md +10 -0
  34. package/docs/v4-runtime-ref-resume-authority.md +69 -0
  35. package/docs/versioning.md +1 -0
  36. package/package.json +6 -4
  37. package/packages/core/artifact-signing.js +61 -0
  38. package/packages/core/buildchain-config.js +66 -1
  39. package/packages/core/dev-delivery-proof.js +37 -3
  40. package/packages/core/dev-delivery-warrant.js +3 -3
  41. package/packages/core/index.js +2 -0
  42. package/packages/core/publication-authority.js +5 -5
  43. package/packages/core/release-candidate.js +79 -19
  44. package/packages/core/release-passport.js +239 -33
  45. package/packages/core/v4-floating-consumer-evidence.js +324 -0
  46. package/packages/core/v4-floating-consumer-policy.js +446 -0
  47. package/packages/core/v4-floating-consumer-release-passport.js +126 -0
  48. package/packages/core/v4-runtime-ref-resume-authority.js +625 -0
  49. package/packages/core/v4-runtime-selector-persistence.js +228 -0
  50. package/packages/core/workflow-yaml-contract.js +48 -0
  51. package/scripts/audit-publication-control-plane.mjs +31 -31
  52. package/scripts/check-inventory.mjs +1 -1
  53. package/scripts/check-v4-floating-consumer-policy-contract.mjs +198 -0
  54. package/scripts/check-v4-public-dogfood-contract.mjs +6 -11
  55. package/scripts/dev-delivery-source-proof-reuse.mjs +631 -0
  56. package/scripts/dev-pr-auto-merge.mjs +37 -48
  57. package/scripts/dev-pr-delivery-warrant.mjs +205 -2
  58. package/scripts/dev-pr-prequeue-guard.mjs +399 -0
  59. package/scripts/ensure-github-release.mjs +3 -3
  60. package/scripts/generate-channel-build-workflow.mjs +3 -0
  61. package/scripts/generate-channel-promotion-workflow.mjs +112 -12
  62. package/scripts/generate-release-candidate-passport.mjs +41 -33
  63. package/scripts/init-repo.mjs +5 -1
  64. package/scripts/inspect-artifact-signing-requests.mjs +6 -0
  65. package/scripts/npm-publish-transaction.mjs +101 -89
  66. package/scripts/resume-from-candidate-run.mjs +11 -14
  67. package/scripts/seal-artifact-signing-requests.mjs +6 -0
  68. package/scripts/site-capability-metadata.mjs +2 -0
  69. package/scripts/v4-consumer-policy.mjs +231 -0
@@ -0,0 +1,399 @@
1
+ import fs from "node:fs";
2
+
3
+ import {
4
+ classifyDevDeliveryDelta,
5
+ verifyProjectCutReplayProof,
6
+ } from "../packages/core/dev-delivery-warrant.js";
7
+
8
+ function mismatch(code, details = {}) {
9
+ const error = new Error(code);
10
+ error.code = code;
11
+ Object.assign(error, details);
12
+ throw error;
13
+ }
14
+
15
+ function readOptionalJson(file) {
16
+ if (!file) return null;
17
+ try {
18
+ return JSON.parse(fs.readFileSync(file, "utf8"));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function attributedBaseDelta(data, previousBase) {
25
+ const files = Array.isArray(data?.files) ? data.files : [];
26
+ const graphKnown =
27
+ data?.status === "ahead" &&
28
+ data?.merge_base_commit?.sha === previousBase &&
29
+ files.length < 300;
30
+ const renames = files
31
+ .filter((entry) => entry.status === "renamed")
32
+ .map((entry) => ({
33
+ from: String(entry.previous_filename || ""),
34
+ to: String(entry.filename || ""),
35
+ }));
36
+ const attributionComplete =
37
+ graphKnown &&
38
+ files.every((entry) => String(entry.filename || "")) &&
39
+ renames.every((entry) => entry.from && entry.to);
40
+ return {
41
+ graphKnown,
42
+ attributionComplete,
43
+ changedPaths: attributionComplete
44
+ ? [
45
+ ...new Set(
46
+ files
47
+ .flatMap((entry) => [
48
+ String(entry.filename || ""),
49
+ String(entry.previous_filename || ""),
50
+ ])
51
+ .filter(Boolean),
52
+ ),
53
+ ].sort()
54
+ : [],
55
+ renames: attributionComplete ? renames : [],
56
+ };
57
+ }
58
+
59
+ function reuseFailure(decision) {
60
+ if (decision.reason === "dev-delta-overlaps-affected-closure") {
61
+ return "pre-enqueue-base-delta-overlap";
62
+ }
63
+ if (
64
+ [
65
+ "dependency-attribution-unknown",
66
+ "affected-closure-paths-unknown",
67
+ ].includes(decision.reason)
68
+ ) {
69
+ return "pre-enqueue-base-attribution-unknown";
70
+ }
71
+ return "pre-enqueue-source-proof-drift";
72
+ }
73
+
74
+ async function githubBaseDelta(
75
+ client,
76
+ options,
77
+ previousBaseSha,
78
+ currentBaseSha,
79
+ ) {
80
+ if (typeof client.getBaseDelta === "function") {
81
+ return client.getBaseDelta(previousBaseSha, currentBaseSha);
82
+ }
83
+ const { data } = await client.request(
84
+ "GET",
85
+ `/repos/${options.repository.owner}/${options.repository.repo}/compare/${previousBaseSha}...${currentBaseSha}`,
86
+ );
87
+ return data;
88
+ }
89
+
90
+ async function githubCommitTree(client, options, mergeCommitSha) {
91
+ if (typeof client.getCommitTree === "function") {
92
+ return client.getCommitTree(mergeCommitSha);
93
+ }
94
+ const { data } = await client.request(
95
+ "GET",
96
+ `/repos/${options.repository.owner}/${options.repository.repo}/git/commits/${mergeCommitSha}`,
97
+ );
98
+ return String(data?.tree?.sha || "").toLowerCase();
99
+ }
100
+
101
+ export async function projectCutQualification(pr, options, client) {
102
+ if (!options.projectCutProofPath) {
103
+ return { ok: false, reason: "project-cut-proof-required" };
104
+ }
105
+ let proof;
106
+ try {
107
+ proof = JSON.parse(fs.readFileSync(options.projectCutProofPath, "utf8"));
108
+ } catch {
109
+ return { ok: false, reason: "project-cut-proof-invalid" };
110
+ }
111
+ const currentBase = await client
112
+ .getBranchSha(options.targetBranch)
113
+ .catch(() => "");
114
+ const verification = verifyProjectCutReplayProof(proof, {
115
+ repository: options.repository.fullName,
116
+ protectedBase: options.targetBranch,
117
+ pullRequestNumber: Number(pr.number),
118
+ sourceHead: String(pr.head?.sha || "").toLowerCase(),
119
+ ...(options.sourcePatchRoot
120
+ ? { sourcePatchRoot: options.sourcePatchRoot }
121
+ : {}),
122
+ currentBase,
123
+ });
124
+ return verification.ok
125
+ ? {
126
+ ok: true,
127
+ reason: verification.reason,
128
+ proofRoot: verification.proofRoot,
129
+ currentBase,
130
+ }
131
+ : {
132
+ ok: false,
133
+ reason: `project-cut-${verification.reason}`,
134
+ currentBase,
135
+ };
136
+ }
137
+
138
+ async function qualifyProjectCut({
139
+ client,
140
+ options,
141
+ pullRequest,
142
+ previousBaseSha,
143
+ currentBaseSha,
144
+ expectedHeadSha,
145
+ observedPullRequest,
146
+ projectCut,
147
+ mergeableAccepted,
148
+ root,
149
+ }) {
150
+ const observedHeadSha = String(
151
+ observedPullRequest.head?.sha || "",
152
+ ).toLowerCase();
153
+ if (observedHeadSha !== expectedHeadSha) {
154
+ mismatch("head-sha-drift-after-lease-readback", { observedHeadSha });
155
+ }
156
+ if (observedPullRequest.mergeable === false) {
157
+ mismatch("pre-enqueue-merge-conflict");
158
+ }
159
+ if (
160
+ !mergeableAccepted(observedPullRequest, "queue", projectCut?.ok === true)
161
+ ) {
162
+ mismatch("not-mergeable-after-latest-base-replay");
163
+ }
164
+
165
+ let reuseDecision = null;
166
+ if (currentBaseSha !== previousBaseSha) {
167
+ const observedPullRequestBase = String(
168
+ observedPullRequest.base?.sha || "",
169
+ ).toLowerCase();
170
+ if (observedPullRequestBase !== currentBaseSha) {
171
+ mismatch("pre-enqueue-project-cut-base-stale", {
172
+ observedPullRequestBase,
173
+ currentBaseSha,
174
+ });
175
+ }
176
+ const proof = readOptionalJson(options.sourceProofPath);
177
+ if (!proof) mismatch("pre-enqueue-base-attribution-unknown");
178
+ if (
179
+ proof.sourceHead !== expectedHeadSha ||
180
+ proof.proofRoot !== options.verifiedDeliveryWarrant?.sourceProofRoot ||
181
+ (options.sourcePatchRoot &&
182
+ proof.sourcePatchRoot !== options.sourcePatchRoot)
183
+ ) {
184
+ mismatch("pre-enqueue-source-proof-drift");
185
+ }
186
+ let delta;
187
+ try {
188
+ delta = attributedBaseDelta(
189
+ await githubBaseDelta(client, options, previousBaseSha, currentBaseSha),
190
+ previousBaseSha,
191
+ );
192
+ } catch {
193
+ mismatch("pre-enqueue-base-attribution-unknown");
194
+ }
195
+ reuseDecision = classifyDevDeliveryDelta({
196
+ proof,
197
+ current: {
198
+ sourceIdentityRoot: proof.sourceIdentityRoot,
199
+ sourcePatchRoot: proof.sourcePatchRoot,
200
+ planRoot: proof.planRoot,
201
+ closureRoot: proof.closureRoot,
202
+ dependencyRoot: proof.dependencyRoot,
203
+ toolchainRoot: proof.toolchainRoot,
204
+ graphKnown: delta.graphKnown,
205
+ changedPaths: delta.changedPaths,
206
+ },
207
+ });
208
+ if (!reuseDecision.reusable) {
209
+ mismatch(reuseFailure(reuseDecision), { reuseDecision });
210
+ }
211
+ }
212
+
213
+ const mergeCommitSha = String(
214
+ observedPullRequest.merge_commit_sha || "",
215
+ ).toLowerCase();
216
+ const replayTree = /^[0-9a-f]{40}$/u.test(mergeCommitSha)
217
+ ? await githubCommitTree(client, options, mergeCommitSha).catch(() => "")
218
+ : "";
219
+ if (
220
+ currentBaseSha !== previousBaseSha &&
221
+ (!/^[0-9a-f]{40}$/u.test(mergeCommitSha) ||
222
+ !/^[0-9a-f]{40}$/u.test(replayTree))
223
+ ) {
224
+ mismatch("pre-enqueue-project-cut-composition-missing", {
225
+ mergeCommitSha,
226
+ replayTree,
227
+ });
228
+ }
229
+ const receipt = {
230
+ schema: "kungfu.buildchain.pre-enqueue-project-cut/v1",
231
+ repository: options.repository.fullName,
232
+ protectedBase: options.targetBranch,
233
+ pullRequestNumber: pullRequest.number,
234
+ sourceHead: expectedHeadSha,
235
+ previousBase: previousBaseSha,
236
+ admittedBase: currentBaseSha,
237
+ baseMoved: currentBaseSha !== previousBaseSha,
238
+ sourceHeadMutationRequired: false,
239
+ composition: { mergeCommitSha, replayTree },
240
+ sourceProofReuseDecisionRoot: reuseDecision ? root(reuseDecision) : "",
241
+ projectCutProofRoot: projectCut?.proofRoot || "",
242
+ decision: "qualified",
243
+ };
244
+ return { receipt, receiptRoot: root(receipt) };
245
+ }
246
+
247
+ function exactQueueEntry(queueState, pullRequest, expectedHeadSha) {
248
+ return queueState.entries.find(
249
+ (candidate) =>
250
+ candidate.pullRequestNumber === pullRequest.number &&
251
+ candidate.pullRequestHeadSha === expectedHeadSha,
252
+ );
253
+ }
254
+
255
+ export async function qualifyPreEnqueueReadback({
256
+ client,
257
+ options,
258
+ pullRequest,
259
+ expectedBaseSha,
260
+ expectedHeadSha,
261
+ projectCut,
262
+ mergeableAccepted,
263
+ root,
264
+ verifyCurrentWarrant,
265
+ }) {
266
+ const [
267
+ observedPullRequest,
268
+ observedBaseSha,
269
+ observedQueueState,
270
+ currentWarrant,
271
+ ] = await Promise.all([
272
+ client.getPullRequest(pullRequest.number, {
273
+ attempts: options.pollMergeableAttempts,
274
+ delayMs: options.pollMergeableDelayMs,
275
+ }),
276
+ client.getBranchSha(options.targetBranch),
277
+ client.getMergeQueueState(options.targetBranch),
278
+ verifyCurrentWarrant(
279
+ client,
280
+ options,
281
+ pullRequest,
282
+ options.verifiedDeliveryWarrant,
283
+ ),
284
+ ]);
285
+ const observedHeadSha = String(
286
+ observedPullRequest.head?.sha || "",
287
+ ).toLowerCase();
288
+ if (observedHeadSha !== expectedHeadSha) {
289
+ mismatch("head-sha-drift-after-lease-readback", { observedHeadSha });
290
+ }
291
+ if (options.warrantMode !== "required") {
292
+ if (observedBaseSha !== expectedBaseSha) {
293
+ mismatch("base-sha-drift-after-lease-readback", { observedBaseSha });
294
+ }
295
+ if (
296
+ !mergeableAccepted(observedPullRequest, "queue", projectCut?.ok === true)
297
+ ) {
298
+ mismatch("not-mergeable-after-lease-readback");
299
+ }
300
+ return {
301
+ observedBaseSha,
302
+ observedQueueState,
303
+ currentWarrant,
304
+ preEnqueueProjectCut: null,
305
+ };
306
+ }
307
+
308
+ const preEnqueueProjectCut = await qualifyProjectCut({
309
+ client,
310
+ options,
311
+ pullRequest,
312
+ previousBaseSha: expectedBaseSha,
313
+ currentBaseSha: observedBaseSha,
314
+ expectedHeadSha,
315
+ observedPullRequest,
316
+ projectCut,
317
+ mergeableAccepted,
318
+ root,
319
+ });
320
+ const [casPullRequest, casBaseSha, casQueueState, casWarrant] =
321
+ await Promise.all([
322
+ client.getPullRequest(pullRequest.number, {
323
+ attempts: options.pollMergeableAttempts,
324
+ delayMs: options.pollMergeableDelayMs,
325
+ }),
326
+ client.getBranchSha(options.targetBranch),
327
+ client.getMergeQueueState(options.targetBranch),
328
+ verifyCurrentWarrant(
329
+ client,
330
+ options,
331
+ pullRequest,
332
+ options.verifiedDeliveryWarrant,
333
+ ),
334
+ ]);
335
+ if (casBaseSha !== observedBaseSha) {
336
+ mismatch("base-sha-drift-after-project-cut", {
337
+ observedBaseSha: casBaseSha,
338
+ });
339
+ }
340
+ if (
341
+ String(casPullRequest.head?.sha || "").toLowerCase() !== expectedHeadSha
342
+ ) {
343
+ mismatch("head-sha-drift-after-project-cut");
344
+ }
345
+ const casPullRequestBase = String(
346
+ casPullRequest.base?.sha || "",
347
+ ).toLowerCase();
348
+ if (casPullRequestBase && casPullRequestBase !== casBaseSha) {
349
+ mismatch("pre-enqueue-project-cut-base-stale", {
350
+ observedPullRequestBase: casPullRequestBase,
351
+ currentBaseSha: casBaseSha,
352
+ });
353
+ }
354
+ if (casPullRequest.mergeable === false) {
355
+ mismatch("pre-enqueue-merge-conflict-after-project-cut");
356
+ }
357
+ if (!mergeableAccepted(casPullRequest, "queue", projectCut?.ok === true)) {
358
+ mismatch("not-mergeable-after-project-cut");
359
+ }
360
+ const casMergeCommitSha = String(
361
+ casPullRequest.merge_commit_sha || "",
362
+ ).toLowerCase();
363
+ if (
364
+ casMergeCommitSha !==
365
+ preEnqueueProjectCut.receipt.composition.mergeCommitSha
366
+ ) {
367
+ mismatch("pre-enqueue-project-cut-composition-drift", {
368
+ observedMergeCommitSha: casMergeCommitSha,
369
+ });
370
+ }
371
+ if (preEnqueueProjectCut.receipt.composition.replayTree) {
372
+ const casReplayTree = await githubCommitTree(
373
+ client,
374
+ options,
375
+ casMergeCommitSha,
376
+ ).catch(() => "");
377
+ if (casReplayTree !== preEnqueueProjectCut.receipt.composition.replayTree) {
378
+ mismatch("pre-enqueue-project-cut-composition-drift", {
379
+ observedReplayTree: casReplayTree,
380
+ });
381
+ }
382
+ }
383
+ const exactEntry = exactQueueEntry(
384
+ casQueueState,
385
+ pullRequest,
386
+ expectedHeadSha,
387
+ );
388
+ const predecessor =
389
+ casQueueState.entries.find((candidate) => candidate !== exactEntry) || null;
390
+ if (predecessor) {
391
+ mismatch("queue-predecessor-after-project-cut", { predecessor });
392
+ }
393
+ return {
394
+ observedBaseSha,
395
+ observedQueueState: casQueueState,
396
+ currentWarrant: casWarrant,
397
+ preEnqueueProjectCut,
398
+ };
399
+ }
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import { pathToFileURL } from "node:url";
3
-
4
3
  function classifyPublicationChannel(channel = "") {
5
4
  const normalized = String(channel || "").trim().toLowerCase();
6
5
  if (!normalized) return undefined;
@@ -131,6 +130,8 @@ export async function ensureGitHubRelease({
131
130
  if (tagRef.status === 404) {
132
131
  throw new Error(`Git tag ${metadata.tag} does not exist in ${repository}`);
133
132
  }
133
+ // Existing exact tags are authoritative; target_commitish is redundant and
134
+ // can require workflow mutation authority for workflow-changing commits.
134
135
  const created = await githubRequest({
135
136
  apiUrl,
136
137
  token,
@@ -142,11 +143,11 @@ export async function ensureGitHubRelease({
142
143
  body: notes || `Buildchain release passport assets for ${metadata.tag}.`,
143
144
  prerelease: metadata.prerelease,
144
145
  make_latest: metadata.makeLatest,
145
- ...(target ? { target_commitish: target } : {}),
146
146
  },
147
147
  });
148
148
  return { action: "created", release: created.data, metadata };
149
149
  }
150
+ if (metadata.prerelease === true && existing.data?.tag_name === metadata.tag && existing.data?.name === (title || metadata.tag) && existing.data?.body === (notes || `Buildchain release passport assets for ${metadata.tag}.`) && existing.data?.prerelease === true && existing.data?.draft !== true && (!target || existing.data?.target_commitish === target)) return { action: "existing", release: existing.data, metadata };
150
151
  const patched = await githubRequest({
151
152
  apiUrl,
152
153
  token,
@@ -156,7 +157,6 @@ export async function ensureGitHubRelease({
156
157
  name: title || existing.data.name || metadata.tag,
157
158
  prerelease: metadata.prerelease,
158
159
  make_latest: metadata.makeLatest,
159
- ...(target ? { target_commitish: target } : {}),
160
160
  },
161
161
  });
162
162
  return { action: "updated", release: patched.data, metadata };
@@ -90,6 +90,9 @@ function forwardedInputs(names) {
90
90
  if (name === "buildchain-contract-lock-path") {
91
91
  return " buildchain-contract-lock-path: ${{ needs.resolve-channel.outputs.contract-lock-path }}";
92
92
  }
93
+ if (name === "buildchain-visible-workflow") {
94
+ return " buildchain-visible-workflow: .github/workflows/build.yml";
95
+ }
93
96
  return ` ${name}: \${{ inputs.${name} }}`;
94
97
  })
95
98
  .join("\n");
@@ -20,9 +20,13 @@ const internalInputs = new Set([
20
20
  "promotion-publication-channel",
21
21
  "promotion-target-ref",
22
22
  "promotion-override-used",
23
+ "promotion-runtime-authorization-json",
24
+ "promotion-runtime-authorization-root",
23
25
  "publication-authority-workflow-path",
24
26
  "buildchain-expected-channel",
25
27
  "buildchain-expected-major",
28
+ "buildchain-alpha-contract-lock-path",
29
+ "buildchain-stable-contract-lock-path",
26
30
  ]);
27
31
 
28
32
  function blockBetween(source, start, end) {
@@ -120,6 +124,12 @@ function forwardedInputs(inputNames, { includeInternal = true, major = 2, unsupp
120
124
  if (name === "promotion-override-used") {
121
125
  return ` ${name}: \${{ needs.resolve-promotion.outputs.override-used == 'true' }}`;
122
126
  }
127
+ if (name === "promotion-runtime-authorization-json") {
128
+ return ` ${name}: \${{ needs.consumer-admission.outputs.runtime-authorization-json }}`;
129
+ }
130
+ if (name === "promotion-runtime-authorization-root") {
131
+ return ` ${name}: \${{ needs.consumer-admission.outputs.runtime-authorization-root }}`;
132
+ }
123
133
  if (name === "buildchain-expected-major") {
124
134
  return ` buildchain-expected-major: "${major}"`;
125
135
  }
@@ -132,6 +142,15 @@ function forwardedInputs(inputNames, { includeInternal = true, major = 2, unsupp
132
142
  }).join("\n");
133
143
  }
134
144
 
145
+ function routedInputs(inputNames, route, major) {
146
+ return forwardedInputs(inputNames, {
147
+ includeInternal: route.forwardInternalInputs,
148
+ major,
149
+ unsupportedInputs: route.unsupportedInputs,
150
+ workflowPath: route.workflowPath,
151
+ });
152
+ }
153
+
135
154
  function validateWorkflowRoute(name, route, expectedLogicalRef) {
136
155
  if (!route || typeof route !== "object") throw new Error(`promotion shell routing missing ${name} route`);
137
156
  if (route.logicalRef !== expectedLogicalRef) {
@@ -167,6 +186,94 @@ export function parsePromotionShellRouting(source, { major = 2 } = {}) {
167
186
  };
168
187
  }
169
188
 
189
+ function consumerAdmissionJob() {
190
+ return ` consumer-admission:
191
+ name: Admit v4 floating consumer policy
192
+ needs: resolve-promotion
193
+ runs-on: ubuntu-24.04
194
+ permissions:
195
+ contents: read
196
+ outputs:
197
+ runtime-authorization-json: \${{ steps.runtime-authority.outputs.runtime-authorization-json }}
198
+ runtime-authorization-root: \${{ steps.runtime-authority.outputs.runtime-authorization-root }}
199
+ steps:
200
+ - name: Checkout exact consumer source
201
+ uses: actions/checkout@v7.0.0
202
+ with:
203
+ ref: \${{ inputs.target-sha || github.sha }}
204
+ path: .buildchain/consumer
205
+ persist-credentials: false
206
+ - name: Checkout exact policy runtime
207
+ uses: actions/checkout@v7.0.0
208
+ with:
209
+ repository: \${{ inputs.buildchain-repository }}
210
+ ref: \${{ needs.resolve-promotion.outputs.router-sha }}
211
+ path: .buildchain/v4-policy-runtime
212
+ persist-credentials: false
213
+ - name: Enforce v4 floating consumer policy
214
+ id: policy
215
+ env:
216
+ BUILDCHAIN_CONSUMER_ROOT: .buildchain/consumer
217
+ BUILDCHAIN_EXPECTED_INVOCATION_CHANNEL: \${{ needs.resolve-promotion.outputs.channel }}
218
+ BUILDCHAIN_INVOKED_WORKFLOW: .github/workflows/release-candidate-promote.yml
219
+ BUILDCHAIN_WORKFLOW_SHA: \${{ needs.resolve-promotion.outputs.router-sha }}
220
+ BUILDCHAIN_RUNTIME_SHA: \${{ needs.resolve-promotion.outputs.router-sha }}
221
+ BUILDCHAIN_STABLE_CONTRACT_LOCK_PATH: \${{ inputs.buildchain-stable-contract-lock-path }}
222
+ BUILDCHAIN_ALPHA_CONTRACT_LOCK_PATH: \${{ inputs.buildchain-alpha-contract-lock-path }}
223
+ BUILDCHAIN_V4_POLICY_RECEIPT_PATH: .buildchain/evidence/v4-consumer-policy-receipt.json
224
+ run: >-
225
+ node .buildchain/v4-policy-runtime/scripts/v4-consumer-policy.mjs scan
226
+ --source-sha "\${{ inputs.target-sha || github.sha }}"
227
+
228
+ - name: Upload rooted consumer admission receipt
229
+ uses: actions/upload-artifact@v7.0.1
230
+ with:
231
+ name: v4-consumer-policy-release-candidate-promote-\${{ github.sha }}
232
+ path: .buildchain/consumer/.buildchain/evidence/v4-consumer-policy-receipt.json
233
+ if-no-files-found: error
234
+
235
+ - name: Authorize transient v4 runtime selection
236
+ id: runtime-authority
237
+ if: \${{ needs.resolve-promotion.outputs.override-used == 'true' }}
238
+ uses: actions/github-script@v8
239
+ env:
240
+ BUILDCHAIN_RUNTIME_AUTHORIZATION_PATH: .buildchain/consumer/.buildchain/evidence/v4-runtime-authorization.json
241
+ with:
242
+ script: |
243
+ const script = require(process.env.GITHUB_WORKSPACE + "/.buildchain/v4-policy-runtime/scripts/authorize-promotion-runtime-override.cjs");
244
+ const result = await script.authorizePromotionRuntimeOverride({
245
+ github,
246
+ context,
247
+ request: {
248
+ consumerRoot: process.env.GITHUB_WORKSPACE + "/.buildchain/consumer",
249
+ runtimeModulePath: process.env.GITHUB_WORKSPACE + "/.buildchain/v4-policy-runtime/packages/core/v4-runtime-ref-resume-authority.js",
250
+ runtimeRepository: "\${{ inputs.buildchain-repository }}",
251
+ consumerPolicyReceiptPath: process.env.GITHUB_WORKSPACE + "/.buildchain/consumer/.buildchain/evidence/v4-consumer-policy-receipt.json",
252
+ consumerPolicyReceiptRoot: "\${{ steps.policy.outputs.v4-consumer-policy-receipt-root }}",
253
+ sourceSha: "\${{ inputs.target-sha || github.sha }}",
254
+ requestedRef: "\${{ needs.resolve-promotion.outputs.runtime-ref }}",
255
+ resolvedRuntimeSha: "\${{ needs.resolve-promotion.outputs.runtime-sha }}",
256
+ reason: "trusted \${{ inputs.resume-candidate-run-id != '' && 'resume' || 'dispatch' }} runtime override \${{ needs.resolve-promotion.outputs.runtime-ref }} for source \${{ inputs.target-sha || github.sha }}",
257
+ mode: "\${{ inputs.resume-candidate-run-id != '' && 'resume' || 'dispatch' }}",
258
+ outputPath: process.env.BUILDCHAIN_RUNTIME_AUTHORIZATION_PATH,
259
+ },
260
+ });
261
+ core.setOutput("runtime-authorization-json", JSON.stringify({
262
+ receipt: result.receipt,
263
+ receiptRoot: result.receiptRoot,
264
+ }));
265
+ core.setOutput("runtime-authorization-root", result.receiptRoot);
266
+
267
+ - name: Upload transient runtime authorization receipt
268
+ if: \${{ needs.resolve-promotion.outputs.override-used == 'true' }}
269
+ uses: actions/upload-artifact@v7.0.1
270
+ with:
271
+ name: v4-runtime-authorization-release-candidate-promote-\${{ github.run_id }}
272
+ path: .buildchain/consumer/.buildchain/evidence/v4-runtime-authorization.json
273
+ if-no-files-found: error
274
+ `;
275
+ }
276
+
170
277
  export function generateChannelPromotionWorkflow(source, { major = 2, shellRouting } = {}) {
171
278
  const routes = shellRouting || {
172
279
  alpha: {
@@ -193,16 +300,8 @@ export function generateChannelPromotionWorkflow(source, { major = 2, shellRouti
193
300
  for (const required of ["buildchain-ref", "buildchain-contract-lock-path", "channel", "target-ref", ...internalInputs]) {
194
301
  if (!inputNames.includes(required)) throw new Error(`advanced promotion workflow missing input: ${required}`);
195
302
  }
196
- const alphaForwarded = forwardedInputs(inputNames, {
197
- includeInternal: alphaRoute.forwardInternalInputs,
198
- major,
199
- unsupportedInputs: alphaRoute.unsupportedInputs, workflowPath: alphaRoute.workflowPath,
200
- });
201
- const stableForwarded = forwardedInputs(inputNames, {
202
- includeInternal: stableRoute.forwardInternalInputs,
203
- major,
204
- unsupportedInputs: stableRoute.unsupportedInputs, workflowPath: stableRoute.workflowPath,
205
- });
303
+ const alphaForwarded = routedInputs(inputNames, alphaRoute, major);
304
+ const stableForwarded = routedInputs(inputNames, stableRoute, major);
206
305
  return `# Generated by scripts/generate-channel-promotion-workflow.mjs. Do not edit directly.
207
306
  name: Release Candidate Promote
208
307
 
@@ -419,9 +518,10 @@ jobs:
419
518
  [[ "$(git -C .buildchain/shell rev-parse HEAD)" = "\${SHELL_SHA}" ]] || { echo "::error::Promotion shell checkout moved"; exit 1; }
420
519
  [[ "$(git -C .buildchain/runtime rev-parse HEAD)" = "\${RUNTIME_SHA}" ]] || { echo "::error::Promotion runtime checkout moved"; exit 1; }
421
520
 
521
+ ${consumerAdmissionJob()}
422
522
  alpha:
423
523
  name: Promote with alpha workflow shell
424
- needs: resolve-promotion
524
+ needs: [resolve-promotion, consumer-admission]
425
525
  if: \${{ needs.resolve-promotion.outputs.channel == 'alpha' }}
426
526
  uses: kungfu-systems/buildchain/${alphaRoute.workflowPath}@${alphaRoute.callRef}
427
527
  permissions:
@@ -439,7 +539,7 @@ ${alphaForwarded}
439
539
 
440
540
  stable:
441
541
  name: Promote with stable workflow shell
442
- needs: resolve-promotion
542
+ needs: [resolve-promotion, consumer-admission]
443
543
  if: \${{ needs.resolve-promotion.outputs.channel == 'stable' }}
444
544
  uses: kungfu-systems/buildchain/${stableRoute.workflowPath}@${stableRoute.callRef}
445
545
  permissions: