@kungfu-tech/buildchain 2.11.14-alpha.0 → 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.
- package/dist/site/buildchain-contract.json +4 -4
- package/dist/site/buildchain-site.json +15 -10
- package/dist/site/kfd-claims.json +2 -2
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +2 -2
- package/dist/site/page-registry.json +9 -4
- package/dist/site/public-surface-audit.json +1 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +6 -6
- package/docs/release-flow.md +10 -1
- package/docs/release-governance.md +42 -0
- package/package.json +1 -1
- package/packages/core/stable-release-gate.js +284 -0
- package/scripts/check-inventory.mjs +2 -0
- package/scripts/stable-release-gate.mjs +295 -0
|
@@ -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
|
+
}
|