@kungfu-tech/buildchain 2.10.4 → 2.10.5

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 (37) hide show
  1. package/README.md +16 -1
  2. package/bin/buildchain.mjs +299 -16
  3. package/dist/site/buildchain-contract.json +7 -7
  4. package/dist/site/buildchain-site.json +42 -37
  5. package/dist/site/capability-registry.json +2 -2
  6. package/dist/site/cli-registry.json +164 -8
  7. package/dist/site/kfd-claims.json +190 -19
  8. package/dist/site/manual-registry.json +10 -10
  9. package/dist/site/node-api-registry.json +24 -11
  10. package/dist/site/page-registry.json +27 -22
  11. package/dist/site/public-surface-audit.json +334 -126
  12. package/dist/site/release-provenance.json +2 -1
  13. package/dist/site/site-manifest.json +14 -14
  14. package/dist/site/workflow-registry.json +4 -1
  15. package/docs/MAP.md +2 -2
  16. package/docs/cli.md +48 -14
  17. package/docs/install.md +1 -1
  18. package/docs/kfd-support.md +80 -21
  19. package/docs/lifecycle-protocol.md +7 -2
  20. package/docs/publish-transaction.md +1 -1
  21. package/docs/readme-badges.md +2 -2
  22. package/docs/release-passport.md +1 -1
  23. package/docs/reusable-build-surface.md +7 -7
  24. package/docs/web-surface-deployments.md +33 -4
  25. package/package.json +3 -2
  26. package/packages/core/buildchain-config.js +10 -5
  27. package/packages/core/buildchain-layout.js +157 -0
  28. package/packages/core/index.js +34 -0
  29. package/packages/core/kfd.js +273 -0
  30. package/packages/core/kfd3-surface-register.js +25 -17
  31. package/packages/core/public-surface-audit.js +14 -7
  32. package/scripts/buildchain-contract-lock.mjs +6 -2
  33. package/scripts/check-inventory.mjs +5 -4
  34. package/scripts/generate-site-bundle.mjs +20 -3
  35. package/scripts/init-repo.mjs +2 -1
  36. package/scripts/web-surface-production-release-pr.mjs +209 -36
  37. package/scripts/web-surface-release-pr-review.mjs +2 -1
@@ -40,9 +40,11 @@ function listDirectories(root, dir) {
40
40
  .sort();
41
41
  }
42
42
 
43
- function commandId(first = "", second = "") {
43
+ function commandId(first = "", second = "", third = "") {
44
44
  const head = String(first || "").trim();
45
45
  const sub = String(second || "").trim();
46
+ const leaf = String(third || "").trim();
47
+ const normalizedLeaf = leaf === "..." ? "" : leaf;
46
48
  if (!head) return "";
47
49
  if (["-h", "--help", "help"].includes(head)) return "help";
48
50
  if (["-v", "--version", "version"].includes(head)) return "version";
@@ -59,7 +61,12 @@ function commandId(first = "", second = "") {
59
61
  if (head === "log" && sub) return "logging";
60
62
  if (head === "diagnostics" && sub) return `diagnostics-${sub}`;
61
63
  if (head === "facts" && sub) return "build-facts";
62
- if (head === "kfd-3" && sub) return `kfd-3-${sub}`;
64
+ if (head === "kfd") {
65
+ if (!sub || sub === "...") return "kfd";
66
+ if (sub === "schema") return normalizedLeaf ? `kfd-schema-${normalizedLeaf}` : "kfd-schema";
67
+ if (/^[1-9][0-9]*$/.test(sub)) return normalizedLeaf ? `kfd-${sub}-${normalizedLeaf}` : `kfd-${sub}`;
68
+ return `kfd-${sub}`;
69
+ }
63
70
  if (head === "sample" && sub) return `sample-${sub}`;
64
71
  if (head === "badges" && sub) return `badges-${sub}`;
65
72
  if (head === "homebrew" && sub) return `homebrew-${sub}`;
@@ -77,10 +84,10 @@ export function enumerateCliCommandsFromBin({ root = process.cwd(), binPath = "b
77
84
  const usage = usageMatch?.[1] || "";
78
85
  const usageCommands = [];
79
86
  for (const line of usage.split(/\r?\n/)) {
80
- const match = line.trim().match(/^buildchain\s+([^\s]+)(?:\s+([^\s]+))?/);
87
+ const match = line.trim().match(/^buildchain\s+([^\s]+)(?:\s+([^\s]+))?(?:\s+([^\s]+))?/);
81
88
  if (!match) continue;
82
89
  usageCommands.push({
83
- id: commandId(match[1], match[2]),
90
+ id: commandId(match[1], match[2], match[3]),
84
91
  usage: line.trim().replace(/\s+/g, " "),
85
92
  });
86
93
  }
@@ -180,15 +187,15 @@ export function enumerateDocCommandRefs({ root = process.cwd() } = {}) {
180
187
  .filter((line) => /^(?:[$>]\s*)?(?:npx\s+(?:@kungfu-tech\/buildchain|buildchain)\s+|node\s+bin\/buildchain\.mjs\s+|buildchain\s+)/.test(line))
181
188
  .join("\n")
182
189
  : segment.text;
183
- for (const match of commandText.matchAll(/(?:^|[\s$>])(?:npx\s+(?:@kungfu-tech\/buildchain|buildchain)\s+|node\s+bin\/buildchain\.mjs\s+|buildchain\s+)([a-z0-9][a-z0-9-]*|--help|--version|-h|-v)(?:\s+([a-z0-9][a-z0-9-]*|--[a-z0-9-]+))?/gm)) {
184
- const id = commandId(match[1], match[2]);
190
+ for (const match of commandText.matchAll(/(?:^|[\s$>])(?:npx\s+(?:@kungfu-tech\/buildchain|buildchain)\s+|node\s+bin\/buildchain\.mjs\s+|buildchain\s+)([a-z0-9][a-z0-9-]*|--help|--version|-h|-v)(?:\s+([a-z0-9][a-z0-9-]*|--[a-z0-9-]+))?(?:\s+([a-z0-9][a-z0-9-]*|--[a-z0-9-]+))?/gm)) {
191
+ const id = commandId(match[1], match[2], match[3]);
185
192
  if (segment.kind === "inline" && !knownCommandIds.has(id)) {
186
193
  continue;
187
194
  }
188
195
  refs.push({
189
196
  id,
190
197
  path: relPath,
191
- command: `buildchain ${match[1]}${match[2] ? ` ${match[2]}` : ""}`,
198
+ command: `buildchain ${match[1]}${match[2] ? ` ${match[2]}` : ""}${match[3] ? ` ${match[3]}` : ""}`,
192
199
  });
193
200
  }
194
201
  }
@@ -10,6 +10,10 @@ import {
10
10
  readBuildchainContractWorld,
11
11
  renderBuildchainContractDriftIssueBody,
12
12
  } from "../packages/core/buildchain-contract.js";
13
+ import {
14
+ BUILDCHAIN_CONTRACT_LOCK_PATH,
15
+ resolveBuildchainContractLockPath,
16
+ } from "../packages/core/buildchain-layout.js";
13
17
  import { writeGitHubOutputs } from "./build-contract-core.mjs";
14
18
 
15
19
  function env(name, fallback = "") {
@@ -58,7 +62,7 @@ function issueModeAllows(mode, evaluation) {
58
62
  }
59
63
 
60
64
  export function checkBuildchainContractLock({
61
- lockPath = env("BUILDCHAIN_CONTRACT_LOCK_PATH", "buildchain.contract-lock.json"),
65
+ lockPath = env("BUILDCHAIN_CONTRACT_LOCK_PATH") || resolveBuildchainContractLockPath(process.cwd()),
62
66
  currentContractPath = env("BUILDCHAIN_CONTRACT_CURRENT_PATH", ".buildchain/runtime/dist/site/buildchain-contract.json"),
63
67
  runtimeRoot = env("BUILDCHAIN_RUNTIME_ROOT", ".buildchain/runtime"),
64
68
  runtimeRef = env("BUILDCHAIN_RUNTIME_REF"),
@@ -124,7 +128,7 @@ export function checkBuildchainContractLock({
124
128
  }
125
129
 
126
130
  export function writeBuildchainContractLock({
127
- output = env("BUILDCHAIN_CONTRACT_LOCK_PATH", "buildchain.contract-lock.json"),
131
+ output = env("BUILDCHAIN_CONTRACT_LOCK_PATH", BUILDCHAIN_CONTRACT_LOCK_PATH),
128
132
  currentContractPath = env("BUILDCHAIN_CONTRACT_CURRENT_PATH", "dist/site/buildchain-contract.json"),
129
133
  runtimeRoot = env("BUILDCHAIN_RUNTIME_ROOT", process.cwd()),
130
134
  buildchainRef = env("BUILDCHAIN_RUNTIME_REF", "v2"),
@@ -54,7 +54,8 @@ const requiredPaths = [
54
54
  "docs/lifecycle-protocol.md",
55
55
  "docs/ownership.md",
56
56
  "tests/buildchain-inventory.json",
57
- "buildchain.toml",
57
+ ".buildchain/buildchain.toml",
58
+ ".buildchain/contract-lock.json",
58
59
  ".github/actionlint.yaml",
59
60
  ".github/workflows/self-hosted-runner-smoke.yml",
60
61
  ".github/workflows/buildchain-ref-promotion.yml",
@@ -126,8 +127,8 @@ if (rootPackage.exports?.["./buildchain-contract"] !== "./packages/core/buildcha
126
127
  if (rootPackage.exports?.["./issue-reporting"] !== "./packages/core/issue-reporting.js") {
127
128
  throw new Error("root package must export @kungfu-tech/buildchain/issue-reporting");
128
129
  }
129
- if (rootPackage.exports?.["./kfd-3-surfaces"] !== "./packages/core/kfd3-surface-register.js") {
130
- throw new Error("root package must export @kungfu-tech/buildchain/kfd-3-surfaces");
130
+ if (rootPackage.exports?.["./kfd"] !== "./packages/core/kfd.js") {
131
+ throw new Error("root package must export @kungfu-tech/buildchain/kfd");
131
132
  }
132
133
  if (rootPackage.exports?.["./release-line-bootstrap"] !== "./packages/core/release-line-bootstrap.js") {
133
134
  throw new Error("root package must export @kungfu-tech/buildchain/release-line-bootstrap");
@@ -498,7 +499,7 @@ for (const requiredSnippet of [
498
499
  "buildFacts[]",
499
500
  "--build-facts-json",
500
501
  "Floating Buildchain contract lock",
501
- "buildchain.contract-lock.json",
502
+ ".buildchain/contract-lock.json",
502
503
  "--kfd-3-prebuild-witness-json",
503
504
  "--kfd-3-artifact-verify-cmd",
504
505
  "currently named `kfd-3`",
@@ -388,12 +388,28 @@ function cliCommandMeta(id) {
388
388
  inspect: { group: "release-passport-trust", purpose: "Inspect release and artifact evidence command families." },
389
389
  "inspect-artifact": { group: "release-passport-trust", purpose: "Inspect artifact evidence." },
390
390
  "inspect-release": { group: "release-passport-trust", purpose: "Inspect release passport evidence." },
391
- "kfd-3": { group: "kfd-trust", purpose: "Inspect KFD-3 surface detection, registry, witness, audit, and query command families." },
391
+ kfd: { group: "kfd-trust", purpose: "Inspect KFD standards, schemas, and versioned KFD command families." },
392
+ "kfd-schema": { group: "kfd-trust", purpose: "Inspect KFD schema command families." },
393
+ "kfd-schema-list": { group: "kfd-trust", purpose: "List machine-readable schemas exposed by the KFD package standards metadata." },
394
+ "kfd-schema-show": { group: "kfd-trust", purpose: "Print a machine-readable KFD schema from the KFD package standards metadata." },
395
+ "kfd-status": { group: "kfd-trust", purpose: "Report implemented KFD support and the active .buildchain repository layout." },
396
+ "kfd-migrate-layout": { group: "kfd-trust", purpose: "Plan or apply migration of legacy root Buildchain files into .buildchain/." },
397
+ "kfd-1": { group: "kfd-trust", purpose: "Inspect KFD-1 contract-world witness, gate, verify, and schema command families." },
398
+ "kfd-1-gate": { group: "kfd-trust", purpose: "Generate KFD-1 release gate evidence from declared contract-world witnesses." },
399
+ "kfd-1-schema": { group: "kfd-trust", purpose: "Print the default KFD-1 schema exposed by the KFD package standards metadata." },
400
+ "kfd-1-verify": { group: "kfd-trust", purpose: "Validate KFD-1 release gate evidence." },
401
+ "kfd-1-witness": { group: "kfd-trust", purpose: "Generate Buildchain's KFD-1 self contract-world witness." },
402
+ "kfd-2": { group: "kfd-trust", purpose: "Inspect KFD-2 trust taxonomy, public claims, and schema command families." },
403
+ "kfd-2-claims": { group: "kfd-trust", purpose: "Generate Buildchain's KFD-2 public trust claim evidence." },
404
+ "kfd-2-schema": { group: "kfd-trust", purpose: "Print the default KFD-2 schema exposed by the KFD package standards metadata." },
405
+ "kfd-2-taxonomy": { group: "kfd-trust", purpose: "Validate KFD-2 trust taxonomy entries from the KFD package standards metadata." },
406
+ "kfd-3": { group: "kfd-trust", purpose: "Inspect KFD-3 surface detection, registry, witness, audit, and query command families under the unified KFD namespace." },
392
407
  "kfd-3-audit": { group: "kfd-trust", purpose: "Compare detected, declared, and enforced KFD-3 public surfaces." },
393
408
  "kfd-3-detect": { group: "kfd-trust", purpose: "Detect standard KFD-3 public surface candidates from source and artifact metadata." },
394
409
  "kfd-3-query": { group: "kfd-trust", purpose: "Return an agent-readable KFD-3 capability map for a product, registry, or release passport." },
395
410
  "kfd-3-register": { group: "kfd-trust", purpose: "Declare detected KFD-3 public surfaces into a product-owned surface registry." },
396
411
  "kfd-3-witness": { group: "kfd-trust", purpose: "Generate release-passport-compatible KFD-3 surface witnesses from a product registry." },
412
+ "kfd-4-schema": { group: "kfd-trust", purpose: "Print the default KFD-4 schema exposed by the KFD package standards metadata." },
397
413
  lifecycle: { group: "reusable-build", purpose: "Run configured lifecycle commands and write deterministic artifact manifests." },
398
414
  log: { group: "observability-diagnostics", purpose: "Inspect Buildchain logging command families." },
399
415
  logging: { group: "observability-diagnostics", purpose: "Emit timestamped build events, summarize logs, and enforce required phases." },
@@ -409,7 +425,7 @@ function cliCommandMeta(id) {
409
425
  "sample-process-tree": { group: "observability-diagnostics", purpose: "Sample process-tree diagnostics for a wrapped command." },
410
426
  span: { group: "observability-diagnostics", purpose: "Run a command inside a Buildchain log span." },
411
427
  "transaction-inspect": { group: "release-passport-trust", purpose: "Inspect durable release transaction state." },
412
- validate: { group: "getting-started", purpose: "Validate buildchain.toml and declared lifecycle surfaces." },
428
+ validate: { group: "getting-started", purpose: "Validate .buildchain/buildchain.toml and declared lifecycle surfaces." },
413
429
  verify: { group: "release-passport-trust", purpose: "Inspect release and artifact verification command families." },
414
430
  "verify-artifact": { group: "release-passport-trust", purpose: "Verify artifact subjects against release passport evidence." },
415
431
  "verify-infra-contract-evidence-bundle": { group: "governance-versioning", purpose: "Fail closed unless an infra-contract lifecycle evidence bundle is complete, hash-bound, and validation-consistent." },
@@ -443,7 +459,8 @@ function nodeApiMeta(exportName) {
443
459
  "./buildchain-contract": { group: "governance-versioning", summary: "Runtime contract world and compatibility digest APIs for floating-ref drift checks." },
444
460
  "./surface-manifest": { group: "site-and-propagation", summary: "Surface manifest timestamp and reproducibility policy APIs." },
445
461
  "./issue-reporting": { group: "observability-diagnostics", summary: "Buildchain-owned issue reporting API for workflow friction feedback." },
446
- "./kfd-3-surfaces": { group: "kfd-trust", summary: "KFD-3 detection, registration, audit, witness, and capability query APIs." },
462
+ "./buildchain-layout": { group: "kfd-trust", summary: "Canonical .buildchain repository layout constants, path resolution, and migration APIs." },
463
+ "./kfd": { group: "kfd-trust", summary: "Unified KFD standards, schema discovery, KFD-1/KFD-2/KFD-3 grouped APIs, KFD-4 schema discovery, and Buildchain KFD claim helpers." },
447
464
  "./public-surface-audit": { group: "kfd-trust", summary: "Reverse audit APIs for CLI, workflow, action, site page, and documentation command surfaces." },
448
465
  "./kfd-gate": { group: "kfd-trust", summary: "KFD-1/KFD-2/KFD-3 release gate evidence and validation APIs." },
449
466
  "./buildchain-kfd-claims": { group: "kfd-trust", summary: "Buildchain self KFD claim registry, witnesses, and public claim APIs." },
@@ -2,6 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
+ import { BUILDCHAIN_CONFIG_PATH } from "../packages/core/buildchain-layout.js";
5
6
  import { detectPackageManager, assertPackageManager } from "../packages/core/package-manager.js";
6
7
 
7
8
  const BUILDCHAIN_WORKFLOW_REF = "kungfu-systems/buildchain/.github/workflows/.build.yml@v2";
@@ -356,7 +357,7 @@ export function initBuildchainRepo({
356
357
  })();
357
358
 
358
359
  const written = [
359
- writeIfAllowed(path.join(resolvedCwd, "buildchain.toml"), toml, { force }),
360
+ writeIfAllowed(path.join(resolvedCwd, BUILDCHAIN_CONFIG_PATH), toml, { force }),
360
361
  writeIfAllowed(path.join(resolvedCwd, ".github", "workflows", "build.yml"), workflowYaml({
361
362
  type,
362
363
  runnerPreset,
@@ -30,6 +30,14 @@ function parseJson(value, name) {
30
30
  }
31
31
  }
32
32
 
33
+ function parseBoolean(value, defaultValue = false) {
34
+ const normalized = optionalString(value).toLowerCase();
35
+ if (!normalized) return defaultValue;
36
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
37
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
38
+ throw new Error(`expected boolean value, got: ${value}`);
39
+ }
40
+
33
41
  function readArg(name, fallback = "") {
34
42
  const index = process.argv.indexOf(`--${name}`);
35
43
  if (index === -1) return fallback;
@@ -51,6 +59,12 @@ function writeJsonFile(filePath, value) {
51
59
  fs.writeFileSync(resolved, `${JSON.stringify(value, null, 2)}\n`);
52
60
  }
53
61
 
62
+ function writeTextFile(filePath, value) {
63
+ const resolved = path.resolve(requiredString(filePath, "filePath"));
64
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
65
+ fs.writeFileSync(resolved, String(value));
66
+ }
67
+
54
68
  function runUrl({ serverUrl = "", repository = "", runId = "" } = {}) {
55
69
  if (!serverUrl || !repository || !runId) return "";
56
70
  return `${serverUrl.replace(/\/$/, "")}/${repository}/actions/runs/${runId}`;
@@ -153,6 +167,113 @@ ${urlLines.join("\n")}
153
167
  This PR intentionally contains one empty release-intent commit.`;
154
168
  }
155
169
 
170
+ export function createProductionReleasePrHandoff({
171
+ repository,
172
+ sourceSha,
173
+ stagingResult = {},
174
+ productionReleaseLabel = "buildchain-release",
175
+ productionReleaseHeadPrefix = "release/",
176
+ productionReleaseChannel = "production",
177
+ runId = "",
178
+ serverUrl = "https://github.com",
179
+ releasePassportArtifact = "buildchain-web-surface-staging-release-passport",
180
+ } = {}) {
181
+ const [owner, repo] = requiredString(repository, "repository").split("/");
182
+ if (!owner || !repo) throw new Error(`invalid repository: ${repository}`);
183
+ const normalizedSourceSha = requiredString(sourceSha, "sourceSha");
184
+ const label = requiredString(productionReleaseLabel, "productionReleaseLabel");
185
+ const branchName = releaseBranchName({
186
+ prefix: productionReleaseHeadPrefix || "release/",
187
+ channel: productionReleaseChannel || "production",
188
+ sourceSha: normalizedSourceSha,
189
+ });
190
+ const workflowRunUrl = runUrl({ serverUrl, repository, runId });
191
+ const title = `Release production from ${normalizedSourceSha.slice(0, 12)}`;
192
+ const body = renderProductionReleasePrBody({
193
+ stagingResult,
194
+ sourceSha: normalizedSourceSha,
195
+ artifactHash: stagingResult.artifactHash || "",
196
+ releasePassportArtifact,
197
+ workflowRunUrl,
198
+ productionReleaseLabel: label,
199
+ branchName,
200
+ });
201
+ const emptyCommitMessage = `buildchain release intent: ${productionReleaseChannel} ${normalizedSourceSha.slice(0, 12)}`;
202
+ const bodyPath = ".buildchain/production-release-pr/body.md";
203
+ const manualCommand = [
204
+ "git fetch origin main",
205
+ `git switch -C ${branchName} ${normalizedSourceSha}`,
206
+ `git commit --allow-empty -m ${JSON.stringify(emptyCommitMessage)}`,
207
+ `git push origin HEAD:${branchName}`,
208
+ `gh pr create --repo ${repository} --base main --head ${branchName} --title ${JSON.stringify(title)} --body-file ${bodyPath}`,
209
+ ].join(" && ");
210
+ return {
211
+ contract: "kungfu-buildchain-web-surface-production-release-pr-handoff",
212
+ schemaVersion: 1,
213
+ repository,
214
+ owner,
215
+ repo,
216
+ base: "main",
217
+ head: `${owner}:${branchName}`,
218
+ branchName,
219
+ sourceSha: normalizedSourceSha,
220
+ title,
221
+ body,
222
+ label,
223
+ productionReleaseChannel,
224
+ releasePassportArtifact,
225
+ workflowRunUrl,
226
+ manualCommand,
227
+ staging: {
228
+ channel: stagingResult.channel || "",
229
+ status: stagingResult.status || "",
230
+ urls: urlsFromResult(stagingResult),
231
+ artifactHash: stagingResult.artifactHash || "",
232
+ target: stagingResult.target || "",
233
+ manifestKey: stagingResult.manifestKey || "",
234
+ },
235
+ };
236
+ }
237
+
238
+ function classifyReleasePrError(error) {
239
+ const message = String(error?.message || error || "");
240
+ if (
241
+ error?.status === 403 &&
242
+ /not permitted to create or approve pull requests|Resource not accessible by integration|permission|forbidden/i.test(message)
243
+ ) {
244
+ return "permission-denied";
245
+ }
246
+ return "failed";
247
+ }
248
+
249
+ function renderStepSummary(result = {}) {
250
+ const status = result.status || result.action || "unknown";
251
+ const lines = [
252
+ "## Buildchain production release PR handoff",
253
+ "",
254
+ `- status: \`${status}\``,
255
+ `- mode: \`${result.mode || "auto"}\``,
256
+ `- source: \`${result.sourceSha || ""}\``,
257
+ `- release branch: \`${result.branchName || ""}\``,
258
+ ];
259
+ if (result.pullUrl) lines.push(`- pull request: ${result.pullUrl}`);
260
+ if (result.error?.message) lines.push(`- error: \`${result.error.message.replace(/`/g, "'")}\``);
261
+ if (result.manualCommand) {
262
+ lines.push("", "Manual PR creation command:", "", "```bash", result.manualCommand, "```");
263
+ }
264
+ return `${lines.join("\n")}\n`;
265
+ }
266
+
267
+ function writeGitHubOutputs(env, outputs) {
268
+ for (const [name, value] of Object.entries(outputs)) {
269
+ const normalized = String(value ?? "");
270
+ console.log(`${name}=${normalized}`);
271
+ if (env.GITHUB_OUTPUT) {
272
+ fs.appendFileSync(env.GITHUB_OUTPUT, `${name}=${normalized}\n`);
273
+ }
274
+ }
275
+ }
276
+
156
277
  async function githubJson({ apiUrl, token, method = "GET", path, body }) {
157
278
  const response = await fetch(`${apiUrl.replace(/\/$/, "")}${path}`, {
158
279
  method,
@@ -264,28 +385,19 @@ export async function openProductionReleasePr({
264
385
  serverUrl = "https://github.com",
265
386
  releasePassportArtifact = "buildchain-web-surface-staging-release-passport",
266
387
  } = {}) {
267
- const [owner, repo] = requiredString(repository, "repository").split("/");
268
- if (!owner || !repo) throw new Error(`invalid repository: ${repository}`);
269
- const normalizedToken = requiredString(token, "token");
270
- const normalizedSourceSha = requiredString(sourceSha, "sourceSha");
271
- const label = requiredString(productionReleaseLabel, "productionReleaseLabel");
272
- const branchName = releaseBranchName({
273
- prefix: productionReleaseHeadPrefix || "release/",
274
- channel: productionReleaseChannel || "production",
275
- sourceSha: normalizedSourceSha,
276
- });
277
- const workflowRunUrl = runUrl({ serverUrl, repository, runId });
278
- const body = renderProductionReleasePrBody({
388
+ const handoff = createProductionReleasePrHandoff({
389
+ repository,
390
+ sourceSha,
279
391
  stagingResult,
280
- sourceSha: normalizedSourceSha,
281
- artifactHash: stagingResult.artifactHash || "",
392
+ productionReleaseLabel,
393
+ productionReleaseHeadPrefix,
394
+ productionReleaseChannel,
395
+ runId,
396
+ serverUrl,
282
397
  releasePassportArtifact,
283
- workflowRunUrl,
284
- productionReleaseLabel: label,
285
- branchName,
286
398
  });
287
- const title = `Release production from ${normalizedSourceSha.slice(0, 12)}`;
288
- const head = `${owner}:${branchName}`;
399
+ const { owner, repo, branchName, title, body, head } = handoff;
400
+ const normalizedToken = requiredString(token, "token");
289
401
  const existing = await githubJson({
290
402
  apiUrl,
291
403
  token: normalizedToken,
@@ -300,13 +412,14 @@ export async function openProductionReleasePr({
300
412
  path: `/repos/${owner}/${repo}/pulls/${pull.number}`,
301
413
  body: { title, body },
302
414
  });
303
- await addLabel({ apiUrl, token: normalizedToken, owner, repo, pullNumber: pull.number, label });
415
+ await addLabel({ apiUrl, token: normalizedToken, owner, repo, pullNumber: pull.number, label: handoff.label });
304
416
  return {
305
417
  action: "updated",
418
+ status: "updated",
419
+ ...handoff,
306
420
  branchName,
307
421
  pullNumber: pull.number,
308
422
  pullUrl: pull.html_url,
309
- sourceSha: normalizedSourceSha,
310
423
  };
311
424
  }
312
425
 
@@ -316,8 +429,8 @@ export async function openProductionReleasePr({
316
429
  owner,
317
430
  repo,
318
431
  branchName,
319
- sourceSha: normalizedSourceSha,
320
- message: `buildchain release intent: ${productionReleaseChannel} ${normalizedSourceSha.slice(0, 12)}`,
432
+ sourceSha: handoff.sourceSha,
433
+ message: `buildchain release intent: ${productionReleaseChannel} ${handoff.sourceSha.slice(0, 12)}`,
321
434
  });
322
435
  const pull = await githubJson({
323
436
  apiUrl,
@@ -332,23 +445,27 @@ export async function openProductionReleasePr({
332
445
  maintainer_can_modify: true,
333
446
  },
334
447
  });
335
- await addLabel({ apiUrl, token: normalizedToken, owner, repo, pullNumber: pull.number, label });
448
+ await addLabel({ apiUrl, token: normalizedToken, owner, repo, pullNumber: pull.number, label: handoff.label });
336
449
  return {
337
450
  action: "created",
451
+ status: "created",
452
+ ...handoff,
338
453
  branchName,
339
454
  commitSha,
340
455
  pullNumber: pull.number,
341
456
  pullUrl: pull.html_url,
342
- sourceSha: normalizedSourceSha,
343
457
  };
344
458
  }
345
459
 
346
460
  export async function webSurfaceProductionReleasePrCli(env = process.env) {
347
461
  const stagingResult = readStagingReleasePrSummary(env);
348
462
  const sourceSha = optionalString(stagingResult.sourceSha) || requiredString(env.GITHUB_SHA, "GITHUB_SHA");
349
- const result = await openProductionReleasePr({
350
- apiUrl: env.GITHUB_API_URL || "https://api.github.com",
351
- token: env.GITHUB_TOKEN,
463
+ const mode = optionalString(env.PRODUCTION_RELEASE_PR_MODE || "auto") || "auto";
464
+ if (!["auto", "summary-only", "disabled"].includes(mode)) {
465
+ throw new Error("PRODUCTION_RELEASE_PR_MODE must be auto, summary-only, or disabled");
466
+ }
467
+ const failOnReleasePrError = parseBoolean(env.FAIL_ON_RELEASE_PR_ERROR, false);
468
+ const handoff = createProductionReleasePrHandoff({
352
469
  repository: env.GITHUB_REPOSITORY,
353
470
  sourceSha,
354
471
  stagingResult,
@@ -359,19 +476,75 @@ export async function webSurfaceProductionReleasePrCli(env = process.env) {
359
476
  serverUrl: env.GITHUB_SERVER_URL || "https://github.com",
360
477
  releasePassportArtifact: env.RELEASE_PASSPORT_ARTIFACT || "buildchain-web-surface-staging-release-passport",
361
478
  });
362
- for (const [name, value] of Object.entries({
479
+
480
+ const bodyPath = optionalString(env.PRODUCTION_RELEASE_PR_BODY_PATH) || ".buildchain/production-release-pr/body.md";
481
+ const summaryPath = optionalString(env.PRODUCTION_RELEASE_PR_SUMMARY_PATH) || ".buildchain/production-release-pr/handoff.json";
482
+ writeTextFile(bodyPath, `${handoff.body}\n`);
483
+
484
+ let result = {
485
+ ...handoff,
486
+ mode,
487
+ status: mode,
488
+ action: "skipped",
489
+ pullNumber: "",
490
+ pullUrl: "",
491
+ bodyPath,
492
+ summaryPath,
493
+ };
494
+
495
+ if (mode === "auto") {
496
+ try {
497
+ result = {
498
+ ...result,
499
+ ...(await openProductionReleasePr({
500
+ apiUrl: env.GITHUB_API_URL || "https://api.github.com",
501
+ token: env.GITHUB_TOKEN,
502
+ repository: env.GITHUB_REPOSITORY,
503
+ sourceSha,
504
+ stagingResult,
505
+ productionReleaseLabel: env.PRODUCTION_RELEASE_LABEL || "buildchain-release",
506
+ productionReleaseHeadPrefix: env.PRODUCTION_RELEASE_HEAD_PREFIX || "release/",
507
+ productionReleaseChannel: env.PRODUCTION_RELEASE_CHANNEL || "production",
508
+ runId: env.GITHUB_RUN_ID,
509
+ serverUrl: env.GITHUB_SERVER_URL || "https://github.com",
510
+ releasePassportArtifact: env.RELEASE_PASSPORT_ARTIFACT || "buildchain-web-surface-staging-release-passport",
511
+ })),
512
+ mode,
513
+ };
514
+ } catch (error) {
515
+ const status = classifyReleasePrError(error);
516
+ result = {
517
+ ...result,
518
+ status,
519
+ action: status,
520
+ error: {
521
+ status: error.status || "",
522
+ message: error.message || String(error),
523
+ },
524
+ };
525
+ if (status !== "permission-denied" || failOnReleasePrError) {
526
+ writeJsonFile(summaryPath, result);
527
+ if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY, renderStepSummary(result));
528
+ throw error;
529
+ }
530
+ }
531
+ }
532
+
533
+ writeJsonFile(summaryPath, result);
534
+ if (env.GITHUB_STEP_SUMMARY) {
535
+ fs.appendFileSync(env.GITHUB_STEP_SUMMARY, renderStepSummary(result));
536
+ }
537
+ writeGitHubOutputs(env, {
538
+ "release-pr-status": result.status,
539
+ "production-release-pr-status": result.status,
363
540
  "production-release-pr-action": result.action,
364
541
  "production-release-pr": String(result.pullNumber || ""),
365
542
  "production-release-pr-url": result.pullUrl || "",
366
543
  "production-release-branch": result.branchName || "",
367
544
  "production-release-source-sha": result.sourceSha || "",
368
- })) {
369
- console.log(`${name}=${value}`);
370
- if (env.GITHUB_OUTPUT) {
371
- const fs = await import("node:fs");
372
- fs.appendFileSync(env.GITHUB_OUTPUT, `${name}=${value}\n`);
373
- }
374
- }
545
+ "production-release-pr-summary-path": summaryPath,
546
+ "production-release-pr-body-path": bodyPath,
547
+ });
375
548
  return result;
376
549
  }
377
550
 
@@ -2,6 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { parse as parseToml } from "smol-toml";
5
+ import { resolveBuildchainConfigPath } from "../packages/core/buildchain-layout.js";
5
6
 
6
7
  export const RELEASE_REVIEW_MARKER = "<!-- buildchain:web-surface-release-review -->";
7
8
 
@@ -51,7 +52,7 @@ export function resolveReleaseReviewState(payload, options = {}) {
51
52
  }
52
53
 
53
54
  export function loadWebSurfaceReleaseUrls(cwd = ".") {
54
- const configPath = path.join(cwd, "buildchain.toml");
55
+ const configPath = path.join(cwd, resolveBuildchainConfigPath(cwd));
55
56
  const raw = fs.readFileSync(configPath, "utf8");
56
57
  const config = parseToml(raw);
57
58
  const stagingUrl = String(config.channels?.staging?.url || "").trim();