@kungfu-tech/buildchain 3.0.1-alpha.2 → 3.0.1-alpha.4

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 (50) hide show
  1. package/AGENTS.md +5 -5
  2. package/README.md +15 -17
  3. package/actions/promote-buildchain-ref/README.md +8 -8
  4. package/actions/report-buildchain-issue/README.md +2 -2
  5. package/actions/run-lifecycle/README.md +1 -1
  6. package/actions/validate-config/README.md +1 -1
  7. package/bin/buildchain.mjs +11 -4
  8. package/dist/site/buildchain-contract.json +59 -24
  9. package/dist/site/buildchain-site.json +78 -83
  10. package/dist/site/controller-registry.json +30 -2
  11. package/dist/site/kfd-claims.json +12 -5
  12. package/dist/site/kfd-upstream-aggregate.json +1 -1
  13. package/dist/site/manual-registry.json +18 -18
  14. package/dist/site/node-api-registry.json +5 -5
  15. package/dist/site/page-registry.json +54 -59
  16. package/dist/site/public-surface-audit.json +11 -9
  17. package/dist/site/publication-registry.json +4 -4
  18. package/dist/site/release-provenance.json +2 -2
  19. package/dist/site/site-manifest.json +22 -22
  20. package/dist/site/workflow-registry.json +8 -1
  21. package/docs/MAP.md +5 -5
  22. package/docs/cli.md +20 -14
  23. package/docs/consumer-issue-reporting.md +1 -1
  24. package/docs/homebrew.md +7 -5
  25. package/docs/install.md +7 -3
  26. package/docs/kfd-agent-hub.md +1 -1
  27. package/docs/lifecycle-protocol.md +3 -3
  28. package/docs/migration-inventory.md +4 -4
  29. package/docs/ownership.md +4 -4
  30. package/docs/publication-artifacts.md +2 -2
  31. package/docs/release-candidate.md +4 -4
  32. package/docs/release-governance.md +13 -13
  33. package/docs/release-passport.md +6 -6
  34. package/docs/release-propagation.md +5 -5
  35. package/docs/reusable-build-surface.md +23 -23
  36. package/docs/runtime-train-validation.md +13 -13
  37. package/docs/shifu-gate-profiles.md +2 -2
  38. package/docs/site-bundle-contract.md +1 -1
  39. package/docs/stable-candidate-patrol.md +2 -2
  40. package/docs/toolkit-observability.md +14 -0
  41. package/docs/web-surface-deployments.md +7 -7
  42. package/fixtures/libnode-shaped/README.md +1 -1
  43. package/package.json +1 -1
  44. package/packages/core/issue-reporting.js +8 -3
  45. package/packages/core/logging.js +101 -0
  46. package/scripts/auditable-demo.mjs +1 -1
  47. package/scripts/check-inventory.mjs +36 -17
  48. package/scripts/init-repo.mjs +2 -2
  49. package/scripts/web-surface-production-release-pr.mjs +99 -0
  50. package/scripts/workflow-friction-report.mjs +10 -0
@@ -214,9 +214,6 @@ export function buildWorkflowFrictionIssueReport(options = {}) {
214
214
  repository,
215
215
  workflow,
216
216
  channel,
217
- releaseIntent,
218
- sourceRef,
219
- sourceSha,
220
217
  frictionClass,
221
218
  });
222
219
  const marker = workflowFrictionMarker(fingerprint);
@@ -265,6 +262,10 @@ export function buildWorkflowFrictionIssueReport(options = {}) {
265
262
  `- Workflow: ${workflow || "(unknown)"}`,
266
263
  `- Run: ${runUrl || runId || "(unknown)"}`,
267
264
  `- Attempt: ${runAttempt || "(unknown)"}`,
265
+ `- Channel: ${channel || "(unknown)"}`,
266
+ `- Release intent: ${releaseIntent || "(unknown)"}`,
267
+ `- Source ref: ${sourceRef || "(unknown)"}`,
268
+ `- Source SHA: ${sourceSha || "(unknown)"}`,
268
269
  `- Friction class: ${frictionClass}`,
269
270
  `- Fingerprint: ${fingerprint}`,
270
271
  options.summary ? `\n${options.summary}` : "",
@@ -275,6 +276,10 @@ export function buildWorkflowFrictionIssueReport(options = {}) {
275
276
  return {
276
277
  contract: BUILDCHAIN_WORKFLOW_FRICTION_ISSUE_CONTRACT,
277
278
  targetRepository: target.fullName,
279
+ repository,
280
+ workflow,
281
+ channel,
282
+ frictionClass,
278
283
  fingerprint,
279
284
  marker,
280
285
  title,
@@ -5,6 +5,13 @@ import path from "node:path";
5
5
 
6
6
  export const BUILDCHAIN_LOG_EVENT_CONTRACT = "kungfu-buildchain-log-event";
7
7
  export const BUILDCHAIN_LOG_SUMMARY_CONTRACT = "kungfu-buildchain-log-summary";
8
+ export const BUILDCHAIN_CONTROL_PLANE_SUMMARY_CONTRACT =
9
+ "kungfu-buildchain-control-plane-summary";
10
+
11
+ const CONTROL_PLANE_EVENT_NAMES = {
12
+ "workflow-friction": "control-plane.workflow-friction.outcome",
13
+ "release-intent": "control-plane.release-intent.outcome",
14
+ };
8
15
 
9
16
  const SECRET_KEY_PATTERN =
10
17
  /(authorization|cookie|credential|password|passwd|private[_-]?key|secret|token|api[_-]?key)/i;
@@ -90,6 +97,98 @@ function groupSummary(events, field) {
90
97
  );
91
98
  }
92
99
 
100
+ function countOutcomes(events) {
101
+ const counts = {};
102
+ for (const event of events) {
103
+ const outcome = String(event.attributes?.outcome || "unknown");
104
+ counts[outcome] = (counts[outcome] || 0) + 1;
105
+ }
106
+ return Object.fromEntries(
107
+ Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)),
108
+ );
109
+ }
110
+
111
+ function ratio(numerator, denominator) {
112
+ return denominator > 0 ? Number((numerator / denominator).toFixed(4)) : null;
113
+ }
114
+
115
+ export function summarizeBuildchainControlPlaneEvents(input = {}) {
116
+ const events = Array.isArray(input)
117
+ ? input
118
+ : typeof input === "string"
119
+ ? readBuildchainLogEvents(input)
120
+ : readBuildchainLogEvents(input.path);
121
+ const workflowFrictionEvents = events.filter(
122
+ (event) => event.event === CONTROL_PLANE_EVENT_NAMES["workflow-friction"],
123
+ );
124
+ const releaseIntentEvents = events.filter(
125
+ (event) => event.event === CONTROL_PLANE_EVENT_NAMES["release-intent"],
126
+ );
127
+ const workflowFriction = countOutcomes(workflowFrictionEvents);
128
+ const releaseIntent = countOutcomes(releaseIntentEvents);
129
+ const incidentDecisions = Number(workflowFriction.created || 0) + Number(workflowFriction.reused || 0);
130
+ const releaseIntentDecisions =
131
+ Number(releaseIntent.created || 0) +
132
+ Number(releaseIntent.reused || 0) +
133
+ Number(releaseIntent.suppressed || 0);
134
+ const suppressionReasons = {};
135
+ for (const event of releaseIntentEvents) {
136
+ if (event.attributes?.outcome !== "suppressed") continue;
137
+ const reason = String(event.attributes?.reason || "unknown");
138
+ suppressionReasons[reason] = (suppressionReasons[reason] || 0) + 1;
139
+ }
140
+ return {
141
+ schemaVersion: 1,
142
+ contract: BUILDCHAIN_CONTROL_PLANE_SUMMARY_CONTRACT,
143
+ eventCount: workflowFrictionEvents.length + releaseIntentEvents.length,
144
+ workflowFriction: {
145
+ eventCount: workflowFrictionEvents.length,
146
+ outcomes: workflowFriction,
147
+ incidentReuseRate: ratio(Number(workflowFriction.reused || 0), incidentDecisions),
148
+ },
149
+ releaseIntent: {
150
+ eventCount: releaseIntentEvents.length,
151
+ outcomes: releaseIntent,
152
+ suppressionRate: ratio(Number(releaseIntent.suppressed || 0), releaseIntentDecisions),
153
+ suppressionReasons: Object.fromEntries(
154
+ Object.entries(suppressionReasons).sort(([left], [right]) => left.localeCompare(right)),
155
+ ),
156
+ },
157
+ };
158
+ }
159
+
160
+ export function recordBuildchainControlPlaneOutcome({
161
+ domain,
162
+ action = "",
163
+ outcome = "",
164
+ reason = "",
165
+ attributes = {},
166
+ } = {}, options = {}) {
167
+ if (!Object.hasOwn(CONTROL_PLANE_EVENT_NAMES, domain)) {
168
+ throw new Error(`unsupported Buildchain control-plane domain: ${domain || "<empty>"}`);
169
+ }
170
+ const logger = options.logger || createBuildchainLogger({
171
+ cwd: options.cwd,
172
+ path: options.path,
173
+ console: options.console ?? false,
174
+ source: "buildchain",
175
+ component: "control-plane",
176
+ phase: domain,
177
+ });
178
+ const normalizedOutcome = outcome || action || "unknown";
179
+ const details = {
180
+ attributes: {
181
+ ...attributes,
182
+ action,
183
+ outcome: normalizedOutcome,
184
+ reason,
185
+ },
186
+ };
187
+ return ["failed", "error"].includes(normalizedOutcome)
188
+ ? logger.warn(CONTROL_PLANE_EVENT_NAMES[domain], details)
189
+ : logger.info(CONTROL_PLANE_EVENT_NAMES[domain], details);
190
+ }
191
+
93
192
  export function summarizeBuildchainLogEvents(input = {}) {
94
193
  const events = Array.isArray(input)
95
194
  ? input
@@ -109,6 +208,8 @@ export function summarizeBuildchainLogEvents(input = {}) {
109
208
  sources: groupSummary(events, "source"),
110
209
  phases: groupSummary(events, "phase"),
111
210
  components: groupSummary(events, "component"),
211
+ events: groupSummary(events, "event"),
212
+ controlPlane: summarizeBuildchainControlPlaneEvents(events),
112
213
  };
113
214
  }
114
215
 
@@ -316,7 +316,7 @@ function runAdapter(values) {
316
316
  const result = spawnSync(
317
317
  adapter,
318
318
  ["--artifact-root", artifactRoot, "--output", output, "--source-coordinate", sourceCoordinate],
319
- { cwd: sourceRoot, env, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 },
319
+ { cwd: sourceRoot, env: environment, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 },
320
320
  );
321
321
  fs.writeFileSync(path.join(diagnostics, "adapter.stdout.log"), result.stdout || "");
322
322
  fs.writeFileSync(path.join(diagnostics, "adapter.stderr.log"), result.stderr || result.error?.message || "");
@@ -19,6 +19,28 @@ import {
19
19
  const root = process.cwd();
20
20
  const sharedActionTsupConfig = fs.readFileSync(path.join(root, "scripts/tsup-action.config.mjs"), "utf8");
21
21
  const commonJsSourcePattern = /\b(require\s*\(|module\.exports|exports\.|require\.main|createRequire)\b/;
22
+
23
+ function assertSelfReleaseImpactContract(impact, { expectedVersion = "" } = {}) {
24
+ if (!impact || typeof impact !== "object" || Array.isArray(impact)) {
25
+ throw new Error("Buildchain self release impact must be a JSON object");
26
+ }
27
+ const version = String(impact.release?.version || "").trim();
28
+ const versionMatch = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/);
29
+ if (!versionMatch) throw new Error("Buildchain self release impact release.version must be a semantic version");
30
+ if (expectedVersion && version !== expectedVersion) {
31
+ throw new Error("Buildchain self release impact version must match package.json version");
32
+ }
33
+ const expectedLine = `v${versionMatch[1]}.${versionMatch[2]}`;
34
+ const line = String(impact.release?.line || "").trim();
35
+ if (line !== expectedLine) {
36
+ throw new Error(`Buildchain self release impact line must be ${expectedLine} for release.version ${version}`);
37
+ }
38
+ const summary = String(impact.summary || "").trim();
39
+ if (!summary.startsWith(`Buildchain ${line} `)) {
40
+ throw new Error(`Buildchain self release impact summary must describe the current ${line} line`);
41
+ }
42
+ }
43
+
22
44
  const requiredPaths = [
23
45
  "AGENTS.md",
24
46
  "CONTRIBUTING.md",
@@ -707,7 +729,7 @@ for (const requiredSnippet of [
707
729
  for (const requiredSnippet of [
708
730
  "Capability Coverage",
709
731
  "KFD-1 / KFD-2 / KFD-3",
710
- "floating `@v2`",
732
+ "floating `@v3`",
711
733
  "npm publish transactions",
712
734
  "Git/source/version/module/product build facts",
713
735
  "GitHub Release",
@@ -778,7 +800,7 @@ for (const requiredSnippet of [
778
800
  for (const [docName, docSource] of Object.entries({ "docs/cli.md": cliDoc, "docs/install.md": installDoc })) {
779
801
  for (const requiredSnippet of [
780
802
  "minimumReleaseAgeExclude",
781
- "@kungfu-tech/buildchain@2.2.5",
803
+ "@kungfu-tech/buildchain@3.0.0",
782
804
  "package/version-specific",
783
805
  ]) {
784
806
  if (!docSource.includes(requiredSnippet)) {
@@ -844,19 +866,16 @@ const selfHostedRunnerSmokeWorkflow = fs.readFileSync(path.join(root, ".github/w
844
866
  const npmDryRunScript = fs.readFileSync(path.join(root, "scripts/npm-publish-dry-run.mjs"), "utf8");
845
867
  const npmPublishTransactionScript = fs.readFileSync(path.join(root, "scripts/npm-publish-transaction.mjs"), "utf8");
846
868
  const rootPackageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
847
- const selfReleaseImpact = JSON.parse(fs.readFileSync(path.join(root, ".buildchain/release-impact.json"), "utf8"));
848
- const selfReleaseLineMatch = String(rootPackageJson.version || "").match(/^(\d+)\.(\d+)\./);
849
- const expectedSelfReleaseLine = selfReleaseLineMatch ? `v${selfReleaseLineMatch[1]}.${selfReleaseLineMatch[2]}` : "";
850
- if (selfReleaseImpact.release?.version !== rootPackageJson.version) {
851
- throw new Error("Buildchain self release impact version must match package.json version");
852
- }
853
- if (!expectedSelfReleaseLine || selfReleaseImpact.release?.line !== expectedSelfReleaseLine) {
854
- throw new Error("Buildchain self release impact line must match package.json major/minor line");
855
- }
869
+ const selfReleaseImpactPath = path.resolve(
870
+ root,
871
+ process.env.BUILDCHAIN_SELF_RELEASE_IMPACT_PATH || ".buildchain/release-impact.json",
872
+ );
873
+ const selfReleaseImpact = JSON.parse(fs.readFileSync(selfReleaseImpactPath, "utf8"));
874
+ assertSelfReleaseImpactContract(selfReleaseImpact, { expectedVersion: rootPackageJson.version });
856
875
  if (!["patch", "minor", "major"].includes(selfReleaseImpact.classification)) {
857
876
  throw new Error("Buildchain self release impact classification must be patch, minor, or major");
858
877
  }
859
- if (!String(selfReleaseImpact.summary || "").trim() || !Array.isArray(selfReleaseImpact.surfaceImpacts) || selfReleaseImpact.surfaceImpacts.length === 0) {
878
+ if (!Array.isArray(selfReleaseImpact.surfaceImpacts) || selfReleaseImpact.surfaceImpacts.length === 0) {
860
879
  throw new Error("Buildchain self release impact requires a summary and surfaceImpacts[]");
861
880
  }
862
881
  for (const requiredSnippet of [
@@ -969,7 +988,7 @@ for (const retiredWorkflow of [
969
988
  const retiredSource = fs.readFileSync(path.join(workflowDir, retiredWorkflow), "utf8");
970
989
  for (const requiredSnippet of [
971
990
  "release path is retired",
972
- "release-candidate-promote.yml@v2",
991
+ "release-candidate-promote.yml@v3",
973
992
  "publish-gate source-lock enforcement",
974
993
  ]) {
975
994
  if (!retiredSource.includes(requiredSnippet)) {
@@ -1115,12 +1134,12 @@ if (inventory.release !== "buildchain-v2") {
1115
1134
  throw new Error("inventory release must be buildchain-v2");
1116
1135
  }
1117
1136
 
1118
- if (inventory.stableRefs?.actions !== "kungfu-systems/buildchain/actions/<name>@v2") {
1119
- throw new Error("inventory stable action ref must point at @v2");
1137
+ if (inventory.stableRefs?.actions !== "kungfu-systems/buildchain/actions/<name>@v3") {
1138
+ throw new Error("inventory stable action ref must point at @v3");
1120
1139
  }
1121
1140
 
1122
- if (inventory.stableRefs?.workflows !== "kungfu-systems/buildchain/.github/workflows/<workflow>.yml@v2") {
1123
- throw new Error("inventory stable workflow ref must point at @v2");
1141
+ if (inventory.stableRefs?.workflows !== "kungfu-systems/buildchain/.github/workflows/<workflow>.yml@v3") {
1142
+ throw new Error("inventory stable workflow ref must point at @v3");
1124
1143
  }
1125
1144
  if (inventory.safety?.releasePassport?.line !== "v2.2") {
1126
1145
  throw new Error("release passport inventory must be registered as a v2.2 surface");
@@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url";
5
5
  import { BUILDCHAIN_CONFIG_PATH } from "../packages/core/buildchain-layout.js";
6
6
  import { detectPackageManager, assertPackageManager } from "../packages/core/package-manager.js";
7
7
 
8
- const BUILDCHAIN_WORKFLOW_REF = "kungfu-systems/buildchain/.github/workflows/.build.yml@v2";
8
+ const BUILDCHAIN_WORKFLOW_REF = "kungfu-systems/buildchain/.github/workflows/.build.yml@v3";
9
9
  const DEFAULT_PUBLICATION_LATEX_IMAGE = "ghcr.io/kungfu-systems/build-images/latex-pdf-builder";
10
10
  const DEFAULT_PUBLICATION_LATEX_DIGEST = "sha256:c20f3809e96836c1c78e97c76939d12f1de3fed0ea9b7c40c43332ec2ea480f8";
11
11
  const DEFAULT_PUBLICATION_LATEX_COMMAND = "latexmk -pdf -outdir=_build paper/main.tex";
@@ -349,7 +349,7 @@ permissions:
349
349
 
350
350
  jobs:
351
351
  publication:
352
- uses: kungfu-systems/buildchain/.github/workflows/publication-artifact.yml@v2
352
+ uses: kungfu-systems/buildchain/.github/workflows/publication-artifact.yml@v3
353
353
  with:
354
354
  buildchain-ref: \${{ inputs.buildchain-ref || '' }}
355
355
  toolchain-type: config
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import {
5
+ defaultBuildchainLogPath,
6
+ recordBuildchainControlPlaneOutcome,
7
+ } from "../packages/core/logging.js";
4
8
 
5
9
  function requiredString(value, name) {
6
10
  const normalized = String(value || "").trim();
@@ -70,6 +74,34 @@ function runUrl({ serverUrl = "", repository = "", runId = "" } = {}) {
70
74
  return `${serverUrl.replace(/\/$/, "")}/${repository}/actions/runs/${runId}`;
71
75
  }
72
76
 
77
+ export function recordProductionReleasePrOutcome(result, env = process.env) {
78
+ const outcome = result.action === "created"
79
+ ? "created"
80
+ : result.action === "updated"
81
+ ? "reused"
82
+ : result.action === "suppressed-merged-release-pr"
83
+ ? "suppressed"
84
+ : ["permission-denied", "app-token-unavailable", "failed"].includes(result.action)
85
+ ? "failed"
86
+ : "skipped";
87
+ return recordBuildchainControlPlaneOutcome({
88
+ domain: "release-intent",
89
+ action: result.action,
90
+ outcome,
91
+ reason: result.suppressionReason || result.status,
92
+ attributes: {
93
+ repository: result.repository,
94
+ channel: result.productionReleaseChannel,
95
+ pullNumber: result.pullNumber,
96
+ sourceSha: result.sourceSha,
97
+ },
98
+ }, {
99
+ path: optionalString(env.BUILDCHAIN_LOG_PATH) ||
100
+ (env.GITHUB_ACTIONS === "true" ? defaultBuildchainLogPath() : false),
101
+ console: false,
102
+ });
103
+ }
104
+
73
105
  function urlsFromResult(result = {}) {
74
106
  const urls = result.urls && typeof result.urls === "object" ? result.urls : {};
75
107
  if (Object.keys(urls).length > 0) return urls;
@@ -374,6 +406,33 @@ async function createOrUpdateBranch({ apiUrl, token, owner, repo, branchName, so
374
406
  return createdCommit.sha;
375
407
  }
376
408
 
409
+ function findMergedProductionReleasePr({
410
+ pullRequests = [],
411
+ repository,
412
+ productionReleaseLabel = "buildchain-release",
413
+ productionReleaseHeadPrefix = "release/",
414
+ base = "main",
415
+ } = {}) {
416
+ const fullName = requiredString(repository, "repository");
417
+ const label = requiredString(productionReleaseLabel, "productionReleaseLabel");
418
+ const headPrefix = optionalString(productionReleaseHeadPrefix);
419
+ const candidates = (Array.isArray(pullRequests) ? pullRequests : []).filter((pull) => {
420
+ const labels = Array.isArray(pull?.labels) ? pull.labels.map((entry) => entry?.name || entry) : [];
421
+ const headRef = optionalString(pull?.head?.ref);
422
+ return Boolean(pull?.merged_at) &&
423
+ pull?.base?.ref === base &&
424
+ pull?.head?.repo?.full_name === fullName &&
425
+ labels.includes(label) &&
426
+ (!headPrefix || headRef.startsWith(headPrefix));
427
+ });
428
+ if (candidates.length > 1) {
429
+ throw new Error(
430
+ `multiple merged production release PRs matched the source commit: ${candidates.map((pull) => `#${pull.number}`).join(", ")}`,
431
+ );
432
+ }
433
+ return candidates[0];
434
+ }
435
+
377
436
  export async function openProductionReleasePr({
378
437
  apiUrl = "https://api.github.com",
379
438
  token,
@@ -400,6 +459,43 @@ export async function openProductionReleasePr({
400
459
  });
401
460
  const { owner, repo, branchName, title, body, head } = handoff;
402
461
  const normalizedToken = requiredString(token, "token");
462
+ const associated = await githubJson({
463
+ apiUrl,
464
+ token: normalizedToken,
465
+ path: `/repos/${owner}/${repo}/commits/${encodeURIComponent(handoff.sourceSha)}/pulls?per_page=100`,
466
+ });
467
+ let mergedReleasePull = findMergedProductionReleasePr({
468
+ pullRequests: associated,
469
+ repository,
470
+ productionReleaseLabel,
471
+ productionReleaseHeadPrefix,
472
+ base: handoff.base,
473
+ });
474
+ if (!mergedReleasePull) {
475
+ const closedByDeterministicHead = await githubJson({
476
+ apiUrl,
477
+ token: normalizedToken,
478
+ path: `/repos/${owner}/${repo}/pulls?state=closed&base=${encodeURIComponent(handoff.base)}&head=${encodeURIComponent(head)}&per_page=100`,
479
+ });
480
+ mergedReleasePull = findMergedProductionReleasePr({
481
+ pullRequests: closedByDeterministicHead,
482
+ repository,
483
+ productionReleaseLabel,
484
+ productionReleaseHeadPrefix,
485
+ base: handoff.base,
486
+ });
487
+ }
488
+ if (mergedReleasePull) {
489
+ return {
490
+ action: "suppressed-merged-release-pr",
491
+ status: "suppressed-merged-release-pr",
492
+ ...handoff,
493
+ branchName,
494
+ pullNumber: mergedReleasePull.number,
495
+ pullUrl: mergedReleasePull.html_url || mergedReleasePull.url || "",
496
+ suppressionReason: "source-commit-already-has-qualifying-merged-release-pr",
497
+ };
498
+ }
403
499
  const existing = await githubJson({
404
500
  apiUrl,
405
501
  token: normalizedToken,
@@ -524,6 +620,7 @@ export async function webSurfaceProductionReleasePrCli(env = process.env) {
524
620
  if (failOnReleasePrError) {
525
621
  writeJsonFile(summaryPath, result);
526
622
  if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY, renderStepSummary(result));
623
+ recordProductionReleasePrOutcome(result, env);
527
624
  throw new Error(result.error.message);
528
625
  }
529
626
  } else {
@@ -562,12 +659,14 @@ export async function webSurfaceProductionReleasePrCli(env = process.env) {
562
659
  if (status !== "permission-denied" || failOnReleasePrError) {
563
660
  writeJsonFile(summaryPath, result);
564
661
  if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY, renderStepSummary(result));
662
+ recordProductionReleasePrOutcome(result, env);
565
663
  throw error;
566
664
  }
567
665
  }
568
666
  }
569
667
  }
570
668
 
669
+ recordProductionReleasePrOutcome(result, env);
571
670
  writeJsonFile(summaryPath, result);
572
671
  if (env.GITHUB_STEP_SUMMARY) {
573
672
  fs.appendFileSync(env.GITHUB_STEP_SUMMARY, renderStepSummary(result));
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { recordBuildchainControlPlaneOutcome } from "../packages/core/logging.js";
4
5
  import { pathToFileURL } from "node:url";
5
6
  import { writeGitHubOutputs } from "./build-contract-core.mjs";
6
7
 
@@ -346,6 +347,15 @@ export async function workflowFrictionReportCli() {
346
347
  })}\n`);
347
348
  console.error(`::warning::${diagnosis.replace(/\r?\n/g, "%0A")}`);
348
349
  }
350
+ recordBuildchainControlPlaneOutcome({
351
+ domain: "workflow-friction",
352
+ action: "classified",
353
+ outcome: "classified",
354
+ attributes: {
355
+ frictionClass: result.frictionClass,
356
+ pullRequest: result.pullRequest,
357
+ },
358
+ });
349
359
  writeGitHubOutputs({
350
360
  "friction-class": result.frictionClass,
351
361
  summary: result.summary,