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

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.
@@ -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",
@@ -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 [
@@ -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,