@kungfu-tech/buildchain 2.11.14-alpha.0 → 2.11.14-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,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
+ }
@@ -1099,6 +1099,33 @@ export function planWebSurfaceDeploy({
1099
1099
  };
1100
1100
  }
1101
1101
 
1102
+ export function verifyWebSurfaceArtifactChannelFacts({ cwd = process.cwd(), plan } = {}) {
1103
+ const resolvedPlan = assertDeployPlan(plan);
1104
+ const artifactRoot = path.resolve(cwd, resolvedPlan.artifact.path);
1105
+ const expectedHosts = new Set((resolvedPlan.manifest?.surfaceBindings || []).map((binding) => {
1106
+ try { return new URL(binding.url).host; } catch { return ""; }
1107
+ }).filter(Boolean));
1108
+ const observed = [];
1109
+ for (const filePath of listFiles(artifactRoot, artifactRoot).filter((entry) => entry.endsWith(".json"))) {
1110
+ let value;
1111
+ try { value = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { continue; }
1112
+ if (!value || typeof value !== "object" || Array.isArray(value) || !String(value.contract || "").includes("manifest")) continue;
1113
+ const facts = [];
1114
+ if (typeof value.canonicalHost === "string" && value.canonicalHost.trim()) facts.push({ field: "canonicalHost", host: value.canonicalHost.trim() });
1115
+ if (Array.isArray(value.pages)) {
1116
+ for (const [index, page] of value.pages.entries()) {
1117
+ if (typeof page?.host === "string" && page.host.trim()) facts.push({ field: `pages[${index}].host`, host: page.host.trim() });
1118
+ }
1119
+ }
1120
+ for (const fact of facts) observed.push({ path: toPosix(path.relative(cwd, filePath)), ...fact });
1121
+ }
1122
+ const mismatches = observed.filter((fact) => !expectedHosts.has(fact.host));
1123
+ if (mismatches.length > 0) {
1124
+ throw new Error(`web-surface artifact channel facts mismatch for ${resolvedPlan.channel}: ${mismatches.map((fact) => `${fact.path}#${fact.field}=${fact.host}`).join(", ")}`);
1125
+ }
1126
+ return { channel: resolvedPlan.channel, expectedHosts: [...expectedHosts].sort(), observed };
1127
+ }
1128
+
1102
1129
  export function applyWebSurfaceDeploy({
1103
1130
  cwd = process.cwd(),
1104
1131
  channel = "preview",
@@ -1125,6 +1152,7 @@ export function applyWebSurfaceDeploy({
1125
1152
  dryRun: true,
1126
1153
  deployedAt: appliedAt,
1127
1154
  });
1155
+ verifyWebSurfaceArtifactChannelFacts({ cwd, plan: resolvedPlan });
1128
1156
  const loadedConfig = loadBuildchainConfig(cwd);
1129
1157
  const config = assertWebSurfaceConfig(loadedConfig);
1130
1158
  const deployConfig = config.deploy?.[resolvedPlan.channel];