@kungfu-tech/buildchain 3.0.2-alpha.2 → 3.0.2-alpha.4

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,446 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { decideChannelCandidate } from "../packages/core/channel-candidate.js";
7
+
8
+ function text(value = "") {
9
+ return String(value ?? "").trim();
10
+ }
11
+
12
+ function bool(value, fallback = false) {
13
+ if (value === undefined || value === null || value === "") return fallback;
14
+ return ["1", "true", "yes", "on"].includes(text(value).toLowerCase());
15
+ }
16
+
17
+ function repository(value) {
18
+ const normalized = text(value);
19
+ if (!/^[^/\s]+\/[^/\s]+$/.test(normalized))
20
+ throw new Error(`repository must be owner/repo, got ${value || "<empty>"}`);
21
+ return normalized;
22
+ }
23
+
24
+ function branch(value, name) {
25
+ const normalized = text(value).replace(/^refs\/heads\//, "");
26
+ if (
27
+ !normalized ||
28
+ normalized.startsWith("-") ||
29
+ /[\s~^:?*[\\]/.test(normalized)
30
+ ) {
31
+ throw new Error(`${name} is not a valid branch name`);
32
+ }
33
+ return normalized;
34
+ }
35
+
36
+ function workflowPath(value, name) {
37
+ const normalized = text(value);
38
+ if (!/^\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml$/.test(normalized)) {
39
+ throw new Error(`${name} must be a repository workflow path`);
40
+ }
41
+ return normalized;
42
+ }
43
+
44
+ function integer(value, fallback) {
45
+ const parsed = Number(value);
46
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
47
+ }
48
+
49
+ export function normalizeDevAlphaPatrolOptions(options = {}) {
50
+ return {
51
+ repository: repository(
52
+ options.repository ??
53
+ process.env.BUILDCHAIN_CHANNEL_PATROL_REPOSITORY ??
54
+ process.env.GITHUB_REPOSITORY,
55
+ ),
56
+ sourceBranch: branch(
57
+ options.sourceBranch ??
58
+ process.env.BUILDCHAIN_CHANNEL_PATROL_SOURCE_BRANCH ??
59
+ "dev/v4/v4.0",
60
+ "sourceBranch",
61
+ ),
62
+ targetBranch: branch(
63
+ options.targetBranch ??
64
+ process.env.BUILDCHAIN_CHANNEL_PATROL_TARGET_BRANCH ??
65
+ "alpha/v4/v4.0",
66
+ "targetBranch",
67
+ ),
68
+ devWorkflowPath: workflowPath(
69
+ options.devWorkflowPath ??
70
+ process.env.BUILDCHAIN_CHANNEL_PATROL_DEV_WORKFLOW ??
71
+ ".github/workflows/dev-verify-patrol.yml",
72
+ "devWorkflowPath",
73
+ ),
74
+ alphaWorkflowPath: workflowPath(
75
+ options.alphaWorkflowPath ??
76
+ process.env.BUILDCHAIN_CHANNEL_PATROL_ALPHA_WORKFLOW ??
77
+ ".github/workflows/alpha-promotion-preflight.yml",
78
+ "alphaWorkflowPath",
79
+ ),
80
+ maxAgeSeconds: integer(
81
+ options.maxAgeSeconds ??
82
+ process.env.BUILDCHAIN_CHANNEL_PATROL_MAX_AGE_SECONDS,
83
+ 7 * 24 * 60 * 60,
84
+ ),
85
+ createPullRequest: bool(
86
+ options.createPullRequest ??
87
+ process.env.BUILDCHAIN_CHANNEL_PATROL_CREATE_PR,
88
+ false,
89
+ ),
90
+ dryRun: bool(
91
+ options.dryRun ?? process.env.BUILDCHAIN_CHANNEL_PATROL_DRY_RUN,
92
+ true,
93
+ ),
94
+ now:
95
+ text(options.now ?? process.env.BUILDCHAIN_CHANNEL_PATROL_NOW) ||
96
+ new Date().toISOString(),
97
+ outputPath:
98
+ text(
99
+ options.outputPath ?? process.env.BUILDCHAIN_CHANNEL_PATROL_OUTPUT_PATH,
100
+ ) || ".buildchain/patrol/dev-alpha-candidate.json",
101
+ };
102
+ }
103
+
104
+ function latestWorkflowEvidence(runs, workflowPathValue, sourceSha) {
105
+ const matching = runs
106
+ .filter(
107
+ (run) => run.path === workflowPathValue && run.head_sha === sourceSha,
108
+ )
109
+ .sort((left, right) => Number(right.id) - Number(left.id));
110
+ if (matching.length === 0)
111
+ throw new Error(
112
+ `missing completed same-SHA workflow run: ${workflowPathValue}`,
113
+ );
114
+ const run = matching[0];
115
+ return {
116
+ workflowPath: workflowPathValue,
117
+ workflowName: run.name,
118
+ runId: run.id,
119
+ runAttempt: run.run_attempt,
120
+ headSha: run.head_sha,
121
+ status: run.status,
122
+ conclusion: run.conclusion,
123
+ completedAt: run.updated_at,
124
+ url: run.html_url,
125
+ };
126
+ }
127
+
128
+ function workflowEvidenceIsFreshAndSuccessful(run, { now, maxAgeSeconds }) {
129
+ const completedAt = Date.parse(run.updated_at);
130
+ const ageSeconds = (Date.parse(now) - completedAt) / 1000;
131
+ return (
132
+ run.status === "completed" &&
133
+ run.conclusion === "success" &&
134
+ Number.isFinite(ageSeconds) &&
135
+ ageSeconds >= 0 &&
136
+ ageSeconds <= maxAgeSeconds
137
+ );
138
+ }
139
+
140
+ function latestRunsBySha(runs, workflowPathValue) {
141
+ const latest = new Map();
142
+ for (const run of runs.filter((row) => row.path === workflowPathValue)) {
143
+ const current = latest.get(run.head_sha);
144
+ if (!current || Number(run.id) > Number(current.id))
145
+ latest.set(run.head_sha, run);
146
+ }
147
+ return latest;
148
+ }
149
+
150
+ export function selectLatestQualifiedSource({
151
+ sourceHistory,
152
+ workflowRunsByPath,
153
+ requiredWorkflowPaths,
154
+ now,
155
+ maxAgeSeconds,
156
+ }) {
157
+ const latestByPath = new Map(
158
+ requiredWorkflowPaths.map((workflow) => [
159
+ workflow,
160
+ latestRunsBySha(workflowRunsByPath.get(workflow) || [], workflow),
161
+ ]),
162
+ );
163
+ for (let index = 0; index < sourceHistory.length; index += 1) {
164
+ const sourceSha = sourceHistory[index];
165
+ const rows = requiredWorkflowPaths.map((workflow) =>
166
+ latestByPath.get(workflow).get(sourceSha),
167
+ );
168
+ if (
169
+ rows.every((run) =>
170
+ workflowEvidenceIsFreshAndSuccessful(run || {}, { now, maxAgeSeconds }),
171
+ )
172
+ ) {
173
+ return {
174
+ sourceSha,
175
+ skippedNewerCommitCount: index,
176
+ workflowEvidence: requiredWorkflowPaths.map((workflow) =>
177
+ latestWorkflowEvidence(
178
+ workflowRunsByPath.get(workflow) || [],
179
+ workflow,
180
+ sourceSha,
181
+ ),
182
+ ),
183
+ };
184
+ }
185
+ }
186
+ throw new Error(
187
+ "no source commit ahead of target has fresh completed successful same-SHA workflow evidence",
188
+ );
189
+ }
190
+
191
+ export async function runDevAlphaCandidatePatrol(
192
+ optionsInput = {},
193
+ clientInput,
194
+ ) {
195
+ const options = normalizeDevAlphaPatrolOptions(optionsInput);
196
+ if (options.sourceBranch === options.targetBranch)
197
+ throw new Error("source and target branches must differ");
198
+ const client =
199
+ clientInput ||
200
+ createGitHubChannelCandidateClient({
201
+ repository: options.repository,
202
+ token: process.env.GITHUB_TOKEN,
203
+ });
204
+ const [observedSourceHeadSha, targetSha] = await Promise.all([
205
+ client.resolveBranch(options.sourceBranch),
206
+ client.resolveBranch(options.targetBranch),
207
+ ]);
208
+ const headComparison = await client.compare(targetSha, observedSourceHeadSha);
209
+ const requiredWorkflowPaths = [
210
+ options.devWorkflowPath,
211
+ options.alphaWorkflowPath,
212
+ ];
213
+ let sourceSha = observedSourceHeadSha;
214
+ let comparison = headComparison;
215
+ let workflowEvidence = [];
216
+ let skippedNewerCommitCount = 0;
217
+ if (
218
+ headComparison.status === "ahead" &&
219
+ Number(headComparison.ahead_by) > 0
220
+ ) {
221
+ const [sourceHistory, ...workflowRunSets] = await Promise.all([
222
+ client.listBranchHistory(options.sourceBranch, targetSha),
223
+ ...requiredWorkflowPaths.map((workflow) =>
224
+ client.listCompletedWorkflowRuns(workflow, options.sourceBranch),
225
+ ),
226
+ ]);
227
+ const selected = selectLatestQualifiedSource({
228
+ sourceHistory,
229
+ workflowRunsByPath: new Map(
230
+ requiredWorkflowPaths.map((workflow, index) => [
231
+ workflow,
232
+ workflowRunSets[index],
233
+ ]),
234
+ ),
235
+ requiredWorkflowPaths,
236
+ now: options.now,
237
+ maxAgeSeconds: options.maxAgeSeconds,
238
+ });
239
+ sourceSha = selected.sourceSha;
240
+ skippedNewerCommitCount = selected.skippedNewerCommitCount;
241
+ workflowEvidence = selected.workflowEvidence;
242
+ if (sourceSha !== observedSourceHeadSha)
243
+ comparison = await client.compare(targetSha, sourceSha);
244
+ }
245
+ const decision = decideChannelCandidate({
246
+ repository: options.repository,
247
+ sourceBranch: options.sourceBranch,
248
+ targetBranch: options.targetBranch,
249
+ sourceSha,
250
+ targetSha,
251
+ comparison: { status: comparison.status, aheadBy: comparison.ahead_by },
252
+ selection: {
253
+ mode: "latest-qualified-source-ancestor",
254
+ observedSourceHeadSha,
255
+ skippedNewerCommitCount,
256
+ },
257
+ workflowEvidence,
258
+ requiredWorkflowPaths,
259
+ maxAgeSeconds: options.maxAgeSeconds,
260
+ now: options.now,
261
+ });
262
+ let pullRequest;
263
+ if (decision.eligible && options.createPullRequest && !options.dryRun) {
264
+ await client.ensureImmutableBranch(decision.sourceLockRef, sourceSha);
265
+ pullRequest = await client.ensurePullRequest({
266
+ head: decision.sourceLockRef,
267
+ base: options.targetBranch,
268
+ title: `Promote qualified ${options.sourceBranch} candidate ${sourceSha.slice(0, 12)} to ${options.targetBranch}`,
269
+ body: [
270
+ "Buildchain exact-source channel candidate.",
271
+ "",
272
+ `- Source branch: \`${options.sourceBranch}\``,
273
+ `- Observed source HEAD: \`${observedSourceHeadSha}\``,
274
+ `- Source SHA: \`${sourceSha}\``,
275
+ `- Skipped newer unqualified commits: \`${skippedNewerCommitCount}\``,
276
+ `- Target branch/head: \`${options.targetBranch}\` / \`${targetSha}\``,
277
+ `- Decision root: \`${decision.decisionRoot}\``,
278
+ ...decision.workflowEvidence.map(
279
+ (row) =>
280
+ `- ${row.workflowName}: [run ${row.runId} attempt ${row.runAttempt}](${row.url})`,
281
+ ),
282
+ "",
283
+ "The source-lock branch must continue to point at the exact source SHA. This patrol never merges the PR, publishes a package, creates a tag, or creates a release.",
284
+ ].join("\n"),
285
+ });
286
+ }
287
+ return {
288
+ schema: "kungfu-buildchain-dev-alpha-candidate-patrol/v1",
289
+ dryRun: options.dryRun,
290
+ createPullRequest: options.createPullRequest,
291
+ decision,
292
+ pullRequest: pullRequest || null,
293
+ };
294
+ }
295
+
296
+ function encodeRef(value) {
297
+ return value.split("/").map(encodeURIComponent).join("/");
298
+ }
299
+
300
+ export function createGitHubChannelCandidateClient({
301
+ repository: repositoryInput,
302
+ token,
303
+ fetchImpl = globalThis.fetch,
304
+ }) {
305
+ const [owner, repo] = repository(repositoryInput).split("/");
306
+ const headers = {
307
+ accept: "application/vnd.github+json",
308
+ authorization: token ? `Bearer ${token}` : undefined,
309
+ "user-agent": "buildchain-dev-alpha-candidate-patrol",
310
+ "x-github-api-version": "2022-11-28",
311
+ };
312
+ async function api(
313
+ requestPath,
314
+ { method = "GET", body, allow404 = false } = {},
315
+ ) {
316
+ const response = await fetchImpl(`https://api.github.com${requestPath}`, {
317
+ method,
318
+ headers: Object.fromEntries(
319
+ Object.entries(headers).filter(([, value]) => value),
320
+ ),
321
+ body: body === undefined ? undefined : JSON.stringify(body),
322
+ });
323
+ const raw = await response.text();
324
+ const payload = raw ? JSON.parse(raw) : undefined;
325
+ if (allow404 && response.status === 404) return undefined;
326
+ if (!response.ok)
327
+ throw new Error(
328
+ `GitHub API ${method} ${requestPath} failed with ${response.status}: ${payload?.message || raw}`,
329
+ );
330
+ return payload;
331
+ }
332
+ return {
333
+ async resolveBranch(ref) {
334
+ const payload = await api(
335
+ `/repos/${owner}/${repo}/git/ref/heads/${encodeRef(ref)}`,
336
+ );
337
+ return text(payload.object?.sha);
338
+ },
339
+ async compare(baseSha, headSha) {
340
+ return api(`/repos/${owner}/${repo}/compare/${baseSha}...${headSha}`);
341
+ },
342
+ async listCompletedWorkflowRuns(workflowPathValue, sourceBranch) {
343
+ const runs = [];
344
+ for (let page = 1; page <= 10; page += 1) {
345
+ const payload = await api(
346
+ `/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(workflowPathValue)}/runs?branch=${encodeURIComponent(sourceBranch)}&status=completed&per_page=100&page=${page}`,
347
+ );
348
+ const rows = payload.workflow_runs || [];
349
+ runs.push(...rows);
350
+ if (rows.length < 100) return runs;
351
+ }
352
+ throw new Error(
353
+ `${workflowPathValue} completed workflow history exceeds 1000 runs`,
354
+ );
355
+ },
356
+ async listBranchHistory(sourceBranch, targetSha) {
357
+ const commits = [];
358
+ for (let page = 1; page <= 10; page += 1) {
359
+ const rows = await api(
360
+ `/repos/${owner}/${repo}/commits?sha=${encodeURIComponent(sourceBranch)}&per_page=100&page=${page}`,
361
+ );
362
+ for (const commit of rows) {
363
+ const commitSha = text(commit.sha);
364
+ if (commitSha === targetSha) return commits;
365
+ commits.push(commitSha);
366
+ }
367
+ if (rows.length < 100) return commits;
368
+ }
369
+ return commits;
370
+ },
371
+ async ensureImmutableBranch(ref, sourceSha) {
372
+ const current = await api(
373
+ `/repos/${owner}/${repo}/git/ref/heads/${encodeRef(ref)}`,
374
+ { allow404: true },
375
+ );
376
+ if (current && current.object?.sha !== sourceSha) {
377
+ throw new Error(
378
+ `source-lock branch ${ref} points to ${current.object?.sha}, not ${sourceSha}`,
379
+ );
380
+ }
381
+ if (current) return current;
382
+ return api(`/repos/${owner}/${repo}/git/refs`, {
383
+ method: "POST",
384
+ body: { ref: `refs/heads/${ref}`, sha: sourceSha },
385
+ });
386
+ },
387
+ async ensurePullRequest({ head, base, title, body }) {
388
+ const open = await api(
389
+ `/repos/${owner}/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${head}`)}&base=${encodeURIComponent(base)}&per_page=20`,
390
+ );
391
+ return (
392
+ open[0] ||
393
+ api(`/repos/${owner}/${repo}/pulls`, {
394
+ method: "POST",
395
+ body: { head, base, title, body },
396
+ })
397
+ );
398
+ },
399
+ };
400
+ }
401
+
402
+ function markdown(result) {
403
+ return [
404
+ "## Buildchain Dev to Alpha candidate patrol",
405
+ "",
406
+ `Eligible: \`${result.decision.eligible}\` (${result.decision.reason})`,
407
+ `Source: \`${result.decision.source.branch}@${result.decision.source.sha}\``,
408
+ `Target: \`${result.decision.target.branch}@${result.decision.target.sha}\``,
409
+ `Dry run: \`${result.dryRun}\``,
410
+ `Pull request: ${result.pullRequest?.html_url || "not created"}`,
411
+ "",
412
+ ].join("\n");
413
+ }
414
+
415
+ async function main() {
416
+ const options = normalizeDevAlphaPatrolOptions();
417
+ const result = await runDevAlphaCandidatePatrol(options);
418
+ fs.mkdirSync(path.dirname(options.outputPath), { recursive: true });
419
+ fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`);
420
+ const summary = markdown(result);
421
+ if (process.env.GITHUB_STEP_SUMMARY)
422
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
423
+ else process.stdout.write(summary);
424
+ if (process.env.GITHUB_OUTPUT) {
425
+ const outputs = {
426
+ "result-path": options.outputPath,
427
+ eligible: String(result.decision.eligible),
428
+ "selected-sha": result.decision.source.sha,
429
+ "source-lock-ref": result.decision.sourceLockRef || "",
430
+ "promotion-pr": result.pullRequest?.html_url || "",
431
+ };
432
+ fs.appendFileSync(
433
+ process.env.GITHUB_OUTPUT,
434
+ `${Object.entries(outputs)
435
+ .map(([key, value]) => `${key}=${value}`)
436
+ .join("\n")}\n`,
437
+ );
438
+ }
439
+ }
440
+
441
+ if (import.meta.url === `file://${process.argv[1]}`) {
442
+ main().catch((error) => {
443
+ console.error(error.stack || error.message);
444
+ process.exit(1);
445
+ });
446
+ }
@@ -337,6 +337,7 @@ const manualMetaById = new Map(Object.entries({
337
337
  "release-activation-transaction": { capabilityGroup: "release-passport-trust", audience: ["release-operator", "agent"], maturity: "preview", order: 125 },
338
338
  "release-candidate": { capabilityGroup: "reusable-build", audience: ["release-operator", "consumer"], maturity: "stable", order: 130 },
339
339
  "stable-candidate-patrol": { capabilityGroup: "governance-versioning", audience: ["release-operator", "consumer"], maturity: "preview", order: 135 },
340
+ "dev-alpha-candidate-patrol": { capabilityGroup: "governance-versioning", audience: ["release-operator", "consumer", "agent"], maturity: "preview", order: 137 },
340
341
  "observed-evidence-patrol": { capabilityGroup: "governance-versioning", audience: ["release-operator", "consumer", "agent"], maturity: "preview", order: 140 },
341
342
  "reusable-build-surface": { capabilityGroup: "reusable-build", audience: ["consumer", "release-operator"], maturity: "stable", order: 200 },
342
343
  "lifecycle-protocol": { capabilityGroup: "reusable-build", audience: ["consumer", "developer"], maturity: "stable", order: 210 },
@@ -516,6 +517,7 @@ function nodeApiMeta(exportName) {
516
517
  "./homebrew": { group: "distribution-indexes", summary: "Homebrew tap fact collection, Formula rendering, update, and check APIs." },
517
518
  "./build-facts": { group: "observability-diagnostics", summary: "Git source, version, module output, product artifact, and legacy Kungfu build fact APIs." },
518
519
  "./candidate-timeline": { group: "observability-diagnostics", summary: "Source-bound candidate event normalization, per-attempt critical-path-safe aggregation, and compact reporting APIs." },
520
+ "./channel-candidate": { group: "governance-versioning", summary: "Exact-source channel candidate decisions, same-SHA workflow evidence validation, and deterministic source-lock reference APIs." },
519
521
  "./cache-evidence": { group: "observability-diagnostics", summary: "Content-addressed cache operation receipts and source/platform-bound evidence-set verification APIs." },
520
522
  "./diagnostics": { group: "observability-diagnostics", summary: "Native diagnostics collection, summarization, cache, compiler, and process-sampler APIs." },
521
523
  "./logging": { group: "observability-diagnostics", summary: "Buildchain JSONL logging, span, summary, and verification APIs." },
@@ -814,6 +816,7 @@ function buildSiteBundle() {
814
816
  "docs/reusable-build-surface.md",
815
817
  "docs/release-candidate.md",
816
818
  "docs/stable-candidate-patrol.md",
819
+ "docs/dev-alpha-candidate-patrol.md",
817
820
  "docs/observed-evidence-patrol.md",
818
821
  "docs/release-governance.md",
819
822
  "docs/release-passport.md",
@@ -900,6 +903,7 @@ function buildSiteBundle() {
900
903
  ["buildchain-patrol-weekly", "repository-patrol"],
901
904
  ["buildchain-patrol-monthly", "repository-patrol"],
902
905
  ["stable-candidate-patrol", "repository-patrol"],
906
+ ["dev-alpha-candidate-patrol", "repository-patrol"],
903
907
  ["buildchain-stable-candidate-patrol", "repository-patrol"],
904
908
  ["buildchain-stable-candidate-qualification", "repository-patrol"],
905
909
  ["patrol-daily", "repository-patrol"],