@kungfu-tech/buildchain 2.4.3 → 2.4.4-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.
@@ -431,6 +431,7 @@ jobs:
431
431
  production-release-on-main: false
432
432
  production-aws-role-arn: arn:aws:iam::123456789012:role/site-production-github-actions
433
433
  production-environment: production
434
+ release-feedback-actor-privacy: public
434
435
  ```
435
436
 
436
437
  When enabled, Buildchain owns the full release apply state machine:
@@ -440,13 +441,37 @@ When enabled, Buildchain owns the full release apply state machine:
440
441
  - Closed PR cleanup runs `cleanup-apply --dry-run false` with the preview role
441
442
  only.
442
443
  - Pushes to `main` run staging `deploy-apply --dry-run false` with the staging
443
- role.
444
+ role, then write a staging release feedback passport artifact and comment the
445
+ associated merged PR with the staging URL, source SHA, artifact identity, run
446
+ URL, and failure context when apply did not complete.
447
+ - Release pull requests that match the configured production gate get a
448
+ Buildchain review comment with the staging URL and production target, so the
449
+ operator can verify staging from the PR page and use merge as the approval
450
+ action.
444
451
  - Production runs when `production-apply` is true and either:
445
452
  - a trusted `workflow_dispatch` passes `production-approved=true`; or
446
453
  - `production-release-on-main=true` and the `main` push commit is associated
447
454
  with exactly one same-repository, merged release pull request matching
448
455
  `production-release-label` and `production-release-head-prefix`.
449
456
  The production job is then gated by the configured GitHub Environment.
457
+ - Production apply writes a production release feedback passport artifact and
458
+ comments the release PR with the production URL, source SHA, artifact
459
+ identity, run URL, rollback pointer, and failure context when apply did not
460
+ complete.
461
+
462
+ The feedback passport records the release responsibility chain:
463
+
464
+ - human decision actor;
465
+ - trigger actor;
466
+ - runner/execution actor;
467
+ - OIDC/deploy identity reference;
468
+ - decision type and time;
469
+ - source event, PR number, merge commit, and required gate label/head-prefix.
470
+
471
+ `release-feedback-actor-privacy` controls actor values in the passport and
472
+ comments. `public` records GitHub actor names, `redacted` records only the actor
473
+ role, and `private-ref` records a stable private reference hash without exposing
474
+ the actor name.
450
475
 
451
476
  For release-PR publishing, callers opt in explicitly:
452
477
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.4.3",
3
+ "version": "2.4.4-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",
@@ -537,6 +537,24 @@ export function createReleasePassport({
537
537
  normalizedTransaction?.releaseSha,
538
538
  );
539
539
  const targetRef = optionalString(release.targetRef || release.target_ref || normalizedPublishEvidence?.targetRef);
540
+ const packageDisplayVersion = optionalString(
541
+ packageVersion ||
542
+ normalizedPackageSet?.main?.version ||
543
+ normalizedPublishSummary?.packages?.find((entry) => entry.role === "main")?.publishedVersion ||
544
+ normalizedPublishEvidence?.version ||
545
+ normalizedTransaction?.version ||
546
+ readPackageVersion(cwd),
547
+ );
548
+ const publishedVersion = optionalString(
549
+ release.publishedVersion ||
550
+ release.published_version ||
551
+ packageDisplayVersion,
552
+ );
553
+ const internalVersion = optionalString(
554
+ release.internalVersion ||
555
+ release.internal_version ||
556
+ normalizedTransaction?.version,
557
+ );
540
558
  return {
541
559
  schemaVersion: 1,
542
560
  contract: RELEASE_PASSPORT_CONTRACT,
@@ -548,6 +566,10 @@ export function createReleasePassport({
548
566
  },
549
567
  release: {
550
568
  tag: normalizedTag,
569
+ internalTag: optionalString(release.internalTag || release.internal_tag || normalizedTag),
570
+ internalVersion,
571
+ publishedVersion,
572
+ versionLabel: optionalString(release.versionLabel || release.version_label || publishedVersion || normalizedTag),
551
573
  line: optionalString(line),
552
574
  sourceSha: optionalString(sourceSha),
553
575
  channel: optionalString(release.channel || normalizedPublishEvidence?.channel || publish.channel),
@@ -568,7 +590,7 @@ export function createReleasePassport({
568
590
  releaseStateSha: optionalString(release.releaseStateSha || release.release_state_sha || normalizedTransaction?.stateSha),
569
591
  package: {
570
592
  name: packageName,
571
- version: packageVersion || readPackageVersion(cwd),
593
+ version: packageDisplayVersion,
572
594
  },
573
595
  exactRef: normalizedTag ? `refs/tags/${normalizedTag}` : "",
574
596
  },
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env node
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { upsertIssueComment } from "./web-surface-release-pr-review.mjs";
6
+
7
+ export const RELEASE_FEEDBACK_MARKERS = {
8
+ staging: "<!-- buildchain:web-surface-release-feedback:staging -->",
9
+ production: "<!-- buildchain:web-surface-release-feedback:production -->",
10
+ };
11
+
12
+ function nowIso() {
13
+ return new Date().toISOString();
14
+ }
15
+
16
+ function optionalString(value = "") {
17
+ return String(value || "").trim();
18
+ }
19
+
20
+ function asBool(value) {
21
+ return value === true || value === "true";
22
+ }
23
+
24
+ function readJsonIfExists(filePath) {
25
+ if (!filePath || !fs.existsSync(filePath)) return undefined;
26
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
27
+ }
28
+
29
+ function runUrl({ serverUrl = "", repository = "", runId = "" } = {}) {
30
+ if (!serverUrl || !repository || !runId) return "";
31
+ return `${serverUrl.replace(/\/$/, "")}/${repository}/actions/runs/${runId}`;
32
+ }
33
+
34
+ function privateActorRef(actor = "", kind = "actor") {
35
+ const value = optionalString(actor);
36
+ if (!value) return "";
37
+ const digest = crypto.createHash("sha256").update(`${kind}:${value}`).digest("hex").slice(0, 16);
38
+ return `private-ref:sha256:${digest}`;
39
+ }
40
+
41
+ export function normalizeActorIdentity(actor = "", { privacyMode = "public", kind = "actor" } = {}) {
42
+ const value = optionalString(actor);
43
+ if (!value) return "";
44
+ if (privacyMode === "redacted") return `${kind}:redacted`;
45
+ if (privacyMode === "private-ref") return privateActorRef(value, kind);
46
+ return value;
47
+ }
48
+
49
+ function urlsFromResult(result = {}) {
50
+ const urls = result.urls && typeof result.urls === "object" ? result.urls : {};
51
+ if (Object.keys(urls).length > 0) return urls;
52
+ return result.url ? { default: result.url } : {};
53
+ }
54
+
55
+ function inferStatus({ result, applyOutcome = "", jobStatus = "" } = {}) {
56
+ if (result?.status) return result.status;
57
+ if (applyOutcome === "success" || jobStatus === "success") return "success";
58
+ if (applyOutcome || jobStatus) return "failure";
59
+ return "unknown";
60
+ }
61
+
62
+ function eventTimestamp(payload = {}) {
63
+ return (
64
+ payload.pull_request?.merged_at ||
65
+ payload.pull_request?.updated_at ||
66
+ payload.head_commit?.timestamp ||
67
+ payload.repository?.pushed_at ||
68
+ nowIso()
69
+ );
70
+ }
71
+
72
+ async function githubJson({ apiUrl = "https://api.github.com", token, url, fetchImpl = fetch }) {
73
+ if (!token) throw new Error("GITHUB_TOKEN is required to resolve associated pull requests");
74
+ const response = await fetchImpl(url.startsWith("http") ? url : `${apiUrl.replace(/\/$/, "")}${url}`, {
75
+ headers: {
76
+ accept: "application/vnd.github+json",
77
+ authorization: `Bearer ${token}`,
78
+ "x-github-api-version": "2022-11-28",
79
+ },
80
+ });
81
+ if (!response.ok) {
82
+ throw new Error(`GitHub API request failed: HTTP ${response.status}`);
83
+ }
84
+ return response.json();
85
+ }
86
+
87
+ export async function resolveFeedbackTarget({
88
+ payload = {},
89
+ repository = "",
90
+ commitSha = "",
91
+ releasePullNumber = "",
92
+ releasePullSource = "",
93
+ apiUrl = "https://api.github.com",
94
+ token = "",
95
+ fetchImpl = fetch,
96
+ } = {}) {
97
+ const explicitNumber = optionalString(releasePullNumber);
98
+ if (explicitNumber) {
99
+ return {
100
+ shouldComment: true,
101
+ pullNumber: Number(explicitNumber),
102
+ source: "release-intent",
103
+ sourceBranch: releasePullSource,
104
+ };
105
+ }
106
+
107
+ const payloadPull = payload.pull_request;
108
+ if (payloadPull?.number) {
109
+ return {
110
+ shouldComment: true,
111
+ pullNumber: Number(payloadPull.number),
112
+ source: "event-pull-request",
113
+ sourceBranch: payloadPull.head?.ref || "",
114
+ };
115
+ }
116
+
117
+ if (!repository || !commitSha || !repository.includes("/")) {
118
+ return { shouldComment: false, reason: "missing-repository-or-commit" };
119
+ }
120
+
121
+ const [owner, repo] = repository.split("/");
122
+ const associated = await githubJson({
123
+ apiUrl,
124
+ token,
125
+ fetchImpl,
126
+ url: `/repos/${owner}/${repo}/commits/${commitSha}/pulls?per_page=100`,
127
+ });
128
+ const sameRepo = `${owner}/${repo}`;
129
+ const candidates = associated.filter((pull) => (
130
+ pull.merged_at &&
131
+ pull.base?.ref === "main" &&
132
+ pull.head?.repo?.full_name === sameRepo
133
+ ));
134
+ if (candidates.length !== 1) {
135
+ return {
136
+ shouldComment: false,
137
+ reason: candidates.length === 0 ? "no-associated-merged-pr" : "multiple-associated-merged-prs",
138
+ candidatePulls: candidates.map((pull) => pull.number),
139
+ };
140
+ }
141
+ return {
142
+ shouldComment: true,
143
+ pullNumber: Number(candidates[0].number),
144
+ source: "associated-merged-pr",
145
+ sourceBranch: candidates[0].head?.ref || "",
146
+ };
147
+ }
148
+
149
+ export function createWebSurfaceReleasePassport({
150
+ channel = "staging",
151
+ repository = "",
152
+ productName = "",
153
+ sourceSha = "",
154
+ result = undefined,
155
+ status = "unknown",
156
+ applyOutcome = "",
157
+ jobStatus = "",
158
+ runId = "",
159
+ runAttempt = "",
160
+ runUrl: workflowRunUrl = "",
161
+ runtimeSha = "",
162
+ rollbackPointer = "",
163
+ payload = {},
164
+ sourceEvent = "",
165
+ target = {},
166
+ gate = {},
167
+ privacyMode = "public",
168
+ actor = "",
169
+ triggeringActor = "",
170
+ runnerActor = "",
171
+ oidcDeployIdentity = "",
172
+ } = {}) {
173
+ const normalizedStatus = inferStatus({ result, applyOutcome, jobStatus }) || status;
174
+ const decisionType = channel === "production" ? "release-pr-merge-or-manual-approval" : "main-merge-staging";
175
+ const normalizedResult = result || {};
176
+ return {
177
+ schemaVersion: 1,
178
+ contract: "kungfu-buildchain-web-surface-release-passport",
179
+ generatedAt: nowIso(),
180
+ product: {
181
+ name: optionalString(productName || repository.split("/").pop() || "web-surface"),
182
+ repository: optionalString(repository),
183
+ type: "web-surface",
184
+ },
185
+ release: {
186
+ channel: optionalString(channel),
187
+ sourceSha: optionalString(normalizedResult.sourceSha || sourceSha),
188
+ status: normalizedStatus,
189
+ url: optionalString(normalizedResult.url),
190
+ urls: urlsFromResult(normalizedResult),
191
+ artifactHash: optionalString(normalizedResult.artifactHash),
192
+ target: optionalString(normalizedResult.target),
193
+ manifestKey: optionalString(normalizedResult.manifestKey),
194
+ runtimeSha: optionalString(runtimeSha || normalizedResult.manifest?.runtimeId),
195
+ rollbackPointer: optionalString(rollbackPointer || normalizedResult.manifest?.rollbackPointer),
196
+ },
197
+ responsibility: {
198
+ privacyMode,
199
+ humanDecisionActor: normalizeActorIdentity(actor, { privacyMode, kind: "human-decision-actor" }),
200
+ triggerActor: normalizeActorIdentity(triggeringActor || actor, { privacyMode, kind: "trigger-actor" }),
201
+ runnerExecutionActor: normalizeActorIdentity(runnerActor || actor, { privacyMode, kind: "runner-execution-actor" }),
202
+ oidcDeployIdentity: optionalString(oidcDeployIdentity),
203
+ decisionType,
204
+ decidedAt: eventTimestamp(payload),
205
+ sourceEvent: optionalString(sourceEvent || payload.action || (payload.ref ? "push" : "")),
206
+ pullRequest: target.pullNumber ? Number(target.pullNumber) : null,
207
+ mergeCommit: optionalString(sourceSha),
208
+ requiredGateEvidence: {
209
+ label: optionalString(gate.label),
210
+ headPrefix: optionalString(gate.headPrefix),
211
+ sourceBranch: optionalString(target.sourceBranch),
212
+ targetResolution: optionalString(target.source || target.reason),
213
+ },
214
+ },
215
+ workflow: {
216
+ runId: optionalString(runId),
217
+ runAttempt: optionalString(runAttempt),
218
+ url: optionalString(workflowRunUrl),
219
+ applyOutcome: optionalString(applyOutcome),
220
+ jobStatus: optionalString(jobStatus),
221
+ },
222
+ evidence: {
223
+ applyResultBound: Boolean(result),
224
+ failureContext: normalizedStatus === "success" ? "" : optionalString(applyOutcome || jobStatus || "missing-apply-result"),
225
+ },
226
+ };
227
+ }
228
+
229
+ export function renderWebSurfaceReleaseFeedbackComment({
230
+ channel = "staging",
231
+ passport = {},
232
+ target = {},
233
+ passportArtifact = "",
234
+ } = {}) {
235
+ const marker = RELEASE_FEEDBACK_MARKERS[channel] || `<!-- buildchain:web-surface-release-feedback:${channel} -->`;
236
+ const release = passport.release || {};
237
+ const urls = release.urls && Object.keys(release.urls).length > 0 ? release.urls : (release.url ? { default: release.url } : {});
238
+ const urlLines = Object.entries(urls).map(([surface, url]) => `- ${surface}: ${url}`);
239
+ const status = release.status || "unknown";
240
+ const title = channel === "production" ? "Buildchain production deploy" : "Buildchain staging deploy";
241
+ const failure = passport.evidence?.failureContext ? [`- Failure context: \`${passport.evidence.failureContext}\``] : [];
242
+ const targetLine = target.pullNumber ? `- Comment target: PR #${target.pullNumber}` : "";
243
+ return [
244
+ marker,
245
+ `## ${title}`,
246
+ "",
247
+ `- Status: \`${status}\``,
248
+ ...urlLines,
249
+ `- Source: \`${release.sourceSha || ""}\``,
250
+ `- Artifact: \`${release.artifactHash || ""}\``,
251
+ `- Target: \`${release.target || ""}\``,
252
+ `- Rollback pointer: \`${release.rollbackPointer || ""}\``,
253
+ `- Run: ${passport.workflow?.url || ""}`,
254
+ `- Passport artifact: \`${passportArtifact || "buildchain-web-surface-release-passport"}\``,
255
+ targetLine,
256
+ ...failure,
257
+ ].filter((line) => line !== "").join("\n");
258
+ }
259
+
260
+ export async function webSurfaceReleaseFeedbackCli(env = process.env, { fetchImpl = fetch } = {}) {
261
+ const eventPath = env.GITHUB_EVENT_PATH;
262
+ if (!eventPath) throw new Error("GITHUB_EVENT_PATH is required");
263
+ const payload = JSON.parse(fs.readFileSync(eventPath, "utf8"));
264
+ const channel = optionalString(env.BUILDCHAIN_WEB_SURFACE_CHANNEL || "staging");
265
+ const result = readJsonIfExists(env.APPLY_RESULT_PATH || "");
266
+ const repository = optionalString(env.GITHUB_REPOSITORY);
267
+ const sourceSha = optionalString(result?.sourceSha || env.GITHUB_SHA);
268
+ const workflowRunUrl = runUrl({
269
+ serverUrl: env.GITHUB_SERVER_URL,
270
+ repository,
271
+ runId: env.GITHUB_RUN_ID,
272
+ });
273
+ const target = await resolveFeedbackTarget({
274
+ payload,
275
+ repository,
276
+ commitSha: sourceSha,
277
+ releasePullNumber: env.PRODUCTION_RELEASE_PR,
278
+ releasePullSource: env.PRODUCTION_RELEASE_SOURCE,
279
+ apiUrl: env.GITHUB_API_URL || "https://api.github.com",
280
+ token: env.GITHUB_TOKEN,
281
+ fetchImpl,
282
+ });
283
+ const passport = createWebSurfaceReleasePassport({
284
+ channel,
285
+ repository,
286
+ productName: env.PRODUCT_NAME,
287
+ sourceSha,
288
+ result,
289
+ applyOutcome: env.APPLY_STEP_OUTCOME,
290
+ jobStatus: env.JOB_STATUS,
291
+ runId: env.GITHUB_RUN_ID,
292
+ runAttempt: env.GITHUB_RUN_ATTEMPT,
293
+ runUrl: workflowRunUrl,
294
+ runtimeSha: env.BUILDCHAIN_RUNTIME_SHA,
295
+ rollbackPointer: env.BUILDCHAIN_ROLLBACK_POINTER,
296
+ payload,
297
+ sourceEvent: env.GITHUB_EVENT_NAME,
298
+ target,
299
+ gate: {
300
+ label: env.PRODUCTION_RELEASE_LABEL,
301
+ headPrefix: env.PRODUCTION_RELEASE_HEAD_PREFIX,
302
+ },
303
+ privacyMode: optionalString(env.RELEASE_FEEDBACK_ACTOR_PRIVACY || "public"),
304
+ actor: env.GITHUB_ACTOR,
305
+ triggeringActor: env.GITHUB_TRIGGERING_ACTOR,
306
+ runnerActor: env.RUNNER_NAME || env.GITHUB_ACTOR,
307
+ oidcDeployIdentity: env.DEPLOY_IDENTITY_REF || env.AWS_ROLE_ARN,
308
+ });
309
+ const outputPath = env.OUTPUT_PATH || `.buildchain/web-surface-${channel}-release-passport.json`;
310
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
311
+ fs.writeFileSync(outputPath, `${JSON.stringify(passport, null, 2)}\n`);
312
+
313
+ const artifactName = env.PASSPORT_ARTIFACT_NAME || `buildchain-web-surface-${channel}-release-passport`;
314
+ if (!target.shouldComment) {
315
+ console.log(`web-surface release feedback comment skipped: ${target.reason}`);
316
+ return { passport, target, outputPath, comment: { action: "skipped", reason: target.reason } };
317
+ }
318
+ const body = renderWebSurfaceReleaseFeedbackComment({
319
+ channel,
320
+ passport,
321
+ target,
322
+ passportArtifact: artifactName,
323
+ });
324
+ const comment = await upsertIssueComment({
325
+ apiUrl: env.GITHUB_API_URL || "https://api.github.com",
326
+ token: env.GITHUB_TOKEN,
327
+ repository,
328
+ issueNumber: target.pullNumber,
329
+ body,
330
+ marker: RELEASE_FEEDBACK_MARKERS[channel],
331
+ fetchImpl,
332
+ });
333
+ console.log(`web-surface release feedback comment ${comment.action}: ${comment.commentId}`);
334
+ return { passport, target, outputPath, comment };
335
+ }
336
+
337
+ if (import.meta.url === `file://${process.argv[1]}`) {
338
+ webSurfaceReleaseFeedbackCli().catch((error) => {
339
+ console.error(error.stack || error.message);
340
+ process.exitCode = 1;
341
+ });
342
+ }
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { parse as parseToml } from "smol-toml";
5
+
6
+ export const RELEASE_REVIEW_MARKER = "<!-- buildchain:web-surface-release-review -->";
7
+
8
+ export function resolveReleaseReviewState(payload, options = {}) {
9
+ const eventName = options.eventName || "";
10
+ const eventAction = options.eventAction || "";
11
+ const repository = options.repository || "";
12
+ const enabled = options.productionReleaseOnMain === true || options.productionReleaseOnMain === "true";
13
+ const requiredLabel = String(options.productionReleaseLabel || "").trim();
14
+ const requiredHeadPrefix = String(options.productionReleaseHeadPrefix || "").trim();
15
+ const pull = payload?.pull_request;
16
+
17
+ if (!enabled) return { shouldComment: false, reason: "release-pr-publish-disabled" };
18
+ if (eventName !== "pull_request") return { shouldComment: false, reason: "not-a-pull-request" };
19
+ if (eventAction === "closed") return { shouldComment: false, reason: "pull-request-closed" };
20
+ if (!pull) return { shouldComment: false, reason: "missing-pull-request-payload" };
21
+ if (!requiredLabel) return { shouldComment: false, reason: "missing-production-release-label" };
22
+
23
+ const labels = (pull.labels || []).map((label) => label.name).filter(Boolean);
24
+ if (!labels.includes(requiredLabel)) {
25
+ return { shouldComment: false, reason: "missing-release-label" };
26
+ }
27
+
28
+ const baseRef = pull.base?.ref || "";
29
+ if (baseRef !== "main") {
30
+ return { shouldComment: false, reason: "base-is-not-main" };
31
+ }
32
+
33
+ const headRef = pull.head?.ref || "";
34
+ if (requiredHeadPrefix && !headRef.startsWith(requiredHeadPrefix)) {
35
+ return { shouldComment: false, reason: "head-prefix-mismatch" };
36
+ }
37
+
38
+ const headRepo = pull.head?.repo?.full_name || "";
39
+ if (repository && headRepo && headRepo !== repository) {
40
+ return { shouldComment: false, reason: "head-repo-mismatch" };
41
+ }
42
+
43
+ return {
44
+ shouldComment: true,
45
+ reason: "release-pr-review-ready",
46
+ pullNumber: pull.number,
47
+ headRef,
48
+ baseRef,
49
+ labels,
50
+ };
51
+ }
52
+
53
+ export function loadWebSurfaceReleaseUrls(cwd = ".") {
54
+ const configPath = path.join(cwd, "buildchain.toml");
55
+ const raw = fs.readFileSync(configPath, "utf8");
56
+ const config = parseToml(raw);
57
+ const stagingUrl = String(config.channels?.staging?.url || "").trim();
58
+ const productionUrl = String(config.channels?.production?.url || "").trim();
59
+ if (!stagingUrl) {
60
+ throw new Error("channels.staging.url is required for a release PR review comment");
61
+ }
62
+ if (!productionUrl) {
63
+ throw new Error("channels.production.url is required for a release PR review comment");
64
+ }
65
+ return { stagingUrl, productionUrl };
66
+ }
67
+
68
+ export function renderReleaseReviewComment({
69
+ stagingUrl,
70
+ productionUrl,
71
+ label,
72
+ headPrefix,
73
+ }) {
74
+ const branchLine = headPrefix
75
+ ? `- Release branch prefix: \`${headPrefix}\``
76
+ : "- Release branch prefix: `(none)`";
77
+ return `${RELEASE_REVIEW_MARKER}
78
+ ## Buildchain release review
79
+
80
+ - Staging review URL: ${stagingUrl}
81
+ - Production target: ${productionUrl}
82
+ - Approval action: merge this release PR after staging has been verified.
83
+ - Release label: \`${label}\`
84
+ ${branchLine}
85
+
86
+ Buildchain treats this merge as the production approval event. The resulting
87
+ \`main\` push will publish production only after Buildchain verifies the merged
88
+ same-repository release PR, the required label, and the release branch prefix.`;
89
+ }
90
+
91
+ export async function upsertIssueComment({
92
+ apiUrl = "https://api.github.com",
93
+ token,
94
+ repository,
95
+ issueNumber,
96
+ body,
97
+ marker = RELEASE_REVIEW_MARKER,
98
+ fetchImpl = fetch,
99
+ }) {
100
+ if (!token) throw new Error("GITHUB_TOKEN is required to write the release PR review comment");
101
+ if (!repository || !repository.includes("/")) throw new Error("GITHUB_REPOSITORY must be owner/repo");
102
+ if (!issueNumber) throw new Error("pull request number is required to write a comment");
103
+
104
+ const [owner, repo] = repository.split("/");
105
+ const base = apiUrl.replace(/\/$/, "");
106
+ const headers = {
107
+ accept: "application/vnd.github+json",
108
+ authorization: `Bearer ${token}`,
109
+ "content-type": "application/json",
110
+ "x-github-api-version": "2022-11-28",
111
+ };
112
+
113
+ const commentsUrl = `${base}/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=100`;
114
+ const commentsResponse = await fetchImpl(commentsUrl, { headers });
115
+ if (!commentsResponse.ok) {
116
+ throw new Error(`failed to list release PR comments: HTTP ${commentsResponse.status}`);
117
+ }
118
+ const comments = await commentsResponse.json();
119
+ const existing = comments.find((comment) => String(comment.body || "").includes(marker));
120
+
121
+ if (existing) {
122
+ const updateResponse = await fetchImpl(`${base}/repos/${owner}/${repo}/issues/comments/${existing.id}`, {
123
+ method: "PATCH",
124
+ headers,
125
+ body: JSON.stringify({ body }),
126
+ });
127
+ if (!updateResponse.ok) {
128
+ throw new Error(`failed to update release PR comment: HTTP ${updateResponse.status}`);
129
+ }
130
+ return { action: "updated", commentId: existing.id };
131
+ }
132
+
133
+ const createResponse = await fetchImpl(`${base}/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
134
+ method: "POST",
135
+ headers,
136
+ body: JSON.stringify({ body }),
137
+ });
138
+ if (!createResponse.ok) {
139
+ throw new Error(`failed to create release PR comment: HTTP ${createResponse.status}`);
140
+ }
141
+ const created = await createResponse.json();
142
+ return { action: "created", commentId: created.id };
143
+ }
144
+
145
+ export async function webSurfaceReleasePrReviewCli(env = process.env) {
146
+ const eventPath = env.GITHUB_EVENT_PATH;
147
+ if (!eventPath) throw new Error("GITHUB_EVENT_PATH is required");
148
+ const payload = JSON.parse(fs.readFileSync(eventPath, "utf8"));
149
+ const state = resolveReleaseReviewState(payload, {
150
+ eventName: env.GITHUB_EVENT_NAME,
151
+ eventAction: env.GITHUB_EVENT_ACTION,
152
+ repository: env.GITHUB_REPOSITORY,
153
+ productionReleaseOnMain: env.PRODUCTION_RELEASE_ON_MAIN,
154
+ productionReleaseLabel: env.PRODUCTION_RELEASE_LABEL,
155
+ productionReleaseHeadPrefix: env.PRODUCTION_RELEASE_HEAD_PREFIX,
156
+ });
157
+
158
+ if (!state.shouldComment) {
159
+ console.log(`release PR review comment skipped: ${state.reason}`);
160
+ return state;
161
+ }
162
+
163
+ const { stagingUrl, productionUrl } = loadWebSurfaceReleaseUrls(env.WORKING_DIRECTORY || ".");
164
+ const body = renderReleaseReviewComment({
165
+ stagingUrl,
166
+ productionUrl,
167
+ label: env.PRODUCTION_RELEASE_LABEL,
168
+ headPrefix: env.PRODUCTION_RELEASE_HEAD_PREFIX,
169
+ });
170
+ const result = await upsertIssueComment({
171
+ apiUrl: env.GITHUB_API_URL || "https://api.github.com",
172
+ token: env.GITHUB_TOKEN,
173
+ repository: env.GITHUB_REPOSITORY,
174
+ issueNumber: state.pullNumber,
175
+ body,
176
+ });
177
+ console.log(`release PR review comment ${result.action}: ${result.commentId}`);
178
+ return { ...state, ...result };
179
+ }
180
+
181
+ if (import.meta.url === `file://${process.argv[1]}`) {
182
+ webSurfaceReleasePrReviewCli().catch((error) => {
183
+ console.error(error.stack || error.message);
184
+ process.exitCode = 1;
185
+ });
186
+ }