@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.
- package/dist/site/buildchain-contract.json +25 -17
- package/dist/site/buildchain-site.json +26 -21
- package/dist/site/kfd-claims.json +5 -4
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +5 -5
- package/dist/site/node-api-registry.json +3 -3
- package/dist/site/page-registry.json +17 -12
- package/dist/site/public-surface-audit.json +4 -3
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +9 -9
- package/dist/site/workflow-registry.json +2 -1
- package/docs/lifecycle-protocol.md +4 -1
- package/docs/publication-artifacts.md +9 -0
- package/docs/release-flow.md +10 -1
- package/docs/release-governance.md +51 -3
- package/docs/reusable-build-surface.md +6 -2
- package/docs/web-surface-deployments.md +15 -1
- package/package.json +1 -1
- package/packages/core/buildchain-contract.js +9 -1
- package/packages/core/publication-artifact.js +56 -8
- package/packages/core/publication-package.js +5 -0
- package/packages/core/stable-release-gate.js +284 -0
- package/scripts/check-inventory.mjs +2 -0
- package/scripts/locked-source-checkout.mjs +43 -9
- package/scripts/publication-artifact.mjs +20 -2
- package/scripts/publication-registry-hydrate.mjs +216 -0
- package/scripts/run-lifecycle-core.mjs +7 -2
- package/scripts/stable-release-gate.mjs +295 -0
- package/scripts/web-surface-core.mjs +28 -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
|
+
}
|
|
@@ -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];
|