@kungfu-tech/buildchain 2.14.1 → 2.14.2-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.
@@ -158,6 +158,8 @@ export function planReleaseLineBootstrap({
158
158
  const alphaRef = `alpha/v${parsedMajor}/${line}`;
159
159
  const releaseRef = `release/v${parsedMajor}/${line}`;
160
160
  const loadedConfig = loadBuildchainConfig(cwd);
161
+ const declaredMergeQueue = loadedConfig?.config?.governance?.dev?.mergeQueue;
162
+ const mergeQueueMode = declaredMergeQueue?.mode || "inherit";
161
163
  const versionFiles = discoverVersionStateFiles(cwd, loadedConfig);
162
164
  const lifecycleVersionState =
163
165
  getLifecycleStage(loadedConfig, "version-state") ||
@@ -201,22 +203,33 @@ export function planReleaseLineBootstrap({
201
203
  enforceAdmins: true,
202
204
  protectedRefs: [devRef, alphaRef, releaseRef],
203
205
  },
206
+ governance: {
207
+ mergeQueue: {
208
+ declared: Boolean(declaredMergeQueue),
209
+ mode: mergeQueueMode,
210
+ source: mergeQueueMode === "inherit" ? "repository-default-branch" : "buildchain-config",
211
+ requiredWorkflows: declaredMergeQueue?.requiredWorkflows || [],
212
+ reconcileBeforeDefaultBranchSwitch: true,
213
+ },
214
+ },
204
215
  repositoryActions: [
205
216
  { action: "create-or-verify-source-ref", ref: source },
206
217
  { action: "commit-initial-version-state", ref: branch, version: resolvedInitialVersion },
207
218
  { action: "create-dev-branch", ref: devRef, from: branch },
208
219
  { action: "create-alpha-branch", ref: alphaRef, from: source },
209
220
  { action: "create-release-branch", ref: releaseRef, from: source },
210
- ...(setDefault ? [{ action: "set-default-branch", ref: devRef }] : []),
211
221
  { action: "protect-branch", ref: devRef },
212
222
  { action: "protect-branch", ref: alphaRef },
213
223
  { action: "protect-branch", ref: releaseRef },
224
+ { action: "reconcile-dev-merge-queue", ref: devRef, mode: mergeQueueMode },
225
+ ...(setDefault ? [{ action: "set-default-branch", ref: devRef }] : []),
214
226
  ...(createAlphaPr ? [{ action: "open-alpha-pr", head: devRef, base: alphaRef }] : []),
215
227
  ],
216
228
  notes: [
217
229
  "This bootstrap creates the new minor line before the first alpha promotion.",
218
230
  "The source ref stays as the baseline for alpha/release until reviewed channel PRs move them.",
219
231
  "The dev branch receives only the initial version-state commit and becomes the active line when setDefault is true.",
232
+ "Merge queue governance is reconciled after branch protection and before the repository default branch moves.",
220
233
  ],
221
234
  };
222
235
  }
@@ -91,6 +91,7 @@ export function normalizePatrolOptions(options = {}) {
91
91
  sameRepositoryOnly: options.sameRepositoryOnly ?? process.env.BUILDCHAIN_PATROL_SAME_REPOSITORY_ONLY,
92
92
  maxActions: intOption(options.maxActions ?? process.env.BUILDCHAIN_PATROL_MAX_ACTIONS, 1),
93
93
  mergeMethod: String(options.mergeMethod || process.env.BUILDCHAIN_PATROL_MERGE_METHOD || "merge").trim(),
94
+ landingMode: String(options.landingMode || process.env.BUILDCHAIN_PATROL_LANDING_MODE || "auto").trim(),
94
95
  dryRun: boolOption(options.dryRun ?? process.env.BUILDCHAIN_PATROL_DRY_RUN, true),
95
96
  outputPath: String(options.outputPath || process.env.BUILDCHAIN_PATROL_OUTPUT_PATH || DEFAULT_OUTPUT_PATH),
96
97
  };
@@ -167,6 +168,7 @@ export async function runBuildchainPatrol(optionsInput = {}, clientInput) {
167
168
  sameRepositoryOnly: options.sameRepositoryOnly,
168
169
  maxMerges: options.maxActions,
169
170
  mergeMethod: options.mergeMethod,
171
+ landingMode: options.landingMode,
170
172
  dryRun: options.dryRun,
171
173
  outputPath: path.join(path.dirname(options.outputPath), "dev-pr-auto-merge.json"),
172
174
  },
@@ -178,7 +180,7 @@ export async function runBuildchainPatrol(optionsInput = {}, clientInput) {
178
180
  result: mergeResult,
179
181
  });
180
182
  result.summary.evaluatedCount += mergeResult.evaluated.length;
181
- result.summary.actionCount += mergeResult.merged.length;
183
+ result.summary.actionCount += mergeResult.actions.length;
182
184
  result.summary.skippedCount += mergeResult.skipped.length;
183
185
  }
184
186
 
@@ -2,8 +2,10 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { spawnSync } from "node:child_process";
5
+ import { loadBuildchainConfig } from "../packages/core/buildchain-config.js";
5
6
 
6
7
  const RULESET_PREFIX = "Buildchain dev merge queue";
8
+ const RULESET_BYPASS_TYPES = new Set(["Integration", "Team", "User"]);
7
9
 
8
10
  function positiveInteger(value, label, fallback) {
9
11
  const parsed = Number(value || fallback);
@@ -19,6 +21,53 @@ function requiredString(value, label) {
19
21
  return normalized;
20
22
  }
21
23
 
24
+ function normalizeRulesetBypassActors(actors = []) {
25
+ const normalized = [];
26
+ const seen = new Set();
27
+ for (const actor of actors) {
28
+ const actorType = requiredString(actor?.actor_type, "ruleset bypass actor type");
29
+ const actorId = Number(actor?.actor_id);
30
+ if (!RULESET_BYPASS_TYPES.has(actorType)) {
31
+ throw new Error(`ruleset bypass actor type must be Integration, Team, or User, got '${actorType}'`);
32
+ }
33
+ if (!Number.isInteger(actorId) || actorId < 1) {
34
+ throw new Error("ruleset bypass actor id must be a positive integer");
35
+ }
36
+ const key = `${actorType}:${actorId}`;
37
+ if (seen.has(key)) continue;
38
+ seen.add(key);
39
+ normalized.push({ actor_id: actorId, actor_type: actorType, bypass_mode: "always" });
40
+ }
41
+ return normalized;
42
+ }
43
+
44
+ export async function resolveRulesetBypassActors({
45
+ api,
46
+ repository,
47
+ apps = [],
48
+ users = [],
49
+ teams = [],
50
+ } = {}) {
51
+ const [owner] = requiredString(repository, "repository").split("/");
52
+ const actors = [];
53
+ for (const slug of apps) {
54
+ const app = await api.request("GET", `apps/${encodeURIComponent(requiredString(slug, "bypass app"))}`);
55
+ actors.push({ actor_id: app.id, actor_type: "Integration" });
56
+ }
57
+ for (const login of users) {
58
+ const user = await api.request("GET", `users/${encodeURIComponent(requiredString(login, "bypass user"))}`);
59
+ actors.push({ actor_id: user.id, actor_type: "User" });
60
+ }
61
+ for (const slug of teams) {
62
+ const team = await api.request(
63
+ "GET",
64
+ `orgs/${encodeURIComponent(owner)}/teams/${encodeURIComponent(requiredString(slug, "bypass team"))}`,
65
+ );
66
+ actors.push({ actor_id: team.id, actor_type: "Team" });
67
+ }
68
+ return normalizeRulesetBypassActors(actors);
69
+ }
70
+
22
71
  export function selectMergeQueueMethod(repositorySettings = {}) {
23
72
  const candidates = [
24
73
  ["MERGE", repositorySettings.allow_merge_commit],
@@ -32,6 +81,19 @@ export function selectMergeQueueMethod(repositorySettings = {}) {
32
81
  return selected;
33
82
  }
34
83
 
84
+ function assertMergeQueueMethod(method, repositorySettings = {}) {
85
+ const normalized = requiredString(method, "merge queue method").toUpperCase();
86
+ const allowed = {
87
+ MERGE: repositorySettings.allow_merge_commit === true,
88
+ SQUASH: repositorySettings.allow_squash_merge === true,
89
+ REBASE: repositorySettings.allow_rebase_merge === true,
90
+ };
91
+ if (!(normalized in allowed) || !allowed[normalized]) {
92
+ throw new Error(`inherited merge queue method '${normalized}' is not enabled by the repository`);
93
+ }
94
+ return normalized;
95
+ }
96
+
35
97
  export function validateMergeGroupWorkflows(workflows = []) {
36
98
  if (workflows.length === 0) {
37
99
  throw new Error("at least one required workflow must be declared with --workflow");
@@ -64,16 +126,23 @@ export function createDevMergeQueuePlan({
64
126
  protection,
65
127
  repositorySettings,
66
128
  rulesets = [],
129
+ bypassActors = [],
67
130
  checkResponseTimeoutMinutes = 120,
68
131
  maxEntriesToBuild = 1,
132
+ queueParameters = {},
133
+ allowInheritedWorkflowEvidence = false,
69
134
  } = {}) {
70
135
  const normalizedRepository = requiredString(repository, "repository");
71
136
  const normalizedBranch = requiredString(branch, "branch");
72
137
  if (!/^dev\/v\d+\/v\d+\.\d+$/.test(normalizedBranch)) {
73
138
  throw new Error(`branch must be a Buildchain dev channel, got '${normalizedBranch}'`);
74
139
  }
75
- const workflowChecks = validateMergeGroupWorkflows(workflows);
76
- const mergeMethod = selectMergeQueueMethod(repositorySettings);
140
+ const workflowChecks = workflows.length === 0 && allowInheritedWorkflowEvidence
141
+ ? []
142
+ : validateMergeGroupWorkflows(workflows);
143
+ const mergeMethod = queueParameters.merge_method
144
+ ? assertMergeQueueMethod(queueParameters.merge_method, repositorySettings)
145
+ : selectMergeQueueMethod(repositorySettings);
77
146
  const requiredChecks = protection?.required_status_checks?.checks || [];
78
147
  if (requiredChecks.length === 0) {
79
148
  throw new Error(`protected branch ${normalizedBranch} must declare required status checks before merge queue enablement`);
@@ -84,6 +153,7 @@ export function createDevMergeQueuePlan({
84
153
  name: rulesetName,
85
154
  target: "branch",
86
155
  enforcement: "active",
156
+ bypass_actors: normalizeRulesetBypassActors(bypassActors),
87
157
  conditions: {
88
158
  ref_name: {
89
159
  include: [`refs/heads/${normalizedBranch}`],
@@ -93,13 +163,23 @@ export function createDevMergeQueuePlan({
93
163
  rules: [{
94
164
  type: "merge_queue",
95
165
  parameters: {
96
- check_response_timeout_minutes: positiveInteger(checkResponseTimeoutMinutes, "check response timeout", 120),
97
- grouping_strategy: "ALLGREEN",
98
- max_entries_to_build: positiveInteger(maxEntriesToBuild, "max entries to build", 1),
99
- max_entries_to_merge: 1,
166
+ check_response_timeout_minutes: positiveInteger(
167
+ queueParameters.check_response_timeout_minutes || checkResponseTimeoutMinutes,
168
+ "check response timeout",
169
+ 120,
170
+ ),
171
+ grouping_strategy: queueParameters.grouping_strategy || "ALLGREEN",
172
+ max_entries_to_build: positiveInteger(
173
+ queueParameters.max_entries_to_build || maxEntriesToBuild,
174
+ "max entries to build",
175
+ 1,
176
+ ),
177
+ max_entries_to_merge: positiveInteger(queueParameters.max_entries_to_merge, "max entries to merge", 1),
100
178
  merge_method: mergeMethod,
101
- min_entries_to_merge: 1,
102
- min_entries_to_merge_wait_minutes: 0,
179
+ min_entries_to_merge: positiveInteger(queueParameters.min_entries_to_merge, "min entries to merge", 1),
180
+ min_entries_to_merge_wait_minutes: Number.isInteger(queueParameters.min_entries_to_merge_wait_minutes)
181
+ ? queueParameters.min_entries_to_merge_wait_minutes
182
+ : 0,
103
183
  },
104
184
  }],
105
185
  };
@@ -110,6 +190,7 @@ export function createDevMergeQueuePlan({
110
190
  branch: normalizedBranch,
111
191
  ok: true,
112
192
  workflowChecks,
193
+ workflowEvidence: workflowChecks.length > 0 ? "declared-workflows" : "inherited-active-ruleset",
113
194
  before: {
114
195
  strict: protection.required_status_checks?.strict === true,
115
196
  requiredStatusChecks: requiredChecks.map((check) => check.context),
@@ -144,13 +225,28 @@ export async function reconcileDevMergeQueue({
144
225
  branch,
145
226
  workflows,
146
227
  apply = false,
228
+ bypassApps = [],
229
+ bypassUsers = [],
230
+ bypassTeams = [],
147
231
  checkResponseTimeoutMinutes = 120,
148
232
  maxEntriesToBuild = 1,
233
+ bypassActors,
234
+ queueParameters = {},
235
+ allowInheritedWorkflowEvidence = false,
149
236
  } = {}) {
150
237
  const encodedBranch = encodeURIComponent(requiredString(branch, "branch"));
151
238
  const repositorySettings = await api.request("GET", `repos/${repository}`);
152
239
  const protection = await api.request("GET", `repos/${repository}/branches/${encodedBranch}/protection`);
153
240
  const rulesets = await api.request("GET", `repos/${repository}/rulesets?includes_parents=false&per_page=100`);
241
+ const resolvedBypassActors = bypassActors === undefined
242
+ ? await resolveRulesetBypassActors({
243
+ api,
244
+ repository,
245
+ apps: bypassApps,
246
+ users: bypassUsers,
247
+ teams: bypassTeams,
248
+ })
249
+ : normalizeRulesetBypassActors(bypassActors);
154
250
  const plan = createDevMergeQueuePlan({
155
251
  repository,
156
252
  branch,
@@ -158,8 +254,11 @@ export async function reconcileDevMergeQueue({
158
254
  protection,
159
255
  repositorySettings,
160
256
  rulesets,
257
+ bypassActors: resolvedBypassActors,
161
258
  checkResponseTimeoutMinutes,
162
259
  maxEntriesToBuild,
260
+ queueParameters,
261
+ allowInheritedWorkflowEvidence,
163
262
  });
164
263
  if (!apply) return { ...plan, action: "planned", applied: false };
165
264
  const results = [];
@@ -177,6 +276,172 @@ export async function reconcileDevMergeQueue({
177
276
  };
178
277
  }
179
278
 
279
+ async function loadDetailedRulesets(api, repository) {
280
+ const rulesets = await api.request("GET", `repos/${repository}/rulesets?includes_parents=false&per_page=100`);
281
+ const details = [];
282
+ for (const ruleset of Array.isArray(rulesets) ? rulesets : []) {
283
+ if (ruleset?.target !== "branch" || ruleset?.enforcement === "disabled") continue;
284
+ if (Array.isArray(ruleset.rules) && ruleset.conditions) {
285
+ details.push(ruleset);
286
+ continue;
287
+ }
288
+ details.push(await api.request("GET", `repos/${repository}/rulesets/${ruleset.id}`));
289
+ }
290
+ return details;
291
+ }
292
+
293
+ function findExactMergeQueueRuleset(rulesets, branch) {
294
+ const include = `refs/heads/${branch}`;
295
+ return rulesets.find((ruleset) =>
296
+ ruleset?.enforcement === "active" &&
297
+ ruleset?.conditions?.ref_name?.include?.length === 1 &&
298
+ ruleset.conditions.ref_name.include[0] === include &&
299
+ ruleset?.rules?.some((rule) => rule?.type === "merge_queue"),
300
+ );
301
+ }
302
+
303
+ function mergeQueueRuleParameters(ruleset) {
304
+ return ruleset?.rules?.find((rule) => rule?.type === "merge_queue")?.parameters || {};
305
+ }
306
+
307
+ export async function resolveConfiguredDevMergeQueuePolicy({
308
+ api,
309
+ repository,
310
+ branch,
311
+ cwd = process.cwd(),
312
+ } = {}) {
313
+ const loadedConfig = loadBuildchainConfig(cwd);
314
+ const declared = loadedConfig?.config?.governance?.dev?.mergeQueue;
315
+ const policy = declared || {
316
+ mode: "inherit",
317
+ requiredWorkflows: [],
318
+ checkResponseTimeoutMinutes: 120,
319
+ maxEntriesToBuild: 1,
320
+ bypassApps: [],
321
+ bypassUsers: [],
322
+ bypassTeams: [],
323
+ };
324
+ if (policy.mode === "disabled") {
325
+ return {
326
+ mode: "disabled",
327
+ declared: Boolean(declared),
328
+ enabled: false,
329
+ reason: "merge queue policy is explicitly disabled",
330
+ sourceBranch: null,
331
+ sourceRulesetId: null,
332
+ policy,
333
+ };
334
+ }
335
+
336
+ if (policy.mode === "enabled") {
337
+ return {
338
+ mode: "enabled",
339
+ declared: true,
340
+ enabled: true,
341
+ reason: "merge queue policy is explicitly enabled",
342
+ sourceBranch: null,
343
+ sourceRulesetId: null,
344
+ policy,
345
+ queueParameters: {},
346
+ inheritedBypassActors: undefined,
347
+ };
348
+ }
349
+
350
+ const repositorySettings = await api.request("GET", `repos/${repository}`);
351
+ const sourceBranch = repositorySettings.default_branch;
352
+ if (!sourceBranch || sourceBranch === branch) {
353
+ const targetRuleset = findExactMergeQueueRuleset(await loadDetailedRulesets(api, repository), branch);
354
+ if (!targetRuleset) {
355
+ return {
356
+ mode: "inherit",
357
+ declared: Boolean(declared),
358
+ enabled: false,
359
+ reason: "the active dev branch has no exact merge queue ruleset",
360
+ sourceBranch: sourceBranch || null,
361
+ sourceRulesetId: null,
362
+ policy,
363
+ };
364
+ }
365
+ return {
366
+ mode: "inherit",
367
+ declared: Boolean(declared),
368
+ enabled: true,
369
+ reason: "the active dev branch merge queue policy is inherited",
370
+ sourceBranch: branch,
371
+ sourceRulesetId: targetRuleset.id,
372
+ policy,
373
+ queueParameters: mergeQueueRuleParameters(targetRuleset),
374
+ inheritedBypassActors: targetRuleset.bypass_actors || [],
375
+ };
376
+ }
377
+ if (!/^dev\/v\d+\/v\d+\.\d+$/.test(sourceBranch)) {
378
+ return {
379
+ mode: "inherit",
380
+ declared: Boolean(declared),
381
+ enabled: false,
382
+ reason: `repository default branch '${sourceBranch}' is not a Buildchain dev channel`,
383
+ sourceBranch,
384
+ sourceRulesetId: null,
385
+ policy,
386
+ };
387
+ }
388
+ const sourceRuleset = findExactMergeQueueRuleset(await loadDetailedRulesets(api, repository), sourceBranch);
389
+ return {
390
+ mode: "inherit",
391
+ declared: Boolean(declared),
392
+ enabled: Boolean(sourceRuleset),
393
+ reason: sourceRuleset
394
+ ? "the active dev branch merge queue policy is inherited"
395
+ : "the active dev branch has no exact merge queue ruleset",
396
+ sourceBranch,
397
+ sourceRulesetId: sourceRuleset?.id || null,
398
+ policy,
399
+ queueParameters: mergeQueueRuleParameters(sourceRuleset),
400
+ inheritedBypassActors: sourceRuleset?.bypass_actors || [],
401
+ };
402
+ }
403
+
404
+ export async function reconcileConfiguredDevMergeQueue({
405
+ api,
406
+ repository,
407
+ branch,
408
+ cwd = process.cwd(),
409
+ apply = false,
410
+ } = {}) {
411
+ const resolution = await resolveConfiguredDevMergeQueuePolicy({ api, repository, branch, cwd });
412
+ if (!resolution.enabled) {
413
+ return {
414
+ schemaVersion: 1,
415
+ contract: "kungfu-buildchain-dev-merge-queue-policy",
416
+ repository,
417
+ branch,
418
+ ok: true,
419
+ action: "not-required",
420
+ applied: false,
421
+ policyResolution: resolution,
422
+ operations: [],
423
+ };
424
+ }
425
+ const workflows = loadWorkflowSources(cwd, resolution.policy.requiredWorkflows);
426
+ const result = await reconcileDevMergeQueue({
427
+ api,
428
+ repository,
429
+ branch,
430
+ workflows,
431
+ apply,
432
+ bypassApps: resolution.policy.bypassApps,
433
+ bypassUsers: resolution.policy.bypassUsers,
434
+ bypassTeams: resolution.policy.bypassTeams,
435
+ bypassActors: resolution.mode === "inherit" ? resolution.inheritedBypassActors : undefined,
436
+ checkResponseTimeoutMinutes: resolution.policy.checkResponseTimeoutMinutes,
437
+ maxEntriesToBuild: resolution.policy.maxEntriesToBuild,
438
+ queueParameters: resolution.mode === "inherit" ? resolution.queueParameters : {},
439
+ allowInheritedWorkflowEvidence:
440
+ resolution.mode === "inherit" && resolution.policy.requiredWorkflows.length === 0,
441
+ });
442
+ return { ...result, policyResolution: resolution };
443
+ }
444
+
180
445
  function createGhApi() {
181
446
  return {
182
447
  request(method, endpoint, body) {
@@ -236,6 +501,17 @@ async function main(args = process.argv.slice(2)) {
236
501
  const cwd = path.resolve(readFlag(args, "cwd", process.cwd()));
237
502
  const repository = readFlag(args, "repository", process.env.GITHUB_REPOSITORY || "");
238
503
  const branch = readFlag(args, "branch", "");
504
+ if (args.includes("--from-config")) {
505
+ const result = await reconcileConfiguredDevMergeQueue({
506
+ api: createGhApi(),
507
+ repository,
508
+ branch,
509
+ cwd,
510
+ apply: args.includes("--apply"),
511
+ });
512
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
513
+ return;
514
+ }
239
515
  const workflows = loadWorkflowSources(cwd, readRepeatedFlag(args, "workflow"));
240
516
  const result = await reconcileDevMergeQueue({
241
517
  api: createGhApi(),
@@ -245,6 +521,9 @@ async function main(args = process.argv.slice(2)) {
245
521
  apply: args.includes("--apply"),
246
522
  checkResponseTimeoutMinutes: readFlag(args, "check-response-timeout-minutes", "120"),
247
523
  maxEntriesToBuild: readFlag(args, "max-entries-to-build", "1"),
524
+ bypassApps: readRepeatedFlag(args, "bypass-app"),
525
+ bypassUsers: readRepeatedFlag(args, "bypass-user"),
526
+ bypassTeams: readRepeatedFlag(args, "bypass-team"),
248
527
  });
249
528
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
250
529
  }