@kungfu-tech/buildchain 2.12.0 → 2.12.1-alpha.2

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.
@@ -0,0 +1,448 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ createStableCandidateLedger,
6
+ markStableCandidatePromoted,
7
+ qualifyStableCandidate,
8
+ registerStableCandidate,
9
+ revokeStableCandidate,
10
+ selectStableCandidate,
11
+ setStableCandidateHold,
12
+ stableCandidatePromotionRefs,
13
+ } from "../packages/core/stable-candidate-ledger.js";
14
+
15
+ const LEDGER_PATH = ".buildchain/stable-candidate-ledger.json";
16
+
17
+ function text(value = "") {
18
+ return String(value ?? "").trim();
19
+ }
20
+
21
+ function bool(value, fallback = false) {
22
+ if (value === undefined || value === null || value === "") return fallback;
23
+ return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
24
+ }
25
+
26
+ function list(value) {
27
+ return [...new Set(String(value || "").split(/[\n,]+/).map((entry) => entry.trim()).filter(Boolean))];
28
+ }
29
+
30
+ function integer(value, fallback) {
31
+ const parsed = Number(value);
32
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
33
+ }
34
+
35
+ function repository(value) {
36
+ const normalized = text(value);
37
+ if (!/^[^/\s]+\/[^/\s]+$/.test(normalized)) {
38
+ throw new Error(`repository must be owner/repo, got ${value || "<empty>"}`);
39
+ }
40
+ return normalized;
41
+ }
42
+
43
+ function targetBranch(value) {
44
+ const normalized = text(value).replace(/^refs\/heads\//, "");
45
+ if (!/^release\/v\d+\/v\d+\.\d+$/.test(normalized)) {
46
+ throw new Error(`target branch must be release/vN/vN.M, got ${value || "<empty>"}`);
47
+ }
48
+ return normalized;
49
+ }
50
+
51
+ function defaultLedgerRef(branch) {
52
+ return `buildchain/candidate-ledger/${branch.replace(/^release\//, "")}`;
53
+ }
54
+
55
+ export function normalizeStableCandidatePatrolOptions(options = {}) {
56
+ const target = targetBranch(options.targetBranch ?? process.env.BUILDCHAIN_STABLE_PATROL_TARGET_BRANCH);
57
+ return {
58
+ repository: repository(options.repository ?? process.env.BUILDCHAIN_STABLE_PATROL_REPOSITORY ?? process.env.GITHUB_REPOSITORY),
59
+ targetBranch: target,
60
+ ledgerRef: text(options.ledgerRef ?? process.env.BUILDCHAIN_STABLE_PATROL_LEDGER_REF) || defaultLedgerRef(target),
61
+ minimumSoakSeconds: integer(options.minimumSoakSeconds ?? process.env.BUILDCHAIN_STABLE_PATROL_MINIMUM_SOAK_SECONDS, 3600),
62
+ requiredChecks: list((options.requiredChecks ?? process.env.BUILDCHAIN_STABLE_PATROL_REQUIRED_CHECKS) || "alpha-release"),
63
+ revokedVersions: list(options.revokedVersions ?? process.env.BUILDCHAIN_STABLE_PATROL_REVOKED_VERSIONS),
64
+ revokeReason: text(options.revokeReason ?? process.env.BUILDCHAIN_STABLE_PATROL_REVOKE_REASON) || "repository-policy-revocation",
65
+ hold: bool(options.hold ?? process.env.BUILDCHAIN_STABLE_PATROL_HOLD, false),
66
+ holdReason: text(options.holdReason ?? process.env.BUILDCHAIN_STABLE_PATROL_HOLD_REASON),
67
+ releaseNow: text(options.releaseNow ?? process.env.BUILDCHAIN_STABLE_PATROL_RELEASE_NOW).replace(/^v/, ""),
68
+ autoPromote: bool(options.autoPromote ?? process.env.BUILDCHAIN_STABLE_PATROL_AUTO_PROMOTE, false),
69
+ autoMerge: bool(options.autoMerge ?? process.env.BUILDCHAIN_STABLE_PATROL_AUTO_MERGE, false),
70
+ dryRun: bool(options.dryRun ?? process.env.BUILDCHAIN_STABLE_PATROL_DRY_RUN, true),
71
+ now: text(options.now ?? process.env.BUILDCHAIN_STABLE_PATROL_NOW) || new Date().toISOString(),
72
+ outputPath: text(options.outputPath ?? process.env.BUILDCHAIN_STABLE_PATROL_OUTPUT_PATH) || ".buildchain/patrol/stable-candidate.json",
73
+ };
74
+ }
75
+
76
+ function targetLinePrefix(branch) {
77
+ const match = branch.match(/^release\/v(\d+)\/v(\d+)\.(\d+)$/);
78
+ return `${match[2]}.${match[3]}.`;
79
+ }
80
+
81
+ function alphaRelease(release, prefix) {
82
+ const version = text(release.tag_name).replace(/^v/, "");
83
+ return release.prerelease === true && version.startsWith(prefix) && /^\d+\.\d+\.\d+-alpha\.\d+$/.test(version)
84
+ ? { version, publishedAt: release.published_at, url: release.html_url, actor: release.author?.login || "", releasePublished: true }
85
+ : undefined;
86
+ }
87
+
88
+ function checkObservation(name, evidence, fallbackCompletedAt) {
89
+ if (name === "alpha-release") {
90
+ return {
91
+ id: name,
92
+ status: evidence.releasePublished ? "pass" : "fail",
93
+ completedAt: evidence.releasePublished ? fallbackCompletedAt : "",
94
+ evidenceUrl: evidence.releaseUrl || "",
95
+ };
96
+ }
97
+ const [kind, explicitName] = name.includes(":") ? name.split(/:(.*)/s, 2) : ["", name];
98
+ const expected = explicitName || name;
99
+ if (kind === "workflow") {
100
+ const run = (evidence.workflowRuns || []).find((entry) => entry.name === expected || entry.path?.endsWith(`/${expected}`));
101
+ return {
102
+ id: name,
103
+ status: run?.conclusion === "success" ? "pass" : "fail",
104
+ completedAt: run?.updated_at || "",
105
+ evidenceUrl: run?.html_url || "",
106
+ };
107
+ }
108
+ const status = (evidence.statuses || []).find((entry) => entry.context === expected);
109
+ if (status) {
110
+ return {
111
+ id: name,
112
+ status: status.state === "success" ? "pass" : "fail",
113
+ completedAt: status.updated_at || "",
114
+ evidenceUrl: status.target_url || "",
115
+ };
116
+ }
117
+ const run = (evidence.checkRuns || []).find((entry) => entry.name === expected || (!kind && entry.name?.includes(expected)));
118
+ return {
119
+ id: name,
120
+ status: run?.conclusion === "success" ? "pass" : "fail",
121
+ completedAt: run?.completed_at || "",
122
+ evidenceUrl: run?.html_url || "",
123
+ };
124
+ }
125
+
126
+ export async function runStableCandidatePatrol(optionsInput = {}, clientInput) {
127
+ const options = normalizeStableCandidatePatrolOptions(optionsInput);
128
+ const client = clientInput || createGitHubStableCandidateClient({
129
+ repository: options.repository,
130
+ token: process.env.GITHUB_TOKEN,
131
+ });
132
+ const stored = await client.readLedger(options.ledgerRef, LEDGER_PATH);
133
+ let ledger = stored?.ledger || createStableCandidateLedger({
134
+ repository: options.repository,
135
+ targetBranch: options.targetBranch,
136
+ now: options.now,
137
+ });
138
+
139
+ const releases = await client.listReleases();
140
+ const prefix = targetLinePrefix(options.targetBranch);
141
+ const releaseDiscoveries = releases.map((release) => alphaRelease(release, prefix)).filter(Boolean);
142
+ const tagDiscoveries = await client.listAlphaTags?.(prefix) || [];
143
+ const discoveryByVersion = new Map(tagDiscoveries.map((candidate) => [candidate.version, candidate]));
144
+ for (const release of releaseDiscoveries) {
145
+ discoveryByVersion.set(release.version, { ...discoveryByVersion.get(release.version), ...release });
146
+ }
147
+ const discoveries = [...discoveryByVersion.values()];
148
+ for (const discovery of discoveries) {
149
+ const candidateSha = discovery.sha || await client.resolveTagSha(`v${discovery.version}`);
150
+ ledger = registerStableCandidate(ledger, { ...discovery, sha: candidateSha }, { now: options.now });
151
+ const evidence = await client.getCommitEvidence(candidateSha);
152
+ ledger = qualifyStableCandidate(ledger, {
153
+ version: discovery.version,
154
+ sha: candidateSha,
155
+ actor: "buildchain-patrol",
156
+ checks: options.requiredChecks.map((name) => checkObservation(name, {
157
+ ...evidence,
158
+ releaseUrl: discovery.url,
159
+ releasePublished: discovery.releasePublished === true,
160
+ }, discovery.publishedAt)),
161
+ }, { minimumSoakSeconds: options.minimumSoakSeconds, now: options.now });
162
+ }
163
+
164
+ for (const version of options.revokedVersions) {
165
+ const candidate = ledger.candidates.find((entry) => entry.version === version.replace(/^v/, ""));
166
+ if (candidate && candidate.state !== "promoted") {
167
+ ledger = revokeStableCandidate(ledger, version, {
168
+ reason: options.revokeReason,
169
+ actor: "repository-policy",
170
+ now: options.now,
171
+ });
172
+ }
173
+ }
174
+ ledger = setStableCandidateHold(ledger, options.hold, {
175
+ reason: options.hold ? options.holdReason || "repository hold" : "",
176
+ now: options.now,
177
+ });
178
+
179
+ for (const candidate of [...ledger.candidates]) {
180
+ if (candidate.state === "promoted" || !candidate.promotionRequest?.stableTag) continue;
181
+ const stable = releases.find((release) => release.tag_name === candidate.promotionRequest.stableTag && release.prerelease !== true);
182
+ if (!stable) continue;
183
+ ledger = markStableCandidatePromoted(ledger, candidate.version, {
184
+ stableTag: stable.tag_name,
185
+ stableSha: await client.resolveTagSha(stable.tag_name),
186
+ now: stable.published_at || options.now,
187
+ });
188
+ if (candidate.promotionRequest?.authority === "human") {
189
+ await client.deleteVariable?.("BUILDCHAIN_STABLE_RELEASE_NOW");
190
+ await client.deleteVariable?.("BUILDCHAIN_STABLE_RELEASE_REASON");
191
+ }
192
+ }
193
+
194
+ const publishedStableVersions = releases
195
+ .filter((release) => release.prerelease !== true && /^v\d+\.\d+\.\d+$/.test(text(release.tag_name)))
196
+ .map((release) => ({
197
+ version: text(release.tag_name).replace(/^v/, ""),
198
+ tag: text(release.tag_name),
199
+ publishedAt: release.published_at || "",
200
+ url: release.html_url || "",
201
+ }));
202
+ ledger.stableReleases = publishedStableVersions;
203
+ for (const candidate of ledger.candidates) {
204
+ const stable = publishedStableVersions.find((entry) => entry.version === candidate.stableVersion);
205
+ if (!stable || ["promoted", "revoked"].includes(candidate.state)) continue;
206
+ ledger = revokeStableCandidate(ledger, candidate.version, {
207
+ reason: `stable-version-already-published:${stable.tag}`,
208
+ actor: "buildchain-patrol",
209
+ now: stable.publishedAt || options.now,
210
+ });
211
+ }
212
+
213
+ const selection = selectStableCandidate(ledger, { releaseNow: options.releaseNow, now: options.now });
214
+ let promotion;
215
+ if (selection.selected) {
216
+ const refs = stableCandidatePromotionRefs(selection.candidate, options.targetBranch);
217
+ promotion = { ...refs, candidateVersion: selection.candidate.version, candidateSha: selection.candidate.sha };
218
+ if (options.autoPromote && !options.dryRun) {
219
+ if (selection.reason === "human-release-now") {
220
+ await client.setVariable?.("BUILDCHAIN_STABLE_RELEASE_NOW", selection.candidate.version);
221
+ await client.setVariable?.(
222
+ "BUILDCHAIN_STABLE_RELEASE_REASON",
223
+ `Buildchain Stable Candidate Patrol human release-now for ${selection.candidate.version}`,
224
+ );
225
+ }
226
+ await client.ensureBranch(refs.sourceRef, selection.candidate.sha);
227
+ const pullRequest = await client.ensurePromotionPullRequest({
228
+ head: refs.sourceRef,
229
+ base: refs.targetRef,
230
+ title: `Release ${refs.stableTag} from qualified ${refs.exactAlphaTag}`,
231
+ body: [
232
+ "Buildchain qualified-alpha stable promotion.",
233
+ "",
234
+ `- Candidate: \`${refs.exactAlphaTag}\``,
235
+ `- Candidate SHA: \`${selection.candidate.sha}\``,
236
+ `- Selection: \`${selection.reason}\``,
237
+ `- Ledger ref: \`${options.ledgerRef}\``,
238
+ "",
239
+ "The source-lock branch freezes the exact candidate; newer alpha publications do not alter this PR.",
240
+ ].join("\n"),
241
+ });
242
+ if (options.autoMerge) await client.enableAutoMerge(pullRequest);
243
+ promotion.pullRequest = pullRequest;
244
+ const storedCandidate = ledger.candidates.find((entry) => entry.version === selection.candidate.version);
245
+ storedCandidate.promotionRequest = {
246
+ stableTag: refs.stableTag,
247
+ sourceRef: refs.sourceRef,
248
+ targetRef: refs.targetRef,
249
+ pullRequestUrl: pullRequest.html_url || pullRequest.url || "",
250
+ requestedAt: options.now,
251
+ authority: selection.authority,
252
+ };
253
+ if (selection.reason === "human-release-now") {
254
+ storedCandidate.decision = { reason: "human-release-now", actor: "human", updatedAt: options.now };
255
+ }
256
+ }
257
+ }
258
+
259
+ if (!options.dryRun) {
260
+ await client.writeLedger(options.ledgerRef, LEDGER_PATH, ledger, stored?.sha);
261
+ }
262
+ const result = {
263
+ schemaVersion: 1,
264
+ contract: "kungfu-buildchain-stable-candidate-patrol",
265
+ repository: options.repository,
266
+ targetBranch: options.targetBranch,
267
+ ledgerRef: options.ledgerRef,
268
+ dryRun: options.dryRun,
269
+ selection,
270
+ promotion,
271
+ summary: {
272
+ discovered: discoveries.length,
273
+ soaking: ledger.candidates.filter((entry) => entry.state === "soaking").length,
274
+ qualified: ledger.candidates.filter((entry) => entry.state === "qualified").length,
275
+ revoked: ledger.candidates.filter((entry) => entry.state === "revoked").length,
276
+ promoted: ledger.candidates.filter((entry) => entry.state === "promoted").length,
277
+ },
278
+ ledger,
279
+ };
280
+ return result;
281
+ }
282
+
283
+ function encodeRef(ref) {
284
+ return ref.split("/").map(encodeURIComponent).join("/");
285
+ }
286
+
287
+ export function createGitHubStableCandidateClient({ repository: repositoryInput, token, fetchImpl = globalThis.fetch }) {
288
+ const [owner, repo] = repository(repositoryInput).split("/");
289
+ const headers = {
290
+ accept: "application/vnd.github+json",
291
+ authorization: token ? `Bearer ${token}` : undefined,
292
+ "user-agent": "buildchain-stable-candidate-patrol",
293
+ "x-github-api-version": "2022-11-28",
294
+ };
295
+ async function api(requestPath, { method = "GET", body, allow404 = false } = {}) {
296
+ const response = await fetchImpl(`https://api.github.com${requestPath}`, {
297
+ method,
298
+ headers: Object.fromEntries(Object.entries(headers).filter(([, value]) => value)),
299
+ body: body === undefined ? undefined : JSON.stringify(body),
300
+ });
301
+ const raw = await response.text();
302
+ const payload = raw ? JSON.parse(raw) : undefined;
303
+ if (allow404 && response.status === 404) return undefined;
304
+ if (!response.ok) throw new Error(`GitHub API ${method} ${requestPath} failed with ${response.status}: ${payload?.message || raw}`);
305
+ return payload;
306
+ }
307
+ return {
308
+ async listReleases() {
309
+ return api(`/repos/${owner}/${repo}/releases?per_page=100`);
310
+ },
311
+ async listAlphaTags(prefix) {
312
+ const refs = await api(`/repos/${owner}/${repo}/git/matching-refs/tags/${encodeRef(`v${prefix}`)}`);
313
+ const candidates = [];
314
+ for (const ref of refs) {
315
+ const tag = text(ref.ref).replace(/^refs\/tags\//, "");
316
+ const version = tag.replace(/^v/, "");
317
+ if (!/^\d+\.\d+\.\d+-alpha\.\d+$/.test(version)) continue;
318
+ const candidateSha = await this.resolveTagSha(tag);
319
+ const commit = await api(`/repos/${owner}/${repo}/commits/${candidateSha}`);
320
+ candidates.push({
321
+ version,
322
+ sha: candidateSha,
323
+ publishedAt: commit.commit?.committer?.date || commit.commit?.author?.date,
324
+ url: ref.url || "",
325
+ actor: commit.committer?.login || commit.author?.login || "",
326
+ releasePublished: false,
327
+ });
328
+ }
329
+ return candidates;
330
+ },
331
+ async resolveTagSha(tag) {
332
+ const ref = await api(`/repos/${owner}/${repo}/git/ref/tags/${encodeRef(tag)}`);
333
+ if (ref.object?.type !== "tag") return ref.object?.sha || "";
334
+ const annotated = await api(`/repos/${owner}/${repo}/git/tags/${ref.object.sha}`);
335
+ return annotated.object?.sha || "";
336
+ },
337
+ async getCommitEvidence(candidateSha) {
338
+ const [statuses, checks, workflows] = await Promise.all([
339
+ api(`/repos/${owner}/${repo}/commits/${candidateSha}/statuses?per_page=100`),
340
+ api(`/repos/${owner}/${repo}/commits/${candidateSha}/check-runs?per_page=100`),
341
+ api(`/repos/${owner}/${repo}/actions/runs?head_sha=${candidateSha}&per_page=100`),
342
+ ]);
343
+ return { statuses, checkRuns: checks.check_runs || [], workflowRuns: workflows.workflow_runs || [] };
344
+ },
345
+ async readLedger(ref, filePath) {
346
+ const value = await api(`/repos/${owner}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`, { allow404: true });
347
+ if (!value) return undefined;
348
+ return { ledger: JSON.parse(Buffer.from(value.content, "base64").toString("utf8")), sha: value.sha };
349
+ },
350
+ async writeLedger(ref, filePath, ledger, existingSha) {
351
+ const existingRef = await api(`/repos/${owner}/${repo}/git/ref/heads/${encodeRef(ref)}`, { allow404: true });
352
+ if (!existingRef) {
353
+ const metadata = await api(`/repos/${owner}/${repo}`);
354
+ const base = await api(`/repos/${owner}/${repo}/git/ref/heads/${encodeRef(metadata.default_branch)}`);
355
+ await api(`/repos/${owner}/${repo}/git/refs`, {
356
+ method: "POST",
357
+ body: { ref: `refs/heads/${ref}`, sha: base.object.sha },
358
+ });
359
+ }
360
+ return api(`/repos/${owner}/${repo}/contents/${filePath}`, {
361
+ method: "PUT",
362
+ body: {
363
+ message: "chore(buildchain): update stable candidate ledger",
364
+ content: Buffer.from(`${JSON.stringify(ledger, null, 2)}\n`).toString("base64"),
365
+ branch: ref,
366
+ ...(existingSha ? { sha: existingSha } : {}),
367
+ },
368
+ });
369
+ },
370
+ async ensureBranch(ref, candidateSha) {
371
+ const current = await api(`/repos/${owner}/${repo}/git/ref/heads/${encodeRef(ref)}`, { allow404: true });
372
+ if (!current) {
373
+ return api(`/repos/${owner}/${repo}/git/refs`, { method: "POST", body: { ref: `refs/heads/${ref}`, sha: candidateSha } });
374
+ }
375
+ if (current.object.sha !== candidateSha) {
376
+ throw new Error(`source-lock branch ${ref} already points to ${current.object.sha}, not ${candidateSha}`);
377
+ }
378
+ return current;
379
+ },
380
+ async ensurePromotionPullRequest({ head, base, title, body }) {
381
+ const open = await api(`/repos/${owner}/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${head}`)}&base=${encodeURIComponent(base)}&per_page=20`);
382
+ return open[0] || api(`/repos/${owner}/${repo}/pulls`, { method: "POST", body: { head, base, title, body } });
383
+ },
384
+ async enableAutoMerge(pullRequest) {
385
+ const query = `mutation($id:ID!){enablePullRequestAutoMerge(input:{pullRequestId:$id,mergeMethod:MERGE}){pullRequest{url}}}`;
386
+ return api("/graphql", { method: "POST", body: { query, variables: { id: pullRequest.node_id } } });
387
+ },
388
+ async setVariable(name, value) {
389
+ const current = await api(`/repos/${owner}/${repo}/actions/variables/${encodeURIComponent(name)}`, { allow404: true });
390
+ if (current) {
391
+ return api(`/repos/${owner}/${repo}/actions/variables/${encodeURIComponent(name)}`, {
392
+ method: "PATCH",
393
+ body: { name, value },
394
+ });
395
+ }
396
+ return api(`/repos/${owner}/${repo}/actions/variables`, { method: "POST", body: { name, value } });
397
+ },
398
+ async deleteVariable(name) {
399
+ const current = await api(`/repos/${owner}/${repo}/actions/variables/${encodeURIComponent(name)}`, { allow404: true });
400
+ if (!current) return undefined;
401
+ return api(`/repos/${owner}/${repo}/actions/variables/${encodeURIComponent(name)}`, { method: "DELETE" });
402
+ },
403
+ };
404
+ }
405
+
406
+ function markdown(result) {
407
+ const selected = result.selection.selected ? result.selection.candidate.version : "none";
408
+ return [
409
+ "## Buildchain stable candidate patrol",
410
+ "",
411
+ `Repository: \`${result.repository}\``,
412
+ `Target: \`${result.targetBranch}\``,
413
+ `Ledger: \`${result.ledgerRef}\``,
414
+ `Selected: \`${selected}\` (${result.selection.reason})`,
415
+ `Dry run: \`${result.dryRun}\``,
416
+ "",
417
+ `Candidates: ${JSON.stringify(result.summary)}`,
418
+ "",
419
+ ].join("\n");
420
+ }
421
+
422
+ async function main() {
423
+ const options = normalizeStableCandidatePatrolOptions();
424
+ const result = await runStableCandidatePatrol(options);
425
+ fs.mkdirSync(path.dirname(options.outputPath), { recursive: true });
426
+ fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`);
427
+ const summary = markdown(result);
428
+ if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
429
+ else process.stdout.write(summary);
430
+ if (process.env.GITHUB_OUTPUT) {
431
+ const lines = {
432
+ "result-path": options.outputPath,
433
+ selected: String(result.selection.selected),
434
+ "selected-version": result.selection.candidate?.version || "",
435
+ "selected-sha": result.selection.candidate?.sha || "",
436
+ "stable-version": result.selection.candidate?.stableVersion || "",
437
+ "promotion-pr": result.promotion?.pullRequest?.html_url || "",
438
+ };
439
+ fs.appendFileSync(process.env.GITHUB_OUTPUT, `${Object.entries(lines).map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
440
+ }
441
+ }
442
+
443
+ if (import.meta.url === `file://${process.argv[1]}`) {
444
+ main().catch((error) => {
445
+ console.error(error.stack || error.message);
446
+ process.exit(1);
447
+ });
448
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import {
4
+ getStableReleasePolicy,
5
+ loadBuildchainConfig,
6
+ } from "../packages/core/buildchain-config.js";
7
+
8
+ function writeOutputs(values, outputFile = process.env.GITHUB_OUTPUT) {
9
+ if (!outputFile) return;
10
+ fs.appendFileSync(outputFile, `${Object.entries(values).map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
11
+ }
12
+
13
+ const cwd = process.env.BUILDCHAIN_STABLE_POLICY_CWD || process.cwd();
14
+ const policy = getStableReleasePolicy(loadBuildchainConfig(cwd));
15
+ writeOutputs({
16
+ strategy: policy.strategy,
17
+ timezone: policy.timezone,
18
+ "publish-at": policy.publishAt,
19
+ "minimum-soak-seconds": policy.minimumSoakSeconds,
20
+ "required-checks": policy.requiredChecks.join(","),
21
+ "ledger-ref": policy.ledgerRef,
22
+ "auto-promote": policy.autoPromote,
23
+ "auto-merge": policy.autoMerge,
24
+ });
25
+ process.stdout.write(`${JSON.stringify(policy, null, 2)}\n`);