@kungfu-tech/buildchain 2.4.6 → 2.4.7-alpha.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.
@@ -145,11 +145,13 @@ and fail before publish-gate side effects if it no longer matches:
145
145
  release-candidate-build-summary-path: .buildchain/artifacts/build-summary.json
146
146
  ```
147
147
 
148
- The action validates repository, channel, source SHA, platform matrix, and the
149
- aggregate build-summary hash before it writes version state, opens release-state,
150
- runs publish transaction logic, or moves tags/branches. If validation fails, run
151
- or attach the verified channel PR build first instead of promoting a stale or
152
- unproven artifact set.
148
+ The action validates repository, channel, source identity, platform matrix, and
149
+ the aggregate build-summary hash before it writes version state, opens
150
+ release-state, runs publish transaction logic, or moves tags/branches. Source
151
+ identity accepts the exact source SHA, the PR merge ref SHA, or the promoted
152
+ channel HEAD's Git tree SHA matching the passport tree hash. If validation
153
+ fails, run or attach the verified channel PR build first instead of promoting a
154
+ stale or unproven artifact set.
153
155
 
154
156
  When enabled, the action creates or resumes a release transaction keyed by
155
157
  repository, version, source SHA, and target ref. It persists that transaction to
@@ -40,6 +40,8 @@ By default issue reporting is fail-soft:
40
40
  build failure.
41
41
  - transient GitHub API 429/5xx errors and connection failures are retried.
42
42
  - if a configured label is missing, issue creation is retried without labels.
43
+ - if issue creation/commenting is still unavailable, the action writes the full
44
+ copyable issue title, fingerprint, and body into the workflow summary.
43
45
  - common token, private-key, password, and authorization values are redacted
44
46
  before submission.
45
47
 
@@ -69,6 +71,8 @@ steps:
69
71
  channel: alpha/v2/v2.4
70
72
  source-sha: ${{ github.sha }}
71
73
  friction-class: duplicate-build
74
+ related-runs-json: ${{ steps.classify.outputs.related-runs-json }}
75
+ heavy-builds-json: ${{ steps.classify.outputs.heavy-builds-json }}
72
76
  comment-cooldown-hours: "24"
73
77
  ```
74
78
 
@@ -34,7 +34,8 @@ contains:
34
34
  - repository and pull request context;
35
35
  - target channel, target ref, and product version or a non-publish
36
36
  `source-<shortSha>` candidate label;
37
- - source head SHA, merge ref SHA, and source tree hash;
37
+ - source head SHA, merge ref SHA, and the Git `HEAD^{tree}` SHA for PR merge
38
+ equivalence after the channel PR lands;
38
39
  - Buildchain runtime ref/SHA and workflow shell ref;
39
40
  - workflow run id/attempt/url;
40
41
  - normalized platform matrix and artifact summaries;
@@ -55,8 +56,10 @@ Promotion workflows that should not rebuild artifacts can enable:
55
56
 
56
57
  With `promote-only-release-candidate: "true"`, promotion fails before
57
58
  version-state, publish transaction, tag, or branch side effects when the
58
- passport does not match the repository, channel, source SHA, platform matrix,
59
- or build-summary hash. The diagnostic tells maintainers to run or attach the
60
- verified channel PR build first instead of allowing publish-gate to discover
61
- the mismatch late.
62
-
59
+ passport does not match the repository, channel, source identity, platform
60
+ matrix, or build-summary hash. Source identity accepts the exact PR source SHA,
61
+ the PR merge ref SHA, or an exact Git tree match with the promoted channel HEAD;
62
+ this keeps post-merge channel commits strict without forcing a rebuild. The
63
+ Buildchain-owned promotion workflow resolves the matching same-repository
64
+ merged channel PR and downloads its PR-stage RC passport automatically before
65
+ promotion starts.
@@ -242,7 +242,9 @@ the artifact source promoted later. Buildchain then uploads
242
242
  `<artifact-name>-release-candidate-<publish-source-sha>` artifact name. Promotion
243
243
  jobs can pass that passport to `promote-buildchain-ref` with
244
244
  `promote-only-release-candidate: "true"` so source, channel, platforms, and the
245
- aggregate build-summary hash are checked before publish-gate side effects.
245
+ aggregate build-summary hash are checked before publish-gate side effects. The
246
+ passport records the locked commit's Git tree SHA, so a post-merge channel HEAD
247
+ can be accepted only when it is tree-equivalent to the PR-stage build evidence.
246
248
 
247
249
  ## Publish Gate
248
250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.4.6",
3
+ "version": "2.4.7-alpha.1",
4
4
  "private": false,
5
5
  "description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -246,6 +246,7 @@ export function buildWorkflowFrictionIssueReport(options = {}) {
246
246
  heavyBuilds.length ? `## Heavy build jobs\n\n${heavyBuilds.map((job) => `- ${job.name || job}: ${job.durationMs || ""}`).join("\n")}` : "",
247
247
  options.diagnosis ? `## Diagnosis\n\n${options.diagnosis}` : "",
248
248
  options.nextAction ? `## Suggested next action\n\n${options.nextAction}` : "",
249
+ options.body ? `## Details\n\n${options.body}` : "",
249
250
  ].filter(Boolean).join("\n"),
250
251
  ),
251
252
  Number(options.maxBodyBytes || DEFAULT_MAX_BODY_BYTES),
@@ -102,7 +102,7 @@ export function createReleaseCandidatePassport({
102
102
  headSha: nonEmptyString(sourceSha, "sourceHeadSha"),
103
103
  baseSha: optionalString(baseSha),
104
104
  mergeRefSha: optionalString(mergeRefSha || sourceSha),
105
- treeHash: optionalString(sourceTreeHash || sha256Json(normalizedSummary.platforms || [])),
105
+ treeHash: optionalString(sourceTreeHash || normalizedSummary.git?.treeSha),
106
106
  },
107
107
  buildchain: {
108
108
  ref: optionalString(buildchain.ref || normalizedSummary.runtime?.ref),
@@ -28,6 +28,7 @@ export function aggregateBuildSummaryCli() {
28
28
  git: {
29
29
  repository: process.env.GITHUB_REPOSITORY || "",
30
30
  sha: process.env.BUILDCHAIN_SOURCE_SHA || process.env.GITHUB_SHA || "",
31
+ treeSha: process.env.BUILDCHAIN_SOURCE_TREE_SHA || "",
31
32
  ref: process.env.BUILDCHAIN_SOURCE_REF || process.env.GITHUB_REF || "",
32
33
  runId: process.env.GITHUB_RUN_ID || "",
33
34
  runAttempt: process.env.GITHUB_RUN_ATTEMPT || "",
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { pathToFileURL } from "node:url";
7
+ import { writeGitHubOutputs } from "./build-contract-core.mjs";
8
+
9
+ const DEFAULT_WORKFLOW_FILE = "build-surface-fixture.yml";
10
+
11
+ function env(name, fallback = "") {
12
+ return process.env[name] || fallback;
13
+ }
14
+
15
+ function splitRepository(repository) {
16
+ const match = String(repository || "").trim().match(/^([^/\s]+)\/([^/\s]+)$/);
17
+ if (!match) {
18
+ throw new Error(`repository must be owner/repo, got ${repository || "<empty>"}`);
19
+ }
20
+ return { owner: match[1], repo: match[2], fullName: `${match[1]}/${match[2]}` };
21
+ }
22
+
23
+ function normalizeBranch(value = "") {
24
+ return String(value || "").replace(/^refs\/heads\//, "").trim();
25
+ }
26
+
27
+ function assertSha(value, label = "sha") {
28
+ const sha = String(value || "").trim();
29
+ if (!/^[0-9a-f]{40}$/i.test(sha)) {
30
+ throw new Error(`${label} must be a 40-character Git SHA`);
31
+ }
32
+ return sha;
33
+ }
34
+
35
+ function githubHeaders(token) {
36
+ const headers = {
37
+ accept: "application/vnd.github+json",
38
+ "user-agent": "buildchain-release-candidate-resolver",
39
+ "x-github-api-version": "2022-11-28",
40
+ };
41
+ if (token) {
42
+ headers.authorization = `Bearer ${token}`;
43
+ }
44
+ return headers;
45
+ }
46
+
47
+ async function githubJson({ apiUrl, token, method = "GET", path: requestPath, fetchImpl = globalThis.fetch }) {
48
+ if (typeof fetchImpl !== "function") {
49
+ throw new Error("fetch is required to resolve release candidate artifacts");
50
+ }
51
+ const url = `${String(apiUrl || "https://api.github.com").replace(/\/+$/, "")}${requestPath}`;
52
+ const response = await fetchImpl(url, { method, headers: githubHeaders(token) });
53
+ const text = await response.text();
54
+ const body = text ? JSON.parse(text) : {};
55
+ if (!response.ok) {
56
+ throw new Error(`GitHub API ${method} ${requestPath} failed with ${response.status}: ${body.message || text}`);
57
+ }
58
+ return body;
59
+ }
60
+
61
+ async function githubDownload({ apiUrl, token, path: requestPath, outputPath, fetchImpl = globalThis.fetch }) {
62
+ const url = `${String(apiUrl || "https://api.github.com").replace(/\/+$/, "")}${requestPath}`;
63
+ const response = await fetchImpl(url, { headers: githubHeaders(token) });
64
+ if (!response.ok) {
65
+ let detail = "";
66
+ try {
67
+ detail = (await response.json())?.message || "";
68
+ } catch {
69
+ detail = "";
70
+ }
71
+ throw new Error(`GitHub artifact download ${requestPath} failed with ${response.status}${detail ? `: ${detail}` : ""}`);
72
+ }
73
+ fs.writeFileSync(outputPath, Buffer.from(await response.arrayBuffer()));
74
+ return outputPath;
75
+ }
76
+
77
+ export function selectMergedChannelPullRequest({ pullRequests = [], targetRef, repository }) {
78
+ const normalizedTarget = normalizeBranch(targetRef);
79
+ const candidates = pullRequests.filter((pr) => {
80
+ const baseRef = normalizeBranch(pr.base?.ref || pr.baseRefName || "");
81
+ const merged = Boolean(pr.merged_at || pr.mergedAt || pr.state === "closed");
82
+ const sameRepo = !repository || !pr.head?.repo?.full_name || pr.head.repo.full_name === repository;
83
+ return merged && sameRepo && baseRef === normalizedTarget;
84
+ });
85
+ candidates.sort((left, right) => {
86
+ const leftTime = Date.parse(left.merged_at || left.updated_at || left.closed_at || "");
87
+ const rightTime = Date.parse(right.merged_at || right.updated_at || right.closed_at || "");
88
+ return (rightTime || 0) - (leftTime || 0);
89
+ });
90
+ return candidates[0];
91
+ }
92
+
93
+ export function selectReleaseCandidateRun({ runs = [], pullRequest, workflowName = "Build Surface Fixture" }) {
94
+ const prNumber = Number(pullRequest?.number || 0);
95
+ const candidates = runs.filter((run) => {
96
+ const runPrs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
97
+ const matchesPr = runPrs.some((pr) => Number(pr.number || 0) === prNumber);
98
+ const matchesWorkflow = !workflowName || run.name === workflowName || run.display_title === workflowName;
99
+ return matchesPr && matchesWorkflow && run.event === "pull_request" && run.status === "completed" && run.conclusion === "success";
100
+ });
101
+ candidates.sort((left, right) => Date.parse(right.updated_at || right.created_at || "") - Date.parse(left.updated_at || left.created_at || ""));
102
+ return candidates[0];
103
+ }
104
+
105
+ export function selectReleaseCandidateArtifacts({ artifacts = [] }) {
106
+ const active = artifacts.filter((artifact) => !artifact.expired);
107
+ const passports = active.filter((artifact) => /-release-candidate-[0-9a-f]{40}$/i.test(artifact.name || ""));
108
+ if (passports.length !== 1) {
109
+ throw new Error(`expected exactly one release-candidate passport artifact, found ${passports.length}`);
110
+ }
111
+ const passport = passports[0];
112
+ const match = passport.name.match(/^(.+)-release-candidate-([0-9a-f]{40})$/i);
113
+ const prefix = match?.[1] || "";
114
+ const sha = match?.[2] || "";
115
+ const summaries = active.filter((artifact) => artifact.name === `${prefix}-summary-${sha}`);
116
+ if (summaries.length !== 1) {
117
+ throw new Error(`expected exactly one build summary artifact named ${prefix}-summary-${sha}, found ${summaries.length}`);
118
+ }
119
+ return { passport, summary: summaries[0], prefix, sourceSha: sha };
120
+ }
121
+
122
+ function unzip(zipPath, outputDir) {
123
+ fs.mkdirSync(outputDir, { recursive: true });
124
+ execFileSync("unzip", ["-q", "-o", zipPath, "-d", outputDir], { stdio: "inherit" });
125
+ }
126
+
127
+ function findDownloadedFile(root, filename) {
128
+ const stack = [root];
129
+ while (stack.length) {
130
+ const current = stack.pop();
131
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
132
+ const fullPath = path.join(current, entry.name);
133
+ if (entry.isDirectory()) {
134
+ stack.push(fullPath);
135
+ } else if (entry.name === filename) {
136
+ return fullPath;
137
+ }
138
+ }
139
+ }
140
+ return "";
141
+ }
142
+
143
+ export async function resolveReleaseCandidateArtifacts({
144
+ repository,
145
+ targetRef,
146
+ targetSha,
147
+ token = env("GITHUB_TOKEN"),
148
+ apiUrl = env("GITHUB_API_URL", "https://api.github.com"),
149
+ workflowFile = DEFAULT_WORKFLOW_FILE,
150
+ workflowName = "Build Surface Fixture",
151
+ outputDir = ".buildchain/release-candidate",
152
+ fetchImpl = globalThis.fetch,
153
+ download = true,
154
+ } = {}) {
155
+ const repoInfo = splitRepository(repository);
156
+ const sha = assertSha(targetSha, "targetSha");
157
+ const normalizedTarget = normalizeBranch(targetRef);
158
+ if (!/^(alpha|release)\/v\d+\/v\d+\.\d+$/.test(normalizedTarget)) {
159
+ return {
160
+ enabled: false,
161
+ reason: `target ref ${normalizedTarget || "(empty)"} does not require release-candidate promotion`,
162
+ };
163
+ }
164
+ const pulls = await githubJson({
165
+ apiUrl,
166
+ token,
167
+ fetchImpl,
168
+ path: `/repos/${repoInfo.owner}/${repoInfo.repo}/commits/${sha}/pulls`,
169
+ });
170
+ const pullRequest = selectMergedChannelPullRequest({
171
+ pullRequests: Array.isArray(pulls) ? pulls : [],
172
+ targetRef: normalizedTarget,
173
+ repository: repoInfo.fullName,
174
+ });
175
+ if (!pullRequest) {
176
+ throw new Error(`no same-repository merged channel PR found for ${sha} into ${normalizedTarget}`);
177
+ }
178
+ const runs = await githubJson({
179
+ apiUrl,
180
+ token,
181
+ fetchImpl,
182
+ path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/workflows/${encodeURIComponent(workflowFile)}/runs?event=pull_request&status=success&per_page=100`,
183
+ });
184
+ const run = selectReleaseCandidateRun({
185
+ runs: Array.isArray(runs.workflow_runs) ? runs.workflow_runs : [],
186
+ pullRequest,
187
+ workflowName,
188
+ });
189
+ if (!run) {
190
+ throw new Error(`no successful ${workflowName} pull_request run found for channel PR #${pullRequest.number}`);
191
+ }
192
+ const artifactResponse = await githubJson({
193
+ apiUrl,
194
+ token,
195
+ fetchImpl,
196
+ path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/runs/${run.id}/artifacts?per_page=100`,
197
+ });
198
+ const selected = selectReleaseCandidateArtifacts({
199
+ artifacts: Array.isArray(artifactResponse.artifacts) ? artifactResponse.artifacts : [],
200
+ });
201
+ const result = {
202
+ enabled: true,
203
+ repository: repoInfo.fullName,
204
+ targetRef: normalizedTarget,
205
+ targetSha: sha,
206
+ pullRequest: {
207
+ number: pullRequest.number,
208
+ url: pullRequest.html_url || pullRequest.url || "",
209
+ headRef: pullRequest.head?.ref || "",
210
+ baseRef: pullRequest.base?.ref || "",
211
+ },
212
+ run: {
213
+ id: String(run.id || ""),
214
+ url: run.html_url || run.url || "",
215
+ name: run.name || workflowName,
216
+ },
217
+ artifacts: {
218
+ passport: selected.passport.name,
219
+ summary: selected.summary.name,
220
+ sourceSha: selected.sourceSha,
221
+ },
222
+ };
223
+ if (!download) {
224
+ return result;
225
+ }
226
+ const resolvedOutput = path.resolve(outputDir);
227
+ fs.mkdirSync(resolvedOutput, { recursive: true });
228
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "buildchain-rc-"));
229
+ const passportDir = path.join(resolvedOutput, "passport");
230
+ const summaryDir = path.join(resolvedOutput, "summary");
231
+ const passportZip = path.join(tempDir, "passport.zip");
232
+ const summaryZip = path.join(tempDir, "summary.zip");
233
+ await githubDownload({
234
+ apiUrl,
235
+ token,
236
+ fetchImpl,
237
+ outputPath: passportZip,
238
+ path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${selected.passport.id}/zip`,
239
+ });
240
+ await githubDownload({
241
+ apiUrl,
242
+ token,
243
+ fetchImpl,
244
+ outputPath: summaryZip,
245
+ path: `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/artifacts/${selected.summary.id}/zip`,
246
+ });
247
+ unzip(passportZip, passportDir);
248
+ unzip(summaryZip, summaryDir);
249
+ fs.rmSync(tempDir, { recursive: true, force: true });
250
+ const passportPath = findDownloadedFile(passportDir, "release-candidate-passport.json");
251
+ const buildSummaryPath = findDownloadedFile(summaryDir, "build-summary.json");
252
+ if (!passportPath || !buildSummaryPath) {
253
+ throw new Error("downloaded release-candidate artifacts did not contain release-candidate-passport.json and build-summary.json");
254
+ }
255
+ const passport = JSON.parse(fs.readFileSync(passportPath, "utf8"));
256
+ return {
257
+ ...result,
258
+ paths: {
259
+ passport: path.relative(process.cwd(), passportPath).split(path.sep).join("/"),
260
+ buildSummary: path.relative(process.cwd(), buildSummaryPath).split(path.sep).join("/"),
261
+ },
262
+ version: passport.target?.version || "",
263
+ candidateHash: passport.candidateHash || "",
264
+ };
265
+ }
266
+
267
+ export async function resolveReleaseCandidateArtifactsCli() {
268
+ const result = await resolveReleaseCandidateArtifacts({
269
+ repository: env("BUILDCHAIN_SOURCE_REPOSITORY", env("GITHUB_REPOSITORY")),
270
+ targetRef: env("BUILDCHAIN_TARGET_REF"),
271
+ targetSha: env("BUILDCHAIN_TARGET_SHA"),
272
+ workflowFile: env("BUILDCHAIN_RC_WORKFLOW_FILE", DEFAULT_WORKFLOW_FILE),
273
+ workflowName: env("BUILDCHAIN_RC_WORKFLOW_NAME", "Build Surface Fixture"),
274
+ outputDir: env("BUILDCHAIN_RC_OUTPUT_DIR", ".buildchain/release-candidate"),
275
+ });
276
+ writeGitHubOutputs({
277
+ "promote-only-release-candidate": String(result.enabled === true),
278
+ "release-candidate-passport-path": result.paths?.passport || "",
279
+ "release-candidate-build-summary-path": result.paths?.buildSummary || "",
280
+ "release-candidate-version": result.version || "",
281
+ "release-candidate-artifact": result.artifacts?.passport || "",
282
+ "release-candidate-build-summary-artifact": result.artifacts?.summary || "",
283
+ "release-candidate-run-id": result.run?.id || "",
284
+ "release-candidate-run-url": result.run?.url || "",
285
+ "release-candidate-pr": result.pullRequest?.number ? String(result.pullRequest.number) : "",
286
+ "release-candidate-diagnosis": result.enabled
287
+ ? `Resolved PR-stage RC passport ${result.artifacts.passport} from ${result.run.url || `run ${result.run.id}`}`
288
+ : result.reason || "",
289
+ });
290
+ console.log(JSON.stringify(result, null, 2));
291
+ return result;
292
+ }
293
+
294
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
295
+ try {
296
+ await resolveReleaseCandidateArtifactsCli();
297
+ } catch (error) {
298
+ console.error(`::error::${String(error.message || error).replace(/\r?\n/g, "%0A")}`);
299
+ process.exitCode = 1;
300
+ }
301
+ }
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { writeGitHubOutputs } from "./build-contract-core.mjs";
6
+
7
+ const DEFAULT_BUILD_WORKFLOW_FILE = "build-surface-fixture.yml";
8
+
9
+ function env(name, fallback = "") {
10
+ return process.env[name] || fallback;
11
+ }
12
+
13
+ function splitRepository(repository) {
14
+ const match = String(repository || "").trim().match(/^([^/\s]+)\/([^/\s]+)$/);
15
+ if (!match) {
16
+ throw new Error(`repository must be owner/repo, got ${repository || "<empty>"}`);
17
+ }
18
+ return { owner: match[1], repo: match[2], fullName: `${match[1]}/${match[2]}` };
19
+ }
20
+
21
+ function normalizeBranch(value = "") {
22
+ return String(value || "").replace(/^refs\/heads\//, "").trim();
23
+ }
24
+
25
+ function assertSha(value, label = "sha") {
26
+ const sha = String(value || "").trim();
27
+ if (!/^[0-9a-f]{40}$/i.test(sha)) {
28
+ throw new Error(`${label} must be a 40-character Git SHA`);
29
+ }
30
+ return sha;
31
+ }
32
+
33
+ function githubHeaders(token) {
34
+ const headers = {
35
+ accept: "application/vnd.github+json",
36
+ "user-agent": "buildchain-workflow-friction-report",
37
+ "x-github-api-version": "2022-11-28",
38
+ };
39
+ if (token) {
40
+ headers.authorization = `Bearer ${token}`;
41
+ }
42
+ return headers;
43
+ }
44
+
45
+ async function githubJson({ apiUrl, token, path: requestPath, fetchImpl = globalThis.fetch }) {
46
+ const url = `${String(apiUrl || "https://api.github.com").replace(/\/+$/, "")}${requestPath}`;
47
+ const response = await fetchImpl(url, { headers: githubHeaders(token) });
48
+ const text = await response.text();
49
+ const body = text ? JSON.parse(text) : {};
50
+ if (!response.ok) {
51
+ throw new Error(`GitHub API ${requestPath} failed with ${response.status}: ${body.message || text}`);
52
+ }
53
+ return body;
54
+ }
55
+
56
+ function runLabel(run = "") {
57
+ if (typeof run === "string") {
58
+ return run;
59
+ }
60
+ const title = run.display_title || run.name || "workflow run";
61
+ const url = run.html_url || run.url || "";
62
+ const status = [run.status, run.conclusion].filter(Boolean).join("/");
63
+ return `${title}${status ? ` (${status})` : ""}${url ? ` ${url}` : ""}`;
64
+ }
65
+
66
+ export function selectFrictionClass({
67
+ duplicatePullRequests = [],
68
+ heavyBuilds = [],
69
+ releaseCandidateOutcome = "",
70
+ } = {}) {
71
+ if (duplicatePullRequests.length > 1) {
72
+ return "duplicate-channel-pr";
73
+ }
74
+ if (heavyBuilds.length > 1) {
75
+ return "duplicate-heavy-build";
76
+ }
77
+ if (releaseCandidateOutcome === "failure") {
78
+ return "late-fail-fast";
79
+ }
80
+ return "buildchain-ref-promotion-failed";
81
+ }
82
+
83
+ export function buildWorkflowFrictionBody({
84
+ repository,
85
+ targetRef,
86
+ targetSha,
87
+ runUrl,
88
+ pullRequests = [],
89
+ heavyBuilds = [],
90
+ relatedRuns = [],
91
+ frictionClass,
92
+ diagnosis,
93
+ nextAction,
94
+ releaseCandidateDiagnosis = "",
95
+ } = {}) {
96
+ return [
97
+ "# Buildchain workflow friction evidence",
98
+ "",
99
+ "## Promotion context",
100
+ "",
101
+ `- Repository: ${repository || "(unknown)"}`,
102
+ `- Target ref: ${targetRef || "(unknown)"}`,
103
+ `- Target SHA: ${targetSha || "(unknown)"}`,
104
+ `- Promotion run: ${runUrl || "(unknown)"}`,
105
+ `- Friction class: ${frictionClass || "(unknown)"}`,
106
+ releaseCandidateDiagnosis ? `- RC resolver: ${releaseCandidateDiagnosis}` : "",
107
+ "",
108
+ pullRequests.length
109
+ ? `## Associated PRs\n\n${pullRequests.map((pr) => `- #${pr.number} ${pr.title || ""} ${pr.html_url || pr.url || ""}`).join("\n")}`
110
+ : "## Associated PRs\n\n- (none detected)",
111
+ "",
112
+ relatedRuns.length
113
+ ? `## Related workflow runs\n\n${relatedRuns.map((run) => `- ${runLabel(run)}`).join("\n")}`
114
+ : "## Related workflow runs\n\n- (none detected)",
115
+ "",
116
+ heavyBuilds.length
117
+ ? `## Heavy build runs\n\n${heavyBuilds.map((run) => `- ${runLabel(run)}`).join("\n")}`
118
+ : "## Heavy build runs\n\n- (none detected)",
119
+ "",
120
+ diagnosis ? `## Diagnosis\n\n${diagnosis}` : "",
121
+ nextAction ? `## Suggested next action\n\n${nextAction}` : "",
122
+ ].filter(Boolean).join("\n");
123
+ }
124
+
125
+ export async function classifyWorkflowFriction({
126
+ repository,
127
+ targetRef,
128
+ targetSha,
129
+ token = env("GITHUB_TOKEN"),
130
+ apiUrl = env("GITHUB_API_URL", "https://api.github.com"),
131
+ buildWorkflowFile = DEFAULT_BUILD_WORKFLOW_FILE,
132
+ releaseCandidateOutcome = env("BUILDCHAIN_RC_RESOLVE_OUTCOME"),
133
+ releaseCandidateDiagnosis = env("BUILDCHAIN_RC_DIAGNOSIS"),
134
+ runUrl = env("BUILDCHAIN_WORKFLOW_RUN_URL"),
135
+ outputDir = ".buildchain/workflow-friction",
136
+ fetchImpl = globalThis.fetch,
137
+ } = {}) {
138
+ const repo = splitRepository(repository);
139
+ const sha = assertSha(targetSha, "targetSha");
140
+ const normalizedTarget = normalizeBranch(targetRef);
141
+ const pullResponse = await githubJson({
142
+ apiUrl,
143
+ token,
144
+ fetchImpl,
145
+ path: `/repos/${repo.owner}/${repo.repo}/commits/${sha}/pulls`,
146
+ });
147
+ const pullRequests = (Array.isArray(pullResponse) ? pullResponse : [])
148
+ .filter((pr) => normalizeBranch(pr.base?.ref || "") === normalizedTarget)
149
+ .filter((pr) => !pr.head?.repo?.full_name || pr.head.repo.full_name === repo.fullName);
150
+ const primaryPullRequest = pullRequests
151
+ .filter((pr) => pr.merged_at || pr.state === "closed")
152
+ .sort((left, right) => Date.parse(right.updated_at || right.merged_at || "") - Date.parse(left.updated_at || left.merged_at || ""))[0]
153
+ || pullRequests[0];
154
+
155
+ let relatedRuns = [];
156
+ let heavyBuilds = [];
157
+ if (primaryPullRequest?.number) {
158
+ const runs = await githubJson({
159
+ apiUrl,
160
+ token,
161
+ fetchImpl,
162
+ path: `/repos/${repo.owner}/${repo.repo}/actions/workflows/${encodeURIComponent(buildWorkflowFile)}/runs?event=pull_request&per_page=100`,
163
+ });
164
+ relatedRuns = (Array.isArray(runs.workflow_runs) ? runs.workflow_runs : [])
165
+ .filter((run) => (run.pull_requests || []).some((pr) => Number(pr.number || 0) === Number(primaryPullRequest.number)));
166
+ heavyBuilds = relatedRuns.filter((run) => run.status === "completed" && ["success", "failure", "cancelled", "timed_out"].includes(run.conclusion || ""));
167
+ }
168
+
169
+ const frictionClass = selectFrictionClass({
170
+ duplicatePullRequests: pullRequests,
171
+ heavyBuilds,
172
+ releaseCandidateOutcome,
173
+ });
174
+ const diagnosisParts = [];
175
+ if (pullRequests.length > 1) {
176
+ diagnosisParts.push(`Detected ${pullRequests.length} channel PRs associated with the same promoted SHA.`);
177
+ }
178
+ if (heavyBuilds.length > 1) {
179
+ diagnosisParts.push(`Detected ${heavyBuilds.length} completed PR-stage heavy build runs for PR #${primaryPullRequest?.number}.`);
180
+ }
181
+ if (releaseCandidateOutcome === "failure") {
182
+ diagnosisParts.push("Promotion reached the post-Verify workflow before required PR-stage RC evidence could be resolved.");
183
+ }
184
+ if (releaseCandidateDiagnosis) {
185
+ diagnosisParts.push(releaseCandidateDiagnosis);
186
+ }
187
+ const diagnosis = diagnosisParts.join(" ") || "Buildchain ref promotion failed after Verify succeeded; inspect the classified evidence and keep the fix in Buildchain.";
188
+ const nextAction = frictionClass === "late-fail-fast"
189
+ ? "Move the missing/stale RC evidence check earlier or make the promotion workflow consume the exact PR-stage RC passport before any publish side effect."
190
+ : "Deduplicate the PR/build path or tighten Buildchain workflow gates so the next channel promotion reaches publish exactly once.";
191
+ fs.mkdirSync(outputDir, { recursive: true });
192
+ const bodyFile = path.join(outputDir, "issue-body.md");
193
+ const body = buildWorkflowFrictionBody({
194
+ repository: repo.fullName,
195
+ targetRef: normalizedTarget,
196
+ targetSha: sha,
197
+ runUrl,
198
+ pullRequests,
199
+ heavyBuilds,
200
+ relatedRuns,
201
+ frictionClass,
202
+ diagnosis,
203
+ nextAction,
204
+ releaseCandidateDiagnosis,
205
+ });
206
+ fs.writeFileSync(bodyFile, `${body}\n`);
207
+ return {
208
+ frictionClass,
209
+ summary: `Buildchain ref promotion failed after Verify with ${frictionClass} evidence.`,
210
+ diagnosis,
211
+ nextAction,
212
+ pullRequest: primaryPullRequest?.number ? `#${primaryPullRequest.number}` : "",
213
+ bodyFile,
214
+ relatedRuns: relatedRuns.map(runLabel),
215
+ heavyBuilds: heavyBuilds.map((run) => ({
216
+ name: runLabel(run),
217
+ durationMs: run.run_started_at && run.updated_at
218
+ ? Math.max(0, Date.parse(run.updated_at) - Date.parse(run.run_started_at))
219
+ : "",
220
+ })),
221
+ };
222
+ }
223
+
224
+ export async function workflowFrictionReportCli() {
225
+ let result;
226
+ try {
227
+ result = await classifyWorkflowFriction({
228
+ repository: env("BUILDCHAIN_SOURCE_REPOSITORY", env("GITHUB_REPOSITORY")),
229
+ targetRef: env("BUILDCHAIN_TARGET_REF"),
230
+ targetSha: env("BUILDCHAIN_TARGET_SHA"),
231
+ buildWorkflowFile: env("BUILDCHAIN_BUILD_WORKFLOW_FILE", DEFAULT_BUILD_WORKFLOW_FILE),
232
+ outputDir: env("BUILDCHAIN_FRICTION_OUTPUT_DIR", ".buildchain/workflow-friction"),
233
+ });
234
+ } catch (error) {
235
+ const outputDir = env("BUILDCHAIN_FRICTION_OUTPUT_DIR", ".buildchain/workflow-friction");
236
+ fs.mkdirSync(outputDir, { recursive: true });
237
+ const bodyFile = path.join(outputDir, "issue-body.md");
238
+ const diagnosis = `Workflow friction auto-classification failed: ${error.message}`;
239
+ result = {
240
+ frictionClass: env("BUILDCHAIN_RC_RESOLVE_OUTCOME") === "failure" ? "late-fail-fast" : "buildchain-ref-promotion-failed",
241
+ summary: "Buildchain ref promotion failed after Verify; auto-classification did not complete.",
242
+ diagnosis,
243
+ nextAction: "Inspect the promotion logs and fix the Buildchain workflow contract so this class of failure is reported before repeated heavy work or publish side effects.",
244
+ pullRequest: "",
245
+ bodyFile,
246
+ relatedRuns: [],
247
+ heavyBuilds: [],
248
+ };
249
+ fs.writeFileSync(bodyFile, `${buildWorkflowFrictionBody({
250
+ repository: env("BUILDCHAIN_SOURCE_REPOSITORY", env("GITHUB_REPOSITORY")),
251
+ targetRef: env("BUILDCHAIN_TARGET_REF"),
252
+ targetSha: env("BUILDCHAIN_TARGET_SHA"),
253
+ runUrl: env("BUILDCHAIN_WORKFLOW_RUN_URL"),
254
+ frictionClass: result.frictionClass,
255
+ diagnosis,
256
+ nextAction: result.nextAction,
257
+ releaseCandidateDiagnosis: env("BUILDCHAIN_RC_DIAGNOSIS"),
258
+ })}\n`);
259
+ console.error(`::warning::${diagnosis.replace(/\r?\n/g, "%0A")}`);
260
+ }
261
+ writeGitHubOutputs({
262
+ "friction-class": result.frictionClass,
263
+ summary: result.summary,
264
+ diagnosis: result.diagnosis,
265
+ "next-action": result.nextAction,
266
+ "pull-request": result.pullRequest,
267
+ "body-file": result.bodyFile,
268
+ "related-runs-json": JSON.stringify(result.relatedRuns),
269
+ "heavy-builds-json": JSON.stringify(result.heavyBuilds),
270
+ });
271
+ console.log(JSON.stringify(result, null, 2));
272
+ return result;
273
+ }
274
+
275
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
276
+ await workflowFrictionReportCli();
277
+ }