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

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.
Files changed (42) hide show
  1. package/bin/buildchain.mjs +18 -0
  2. package/dist/site/buildchain-contract.json +8 -8
  3. package/dist/site/buildchain-site.json +92 -26
  4. package/dist/site/capability-registry.json +5 -5
  5. package/dist/site/cli-registry.json +12 -0
  6. package/dist/site/kfd-claims.json +182 -14
  7. package/dist/site/kfd-upstream-aggregate.json +1 -1
  8. package/dist/site/manual-registry.json +8 -7
  9. package/dist/site/node-api-registry.json +20 -7
  10. package/dist/site/page-registry.json +81 -15
  11. package/dist/site/public-surface-audit.json +95 -10
  12. package/dist/site/publication-registry.json +4 -4
  13. package/dist/site/release-provenance.json +1 -0
  14. package/dist/site/site-manifest.json +11 -11
  15. package/dist/site/workflow-registry.json +68 -3
  16. package/docs/MAP.md +3 -0
  17. package/docs/binary-distribution.md +20 -1
  18. package/docs/cli.md +17 -0
  19. package/docs/kfd-support.md +44 -1
  20. package/docs/lifecycle-protocol.md +12 -0
  21. package/docs/release-governance.md +23 -0
  22. package/docs/reusable-build-surface.md +69 -0
  23. package/docs/stable-candidate-patrol.md +188 -0
  24. package/package.json +3 -1
  25. package/packages/core/buildchain-config.js +58 -0
  26. package/packages/core/buildchain-kfd-claims.js +51 -2
  27. package/packages/core/buildchain-layout.js +46 -0
  28. package/packages/core/index.js +19 -0
  29. package/packages/core/kfd3-surface-register.js +59 -2
  30. package/packages/core/stable-candidate-ledger.js +276 -0
  31. package/scripts/build-standalone-binary.mjs +2 -1
  32. package/scripts/check-inventory.mjs +4 -1
  33. package/scripts/generate-site-bundle.mjs +9 -1
  34. package/scripts/init-repo.mjs +1 -1
  35. package/scripts/locked-source-checkout.mjs +52 -12
  36. package/scripts/npm-publish-dry-run.mjs +1 -1
  37. package/scripts/publication-artifact.mjs +1 -1
  38. package/scripts/publication-package.mjs +1 -1
  39. package/scripts/release-propagation.mjs +1 -1
  40. package/scripts/stable-candidate-patrol.mjs +448 -0
  41. package/scripts/stable-candidate-policy.mjs +25 -0
  42. package/scripts/stable-candidate-qualification.mjs +239 -0
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+
3
+ const DEFAULTS = {
4
+ buildWorkflowFile: "build-surface-fixture.yml",
5
+ buildWorkflowName: "Build Surface Fixture",
6
+ canaryRepository: "kungfu-systems/site-libkungfu-dev",
7
+ canaryWorkflowFile: "buildchain-stable-canary.yml",
8
+ canaryWorkflowName: "Buildchain Stable Canary",
9
+ canaryStatusContext: "buildchain-canary/site-libkungfu-dev",
10
+ pollAttempts: 80,
11
+ pollIntervalMs: 15_000,
12
+ };
13
+
14
+ function text(value = "") {
15
+ return String(value ?? "").trim();
16
+ }
17
+
18
+ function integer(value, fallback) {
19
+ const parsed = Number(value);
20
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
21
+ }
22
+
23
+ function bool(value, fallback = false) {
24
+ if (value === undefined || value === null || value === "") return fallback;
25
+ return ["1", "true", "yes", "on"].includes(text(value).toLowerCase());
26
+ }
27
+
28
+ function repository(value) {
29
+ const normalized = text(value);
30
+ if (!/^[^/\s]+\/[^/\s]+$/.test(normalized)) {
31
+ throw new Error(`repository must be owner/repo, got ${value || "<empty>"}`);
32
+ }
33
+ return normalized;
34
+ }
35
+
36
+ export function normalizeStableCandidateQualificationOptions(options = {}) {
37
+ const candidateSha = text(options.candidateSha ?? process.env.BUILDCHAIN_QUALIFICATION_CANDIDATE_SHA);
38
+ if (!/^[0-9a-f]{40}$/i.test(candidateSha)) {
39
+ throw new Error(`candidate SHA must be 40 hexadecimal characters, got ${candidateSha || "<empty>"}`);
40
+ }
41
+ return {
42
+ repository: repository(options.repository ?? process.env.BUILDCHAIN_QUALIFICATION_REPOSITORY ?? process.env.GITHUB_REPOSITORY),
43
+ candidateSha,
44
+ buildWorkflowFile: text(options.buildWorkflowFile ?? process.env.BUILDCHAIN_QUALIFICATION_BUILD_WORKFLOW_FILE) || DEFAULTS.buildWorkflowFile,
45
+ buildWorkflowName: text(options.buildWorkflowName ?? process.env.BUILDCHAIN_QUALIFICATION_BUILD_WORKFLOW_NAME) || DEFAULTS.buildWorkflowName,
46
+ canaryRepository: repository(options.canaryRepository ?? process.env.BUILDCHAIN_QUALIFICATION_CANARY_REPOSITORY ?? DEFAULTS.canaryRepository),
47
+ canaryWorkflowFile: text(options.canaryWorkflowFile ?? process.env.BUILDCHAIN_QUALIFICATION_CANARY_WORKFLOW_FILE) || DEFAULTS.canaryWorkflowFile,
48
+ canaryWorkflowName: text(options.canaryWorkflowName ?? process.env.BUILDCHAIN_QUALIFICATION_CANARY_WORKFLOW_NAME) || DEFAULTS.canaryWorkflowName,
49
+ canaryStatusContext: text(options.canaryStatusContext ?? process.env.BUILDCHAIN_QUALIFICATION_CANARY_STATUS_CONTEXT) || DEFAULTS.canaryStatusContext,
50
+ pollAttempts: integer(options.pollAttempts ?? process.env.BUILDCHAIN_QUALIFICATION_POLL_ATTEMPTS, DEFAULTS.pollAttempts),
51
+ pollIntervalMs: integer(options.pollIntervalMs ?? process.env.BUILDCHAIN_QUALIFICATION_POLL_INTERVAL_MS, DEFAULTS.pollIntervalMs),
52
+ dryRun: bool(options.dryRun ?? process.env.BUILDCHAIN_QUALIFICATION_DRY_RUN, false),
53
+ };
54
+ }
55
+
56
+ function successful(run) {
57
+ return run?.status === "completed" && run?.conclusion === "success";
58
+ }
59
+
60
+ function active(run) {
61
+ return run && run.status !== "completed";
62
+ }
63
+
64
+ async function ensureWorkflowEvidence({ client, repository, workflowFile, workflowName, ref, headSha, runName, options }) {
65
+ let run = await client.findWorkflowRun({ repository, workflowFile, workflowName, headSha, runName });
66
+ if (successful(run)) return { state: "existing", run };
67
+
68
+ if (!active(run)) {
69
+ if (options.dryRun) return { state: "planned", run };
70
+ await client.dispatchWorkflow({ repository, workflowFile, ref, inputs: runName ? { buildchain_ref: headSha } : {} });
71
+ }
72
+
73
+ if (options.dryRun) return { state: "waiting", run };
74
+ run = await client.waitForWorkflowRun({
75
+ repository,
76
+ workflowFile,
77
+ workflowName,
78
+ headSha,
79
+ runName,
80
+ attempts: options.pollAttempts,
81
+ intervalMs: options.pollIntervalMs,
82
+ });
83
+ if (!successful(run)) {
84
+ throw new Error(`${repository} ${workflowName} did not succeed for ${headSha}: ${run?.conclusion || run?.status || "not-found"}`);
85
+ }
86
+ return { state: "produced", run };
87
+ }
88
+
89
+ export async function runStableCandidateQualification(optionsInput = {}, clientInput) {
90
+ const options = normalizeStableCandidateQualificationOptions(optionsInput);
91
+ const client = clientInput || createGitHubQualificationClient({ token: process.env.GITHUB_TOKEN });
92
+ const candidate = await client.resolveExactAlpha(options.repository, options.candidateSha);
93
+ if (!candidate) {
94
+ return { schemaVersion: 1, contract: "kungfu-buildchain-stable-candidate-qualification", status: "skipped", reason: "exact-alpha-release-not-found", candidateSha: options.candidateSha };
95
+ }
96
+
97
+ const build = await ensureWorkflowEvidence({
98
+ client,
99
+ repository: options.repository,
100
+ workflowFile: options.buildWorkflowFile,
101
+ workflowName: options.buildWorkflowName,
102
+ ref: candidate.tag,
103
+ headSha: options.candidateSha,
104
+ runName: "",
105
+ options,
106
+ });
107
+
108
+ let status = await client.findCommitStatus(options.repository, options.candidateSha, options.canaryStatusContext);
109
+ let canary = { state: status?.state === "success" ? "existing" : "pending", run: undefined };
110
+ if (status?.state !== "success") {
111
+ const canaryDefaultBranch = await client.defaultBranch(options.canaryRepository);
112
+ canary = await ensureWorkflowEvidence({
113
+ client,
114
+ repository: options.canaryRepository,
115
+ workflowFile: options.canaryWorkflowFile,
116
+ workflowName: options.canaryWorkflowName,
117
+ ref: canaryDefaultBranch,
118
+ headSha: options.candidateSha,
119
+ runName: `${options.canaryWorkflowName} / ${options.candidateSha}`,
120
+ options,
121
+ });
122
+ if (!options.dryRun) {
123
+ status = await client.createCommitStatus({
124
+ repository: options.repository,
125
+ sha: options.candidateSha,
126
+ context: options.canaryStatusContext,
127
+ targetUrl: canary.run.html_url,
128
+ description: `No-apply ${options.canaryRepository} canary passed`,
129
+ });
130
+ }
131
+ }
132
+
133
+ return {
134
+ schemaVersion: 1,
135
+ contract: "kungfu-buildchain-stable-candidate-qualification",
136
+ status: options.dryRun ? "planned" : "qualified-evidence-ready",
137
+ dryRun: options.dryRun,
138
+ candidate,
139
+ build: { state: build.state, url: build.run?.html_url || "" },
140
+ canary: { state: canary.state, url: canary.run?.html_url || status?.target_url || "" },
141
+ attestation: { context: options.canaryStatusContext, state: options.dryRun ? "planned" : status?.state || "" },
142
+ };
143
+ }
144
+
145
+ export function createGitHubQualificationClient({
146
+ token,
147
+ attestationToken = process.env.BUILDCHAIN_QUALIFICATION_ATTESTATION_TOKEN || token,
148
+ fetchImpl = globalThis.fetch,
149
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
150
+ }) {
151
+ const headersFor = (requestToken) => ({
152
+ accept: "application/vnd.github+json",
153
+ authorization: requestToken ? `Bearer ${requestToken}` : undefined,
154
+ "user-agent": "buildchain-stable-candidate-qualification",
155
+ "x-github-api-version": "2022-11-28",
156
+ });
157
+ async function api(requestPath, { method = "GET", body, requestToken = token } = {}) {
158
+ const response = await fetchImpl(`https://api.github.com${requestPath}`, {
159
+ method,
160
+ headers: Object.fromEntries(Object.entries(headersFor(requestToken)).filter(([, value]) => value)),
161
+ body: body === undefined ? undefined : JSON.stringify(body),
162
+ });
163
+ const raw = await response.text();
164
+ const payload = raw ? JSON.parse(raw) : undefined;
165
+ if (!response.ok) throw new Error(`GitHub API ${method} ${requestPath} failed with ${response.status}: ${payload?.message || raw}`);
166
+ return payload;
167
+ }
168
+ const encodeRef = (ref) => ref.split("/").map(encodeURIComponent).join("/");
169
+ async function resolveTagSha(repositoryName, tag) {
170
+ const ref = await api(`/repos/${repositoryName}/git/ref/tags/${encodeRef(tag)}`);
171
+ if (ref.object?.type !== "tag") return ref.object?.sha || "";
172
+ const annotated = await api(`/repos/${repositoryName}/git/tags/${ref.object.sha}`);
173
+ return annotated.object?.sha || "";
174
+ }
175
+ async function matchingRun(query) {
176
+ const payload = await api(`/repos/${query.repository}/actions/workflows/${encodeURIComponent(query.workflowFile)}/runs?per_page=100`);
177
+ return (payload.workflow_runs || [])
178
+ .filter((run) => (
179
+ (!query.runName && (!query.headSha || run.head_sha === query.headSha))
180
+ || (query.runName && (run.display_title === query.runName || run.name === query.runName))
181
+ ))
182
+ .sort((left, right) => String(right.created_at).localeCompare(String(left.created_at)))[0];
183
+ }
184
+ return {
185
+ async resolveExactAlpha(repositoryName, candidateSha) {
186
+ const releases = await api(`/repos/${repositoryName}/releases?per_page=100`);
187
+ for (const release of releases) {
188
+ if (!release.prerelease || !/^v\d+\.\d+\.\d+-alpha\.\d+$/.test(release.tag_name || "")) continue;
189
+ const sha = await resolveTagSha(repositoryName, release.tag_name);
190
+ if (sha === candidateSha) return { version: release.tag_name.slice(1), tag: release.tag_name, sha, releaseUrl: release.html_url };
191
+ }
192
+ return undefined;
193
+ },
194
+ async defaultBranch(repositoryName) {
195
+ return (await api(`/repos/${repositoryName}`)).default_branch;
196
+ },
197
+ findWorkflowRun: matchingRun,
198
+ async dispatchWorkflow({ repository: repositoryName, workflowFile, ref, inputs }) {
199
+ await api(`/repos/${repositoryName}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`, { method: "POST", body: { ref, inputs } });
200
+ },
201
+ async waitForWorkflowRun(query) {
202
+ let latest;
203
+ for (let attempt = 0; attempt < query.attempts; attempt += 1) {
204
+ latest = await matchingRun(query);
205
+ if (latest?.status === "completed") return latest;
206
+ await sleep(query.intervalMs);
207
+ }
208
+ return latest;
209
+ },
210
+ async findCommitStatus(repositoryName, sha, context) {
211
+ const statuses = await api(`/repos/${repositoryName}/commits/${sha}/statuses?per_page=100`);
212
+ return statuses.find((entry) => entry.context === context);
213
+ },
214
+ async createCommitStatus({ repository: repositoryName, sha, context, targetUrl, description }) {
215
+ return api(`/repos/${repositoryName}/statuses/${sha}`, {
216
+ method: "POST",
217
+ requestToken: attestationToken,
218
+ body: { state: "success", context, target_url: targetUrl, description },
219
+ });
220
+ },
221
+ };
222
+ }
223
+
224
+ async function main() {
225
+ const result = await runStableCandidateQualification();
226
+ const output = `${JSON.stringify(result, null, 2)}\n`;
227
+ process.stdout.write(output);
228
+ if (process.env.GITHUB_STEP_SUMMARY) {
229
+ const fs = await import("node:fs");
230
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Stable candidate qualification\n\n\`\`\`json\n${output}\`\`\`\n`);
231
+ }
232
+ }
233
+
234
+ if (import.meta.url === `file://${process.argv[1]}`) {
235
+ main().catch((error) => {
236
+ console.error(error.stack || error.message);
237
+ process.exit(1);
238
+ });
239
+ }