@kungfu-tech/buildchain 2.4.12-alpha.0 → 2.5.0-alpha.0

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/CONTRIBUTING.md CHANGED
@@ -103,6 +103,15 @@ release/vX/vX.Y -> publish-gate/major
103
103
  ```
104
104
 
105
105
  - Open normal changes against the relevant `dev/*` branch.
106
+ - Treat `dev/vX/vX.Y` branches as protected development channels. Do not use
107
+ them for direct ad-hoc commits. Work from `feature/*`, `fix/*`, `chore/*`,
108
+ `docs/*`, `ci/*`, or `refactor/*` branches and open a PR into the target dev
109
+ line.
110
+ - Repositories may call
111
+ `.github/workflows/dev-pr-auto-merge.yml` from their own scheduled or manual
112
+ wrapper to merge ready, conflict-free dev PRs. The wrapper is policy-gated:
113
+ required checks, ready/block labels, same-repository heads, approvals,
114
+ branch prefixes, max merges, and dry-run are all declared inputs.
106
115
  - When a Buildchain change needs downstream validation before stable refs move,
107
116
  publish a temporary runtime train ref such as
108
117
  `train/v2/v2.3/<capability>` and ask consumers to run trusted
@@ -141,6 +141,9 @@
141
141
  "release/vX/vX.Y",
142
142
  "publish-gate/major"
143
143
  ],
144
+ "protectedDevelopmentBranches": [
145
+ "dev/vX/vX.Y"
146
+ ],
144
147
  "releasePassport": {
145
148
  "entrypoint": "buildchain.release.json",
146
149
  "bundle": "buildchain-release-bundle.tar.gz",
@@ -9,6 +9,9 @@
9
9
  "release/vX/vX.Y",
10
10
  "publish-gate/major"
11
11
  ],
12
+ "protectedDevelopmentBranches": [
13
+ "dev/vX/vX.Y"
14
+ ],
12
15
  "releasePassport": {
13
16
  "entrypoint": "buildchain.release.json",
14
17
  "bundle": "buildchain-release-bundle.tar.gz",
@@ -20,6 +20,12 @@
20
20
  "surface": "release-governance",
21
21
  "status": "active"
22
22
  },
23
+ {
24
+ "id": "dev-pr-auto-merge",
25
+ "path": ".github/workflows/dev-pr-auto-merge.yml",
26
+ "surface": "dev-governance",
27
+ "status": "active"
28
+ },
23
29
  {
24
30
  "id": "binary-distribution",
25
31
  "path": ".github/workflows/binary-distribution.yml",
package/docs/MAP.md CHANGED
@@ -24,6 +24,7 @@ running artifact), *use* (consume / extend) - and a **status**:
24
24
  | How do I import Buildchain toolkit APIs from JavaScript build code? | [`toolkit-observability.md`](toolkit-observability.md) + [`../packages/core/README.md`](../packages/core/README.md) | use | stable |
25
25
  | How do I initialize a new repository? | [`cli.md`](cli.md) + [`lifecycle-protocol.md`](lifecycle-protocol.md) | use | stable |
26
26
  | Why does Buildchain use branch-driven release governance? | [`release-governance.md`](release-governance.md) | why | stable |
27
+ | How do protected dev branches and scheduled ready-PR merging work? | [`release-governance.md`](release-governance.md#protected-dev-branches) | use | stable |
27
28
  | How does Buildchain decide patch, minor, and major release lines? | [`versioning.md`](versioning.md) | why | stable |
28
29
  | What exact branch/tag state machine runs on alpha, release, and major gate? | [`release-flow.md`](release-flow.md) | verify | stable |
29
30
  | What did Buildchain migrate or retire from old action repositories? | [`migration-inventory.md`](migration-inventory.md) | verify | stable |
@@ -57,6 +58,8 @@ running artifact), *use* (consume / extend) - and a **status**:
57
58
  [`versioning.md`](versioning.md).
58
59
  - **dry-run / what would happen if this channel PR merges** -> [`cli.md`](cli.md)
59
60
  and [`release-flow.md`](release-flow.md).
61
+ - **protected dev branches / scheduled ready-PR merge** ->
62
+ [`release-governance.md`](release-governance.md#protected-dev-branches).
60
63
  - **pnpm / npm / yarn / package-manager adapters** ->
61
64
  [`lifecycle-protocol.md`](lifecycle-protocol.md).
62
65
  - **pip / Conan / CMake / custom commands** -> [`lifecycle-protocol.md`](lifecycle-protocol.md)
@@ -21,13 +21,14 @@ The active Buildchain-native reusable surface is:
21
21
  | --- | --- |
22
22
  | `.github/workflows/.build.yml` | active reusable build contract: runner presets, trusted event gate, publish source lock, lifecycle commands, deterministic artifacts, aggregate summary, and release manifest outputs |
23
23
 
24
- Other hidden reusable workflow files from the old `workflows` repository may
25
- still exist in `.github/workflows`. They are retained so the repository can lint
26
- them, document migration boundaries, and make retired paths fail closed. They
27
- are not new Buildchain v2 integration targets unless a dedicated document says
28
- otherwise. In particular, the legacy `.release-new-version.yml` path is not the
29
- modern publish surface; new publish integrations should use `.build.yml`,
30
- `buildchain.toml`, `lifecycle.publish`, and publish transaction evidence.
24
+ Hidden reusable workflow files from the old `workflows` repository are retained
25
+ only when they still have an active compatibility or migration boundary. Retired
26
+ PR orchestration paths are not kept in `.github/workflows`; they remain listed
27
+ in the inventory as excluded legacy surfaces. In particular,
28
+ `.batch-pull-request.yml` is removed with the retired batch PR action family,
29
+ and the legacy `.release-new-version.yml` path is not the modern publish
30
+ surface. New publish integrations should use `.build.yml`, `buildchain.toml`,
31
+ `lifecycle.publish`, and publish transaction evidence.
31
32
 
32
33
  ## Migrated Actions
33
34
 
@@ -64,6 +65,15 @@ because the action is part of that retired path.
64
65
  | `action-sync-pr` | retired PR synchronization helper; not part of the Buildchain reusable contract |
65
66
  | `action-update-dependencies-version` | retired dependency-version helper; use package-manager adapters and lifecycle commands |
66
67
 
68
+ ## Retired Workflows Excluded From v2
69
+
70
+ These legacy workflow entrypoints are intentionally not shipped from the root
71
+ `.github/workflows` directory.
72
+
73
+ | Previous workflow | Reason |
74
+ | --- | --- |
75
+ | `.batch-pull-request.yml` | retired PR orchestration helper; v2.5 dev integration governance will use a new protected-dev PR protocol instead |
76
+
67
77
  ## Buildchain-Native Actions
68
78
 
69
79
  These actions are new Buildchain v2 surfaces rather than migrations from an
@@ -219,6 +219,68 @@ Checking out `publish-gate/major` should therefore look like a frozen release
219
219
  state, not like a branch where day-to-day source work continues. Day-to-day
220
220
  source work continues on `dev/vX/vX.Y`.
221
221
 
222
+ ## Protected Dev Branches
223
+
224
+ `dev/vX/vX.Y` is a protected development channel, not a scratch branch. Normal
225
+ source changes should be made on work branches such as `feature/*`, `fix/*`,
226
+ `chore/*`, `docs/*`, `ci/*`, or `refactor/*`, then reviewed through a pull
227
+ request into the target dev line.
228
+
229
+ This keeps the earliest development channel audit-friendly:
230
+
231
+ - the version line being changed is visible in the PR base branch;
232
+ - CI and required checks run before the channel moves;
233
+ - branch protection can prevent direct pushes and stale merges;
234
+ - later `dev -> alpha -> release` promotion inherits a reviewable source
235
+ lineage instead of trying to reconstruct how the dev branch changed.
236
+
237
+ Buildchain provides the reusable
238
+ `.github/workflows/dev-pr-auto-merge.yml` workflow for repositories that want a
239
+ scheduled or manual "merge ready dev PRs" pass. The consumer repository owns
240
+ the trigger schedule, but the merge decision is declared through workflow
241
+ inputs: target dev branch, required status/check names, ready and block labels,
242
+ allowed work-branch prefixes, review requirements, maximum merges per run,
243
+ merge method, and dry-run mode.
244
+
245
+ The workflow defaults are conservative. A PR is skipped unless it targets the
246
+ configured dev line, is not a draft, has the ready label, has no block label,
247
+ comes from the same repository, uses an allowed work-branch prefix, has a
248
+ current approval, is mergeable, and has the configured required checks passing.
249
+ After each merge, the next PR is re-evaluated before it can move the protected
250
+ dev branch. This prevents one merge from silently making the next candidate
251
+ stale or conflicting.
252
+
253
+ Typical consumer wrapper:
254
+
255
+ ```yaml
256
+ name: Merge Ready Dev PRs
257
+
258
+ on:
259
+ schedule:
260
+ - cron: "17 * * * *"
261
+ workflow_dispatch:
262
+ inputs:
263
+ dry-run:
264
+ type: boolean
265
+ default: true
266
+
267
+ jobs:
268
+ merge-dev:
269
+ uses: kungfu-systems/buildchain/.github/workflows/dev-pr-auto-merge.yml@v2
270
+ permissions:
271
+ contents: write
272
+ pull-requests: write
273
+ checks: read
274
+ statuses: read
275
+ with:
276
+ target-branch: dev/v2/v2.5
277
+ required-status-checks: Verify
278
+ ready-label: ready
279
+ block-labels: blocked,do-not-merge
280
+ max-merges: 1
281
+ dry-run: ${{ inputs.dry-run || false }}
282
+ ```
283
+
222
284
  ## Package-Manager Adapters
223
285
 
224
286
  Old ABV assumed JavaScript repositories with root version state and often
@@ -44,6 +44,7 @@ expected ref flow requires a major bump.
44
44
  | 2026-07-02 | Release passport and binary distribution are a minor surface. | `v2.2` | They add agent-readable release passport files, artifact evidence, impact ledger, agent index, GitHub Release collection and verification commands, and standalone binary assets. |
45
45
  | 2026-07-02 | Web surface host mapping is a minor surface. | `v2.3` | It adds first-class multi-host surface bindings, reusable workflow URL outputs, per-surface deployment overrides, and an agent-readable fixture contract. |
46
46
  | 2026-07-03 | Infra contract lifecycle is a minor surface. | `v2.4` | It adds the provider-neutral `infra-contract` CLI command family, project type, adapter capability contract, lifecycle evidence bundle, propagation evidence, CI evidence mode, and consumer-facing contract artifacts. |
47
+ | 2026-07-04 | Scheduled integration governance is a minor surface. | `v2.5` | It adds scheduled feature-branch discovery, conflict-free integration, reporting, and agent-visible governance automation for dev-line maintenance. |
47
48
 
48
49
  ## Runner Policy
49
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.4.12-alpha.0",
3
+ "version": "2.5.0-alpha.0",
4
4
  "private": false,
5
5
  "description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -41,6 +41,7 @@ const requiredPaths = [
41
41
  ".github/actionlint.yaml",
42
42
  ".github/workflows/self-hosted-runner-smoke.yml",
43
43
  ".github/workflows/buildchain-ref-promotion.yml",
44
+ ".github/workflows/dev-pr-auto-merge.yml",
44
45
  ".github/workflows/release-candidate-promote.yml",
45
46
  ".github/workflows/npm-publish.yml",
46
47
  ".github/workflows/binary-distribution.yml",
@@ -329,6 +330,9 @@ if (!Array.isArray(inventory.workflowSources) || inventory.workflowSources.lengt
329
330
  if (!Array.isArray(inventory.retiredActionsExcluded) || inventory.retiredActionsExcluded.length === 0) {
330
331
  throw new Error("retiredActionsExcluded must list retired legacy actions");
331
332
  }
333
+ if (!Array.isArray(inventory.retiredWorkflowsExcluded) || inventory.retiredWorkflowsExcluded.length === 0) {
334
+ throw new Error("retiredWorkflowsExcluded must list retired legacy workflows");
335
+ }
332
336
 
333
337
  const actualActions = fs
334
338
  .readdirSync(path.join(root, "actions"), { withFileTypes: true })
@@ -356,6 +360,13 @@ for (const retiredRepo of inventory.retiredActionsExcluded || []) {
356
360
  }
357
361
  }
358
362
 
363
+ for (const retiredWorkflow of inventory.retiredWorkflowsExcluded || []) {
364
+ const retiredPath = path.join(root, retiredWorkflow);
365
+ if (fs.existsSync(retiredPath)) {
366
+ throw new Error(`retired workflow must not be shipped in buildchain v2: ${retiredWorkflow}`);
367
+ }
368
+ }
369
+
359
370
  for (const action of shippedActions) {
360
371
  for (const key of ["path", "runtime", "build", "bundle"]) {
361
372
  if (!action[key]) {
@@ -0,0 +1,436 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const DEFAULT_BLOCK_LABELS = ["blocked", "do-not-merge", "work-in-progress"];
6
+ const DEFAULT_ALLOWED_HEAD_PREFIXES = ["feature/", "fix/", "chore/", "docs/", "ci/", "refactor/"];
7
+ const DEFAULT_REQUIRED_CHECKS = ["Verify"];
8
+ const SUCCESS_STATES = new Set(["success"]);
9
+ const SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
10
+
11
+ function splitList(value, fallback = []) {
12
+ if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
13
+ const text = String(value ?? "").trim();
14
+ if (!text) return [...fallback];
15
+ return text
16
+ .split(/[\n,]+/)
17
+ .map((entry) => entry.trim())
18
+ .filter(Boolean);
19
+ }
20
+
21
+ function boolOption(value, fallback = false) {
22
+ if (value === undefined || value === null || value === "") return fallback;
23
+ if (typeof value === "boolean") return value;
24
+ const text = String(value).trim().toLowerCase();
25
+ return ["1", "true", "yes", "on"].includes(text);
26
+ }
27
+
28
+ function intOption(value, fallback) {
29
+ const parsed = Number(value);
30
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
31
+ }
32
+
33
+ function normalizeRepo(value) {
34
+ const text = String(value || "").trim();
35
+ const match = text.match(/^([^/\s]+)\/([^/\s]+)$/);
36
+ if (!match) throw new Error(`repository must be owner/repo, got: ${text || "<empty>"}`);
37
+ return { owner: match[1], repo: match[2], fullName: `${match[1]}/${match[2]}` };
38
+ }
39
+
40
+ function normalizeOptions(options = {}) {
41
+ return {
42
+ repository: normalizeRepo(options.repository || process.env.GITHUB_REPOSITORY),
43
+ targetBranch: String(options.targetBranch || "").replace(/^refs\/heads\//, ""),
44
+ readyLabel: String(options.readyLabel ?? "ready").trim(),
45
+ blockLabels: splitList(options.blockLabels, DEFAULT_BLOCK_LABELS).map((label) => label.toLowerCase()),
46
+ allowedHeadPrefixes: splitList(options.allowedHeadPrefixes, DEFAULT_ALLOWED_HEAD_PREFIXES),
47
+ requiredChecks: splitList(options.requiredChecks, DEFAULT_REQUIRED_CHECKS),
48
+ requireApproval: boolOption(options.requireApproval, true),
49
+ sameRepositoryOnly: boolOption(options.sameRepositoryOnly, true),
50
+ maxMerges: intOption(options.maxMerges, 1),
51
+ mergeMethod: String(options.mergeMethod || "merge").trim(),
52
+ dryRun: boolOption(options.dryRun, true),
53
+ pollMergeableAttempts: intOption(options.pollMergeableAttempts, 3),
54
+ pollMergeableDelayMs: intOption(options.pollMergeableDelayMs, 1000),
55
+ outputPath: String(options.outputPath || ".buildchain/dev-pr-auto-merge/result.json"),
56
+ };
57
+ }
58
+
59
+ function labelsOf(pr) {
60
+ return (pr.labels || []).map((label) => String(label.name || label).toLowerCase());
61
+ }
62
+
63
+ function hasReadyLabel(pr, readyLabel) {
64
+ if (!readyLabel) return true;
65
+ return labelsOf(pr).includes(readyLabel.toLowerCase());
66
+ }
67
+
68
+ function hasBlockedLabel(pr, blockLabels) {
69
+ const labels = labelsOf(pr);
70
+ return blockLabels.some((label) => labels.includes(label));
71
+ }
72
+
73
+ function headPrefixAllowed(pr, prefixes) {
74
+ if (prefixes.length === 0) return true;
75
+ const headRef = String(pr.head?.ref || "");
76
+ return prefixes.some((prefix) => headRef.startsWith(prefix));
77
+ }
78
+
79
+ function sameRepositoryAllowed(pr, repository, sameRepositoryOnly) {
80
+ if (!sameRepositoryOnly) return true;
81
+ return pr.head?.repo?.full_name === repository.fullName;
82
+ }
83
+
84
+ function latestReviewStates(reviews = []) {
85
+ const latest = new Map();
86
+ for (const review of reviews) {
87
+ const user = review.user?.login;
88
+ if (!user) continue;
89
+ latest.set(user, String(review.state || "").toUpperCase());
90
+ }
91
+ return [...latest.values()];
92
+ }
93
+
94
+ function hasRequiredApproval(reviews = []) {
95
+ const states = latestReviewStates(reviews);
96
+ return states.includes("APPROVED") && !states.includes("CHANGES_REQUESTED");
97
+ }
98
+
99
+ function checkMatchesRequired(name, required) {
100
+ const haystack = String(name || "").toLowerCase();
101
+ return haystack === required.toLowerCase() || haystack.includes(required.toLowerCase());
102
+ }
103
+
104
+ function summarizeChecks({ statuses = [], checkRuns = [] } = {}, requiredChecks = []) {
105
+ const summary = [];
106
+ for (const required of requiredChecks) {
107
+ const matchingStatuses = statuses.filter((status) => checkMatchesRequired(status.context, required));
108
+ const matchingRuns = checkRuns.filter((run) => checkMatchesRequired(run.name, required));
109
+ const passedStatuses = matchingStatuses.filter((status) => SUCCESS_STATES.has(String(status.state || "").toLowerCase()));
110
+ const passedRuns = matchingRuns.filter((run) => SUCCESS_CONCLUSIONS.has(String(run.conclusion || "").toLowerCase()));
111
+ const passed = passedStatuses.length > 0 || passedRuns.length > 0;
112
+ summary.push({
113
+ required,
114
+ passed,
115
+ matches: [
116
+ ...matchingStatuses.map((status) => ({
117
+ type: "status",
118
+ name: status.context,
119
+ state: status.state,
120
+ })),
121
+ ...matchingRuns.map((run) => ({
122
+ type: "check-run",
123
+ name: run.name,
124
+ conclusion: run.conclusion,
125
+ status: run.status,
126
+ })),
127
+ ],
128
+ });
129
+ }
130
+ return {
131
+ required: requiredChecks,
132
+ entries: summary,
133
+ passed: summary.every((entry) => entry.passed),
134
+ };
135
+ }
136
+
137
+ function mergeableAccepted(pr) {
138
+ if (pr.mergeable === false) return false;
139
+ const state = String(pr.mergeable_state || pr.mergeStateStatus || "").toLowerCase();
140
+ if (!state) return pr.mergeable === true;
141
+ return ["clean", "has_hooks", "unstable", "unknown"].includes(state);
142
+ }
143
+
144
+ function skip(reason, details = {}) {
145
+ return { action: "skip", reason, ...details };
146
+ }
147
+
148
+ export async function evaluatePullRequest(pr, options, client) {
149
+ options = options.repository?.fullName ? options : normalizeOptions(options);
150
+ if (pr.draft) return skip("draft");
151
+ if (!sameRepositoryAllowed(pr, options.repository, options.sameRepositoryOnly)) {
152
+ return skip("fork-or-cross-repository-head", { headRepository: pr.head?.repo?.full_name || "" });
153
+ }
154
+ if (!headPrefixAllowed(pr, options.allowedHeadPrefixes)) {
155
+ return skip("head-prefix-not-allowed", { headRef: pr.head?.ref || "" });
156
+ }
157
+ if (!hasReadyLabel(pr, options.readyLabel)) {
158
+ return skip("missing-ready-label", { requiredLabel: options.readyLabel });
159
+ }
160
+ if (hasBlockedLabel(pr, options.blockLabels)) {
161
+ return skip("blocked-label", { labels: labelsOf(pr) });
162
+ }
163
+
164
+ const detailed = await client.getPullRequest(pr.number, {
165
+ attempts: options.pollMergeableAttempts,
166
+ delayMs: options.pollMergeableDelayMs,
167
+ });
168
+ if (!mergeableAccepted(detailed)) {
169
+ return skip("not-mergeable", {
170
+ mergeable: detailed.mergeable,
171
+ mergeableState: detailed.mergeable_state || detailed.mergeStateStatus || "",
172
+ });
173
+ }
174
+
175
+ if (options.requireApproval) {
176
+ const reviews = await client.listReviews(pr.number);
177
+ if (!hasRequiredApproval(reviews)) return skip("missing-approval");
178
+ }
179
+
180
+ const checks = await client.listCommitChecks(pr.head?.sha);
181
+ const checkSummary = summarizeChecks(checks, options.requiredChecks);
182
+ if (!checkSummary.passed) return skip("required-checks-not-passing", { checks: checkSummary });
183
+
184
+ return {
185
+ action: options.dryRun ? "would-merge" : "merge",
186
+ reason: options.dryRun ? "dry-run" : "eligible",
187
+ checks: checkSummary,
188
+ };
189
+ }
190
+
191
+ function delay(ms) {
192
+ return new Promise((resolve) => setTimeout(resolve, ms));
193
+ }
194
+
195
+ function parseLinkHeader(header) {
196
+ const links = {};
197
+ for (const part of String(header || "").split(",")) {
198
+ const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/);
199
+ if (match) links[match[2]] = match[1];
200
+ }
201
+ return links;
202
+ }
203
+
204
+ export class GitHubClient {
205
+ constructor({ token, repository, apiUrl = "https://api.github.com", fetchImpl = globalThis.fetch } = {}) {
206
+ if (!fetchImpl) throw new Error("fetch is required");
207
+ if (!token) throw new Error("GITHUB_TOKEN is required");
208
+ this.token = token;
209
+ this.repository = repository;
210
+ this.apiUrl = apiUrl.replace(/\/+$/, "");
211
+ this.fetch = fetchImpl;
212
+ }
213
+
214
+ async request(method, requestPath, { body, accept = "application/vnd.github+json" } = {}) {
215
+ const url = requestPath.startsWith("http") ? requestPath : `${this.apiUrl}${requestPath}`;
216
+ const response = await this.fetch(url, {
217
+ method,
218
+ headers: {
219
+ accept,
220
+ authorization: `Bearer ${this.token}`,
221
+ "content-type": "application/json",
222
+ "x-github-api-version": "2022-11-28",
223
+ },
224
+ body: body === undefined ? undefined : JSON.stringify(body),
225
+ });
226
+ const text = await response.text();
227
+ const data = text ? JSON.parse(text) : null;
228
+ if (!response.ok) {
229
+ const message = data?.message || text || `${method} ${url} failed`;
230
+ const error = new Error(message);
231
+ error.status = response.status;
232
+ error.data = data;
233
+ throw error;
234
+ }
235
+ return { data, response };
236
+ }
237
+
238
+ async paginate(requestPath) {
239
+ let next = requestPath;
240
+ const items = [];
241
+ while (next) {
242
+ const { data, response } = await this.request("GET", next);
243
+ items.push(...data);
244
+ next = parseLinkHeader(response.headers.get("link")).next;
245
+ }
246
+ return items;
247
+ }
248
+
249
+ async listPullRequests(base) {
250
+ const query = new URLSearchParams({
251
+ state: "open",
252
+ base,
253
+ sort: "updated",
254
+ direction: "asc",
255
+ per_page: "100",
256
+ });
257
+ return this.paginate(`/repos/${this.repository.owner}/${this.repository.repo}/pulls?${query}`);
258
+ }
259
+
260
+ async getPullRequest(number, { attempts = 3, delayMs = 1000 } = {}) {
261
+ let last;
262
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
263
+ const { data } = await this.request(
264
+ "GET",
265
+ `/repos/${this.repository.owner}/${this.repository.repo}/pulls/${number}`,
266
+ );
267
+ last = data;
268
+ if (data.mergeable !== null && data.mergeable !== undefined) return data;
269
+ if (attempt < attempts) await delay(delayMs);
270
+ }
271
+ return last;
272
+ }
273
+
274
+ async listReviews(number) {
275
+ return this.paginate(`/repos/${this.repository.owner}/${this.repository.repo}/pulls/${number}/reviews?per_page=100`);
276
+ }
277
+
278
+ async listCommitChecks(sha) {
279
+ if (!sha) return { statuses: [], checkRuns: [] };
280
+ const [{ data: statusData }, { data: checkRunData }] = await Promise.all([
281
+ this.request("GET", `/repos/${this.repository.owner}/${this.repository.repo}/commits/${sha}/status`),
282
+ this.request("GET", `/repos/${this.repository.owner}/${this.repository.repo}/commits/${sha}/check-runs?per_page=100`),
283
+ ]);
284
+ return {
285
+ statuses: statusData.statuses || [],
286
+ checkRuns: checkRunData.check_runs || [],
287
+ };
288
+ }
289
+
290
+ async mergePullRequest(number, { method, sha }) {
291
+ const { data } = await this.request("PUT", `/repos/${this.repository.owner}/${this.repository.repo}/pulls/${number}/merge`, {
292
+ body: {
293
+ merge_method: method,
294
+ sha,
295
+ },
296
+ });
297
+ return data;
298
+ }
299
+
300
+ async getBranchSha(branch) {
301
+ const ref = encodeURIComponent(`heads/${branch}`).replace(/%2F/g, "/");
302
+ const { data } = await this.request("GET", `/repos/${this.repository.owner}/${this.repository.repo}/git/ref/${ref}`);
303
+ return data.object?.sha || "";
304
+ }
305
+ }
306
+
307
+ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
308
+ const options = normalizeOptions(optionsInput);
309
+ if (!options.targetBranch) throw new Error("target branch is required");
310
+ const client = clientInput || new GitHubClient({
311
+ token: optionsInput.token || process.env.GITHUB_TOKEN,
312
+ repository: options.repository,
313
+ apiUrl: optionsInput.apiUrl || process.env.GITHUB_API_URL || "https://api.github.com",
314
+ });
315
+
316
+ const pullRequests = await client.listPullRequests(options.targetBranch);
317
+ const result = {
318
+ schemaVersion: 1,
319
+ contract: "kungfu-buildchain-dev-pr-auto-merge",
320
+ repository: options.repository.fullName,
321
+ targetBranch: options.targetBranch,
322
+ dryRun: options.dryRun,
323
+ maxMerges: options.maxMerges,
324
+ evaluated: [],
325
+ merged: [],
326
+ skipped: [],
327
+ finalBaseSha: "",
328
+ };
329
+
330
+ for (const pr of pullRequests) {
331
+ if (result.merged.length >= options.maxMerges) {
332
+ const entry = { number: pr.number, title: pr.title || "", action: "skip", reason: "max-merges-reached" };
333
+ result.evaluated.push(entry);
334
+ result.skipped.push(entry);
335
+ continue;
336
+ }
337
+
338
+ const decision = await evaluatePullRequest(pr, options, client);
339
+ const entry = {
340
+ number: pr.number,
341
+ title: pr.title || "",
342
+ headRef: pr.head?.ref || "",
343
+ headSha: pr.head?.sha || "",
344
+ action: decision.action,
345
+ reason: decision.reason,
346
+ checks: decision.checks,
347
+ };
348
+ result.evaluated.push(entry);
349
+
350
+ if (decision.action === "merge") {
351
+ const mergeResult = await client.mergePullRequest(pr.number, {
352
+ method: options.mergeMethod,
353
+ sha: pr.head?.sha,
354
+ });
355
+ const mergedEntry = { ...entry, mergeSha: mergeResult.sha || "" };
356
+ result.merged.push(mergedEntry);
357
+ result.evaluated[result.evaluated.length - 1] = mergedEntry;
358
+ } else if (decision.action === "would-merge") {
359
+ result.merged.push(entry);
360
+ } else {
361
+ result.skipped.push(entry);
362
+ }
363
+ }
364
+
365
+ result.finalBaseSha = await client.getBranchSha(options.targetBranch).catch(() => "");
366
+ return result;
367
+ }
368
+
369
+ export function renderMarkdownSummary(result) {
370
+ const lines = [
371
+ "## Buildchain dev PR auto-merge",
372
+ "",
373
+ `Repository: \`${result.repository}\``,
374
+ `Target branch: \`${result.targetBranch}\``,
375
+ `Mode: \`${result.dryRun ? "dry-run" : "merge"}\``,
376
+ `Evaluated PRs: ${result.evaluated.length}`,
377
+ `Eligible ${result.dryRun ? "dry-run" : "merged"} PRs: ${result.merged.length}`,
378
+ "",
379
+ "| PR | Action | Reason | Head |",
380
+ "| --- | --- | --- | --- |",
381
+ ];
382
+ for (const entry of result.evaluated) {
383
+ lines.push(`| #${entry.number} | ${entry.action} | ${entry.reason} | \`${entry.headRef || ""}\` |`);
384
+ }
385
+ if (result.evaluated.length === 0) lines.push("| - | skip | no open pull requests | - |");
386
+ if (!result.dryRun && result.finalBaseSha) {
387
+ lines.push("", `Final target branch SHA: \`${result.finalBaseSha}\``);
388
+ }
389
+ return `${lines.join("\n")}\n`;
390
+ }
391
+
392
+ export function writeGitHubOutputs(outputs, outputFile = process.env.GITHUB_OUTPUT) {
393
+ if (!outputFile) return;
394
+ const lines = [];
395
+ for (const [key, value] of Object.entries(outputs)) {
396
+ lines.push(`${key}=${String(value).replace(/\n/g, "%0A")}`);
397
+ }
398
+ fs.appendFileSync(outputFile, `${lines.join("\n")}\n`);
399
+ }
400
+
401
+ async function main() {
402
+ const options = normalizeOptions({
403
+ repository: process.env.BUILDCHAIN_DEV_PR_REPOSITORY || process.env.GITHUB_REPOSITORY,
404
+ targetBranch: process.env.BUILDCHAIN_DEV_PR_TARGET_BRANCH || process.env.GITHUB_REF_NAME,
405
+ readyLabel: process.env.BUILDCHAIN_DEV_PR_READY_LABEL,
406
+ blockLabels: process.env.BUILDCHAIN_DEV_PR_BLOCK_LABELS,
407
+ allowedHeadPrefixes: process.env.BUILDCHAIN_DEV_PR_ALLOWED_HEAD_PREFIXES,
408
+ requiredChecks: process.env.BUILDCHAIN_DEV_PR_REQUIRED_CHECKS,
409
+ requireApproval: process.env.BUILDCHAIN_DEV_PR_REQUIRE_APPROVAL,
410
+ sameRepositoryOnly: process.env.BUILDCHAIN_DEV_PR_SAME_REPOSITORY_ONLY,
411
+ maxMerges: process.env.BUILDCHAIN_DEV_PR_MAX_MERGES,
412
+ mergeMethod: process.env.BUILDCHAIN_DEV_PR_MERGE_METHOD,
413
+ dryRun: process.env.BUILDCHAIN_DEV_PR_DRY_RUN,
414
+ outputPath: process.env.BUILDCHAIN_DEV_PR_OUTPUT_PATH,
415
+ });
416
+ const result = await runDevPrAutoMerge(options);
417
+ fs.mkdirSync(path.dirname(options.outputPath), { recursive: true });
418
+ fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`);
419
+ const summary = renderMarkdownSummary(result);
420
+ if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
421
+ else process.stdout.write(summary);
422
+ writeGitHubOutputs({
423
+ "evaluated-count": result.evaluated.length,
424
+ "merged-count": result.merged.length,
425
+ "skipped-count": result.skipped.length,
426
+ "final-base-sha": result.finalBaseSha,
427
+ "result-path": options.outputPath,
428
+ });
429
+ }
430
+
431
+ if (import.meta.url === `file://${process.argv[1]}`) {
432
+ main().catch((error) => {
433
+ console.error(error.stack || error.message);
434
+ process.exit(1);
435
+ });
436
+ }
@@ -84,6 +84,7 @@ function buildSiteBundle() {
84
84
  { id: "build", path: ".github/workflows/.build.yml", surface: "reusable-build", status: "active" },
85
85
  { id: "web-surface", path: ".github/workflows/.web-surface.yml", surface: "site-app-deployment", status: "active" },
86
86
  { id: "buildchain-ref-promotion", path: ".github/workflows/buildchain-ref-promotion.yml", surface: "release-governance", status: "active" },
87
+ { id: "dev-pr-auto-merge", path: ".github/workflows/dev-pr-auto-merge.yml", surface: "dev-governance", status: "active" },
87
88
  { id: "binary-distribution", path: ".github/workflows/binary-distribution.yml", surface: "release-passport", status: "active" },
88
89
  ],
89
90
  actions: [
@@ -99,6 +100,7 @@ function buildSiteBundle() {
99
100
  exactTags: "v-prefixed exact tags are immutable release records.",
100
101
  floatingTags: "vX, vX.Y, and vX.Y-alpha are channel pointers updated by Buildchain transactions.",
101
102
  channelBranches: ["dev/vX/vX.Y", "alpha/vX/vX.Y", "release/vX/vX.Y", "publish-gate/major"],
103
+ protectedDevelopmentBranches: ["dev/vX/vX.Y"],
102
104
  releasePassport: {
103
105
  entrypoint: "buildchain.release.json",
104
106
  bundle: "buildchain-release-bundle.tar.gz",
@@ -4,7 +4,17 @@ import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { writeGitHubOutputs } from "./build-contract-core.mjs";
6
6
 
7
- const DEFAULT_BUILD_WORKFLOW_FILE = "build-surface-fixture.yml";
7
+ const DEFAULT_BUILD_WORKFLOW_FILE = "build.yml";
8
+ const DEFAULT_BUILD_WORKFLOW_NAME = "Build";
9
+
10
+ class GitHubApiError extends Error {
11
+ constructor(message, { status, path: requestPath } = {}) {
12
+ super(message);
13
+ this.name = "GitHubApiError";
14
+ this.status = status;
15
+ this.path = requestPath;
16
+ }
17
+ }
8
18
 
9
19
  function env(name, fallback = "") {
10
20
  return process.env[name] || fallback;
@@ -48,11 +58,71 @@ async function githubJson({ apiUrl, token, path: requestPath, fetchImpl = global
48
58
  const text = await response.text();
49
59
  const body = text ? JSON.parse(text) : {};
50
60
  if (!response.ok) {
51
- throw new Error(`GitHub API ${requestPath} failed with ${response.status}: ${body.message || text}`);
61
+ throw new GitHubApiError(`GitHub API ${requestPath} failed with ${response.status}: ${body.message || text}`, {
62
+ status: response.status,
63
+ path: requestPath,
64
+ });
52
65
  }
53
66
  return body;
54
67
  }
55
68
 
69
+ function workflowRunMatchesConfiguredWorkflow(run, { buildWorkflowFile = "", buildWorkflowName = "" } = {}) {
70
+ const expectedName = String(buildWorkflowName || "").trim();
71
+ const expectedFile = String(buildWorkflowFile || "").trim();
72
+ const runName = String(run.name || run.workflow_name || "").trim();
73
+ const runPath = String(run.path || run.workflow_path || "").trim();
74
+ if (expectedName && runName === expectedName) {
75
+ return true;
76
+ }
77
+ if (expectedFile && (runPath === expectedFile || runPath.endsWith(`/${expectedFile}`))) {
78
+ return true;
79
+ }
80
+ return !expectedName && !expectedFile;
81
+ }
82
+
83
+ async function listPullRequestWorkflowRuns({
84
+ apiUrl,
85
+ token,
86
+ repo,
87
+ buildWorkflowFile,
88
+ buildWorkflowName,
89
+ fetchImpl,
90
+ }) {
91
+ const diagnostics = [];
92
+ const workflowFile = String(buildWorkflowFile || "").trim();
93
+ if (workflowFile) {
94
+ try {
95
+ const runs = await githubJson({
96
+ apiUrl,
97
+ token,
98
+ fetchImpl,
99
+ path: `/repos/${repo.owner}/${repo.repo}/actions/workflows/${encodeURIComponent(workflowFile)}/runs?event=pull_request&per_page=100`,
100
+ });
101
+ return {
102
+ runs: Array.isArray(runs.workflow_runs) ? runs.workflow_runs : [],
103
+ diagnostics,
104
+ };
105
+ } catch (error) {
106
+ if (error.status !== 404) {
107
+ throw error;
108
+ }
109
+ diagnostics.push(`Configured PR-stage workflow file ${workflowFile} was not found; fell back to repository pull_request workflow runs.`);
110
+ }
111
+ }
112
+
113
+ const runs = await githubJson({
114
+ apiUrl,
115
+ token,
116
+ fetchImpl,
117
+ path: `/repos/${repo.owner}/${repo.repo}/actions/runs?event=pull_request&per_page=100`,
118
+ });
119
+ return {
120
+ runs: (Array.isArray(runs.workflow_runs) ? runs.workflow_runs : [])
121
+ .filter((run) => workflowRunMatchesConfiguredWorkflow(run, { buildWorkflowFile, buildWorkflowName })),
122
+ diagnostics,
123
+ };
124
+ }
125
+
56
126
  function runLabel(run = "") {
57
127
  if (typeof run === "string") {
58
128
  return run;
@@ -129,6 +199,7 @@ export async function classifyWorkflowFriction({
129
199
  token = env("GITHUB_TOKEN"),
130
200
  apiUrl = env("GITHUB_API_URL", "https://api.github.com"),
131
201
  buildWorkflowFile = DEFAULT_BUILD_WORKFLOW_FILE,
202
+ buildWorkflowName = DEFAULT_BUILD_WORKFLOW_NAME,
132
203
  releaseCandidateOutcome = env("BUILDCHAIN_RC_RESOLVE_OUTCOME"),
133
204
  releaseCandidateDiagnosis = env("BUILDCHAIN_RC_DIAGNOSIS"),
134
205
  runUrl = env("BUILDCHAIN_WORKFLOW_RUN_URL"),
@@ -154,14 +225,18 @@ export async function classifyWorkflowFriction({
154
225
 
155
226
  let relatedRuns = [];
156
227
  let heavyBuilds = [];
228
+ let workflowRunDiagnostics = [];
157
229
  if (primaryPullRequest?.number) {
158
- const runs = await githubJson({
230
+ const workflowRuns = await listPullRequestWorkflowRuns({
159
231
  apiUrl,
160
232
  token,
233
+ repo,
234
+ buildWorkflowFile,
235
+ buildWorkflowName,
161
236
  fetchImpl,
162
- path: `/repos/${repo.owner}/${repo.repo}/actions/workflows/${encodeURIComponent(buildWorkflowFile)}/runs?event=pull_request&per_page=100`,
163
237
  });
164
- relatedRuns = (Array.isArray(runs.workflow_runs) ? runs.workflow_runs : [])
238
+ workflowRunDiagnostics = workflowRuns.diagnostics;
239
+ relatedRuns = workflowRuns.runs
165
240
  .filter((run) => (run.pull_requests || []).some((pr) => Number(pr.number || 0) === Number(primaryPullRequest.number)));
166
241
  heavyBuilds = relatedRuns.filter((run) => run.status === "completed" && ["success", "failure", "cancelled", "timed_out"].includes(run.conclusion || ""));
167
242
  }
@@ -184,6 +259,7 @@ export async function classifyWorkflowFriction({
184
259
  if (releaseCandidateDiagnosis) {
185
260
  diagnosisParts.push(releaseCandidateDiagnosis);
186
261
  }
262
+ diagnosisParts.push(...workflowRunDiagnostics);
187
263
  const diagnosis = diagnosisParts.join(" ") || "Buildchain ref promotion failed after Verify succeeded; inspect the classified evidence and keep the fix in Buildchain.";
188
264
  const nextAction = frictionClass === "late-fail-fast"
189
265
  ? "Move the missing/stale RC evidence check earlier or make the promotion workflow consume the exact PR-stage RC passport before any publish side effect."
@@ -229,6 +305,7 @@ export async function workflowFrictionReportCli() {
229
305
  targetRef: env("BUILDCHAIN_TARGET_REF"),
230
306
  targetSha: env("BUILDCHAIN_TARGET_SHA"),
231
307
  buildWorkflowFile: env("BUILDCHAIN_BUILD_WORKFLOW_FILE", DEFAULT_BUILD_WORKFLOW_FILE),
308
+ buildWorkflowName: env("BUILDCHAIN_BUILD_WORKFLOW_NAME", env("BUILDCHAIN_RC_WORKFLOW_NAME", DEFAULT_BUILD_WORKFLOW_NAME)),
232
309
  outputDir: env("BUILDCHAIN_FRICTION_OUTPUT_DIR", ".buildchain/workflow-friction"),
233
310
  });
234
311
  } catch (error) {