@kungfu-tech/buildchain 2.4.4-alpha.0 → 2.4.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.
|
@@ -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.4
|
|
3
|
+
"version": "2.4.4",
|
|
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:
|
|
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
|
+
}
|