@kungfu-tech/buildchain 4.0.1-alpha.8 → 4.0.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.
Files changed (98) hide show
  1. package/README.md +9 -6
  2. package/actions/v4-release-candidate-promote/README.md +3 -0
  3. package/architecture/agent-change-map.md +1015 -0
  4. package/architecture/ci-lane-change-budget.json +70 -1
  5. package/architecture/decisions/0004-v4-declarative-release-tail.md +47 -0
  6. package/architecture/internal-capabilities.json +156 -11
  7. package/architecture/maintainability-debt.json +3229 -0
  8. package/architecture/maintainability-policy.json +480 -129
  9. package/architecture/v3-core-mechanism-inventory.json +4 -3
  10. package/architecture/v3-v4-capability-closure.md +59 -0
  11. package/architecture/v3-v4-live-capability-inventory.json +102404 -0
  12. package/architecture/v4-cross-platform-adopter-qualification.json +52 -0
  13. package/architecture/v4-delivery-warrant-shadow-fixtures.json +3 -3
  14. package/architecture/v4-runtime-semantic-closure.json +710 -0
  15. package/bin/internal/trust-release-release-handlers.mjs +5 -0
  16. package/contracts/fixtures/v4-tail-reseal-v1/valid.json +1 -1
  17. package/contracts/v4-publication-qualification-v1.schema.json +51 -0
  18. package/dist/site/agent-index.json +1 -0
  19. package/dist/site/artifact-schemas.json +2 -0
  20. package/dist/site/buildchain-contract.json +123 -32
  21. package/dist/site/buildchain-site.json +96 -28
  22. package/dist/site/capability-registry.json +6 -6
  23. package/dist/site/cli-registry.json +5 -2
  24. package/dist/site/controller-registry.json +41 -5
  25. package/dist/site/kfd-claims.json +170 -21
  26. package/dist/site/kfd-upstream-aggregate.json +1 -1
  27. package/dist/site/manual-registry.json +6 -6
  28. package/dist/site/node-api-registry.json +1474 -215
  29. package/dist/site/page-registry.json +85 -17
  30. package/dist/site/public-surface-audit.json +85 -20
  31. package/dist/site/publication-authority-registry.json +28 -1
  32. package/dist/site/publication-registry.json +4 -4
  33. package/dist/site/release-provenance.json +5 -0
  34. package/dist/site/schemas/v4-publication-qualification-v1.schema.json +51 -0
  35. package/dist/site/site-manifest.json +10 -10
  36. package/dist/site/workflow-registry.json +94 -18
  37. package/docs/MAP.md +1 -1
  38. package/docs/cli-reference.md +4 -4
  39. package/docs/dev-delivery-warrant.md +12 -9
  40. package/docs/node-api-reference.md +291 -206
  41. package/docs/publish-transaction.md +11 -4
  42. package/docs/release-candidate.md +7 -0
  43. package/docs/v4-adopter-delivery.md +23 -4
  44. package/docs/v4-declarative-release-tail-migration.md +43 -0
  45. package/package.json +8 -3
  46. package/packages/core/adopter-delivery-passport.js +36 -0
  47. package/packages/core/buildchain-contract.js +31 -1
  48. package/packages/core/buildchain-publication-authority.js +1 -0
  49. package/packages/core/dev-delivery-warrant-settlement.js +239 -3
  50. package/packages/core/dev-delivery-warrant-state.js +15 -2
  51. package/packages/core/dev-delivery-warrant.js +21 -4
  52. package/packages/core/github-governance-authority.js +4 -4
  53. package/packages/core/index.js +1 -0
  54. package/packages/core/paper-scaffold-content.js +401 -0
  55. package/packages/core/paper.js +12 -378
  56. package/packages/core/publication-control-plane-audit.js +135 -111
  57. package/packages/core/publication-rehearsal-projection.js +104 -0
  58. package/packages/core/publication-rehearsal-runtime.js +190 -0
  59. package/packages/core/release-candidate-recovery.js +6 -5
  60. package/packages/core/release-passport.js +82 -184
  61. package/packages/core/v4-cross-platform-adopter-qualification.js +343 -0
  62. package/packages/core/v4-floating-consumer-policy.js +3 -2
  63. package/packages/core/v4-floating-consumer-release-passport.js +134 -15
  64. package/packages/core/v4-publication-qualification.js +222 -0
  65. package/scripts/artifact-signing-controller-core.mjs +1 -1
  66. package/scripts/audit-publication-control-plane.mjs +17 -17
  67. package/scripts/buildchain-cli-help.mjs +3 -0
  68. package/scripts/check-internal-architecture.mjs +23 -3
  69. package/scripts/check-inventory.mjs +23 -5
  70. package/scripts/check-maintainability.mjs +145 -4
  71. package/scripts/check-project-cut-merge-queue-admission.mjs +87 -0
  72. package/scripts/check-v3-v4-capability-inventory.mjs +574 -0
  73. package/scripts/check-v4-floating-consumer-policy-contract.mjs +10 -3
  74. package/scripts/check-v4-runtime-semantic-closure.mjs +266 -0
  75. package/scripts/dev-delivery-warrant-options.mjs +27 -11
  76. package/scripts/dev-delivery-warrant.mjs +45 -5
  77. package/scripts/dev-pr-auto-merge.mjs +25 -11
  78. package/scripts/dev-pr-delivery-warrant.mjs +44 -0
  79. package/scripts/dev-pr-prequeue-guard.mjs +0 -18
  80. package/scripts/dispatch-artifact-signing-authority.mjs +1 -1
  81. package/scripts/generate-agent-change-map.mjs +118 -0
  82. package/scripts/generate-release-candidate-passport.mjs +26 -1
  83. package/scripts/generate-site-bundle.mjs +6 -0
  84. package/scripts/maintainability-domain-metrics.mjs +237 -0
  85. package/scripts/maintainability-governance.mjs +473 -0
  86. package/scripts/maintainability-metrics.mjs +28 -6
  87. package/scripts/project-cut-merge-queue-admission.mjs +394 -0
  88. package/scripts/promotion-routing-evidence.mjs +35 -6
  89. package/scripts/release-candidate-resolver.mjs +44 -0
  90. package/scripts/release-line-policy.mjs +25 -15
  91. package/scripts/release-tail.mjs +19 -2
  92. package/scripts/resume-from-candidate-run.mjs +106 -103
  93. package/scripts/run-lifecycle-core.mjs +341 -376
  94. package/scripts/site-capability-metadata.mjs +4 -0
  95. package/scripts/stable-candidate-qualification.mjs +9 -10
  96. package/scripts/v3-v4-capability-catalog.mjs +439 -0
  97. package/scripts/v4-cross-platform-adopter-qualification.mjs +228 -0
  98. package/scripts/v4-declarative-promotion-admission.mjs +42 -0
@@ -0,0 +1,394 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ const GIT_OID = /^[0-9a-f]{40}$/u;
8
+ const CONTENT_ROOT = /^sha256:[0-9a-f]{64}$/u;
9
+ const FAMILY_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u;
10
+ const FAMILY_MARKER =
11
+ /<!-- kungfu-family-queue-lease:v1 ([A-Za-z0-9_-]+) -->/gu;
12
+ const FAMILY_LEASE_SCHEMA = "kungfu.project-cut.family-queue-lease/v1";
13
+
14
+ export class ProjectCutAdmissionError extends Error {
15
+ constructor(reasonCode, message) {
16
+ super(message);
17
+ this.name = "ProjectCutAdmissionError";
18
+ this.reasonCode = reasonCode;
19
+ }
20
+ }
21
+
22
+ function stableValue(value) {
23
+ if (Array.isArray(value)) return value.map(stableValue);
24
+ if (value && typeof value === "object") {
25
+ return Object.fromEntries(
26
+ Object.entries(value)
27
+ .filter(([, entry]) => entry !== undefined)
28
+ .sort(([left], [right]) => left.localeCompare(right))
29
+ .map(([key, entry]) => [key, stableValue(entry)]),
30
+ );
31
+ }
32
+ return value;
33
+ }
34
+
35
+ function canonicalBytes(value) {
36
+ return Buffer.from(`${JSON.stringify(stableValue(value))}\n`, "utf8");
37
+ }
38
+
39
+ function contentRoot(value) {
40
+ return `sha256:${crypto.createHash("sha256").update(canonicalBytes(value)).digest("hex")}`;
41
+ }
42
+
43
+ function exactRoot(value, label) {
44
+ const normalized = String(value || "")
45
+ .trim()
46
+ .toLowerCase();
47
+ if (!CONTENT_ROOT.test(normalized))
48
+ throw new Error(`${label} must be an exact SHA-256 root`);
49
+ return normalized;
50
+ }
51
+
52
+ function familyId(value, label) {
53
+ const normalized = String(value || "").trim();
54
+ if (!FAMILY_ID.test(normalized)) throw new Error(`${label} is invalid`);
55
+ return normalized;
56
+ }
57
+
58
+ function git(
59
+ cwd,
60
+ args,
61
+ {
62
+ env = {},
63
+ input = undefined,
64
+ encoding = "utf8",
65
+ reason = "git-failed",
66
+ } = {},
67
+ ) {
68
+ const result = spawnSync("git", args, {
69
+ cwd,
70
+ env: { ...process.env, ...env },
71
+ input,
72
+ encoding,
73
+ maxBuffer: 64 * 1024 * 1024,
74
+ });
75
+ if (result.status !== 0) {
76
+ const detail = String(result.stderr || result.stdout || "").trim();
77
+ throw new ProjectCutAdmissionError(
78
+ reason,
79
+ `git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`,
80
+ );
81
+ }
82
+ return encoding === null ? result.stdout : String(result.stdout || "").trim();
83
+ }
84
+
85
+ function resolveCommit(cwd, revision, label) {
86
+ const oid = git(cwd, ["rev-parse", "--verify", `${revision}^{commit}`], {
87
+ reason: `${label}-unresolved`,
88
+ });
89
+ if (!GIT_OID.test(oid))
90
+ throw new ProjectCutAdmissionError(
91
+ `${label}-unresolved`,
92
+ `${label} is not an exact commit`,
93
+ );
94
+ return oid;
95
+ }
96
+
97
+ function linearSourceCommits(cwd, fork, head) {
98
+ const commits = git(
99
+ cwd,
100
+ ["rev-list", "--reverse", "--topo-order", `${fork}..${head}`],
101
+ {
102
+ reason: "source-history-unreadable",
103
+ },
104
+ )
105
+ .split(/\s+/u)
106
+ .filter(Boolean);
107
+ if (commits.length === 0)
108
+ throw new ProjectCutAdmissionError(
109
+ "empty-source-range",
110
+ "source range is empty",
111
+ );
112
+ let expectedParent = fork;
113
+ for (const commit of commits) {
114
+ const [observed, ...parents] = git(
115
+ cwd,
116
+ ["rev-list", "--parents", "-n", "1", commit],
117
+ {
118
+ reason: "source-history-unreadable",
119
+ },
120
+ ).split(/\s+/u);
121
+ if (
122
+ observed !== commit ||
123
+ parents.length !== 1 ||
124
+ parents[0] !== expectedParent
125
+ ) {
126
+ throw new ProjectCutAdmissionError(
127
+ "nonlinear-source-range",
128
+ "source range is not one immutable linear patch series",
129
+ );
130
+ }
131
+ expectedParent = commit;
132
+ }
133
+ return commits;
134
+ }
135
+
136
+ function patchId(cwd, patch) {
137
+ const output = git(cwd, ["patch-id", "--stable"], {
138
+ input: patch,
139
+ encoding: "utf8",
140
+ reason: "patch-id-failed",
141
+ });
142
+ const id = output.split(/\s+/u)[0] || "";
143
+ if (!GIT_OID.test(id))
144
+ throw new ProjectCutAdmissionError(
145
+ "patch-id-failed",
146
+ "patch has no stable identity",
147
+ );
148
+ return id;
149
+ }
150
+
151
+ function virtualCommitOid(cwd, tree, base, head) {
152
+ const body = [
153
+ `tree ${tree}`,
154
+ `parent ${base}`,
155
+ "author Buildchain Project Cut <project-cut@kungfu.tech> 0 +0000",
156
+ "committer Buildchain Project Cut <project-cut@kungfu.tech> 0 +0000",
157
+ "",
158
+ `Replay ${head} onto ${base}`,
159
+ "",
160
+ ].join("\n");
161
+ return git(cwd, ["hash-object", "-t", "commit", "--stdin"], {
162
+ input: body,
163
+ reason: "candidate-commit-hash-failed",
164
+ });
165
+ }
166
+
167
+ function replayProjectCut(cwd, base, head) {
168
+ const fork = git(cwd, ["merge-base", base, head], {
169
+ reason: "no-common-fork",
170
+ });
171
+ if (!GIT_OID.test(fork))
172
+ throw new ProjectCutAdmissionError(
173
+ "no-common-fork",
174
+ "base and head have no exact fork",
175
+ );
176
+ const commits = linearSourceCommits(cwd, fork, head);
177
+ const temporary = fs.mkdtempSync(
178
+ path.join(os.tmpdir(), "buildchain-project-cut-"),
179
+ );
180
+ const indexPath = path.join(temporary, "index");
181
+ const indexEnvironment = { GIT_INDEX_FILE: indexPath };
182
+ try {
183
+ git(cwd, ["read-tree", base], {
184
+ env: indexEnvironment,
185
+ reason: "base-tree-unreadable",
186
+ });
187
+ const sourcePatch = git(
188
+ cwd,
189
+ ["diff", "--binary", "--full-index", "--no-ext-diff", fork, head],
190
+ {
191
+ encoding: null,
192
+ reason: "source-patch-unreadable",
193
+ },
194
+ );
195
+ const emptySourcePatch = sourcePatch.length === 0;
196
+ if (emptySourcePatch && commits.length !== 1)
197
+ throw new ProjectCutAdmissionError("empty-source-composition", "an empty source composition must be exactly one linear commit");
198
+ if (!emptySourcePatch) git(cwd, ["apply", "--cached", "--3way", "--whitespace=nowarn", "-"], {
199
+ env: indexEnvironment, input: sourcePatch, encoding: "utf8", reason: "project-cut-conflict",
200
+ });
201
+ const candidateTreeOid = git(cwd, ["write-tree"], {
202
+ env: indexEnvironment,
203
+ reason: "candidate-tree-unreadable",
204
+ });
205
+ const replayPatch = git(
206
+ cwd,
207
+ ["diff", "--cached", "--binary", "--full-index", "--no-ext-diff", base],
208
+ {
209
+ env: indexEnvironment,
210
+ encoding: null,
211
+ reason: "replay-patch-unreadable",
212
+ },
213
+ );
214
+ const sourceNames = git(
215
+ cwd,
216
+ ["diff", "--name-status", "--no-renames", fork, head],
217
+ {
218
+ reason: "source-paths-unreadable",
219
+ },
220
+ );
221
+ const replayNames = git(
222
+ cwd,
223
+ ["diff", "--cached", "--name-status", "--no-renames", base],
224
+ {
225
+ env: indexEnvironment,
226
+ reason: "replay-paths-unreadable",
227
+ },
228
+ );
229
+ const compositionDrifted = emptySourcePatch ? replayPatch.length !== 0 ||
230
+ sourceNames !== "" || replayNames !== "" : patchId(cwd, sourcePatch) !==
231
+ patchId(cwd, replayPatch) || sourceNames !== replayNames;
232
+ if (compositionDrifted) {
233
+ throw new ProjectCutAdmissionError(
234
+ "composition-drift",
235
+ "latest-base replay changes the immutable source composition",
236
+ );
237
+ }
238
+ return {
239
+ baseCommitOid: base,
240
+ headCommitOid: head,
241
+ forkCommitOid: fork,
242
+ candidateCommitOid: virtualCommitOid(cwd, candidateTreeOid, base, head),
243
+ candidateTreeOid,
244
+ replayedCommitCount: commits.length,
245
+ };
246
+ } finally {
247
+ fs.rmSync(temporary, { recursive: true, force: true });
248
+ }
249
+ }
250
+
251
+ function encodeMarker(payload) {
252
+ return canonicalBytes(payload)
253
+ .toString("base64")
254
+ .replace(/\+/gu, "-")
255
+ .replace(/\//gu, "_")
256
+ .replace(/=+$/gu, "");
257
+ }
258
+
259
+ function decodeMarker(token) {
260
+ const padded = `${token}${"=".repeat((4 - (token.length % 4)) % 4)}`
261
+ .replace(/-/gu, "+")
262
+ .replace(/_/gu, "/");
263
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
264
+ }
265
+
266
+ function familyLease(input, replay) {
267
+ const binding = {
268
+ schema: FAMILY_LEASE_SCHEMA,
269
+ state: "active",
270
+ initiativeId: familyId(input.initiativeId, "initiativeId"),
271
+ assignmentId: familyId(input.assignmentId, "assignmentId"),
272
+ deliveryClass: familyId(input.deliveryClass, "deliveryClass"),
273
+ admissionProofRoot: exactRoot(
274
+ input.admissionProofRoot,
275
+ "admissionProofRoot",
276
+ ),
277
+ queueAttempt: String(input.queueAttempt || "").trim(),
278
+ pullRequestHead: replay.headCommitOid,
279
+ baseCommitOid: replay.baseCommitOid,
280
+ forkCommitOid: replay.forkCommitOid,
281
+ candidateTreeOid: replay.candidateTreeOid,
282
+ };
283
+ const expectedAttempt = `${replay.headCommitOid}@${replay.baseCommitOid}`;
284
+ if (binding.queueAttempt !== expectedAttempt) {
285
+ throw new ProjectCutAdmissionError(
286
+ "queue-attempt-drift",
287
+ `queueAttempt must equal ${expectedAttempt}`,
288
+ );
289
+ }
290
+ const bindingRoot = contentRoot(binding);
291
+ const statusContext = `Queue family lease/${bindingRoot.slice(7, 23)}`;
292
+ const payload = { ...binding, bindingRoot, statusContext };
293
+ const leaseRoot = contentRoot(payload);
294
+ const sealed = { ...payload, leaseRoot };
295
+ return {
296
+ marker: `<!-- kungfu-family-queue-lease:v1 ${encodeMarker(sealed)} -->`,
297
+ leaseRoot,
298
+ statusContext,
299
+ pullRequestHead: replay.headCommitOid,
300
+ };
301
+ }
302
+
303
+ export function parseFamilyQueueLeaseMarker(text) {
304
+ const matches = [...String(text || "").matchAll(FAMILY_MARKER)];
305
+ if (matches.length !== 1) return null;
306
+ try {
307
+ const payload = decodeMarker(matches[0][1]);
308
+ const { leaseRoot, bindingRoot, statusContext, ...binding } = payload;
309
+ if (payload.schema !== FAMILY_LEASE_SCHEMA || payload.state !== "active")
310
+ return null;
311
+ if (contentRoot(binding) !== bindingRoot) return null;
312
+ if (statusContext !== `Queue family lease/${bindingRoot.slice(7, 23)}`)
313
+ return null;
314
+ if (contentRoot({ ...binding, bindingRoot, statusContext }) !== leaseRoot)
315
+ return null;
316
+ if (encodeMarker(payload) !== matches[0][1]) return null;
317
+ exactRoot(leaseRoot, "leaseRoot");
318
+ exactRoot(payload.admissionProofRoot, "admissionProofRoot");
319
+ for (const field of [
320
+ "pullRequestHead",
321
+ "baseCommitOid",
322
+ "forkCommitOid",
323
+ "candidateTreeOid",
324
+ ]) {
325
+ if (!GIT_OID.test(String(payload[field] || ""))) return null;
326
+ }
327
+ return payload;
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+
333
+ export function releaseFamilyQueueLease(markerText, input = {}) {
334
+ const lease = parseFamilyQueueLeaseMarker(markerText);
335
+ if (!lease) throw new Error("family queue lease marker is invalid");
336
+ const expectedHead = String(input.expectedPullRequestHead || "")
337
+ .trim()
338
+ .toLowerCase();
339
+ if (!GIT_OID.test(expectedHead) || expectedHead !== lease.pullRequestHead) {
340
+ throw new Error("family queue lease pull request head drifted");
341
+ }
342
+ const terminalReason = String(input.terminalReason || "").trim();
343
+ if (!["controller-failed", "merged"].includes(terminalReason))
344
+ throw new Error("terminalReason is invalid");
345
+ const evidenceRoot = input.evidenceRoot
346
+ ? exactRoot(input.evidenceRoot, "evidenceRoot")
347
+ : null;
348
+ if (terminalReason === "merged" && !evidenceRoot)
349
+ throw new Error("merged family lease requires evidenceRoot");
350
+ const receipt = {
351
+ schema: "project.cut.family-queue-release/v1",
352
+ state: "released",
353
+ leaseRoot: lease.leaseRoot,
354
+ pullRequestHead: lease.pullRequestHead,
355
+ terminalReason,
356
+ evidenceRoot,
357
+ };
358
+ return { ...receipt, releaseRoot: contentRoot(receipt) };
359
+ }
360
+
361
+ export function qualifyProjectCut(input = {}) {
362
+ const cwd = path.resolve(input.cwd || process.cwd());
363
+ const base = resolveCommit(cwd, input.base, "base");
364
+ const head = resolveCommit(cwd, input.head, "head");
365
+ const replay = replayProjectCut(cwd, base, head);
366
+ const familyInputs = [
367
+ input.initiativeId,
368
+ input.assignmentId,
369
+ input.deliveryClass,
370
+ input.queueAttempt,
371
+ input.admissionProofRoot,
372
+ ];
373
+ const familyCount = familyInputs.filter((value) =>
374
+ String(value || "").trim(),
375
+ ).length;
376
+ if (familyCount !== 0 && familyCount !== familyInputs.length) {
377
+ throw new ProjectCutAdmissionError(
378
+ "partial-family-binding",
379
+ "family admission inputs must be all present or all absent",
380
+ );
381
+ }
382
+ return {
383
+ schema: "project.cut.merge-queue-admission/v1",
384
+ ok: true,
385
+ decision: "qualified",
386
+ retryable: false,
387
+ compositionChanged: false,
388
+ reasonCodes: [],
389
+ ...replay,
390
+ ...(familyCount === familyInputs.length
391
+ ? { familyLease: familyLease(input, replay) }
392
+ : {}),
393
+ };
394
+ }
@@ -1,9 +1,7 @@
1
1
  #!/usr/bin/env node
2
-
3
2
  import fs from "node:fs";
4
3
  import path from "node:path";
5
4
  import { pathToFileURL } from "node:url";
6
-
7
5
  function createPromotionRoutingEvidence({
8
6
  env = process.env,
9
7
  cwd = process.cwd(),
@@ -55,13 +53,45 @@ function createPromotionRoutingEvidence({
55
53
  }
56
54
  return { routing, routingPath, candidatePath };
57
55
  }
58
-
56
+ function bundlePromotionControllerEvidence({
57
+ env = process.env,
58
+ cwd = process.cwd(),
59
+ } = {}) {
60
+ const finalizationNeeded = env.FINALIZATION_NEEDED === "true";
61
+ const files = [
62
+ [env.RELEASE_CANDIDATE_PASSPORT, "release-candidate-passport.json"],
63
+ [env.PUBLISH_EVIDENCE, "publish-evidence.json"],
64
+ ...(!finalizationNeeded
65
+ ? [[env.RELEASE_PASSPORT, "release-passport.json"]]
66
+ : []),
67
+ ...(env.PUBLICATION_COMMIT_ENABLED === "true" && !finalizationNeeded
68
+ ? [[env.PUBLICATION_COMMIT_EVIDENCE, "publication-commit-evidence.json"]]
69
+ : []),
70
+ ...(!finalizationNeeded ? [
71
+ [env.RELEASE_ACTIVATION_RECEIPTS, "release-activation-receipt-set.json"],
72
+ [env.RELEASED_PRODUCT_EVIDENCE, "released-product-evidence.json"],
73
+ [env.RELEASE_PROPAGATION_CAPTURE, "release-propagation-capture.json"],
74
+ ].filter(([source]) => source) : []),
75
+ ];
76
+ const outputDir = path.join(cwd, ".buildchain/controller/promotion-evidence");
77
+ fs.mkdirSync(outputDir, { recursive: true });
78
+ for (const [source, name] of files) {
79
+ if (!source || !fs.existsSync(path.resolve(cwd, source)))
80
+ throw new Error(`promotion controller evidence is missing: ${source || name}`);
81
+ fs.copyFileSync(path.resolve(cwd, source), path.join(outputDir, name));
82
+ }
83
+ return { files: files.map(([, name]) => name), outputDir };
84
+ }
59
85
  if (
60
86
  process.argv[1] &&
61
87
  import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
62
88
  ) {
63
89
  try {
64
- createPromotionRoutingEvidence();
90
+ if (process.argv[2] === "bundle-controller-evidence") {
91
+ bundlePromotionControllerEvidence();
92
+ } else {
93
+ createPromotionRoutingEvidence();
94
+ }
65
95
  } catch (error) {
66
96
  console.error(
67
97
  `promotion routing evidence failed: ${error instanceof Error ? error.message : String(error)}`,
@@ -69,5 +99,4 @@ if (
69
99
  process.exitCode = 1;
70
100
  }
71
101
  }
72
-
73
- export { createPromotionRoutingEvidence };
102
+ export { bundlePromotionControllerEvidence, createPromotionRoutingEvidence };
@@ -9,6 +9,8 @@ import { pipeline } from "node:stream/promises";
9
9
  import { pathToFileURL } from "node:url";
10
10
  import { createResolvedPublicationSealedBundle } from "./publication-candidate-sealer.mjs";
11
11
  import { writeGitHubOutputs } from "./build-contract-core.mjs";
12
+ import { v4ContentRoot } from "../packages/core/v4-canonical-contracts.js";
13
+ import { v4PublicationQualificationRoot, validateV4PublicationQualificationReceipt } from "../packages/core/v4-publication-qualification.js";
12
14
 
13
15
  const DEFAULT_WORKFLOW_FILE = "build-surface-fixture.yml";
14
16
 
@@ -434,6 +436,42 @@ function findDownloadedFile(root, filename) {
434
436
  return "";
435
437
  }
436
438
 
439
+ function resolveV4PublicationEvidence(passportDir, passport, outputPath) {
440
+ const stageCapsulesPath = findDownloadedFile(passportDir, "release-candidate-stage-capsules.json");
441
+ const publicationQualificationPath = findDownloadedFile(passportDir, "release-candidate-publication-qualification.json");
442
+ if (!passport.consumerPolicy?.receiptRoot) {
443
+ return {
444
+ paths: {
445
+ stageCapsules: stageCapsulesPath ? outputPath(stageCapsulesPath) : "",
446
+ publicationQualification: publicationQualificationPath ? outputPath(publicationQualificationPath) : "",
447
+ },
448
+ publicationQualificationRoot: "",
449
+ };
450
+ }
451
+ if (!stageCapsulesPath || !publicationQualificationPath) {
452
+ throw new Error("v4 release candidate is missing Stage Capsules or publication qualification receipt");
453
+ }
454
+ const stageCapsules = JSON.parse(fs.readFileSync(stageCapsulesPath, "utf8"));
455
+ const qualification = JSON.parse(fs.readFileSync(publicationQualificationPath, "utf8"));
456
+ validateV4PublicationQualificationReceipt(qualification, {
457
+ repository: passport.repository,
458
+ candidateRoot: `sha256:${passport.candidateHash}`,
459
+ sourceSha: passport.source.headSha,
460
+ sourceRoot: v4ContentRoot("candidate-identity", passport.source),
461
+ artifactRoot: v4PublicationQualificationRoot(stageCapsules.capsules.map(({ publicationArtifact }) => publicationArtifact)),
462
+ policyDigest: passport.consumerPolicy.receiptRoot,
463
+ });
464
+ if (stageCapsules.publicationQualificationRoot !== qualification.receiptRoot) throw new Error("Stage Capsules do not bind the publication qualification receipt");
465
+ if (stageCapsules.capsules.some(({ capsule }) => capsule.identity.qualificationRoot !== qualification.receiptRoot)) throw new Error("a Stage Capsule does not bind the publication qualification receipt");
466
+ return {
467
+ paths: {
468
+ stageCapsules: outputPath(stageCapsulesPath),
469
+ publicationQualification: outputPath(publicationQualificationPath),
470
+ },
471
+ publicationQualificationRoot: qualification.receiptRoot,
472
+ };
473
+ }
474
+
437
475
  export async function resolveReleaseCandidateArtifacts({
438
476
  repository,
439
477
  targetRef,
@@ -667,6 +705,7 @@ export async function resolveReleaseCandidateArtifacts({
667
705
  throw new Error("downloaded release-candidate artifacts did not contain release-candidate-passport.json and build-summary.json");
668
706
  }
669
707
  const passport = JSON.parse(fs.readFileSync(passportPath, "utf8"));
708
+ const v4Publication = resolveV4PublicationEvidence(passportDir, passport, outputPath);
670
709
  const platformManifestPaths = findDownloadedFiles(payloadDir, "manifest.json");
671
710
  const githubArtifactAttestationPolicyPaths = findDownloadedFiles(
672
711
  payloadDir,
@@ -722,6 +761,7 @@ export async function resolveReleaseCandidateArtifacts({
722
761
  paths: {
723
762
  passport: outputPath(passportPath),
724
763
  buildSummary: outputPath(buildSummaryPath),
764
+ ...v4Publication.paths,
725
765
  payloads: outputPath(payloadDir),
726
766
  platformManifests: platformManifestPaths.map(outputPath),
727
767
  githubArtifactAttestationPolicies: githubArtifactAttestationPolicyPaths.map(outputPath),
@@ -738,6 +778,7 @@ export async function resolveReleaseCandidateArtifacts({
738
778
  githubArtifactAttestationPolicyCount: githubArtifactAttestationPolicyPaths.length,
739
779
  npmTarballCount: npmTarballPaths.length,
740
780
  publishRequiredArtifacts: generatedRequiredArtifacts,
781
+ publicationQualificationRoot: v4Publication.publicationQualificationRoot,
741
782
  };
742
783
  }
743
784
 
@@ -766,6 +807,9 @@ export async function resolveReleaseCandidateArtifactsCli() {
766
807
  "promote-only-release-candidate": String(result.enabled === true),
767
808
  "release-candidate-passport-path": result.paths?.passport || "",
768
809
  "release-candidate-build-summary-path": result.paths?.buildSummary || "",
810
+ "release-candidate-stage-capsules-path": result.paths?.stageCapsules || "",
811
+ "release-candidate-publication-qualification-path": result.paths?.publicationQualification || "",
812
+ "release-candidate-publication-qualification-root": result.publicationQualificationRoot || "",
769
813
  "release-candidate-version": result.version || "",
770
814
  "release-candidate-source-sha": result.artifacts?.sourceSha || "",
771
815
  "release-candidate-artifact": result.artifacts?.passport || "",
@@ -47,16 +47,27 @@ export function parseVersionStateRef(ref) {
47
47
 
48
48
  export function parseReleaseLineRecoveryRef(ref) {
49
49
  const normalizedRef = normalizeRef(ref);
50
- const match = normalizedRef.match(/^fix\/release-line-v(\d+)-v(\d+\.\d+)-[0-9A-Za-z._-]+$/);
50
+ const match = normalizedRef.match(/^fix\/(alpha|release)-line-v(\d+)-v(\d+\.\d+)-[0-9A-Za-z._-]+$/);
51
51
  if (!match) return undefined;
52
52
  return {
53
- major: Number(match[1]),
54
- loose: Number(match[2]),
55
- normalizedRef: `release/v${match[1]}/v${match[2]}`,
56
- lineSuffix: `/v${match[1]}/v${match[2]}`,
53
+ channel: match[1],
54
+ major: Number(match[2]),
55
+ loose: Number(match[3]),
56
+ normalizedRef: `${match[1]}/v${match[2]}/v${match[3]}`,
57
57
  };
58
58
  }
59
59
 
60
+ function getRecoveryBumpKeyword({ target, baseRef, baseChannel, version, looseVersionNumber, headRef }) {
61
+ if (!target) return "";
62
+ if (baseChannel !== target.channel || target.normalizedRef !== normalizeRef(baseRef)) {
63
+ throw new Error(`Versions not match for head/base refs: ${headRef} -> ${baseRef}`);
64
+ }
65
+ if (target.major !== version.major || target.loose !== looseVersionNumber) {
66
+ throw new Error(`The version of head ref ${headRef} does not match current ${version.version}`);
67
+ }
68
+ return baseChannel === "release" ? "patch" : "prerelease";
69
+ }
70
+
60
71
  export function parsePublishGateChannelRef(ref) {
61
72
  const normalizedRef = normalizeRef(ref);
62
73
  const match = normalizedRef.match(/^publish-gate\/(alpha|release)\/(v(\d+)\/v(\d+\.\d+))\/[^/]+$/);
@@ -131,6 +142,14 @@ export function getBumpKeyword({ cwd = process.cwd(), headRef, baseRef, loose =
131
142
  const authorityTarget = parseAuthorityRef(baseRef);
132
143
  const headChannel = getChannel(headRef);
133
144
  const baseChannel = getChannel(baseRef);
145
+ const recoveryKeyword = getRecoveryBumpKeyword({
146
+ target: releaseLineRecoveryTarget,
147
+ baseRef,
148
+ baseChannel,
149
+ version,
150
+ looseVersionNumber,
151
+ headRef,
152
+ });
134
153
  const key = `${headChannel}->${baseChannel}`;
135
154
  const keywords = {
136
155
  "dev->alpha": "prerelease",
@@ -157,16 +176,7 @@ export function getBumpKeyword({ cwd = process.cwd(), headRef, baseRef, loose =
157
176
  return "none";
158
177
  }
159
178
 
160
- if (releaseLineRecoveryTarget) {
161
- if (baseChannel !== "release" || releaseLineRecoveryTarget.normalizedRef !== normalizeRef(baseRef)) {
162
- throw new Error(`Versions not match for head/base refs: ${headRef} -> ${baseRef}`);
163
- }
164
- const mismatchMsg = `The version of head ref ${headRef} does not match current ${version.version}`;
165
- if (releaseLineRecoveryTarget.major !== version.major || releaseLineRecoveryTarget.loose !== looseVersionNumber) {
166
- throw new Error(mismatchMsg);
167
- }
168
- return "patch";
169
- }
179
+ if (recoveryKeyword) return recoveryKeyword;
170
180
 
171
181
  if (publishGateTarget) {
172
182
  if (publishGateTarget.channel !== baseChannel || publishGateTarget.normalizedRef !== normalizeRef(baseRef)) {
@@ -42,7 +42,8 @@ export async function runReleaseTailCli(args = process.argv.slice(2)) {
42
42
  const [mode = "status", ...options] = args;
43
43
  if (mode === "rehearse") {
44
44
  const capsulePath = flag(options, "capsule");
45
- const candidateRoot = flag(options, "candidate-root");
45
+ const candidateRoot =
46
+ flag(options, "candidate-root") || flag(options, "capsule-root");
46
47
  const statePath = flag(options, "state");
47
48
  const evidencePath = flag(options, "evidence");
48
49
  for (const [name, value] of Object.entries({
@@ -59,6 +60,22 @@ export async function runReleaseTailCli(args = process.argv.slice(2)) {
59
60
  "local rehearsal --mode must be simulate or replay; authorized provider rehearsal uses the reusable workflow",
60
61
  );
61
62
  try {
63
+ const environmentInput = flag(options, "environment-json");
64
+ const environment = environmentInput
65
+ ? readJson(environmentInput, "--environment-json")
66
+ : {};
67
+ if (
68
+ !environment ||
69
+ typeof environment !== "object" ||
70
+ Array.isArray(environment) ||
71
+ Object.keys(environment).length > 0
72
+ ) {
73
+ const error = new Error(
74
+ "--environment-json must be an empty object; v4 binds every semantic input into the source-bound capsule",
75
+ );
76
+ error.code = "ambient-environment-forbidden";
77
+ throw error;
78
+ }
62
79
  const result = await executeV4PublicationRehearsal({
63
80
  capsule: readJson(capsulePath, "--capsule"),
64
81
  candidateRoot,
@@ -143,7 +160,7 @@ export async function runReleaseTailCli(args = process.argv.slice(2)) {
143
160
  return report;
144
161
  }
145
162
  throw new Error(
146
- "usage: buildchain release-tail <plan|init|status|verify|compat|rehearse> [--declaration <json-or-path>] [--capsule <absolute-path>] [--candidate-root <absolute-path>] [--mode <simulate|replay>] [--state <absolute-path>] [--evidence <absolute-path>] [--hooks-json <json-or-path>] [--output <path>]",
163
+ "usage: buildchain release-tail <plan|init|status|verify|compat|rehearse> [--declaration <json-or-path>] [--capsule <absolute-path>] [--candidate-root <absolute-path>|--capsule-root <absolute-path>] [--environment-json <empty-json-or-path>] [--mode <simulate|replay>] [--state <absolute-path>] [--evidence <absolute-path>] [--hooks-json <json-or-path>] [--output <path>]",
147
164
  );
148
165
  }
149
166