@kungfu-tech/buildchain 2.11.13 → 2.11.14-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.
@@ -0,0 +1,284 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const STABLE_RELEASE_POLICY_CONTRACT = "kungfu-buildchain-stable-release-policy";
5
+ export const STABLE_RELEASE_GATE_CONTRACT = "kungfu-buildchain-stable-release-gate";
6
+
7
+ function string(value = "") {
8
+ return String(value ?? "").trim();
9
+ }
10
+
11
+ function positiveInteger(value, label) {
12
+ const number = Number(value);
13
+ if (!Number.isInteger(number) || number < 0) {
14
+ throw new Error(`${label} must be a non-negative integer`);
15
+ }
16
+ return number;
17
+ }
18
+
19
+ function timestamp(value, label) {
20
+ const normalized = string(value);
21
+ const milliseconds = Date.parse(normalized);
22
+ if (!normalized || !Number.isFinite(milliseconds)) {
23
+ throw new Error(`${label} must be an ISO-8601 timestamp`);
24
+ }
25
+ return { iso: new Date(milliseconds).toISOString(), milliseconds };
26
+ }
27
+
28
+ function loadJsonInput({ cwd = process.cwd(), input = "", label }) {
29
+ const normalized = string(input);
30
+ if (!normalized) {
31
+ return undefined;
32
+ }
33
+ if (normalized.startsWith("{")) {
34
+ return JSON.parse(normalized);
35
+ }
36
+ const resolved = path.isAbsolute(normalized) ? normalized : path.resolve(cwd, normalized);
37
+ if (!fs.existsSync(resolved)) {
38
+ throw new Error(`${label} path does not exist: ${normalized}`);
39
+ }
40
+ return JSON.parse(fs.readFileSync(resolved, "utf8"));
41
+ }
42
+
43
+ export function loadStableReleasePolicy({ cwd = process.cwd(), input = "" } = {}) {
44
+ const policy = loadJsonInput({ cwd, input, label: "stable release policy" });
45
+ if (!policy) {
46
+ return undefined;
47
+ }
48
+ if (policy.contract !== STABLE_RELEASE_POLICY_CONTRACT) {
49
+ throw new Error(`stable release policy contract must be ${STABLE_RELEASE_POLICY_CONTRACT}`);
50
+ }
51
+ if (Number(policy.schemaVersion) !== 1) {
52
+ throw new Error("stable release policy schemaVersion must be 1");
53
+ }
54
+ const minimumStableIntervalSeconds = positiveInteger(
55
+ policy.minimumStableIntervalSeconds,
56
+ "minimumStableIntervalSeconds",
57
+ );
58
+ const minimumCanarySoakSeconds = positiveInteger(
59
+ policy.minimumCanarySoakSeconds,
60
+ "minimumCanarySoakSeconds",
61
+ );
62
+ const productPathPrefixes = [...new Set(
63
+ (Array.isArray(policy.productPathPrefixes) ? policy.productPathPrefixes : [])
64
+ .map(string)
65
+ .filter(Boolean),
66
+ )];
67
+ if (productPathPrefixes.length === 0) {
68
+ throw new Error("stable release policy requires productPathPrefixes[]");
69
+ }
70
+ const requiredCanaries = (Array.isArray(policy.requiredCanaries) ? policy.requiredCanaries : [])
71
+ .map((canary, index) => {
72
+ const id = string(canary?.id);
73
+ const source = string(canary?.source);
74
+ if (!id) {
75
+ throw new Error(`requiredCanaries[${index}].id is required`);
76
+ }
77
+ if (!new Set(["release-candidate", "commit-status"]).has(source)) {
78
+ throw new Error(`requiredCanaries[${index}].source must be release-candidate or commit-status`);
79
+ }
80
+ if (source === "commit-status" && !string(canary.context)) {
81
+ throw new Error(`requiredCanaries[${index}].context is required for commit-status canaries`);
82
+ }
83
+ return {
84
+ id,
85
+ source,
86
+ repository: string(canary.repository),
87
+ workflow: string(canary.workflow),
88
+ context: string(canary.context),
89
+ allowedAttestors: [...new Set(
90
+ (Array.isArray(canary.allowedAttestors) ? canary.allowedAttestors : [])
91
+ .map(string)
92
+ .filter(Boolean),
93
+ )],
94
+ };
95
+ });
96
+ if (requiredCanaries.length === 0) {
97
+ throw new Error("stable release policy requires requiredCanaries[]");
98
+ }
99
+ if (new Set(requiredCanaries.map((canary) => canary.id)).size !== requiredCanaries.length) {
100
+ throw new Error("stable release policy canary ids must be unique");
101
+ }
102
+ return {
103
+ schemaVersion: 1,
104
+ contract: STABLE_RELEASE_POLICY_CONTRACT,
105
+ enabled: policy.enabled !== false,
106
+ minimumStableIntervalSeconds,
107
+ minimumCanarySoakSeconds,
108
+ productPathPrefixes,
109
+ requiredCanaries,
110
+ };
111
+ }
112
+
113
+ function check(ok, id, message, details = {}) {
114
+ return { id, status: ok ? "pass" : "fail", message, details };
115
+ }
116
+
117
+ function matchesProductPath(file, prefixes) {
118
+ return prefixes.some((prefix) => file === prefix || file.startsWith(prefix));
119
+ }
120
+
121
+ export function evaluateStableReleaseGate({
122
+ policy,
123
+ channel = "",
124
+ candidate = {},
125
+ previousStable = undefined,
126
+ changedPaths = [],
127
+ impact = {},
128
+ canaries = [],
129
+ now = new Date().toISOString(),
130
+ } = {}) {
131
+ const normalizedChannel = string(channel);
132
+ if (!policy || policy.enabled === false || normalizedChannel !== "release") {
133
+ return {
134
+ schemaVersion: 1,
135
+ contract: STABLE_RELEASE_GATE_CONTRACT,
136
+ applies: false,
137
+ ok: true,
138
+ channel: normalizedChannel,
139
+ checks: [],
140
+ summary: { reason: !policy ? "policy-not-configured" : policy.enabled === false ? "policy-disabled" : "non-stable-channel" },
141
+ };
142
+ }
143
+
144
+ const nowTime = timestamp(now, "now");
145
+ const candidatePublished = timestamp(candidate.publishedAt, "candidate.publishedAt");
146
+ const candidateSha = string(candidate.sha);
147
+ const candidateTag = string(candidate.tag);
148
+ const checks = [];
149
+ checks.push(check(/^[0-9a-f]{40}$/i.test(candidateSha), "candidate.sha", "candidate alpha SHA is exact", { sha: candidateSha }));
150
+ checks.push(check(/-alpha\.\d+$/.test(candidateTag), "candidate.tag", "candidate is an exact alpha tag", { tag: candidateTag }));
151
+
152
+ if (previousStable) {
153
+ const previousPublished = timestamp(previousStable.publishedAt, "previousStable.publishedAt");
154
+ const elapsedSeconds = Math.floor((nowTime.milliseconds - previousPublished.milliseconds) / 1000);
155
+ checks.push(check(
156
+ elapsedSeconds >= policy.minimumStableIntervalSeconds,
157
+ "stable.minimum_interval",
158
+ "minimum interval since the previous stable release is satisfied",
159
+ {
160
+ previousTag: string(previousStable.tag),
161
+ previousPublishedAt: previousPublished.iso,
162
+ elapsedSeconds,
163
+ requiredSeconds: policy.minimumStableIntervalSeconds,
164
+ },
165
+ ));
166
+ } else {
167
+ checks.push(check(true, "stable.minimum_interval", "no previous stable release exists", { firstStable: true }));
168
+ }
169
+
170
+ const normalizedChangedPaths = [...new Set(changedPaths.map(string).filter(Boolean))].sort();
171
+ const productChangedPaths = normalizedChangedPaths.filter((file) =>
172
+ matchesProductPath(file, policy.productPathPrefixes));
173
+ checks.push(check(
174
+ productChangedPaths.length > 0,
175
+ "stable.product_diff",
176
+ "candidate contains a product or contract difference from the current stable release",
177
+ { changedPaths: productChangedPaths, comparedPathCount: normalizedChangedPaths.length },
178
+ ));
179
+
180
+ const surfaceImpacts = Array.isArray(impact?.surfaceImpacts) ? impact.surfaceImpacts : [];
181
+ checks.push(check(
182
+ surfaceImpacts.length > 0 && string(impact?.summary) !== "",
183
+ "stable.impact",
184
+ "version-bound impact declares a summary and at least one surface",
185
+ { summary: string(impact?.summary), surfaceIds: surfaceImpacts.map((entry) => string(entry?.id)).filter(Boolean) },
186
+ ));
187
+
188
+ const canaryById = new Map(canaries.map((canary) => [string(canary?.id), canary]));
189
+ const canaryCompletionTimes = [];
190
+ for (const required of policy.requiredCanaries) {
191
+ const evidence = canaryById.get(required.id);
192
+ const completedAt = evidence?.completedAt
193
+ ? timestamp(evidence.completedAt, `canary ${required.id} completedAt`)
194
+ : undefined;
195
+ const attestorAllowed = required.allowedAttestors.length === 0 ||
196
+ required.allowedAttestors.includes(string(evidence?.attestor));
197
+ const valid = Boolean(
198
+ evidence &&
199
+ string(evidence.status) === "success" &&
200
+ string(evidence.candidateSha) === candidateSha &&
201
+ completedAt &&
202
+ completedAt.milliseconds >= candidatePublished.milliseconds &&
203
+ attestorAllowed,
204
+ );
205
+ if (completedAt) {
206
+ canaryCompletionTimes.push(completedAt.milliseconds);
207
+ }
208
+ checks.push(check(
209
+ valid,
210
+ `stable.canary.${required.id}`,
211
+ `required canary ${required.id} passed for the exact alpha candidate`,
212
+ {
213
+ source: required.source,
214
+ context: required.context,
215
+ repository: string(evidence?.repository || required.repository),
216
+ workflow: string(evidence?.workflow || required.workflow),
217
+ runtimeRef: string(evidence?.runtimeRef),
218
+ candidateSha: string(evidence?.candidateSha),
219
+ completedAt: completedAt?.iso || "",
220
+ evidenceUrl: string(evidence?.evidenceUrl),
221
+ attestor: string(evidence?.attestor),
222
+ attestorAllowed,
223
+ },
224
+ ));
225
+ }
226
+
227
+ const soakStartMilliseconds = Math.max(candidatePublished.milliseconds, ...canaryCompletionTimes);
228
+ const soakElapsedSeconds = Math.floor((nowTime.milliseconds - soakStartMilliseconds) / 1000);
229
+ checks.push(check(
230
+ canaryCompletionTimes.length === policy.requiredCanaries.length &&
231
+ soakElapsedSeconds >= policy.minimumCanarySoakSeconds,
232
+ "stable.canary_soak",
233
+ "minimum soak interval after the final required canary is satisfied",
234
+ {
235
+ soakStartedAt: new Date(soakStartMilliseconds).toISOString(),
236
+ elapsedSeconds: soakElapsedSeconds,
237
+ requiredSeconds: policy.minimumCanarySoakSeconds,
238
+ },
239
+ ));
240
+
241
+ const ok = checks.every((entry) => entry.status === "pass");
242
+ return {
243
+ schemaVersion: 1,
244
+ contract: STABLE_RELEASE_GATE_CONTRACT,
245
+ applies: true,
246
+ ok,
247
+ channel: normalizedChannel,
248
+ evaluatedAt: nowTime.iso,
249
+ candidate: {
250
+ tag: candidateTag,
251
+ sha: candidateSha,
252
+ publishedAt: candidatePublished.iso,
253
+ },
254
+ previousStable: previousStable
255
+ ? {
256
+ tag: string(previousStable.tag),
257
+ sha: string(previousStable.sha),
258
+ publishedAt: timestamp(previousStable.publishedAt, "previousStable.publishedAt").iso,
259
+ }
260
+ : undefined,
261
+ policy: {
262
+ minimumStableIntervalSeconds: policy.minimumStableIntervalSeconds,
263
+ minimumCanarySoakSeconds: policy.minimumCanarySoakSeconds,
264
+ requiredCanaries: policy.requiredCanaries.map((canary) => canary.id),
265
+ },
266
+ checks,
267
+ summary: {
268
+ decision: ok ? "allow" : "block",
269
+ failedChecks: checks.filter((entry) => entry.status === "fail").map((entry) => entry.id),
270
+ productChangedPaths,
271
+ },
272
+ };
273
+ }
274
+
275
+ export function assertStableReleaseGate(input = {}) {
276
+ const report = evaluateStableReleaseGate(input);
277
+ if (!report.ok) {
278
+ throw Object.assign(
279
+ new Error(`stable release gate blocked promotion: ${report.summary.failedChecks.join(", ")}`),
280
+ { report },
281
+ );
282
+ }
283
+ return report;
284
+ }
@@ -725,6 +725,8 @@ for (const requiredSnippet of [
725
725
  "publish-source-ref: ${{ steps.publish-gate.outputs.ref }}",
726
726
  "publish-source-sha: ${{ steps.publish-gate.outputs.sha }}",
727
727
  "publish-source-locked: ${{ steps.publish-gate.outputs.locked }}",
728
+ "Enforce Buildchain stable release canary gate",
729
+ "BUILDCHAIN_STABLE_RELEASE_POLICY: .buildchain/stable-release-policy.json",
728
730
  ]) {
729
731
  if (!releaseCandidatePromoteWorkflow.includes(requiredSnippet)) {
730
732
  throw new Error(`release candidate promote workflow missing KFD gate pass-through: ${requiredSnippet}`);
@@ -406,6 +406,7 @@ function cliCommandMeta(id) {
406
406
  "kfd-1-witness": { group: "kfd-trust", purpose: "Generate Buildchain's KFD-1 self contract-world witness." },
407
407
  "kfd-2": { group: "kfd-trust", purpose: "Inspect KFD-2 trust taxonomy, public claims, and schema command families." },
408
408
  "kfd-2-claims": { group: "kfd-trust", purpose: "Generate Buildchain's KFD-2 public trust claim evidence." },
409
+ "kfd-2-product-claims": { group: "kfd-trust", purpose: "Validate and render product-owned KFD-2 claims in the canonical .buildchain/kfd layout." },
409
410
  "kfd-2-schema": { group: "kfd-trust", purpose: "Print the default KFD-2 schema exposed by the KFD package standards metadata." },
410
411
  "kfd-2-taxonomy": { group: "kfd-trust", purpose: "Validate KFD-2 trust taxonomy entries from the KFD package standards metadata." },
411
412
  "kfd-2-trust-assessment": { group: "kfd-trust", purpose: "Expose and validate the KFD package foundation KFD-2 trust assessment." },
@@ -0,0 +1,295 @@
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 {
6
+ assertStableReleaseGate,
7
+ evaluateStableReleaseGate,
8
+ loadStableReleasePolicy,
9
+ } from "../packages/core/stable-release-gate.js";
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 githubHeaders(token) {
24
+ return {
25
+ accept: "application/vnd.github+json",
26
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
27
+ "user-agent": "buildchain-stable-release-gate",
28
+ "x-github-api-version": "2022-11-28",
29
+ };
30
+ }
31
+
32
+ async function githubJson({
33
+ apiUrl = "https://api.github.com",
34
+ token = "",
35
+ requestPath,
36
+ fetchImpl = globalThis.fetch,
37
+ }) {
38
+ const response = await fetchImpl(`${apiUrl.replace(/\/+$/, "")}${requestPath}`, {
39
+ headers: githubHeaders(token),
40
+ });
41
+ const text = await response.text();
42
+ const body = text ? JSON.parse(text) : undefined;
43
+ if (!response.ok) {
44
+ throw new Error(`GitHub API GET ${requestPath} failed with ${response.status}: ${body?.message || text}`);
45
+ }
46
+ return body;
47
+ }
48
+
49
+ function parseAlphaVersion(version) {
50
+ const normalized = String(version || "").replace(/^v/, "").trim();
51
+ const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)-alpha\.(\d+)$/);
52
+ if (!match) {
53
+ throw new Error(`stable release gate requires an exact alpha version, got ${version || "<empty>"}`);
54
+ }
55
+ return {
56
+ version: normalized,
57
+ tag: `v${normalized}`,
58
+ major: Number(match[1]),
59
+ minor: Number(match[2]),
60
+ patch: Number(match[3]),
61
+ };
62
+ }
63
+
64
+ async function resolveTagCommitSha({ api, repository, tag }) {
65
+ const ref = await api(`/repos/${repository.owner}/${repository.repo}/git/ref/tags/${encodeURIComponent(tag)}`);
66
+ let sha = ref.object?.sha || "";
67
+ if (ref.object?.type === "tag") {
68
+ const annotated = await api(`/repos/${repository.owner}/${repository.repo}/git/tags/${sha}`);
69
+ sha = annotated.object?.sha || "";
70
+ }
71
+ if (!/^[0-9a-f]{40}$/i.test(sha)) {
72
+ throw new Error(`tag ${tag} did not resolve to a 40-character commit SHA`);
73
+ }
74
+ return sha;
75
+ }
76
+
77
+ function selectPreviousStable({ releases = [], candidate }) {
78
+ const prefix = `v${candidate.major}.${candidate.minor}.`;
79
+ return releases
80
+ .map((release) => {
81
+ const match = String(release.tag_name || "").match(
82
+ new RegExp(`^${prefix.replaceAll(".", "\\.")}(\\d+)$`),
83
+ );
84
+ return match ? { release, patch: Number(match[1]) } : undefined;
85
+ })
86
+ .filter((entry) => entry && entry.patch < candidate.patch && entry.release.published_at)
87
+ .sort((left, right) => right.patch - left.patch)[0]?.release;
88
+ }
89
+
90
+ function loadImpact({ cwd, input }) {
91
+ const normalized = String(input || "").trim();
92
+ if (!normalized) {
93
+ return {};
94
+ }
95
+ if (normalized.startsWith("{")) {
96
+ return JSON.parse(normalized);
97
+ }
98
+ const resolved = path.isAbsolute(normalized) ? normalized : path.resolve(cwd, normalized);
99
+ return JSON.parse(fs.readFileSync(resolved, "utf8"));
100
+ }
101
+
102
+ function parseRunUrl(url = "") {
103
+ const match = String(url).match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)/);
104
+ return match
105
+ ? { owner: match[1], repo: match[2], repository: `${match[1]}/${match[2]}`, runId: match[3] }
106
+ : undefined;
107
+ }
108
+
109
+ async function resolveCanaryEvidence({
110
+ api,
111
+ repository,
112
+ policy,
113
+ candidateTag,
114
+ candidateSha,
115
+ releaseCandidateRunId,
116
+ releaseCandidateRunUrl,
117
+ }) {
118
+ let statuses;
119
+ const evidence = [];
120
+ for (const canary of policy.requiredCanaries) {
121
+ if (canary.source === "release-candidate") {
122
+ if (!releaseCandidateRunId) {
123
+ evidence.push({ id: canary.id, status: "missing", candidateSha });
124
+ continue;
125
+ }
126
+ const run = await api(
127
+ `/repos/${repository.owner}/${repository.repo}/actions/runs/${encodeURIComponent(releaseCandidateRunId)}`,
128
+ );
129
+ const workflowMatches = !canary.workflow ||
130
+ canary.workflow === run.name || canary.workflow === run.path?.split("/").pop();
131
+ evidence.push({
132
+ id: canary.id,
133
+ status: run.conclusion === "success" && workflowMatches ? "success" : run.conclusion || "failure",
134
+ candidateSha,
135
+ completedAt: run.updated_at || run.run_started_at || "",
136
+ evidenceUrl: run.html_url || releaseCandidateRunUrl,
137
+ repository: repository.fullName,
138
+ workflow: run.name || run.path || "",
139
+ attestor: run.actor?.login || run.triggering_actor?.login || "",
140
+ });
141
+ continue;
142
+ }
143
+
144
+ statuses ||= await api(
145
+ `/repos/${repository.owner}/${repository.repo}/commits/${candidateSha}/statuses?per_page=100`,
146
+ );
147
+ const status = statuses.find((entry) => entry.context === canary.context);
148
+ const targetRun = parseRunUrl(status?.target_url);
149
+ let targetRunEvidence;
150
+ if (targetRun && (!canary.repository || targetRun.repository === canary.repository)) {
151
+ targetRunEvidence = await api(
152
+ `/repos/${targetRun.owner}/${targetRun.repo}/actions/runs/${targetRun.runId}`,
153
+ );
154
+ }
155
+ const workflowMatches = !canary.workflow ||
156
+ canary.workflow === targetRunEvidence?.name ||
157
+ canary.workflow === targetRunEvidence?.path?.split("/").pop();
158
+ const repositoryMatches = !canary.repository || targetRun?.repository === canary.repository;
159
+ const runtimeRef = String(
160
+ targetRunEvidence?.inputs?.buildchain_ref ||
161
+ targetRunEvidence?.inputs?.buildchainRef ||
162
+ "",
163
+ ).trim();
164
+ const runtimeRefMatches = runtimeRef === candidateTag;
165
+ evidence.push({
166
+ id: canary.id,
167
+ status:
168
+ status?.state === "success" &&
169
+ targetRunEvidence?.conclusion === "success" &&
170
+ workflowMatches &&
171
+ repositoryMatches &&
172
+ runtimeRefMatches
173
+ ? "success"
174
+ : status?.state || "missing",
175
+ candidateSha,
176
+ completedAt: targetRunEvidence?.updated_at || status?.updated_at || "",
177
+ evidenceUrl: status?.target_url || "",
178
+ repository: targetRun?.repository || canary.repository,
179
+ workflow: targetRunEvidence?.name || targetRunEvidence?.path || "",
180
+ attestor: status?.creator?.login || "",
181
+ runtimeRef,
182
+ });
183
+ }
184
+ return evidence;
185
+ }
186
+
187
+ export async function collectStableReleaseGateReport({
188
+ cwd = process.cwd(),
189
+ repository: repositoryInput,
190
+ channel = "",
191
+ policyInput = ".buildchain/stable-release-policy.json",
192
+ impactInput = ".buildchain/release-impact.json",
193
+ candidateVersion = "",
194
+ releaseCandidateRunId = "",
195
+ releaseCandidateRunUrl = "",
196
+ now = new Date().toISOString(),
197
+ apiUrl = "https://api.github.com",
198
+ token = "",
199
+ fetchImpl = globalThis.fetch,
200
+ } = {}) {
201
+ const policy = loadStableReleasePolicy({ cwd, input: policyInput });
202
+ if (!policy || policy.enabled === false || channel !== "release") {
203
+ return evaluateStableReleaseGate({ policy, channel });
204
+ }
205
+ const repository = splitRepository(repositoryInput);
206
+ const candidate = parseAlphaVersion(candidateVersion);
207
+ const api = (requestPath) => githubJson({ apiUrl, token, requestPath, fetchImpl });
208
+ const [candidateRelease, releases, candidateSha] = await Promise.all([
209
+ api(`/repos/${repository.owner}/${repository.repo}/releases/tags/${encodeURIComponent(candidate.tag)}`),
210
+ api(`/repos/${repository.owner}/${repository.repo}/releases?per_page=100`),
211
+ resolveTagCommitSha({ api, repository, tag: candidate.tag }),
212
+ ]);
213
+ const previousRelease = selectPreviousStable({ releases, candidate });
214
+ const previousSha = previousRelease
215
+ ? await resolveTagCommitSha({ api, repository, tag: previousRelease.tag_name })
216
+ : "";
217
+ const comparison = previousRelease
218
+ ? await api(
219
+ `/repos/${repository.owner}/${repository.repo}/compare/${encodeURIComponent(previousRelease.tag_name)}...${encodeURIComponent(candidate.tag)}`,
220
+ )
221
+ : { files: [] };
222
+ const canaries = await resolveCanaryEvidence({
223
+ api,
224
+ repository,
225
+ policy,
226
+ candidateTag: candidate.tag,
227
+ candidateSha,
228
+ releaseCandidateRunId,
229
+ releaseCandidateRunUrl,
230
+ });
231
+ return assertStableReleaseGate({
232
+ policy,
233
+ channel,
234
+ candidate: {
235
+ tag: candidate.tag,
236
+ sha: candidateSha,
237
+ publishedAt: candidateRelease.published_at,
238
+ },
239
+ previousStable: previousRelease
240
+ ? {
241
+ tag: previousRelease.tag_name,
242
+ sha: previousSha,
243
+ publishedAt: previousRelease.published_at,
244
+ }
245
+ : undefined,
246
+ changedPaths: (comparison.files || []).map((file) => file.filename),
247
+ impact: loadImpact({ cwd, input: impactInput }),
248
+ canaries,
249
+ now,
250
+ });
251
+ }
252
+
253
+ export async function stableReleaseGateCli() {
254
+ const cwd = process.cwd();
255
+ const policyInput = env("BUILDCHAIN_STABLE_RELEASE_POLICY", ".buildchain/stable-release-policy.json");
256
+ const outputPath = env(
257
+ "BUILDCHAIN_STABLE_RELEASE_GATE_OUTPUT",
258
+ ".buildchain/release-passport/stable-release-gate.json",
259
+ );
260
+ const report = await collectStableReleaseGateReport({
261
+ cwd,
262
+ repository: env("GITHUB_REPOSITORY"),
263
+ channel: env("BUILDCHAIN_PROMOTION_CHANNEL"),
264
+ policyInput,
265
+ impactInput: env("BUILDCHAIN_RELEASE_IMPACT", ".buildchain/release-impact.json"),
266
+ candidateVersion: env("BUILDCHAIN_RELEASE_CANDIDATE_VERSION"),
267
+ releaseCandidateRunId: env("BUILDCHAIN_RELEASE_CANDIDATE_RUN_ID"),
268
+ releaseCandidateRunUrl: env("BUILDCHAIN_RELEASE_CANDIDATE_RUN_URL"),
269
+ apiUrl: env("GITHUB_API_URL", "https://api.github.com"),
270
+ token: env("GITHUB_TOKEN"),
271
+ });
272
+ const resolvedOutput = path.isAbsolute(outputPath) ? outputPath : path.resolve(cwd, outputPath);
273
+ fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
274
+ fs.writeFileSync(resolvedOutput, `${JSON.stringify(report, null, 2)}\n`);
275
+ console.log(`stable-release-gate=${report.applies ? report.summary.decision : report.summary.reason}`);
276
+ console.log(`stable-release-gate-report=${path.relative(cwd, resolvedOutput).split(path.sep).join("/")}`);
277
+ return report;
278
+ }
279
+
280
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
281
+ stableReleaseGateCli().catch((error) => {
282
+ const report = error?.report;
283
+ if (report) {
284
+ const outputPath = env(
285
+ "BUILDCHAIN_STABLE_RELEASE_GATE_OUTPUT",
286
+ ".buildchain/release-passport/stable-release-gate.json",
287
+ );
288
+ const resolvedOutput = path.resolve(process.cwd(), outputPath);
289
+ fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
290
+ fs.writeFileSync(resolvedOutput, `${JSON.stringify(report, null, 2)}\n`);
291
+ }
292
+ console.error(`::error::${String(error.message || error).replace(/\r?\n/g, "%0A")}`);
293
+ process.exitCode = 1;
294
+ });
295
+ }