@rmartz/bot-automerge 0.2.1 → 1.0.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/README.md CHANGED
@@ -11,69 +11,46 @@ merge?", bot-automerge answers "is this a bot PR we trust enough to enable
11
11
  auto-merge on?". Unlike merge-safety, it posts **no check-run**.
12
12
 
13
13
  It is distributed the same way [`@rmartz/repo-hygiene`](https://github.com/rmartz/repo-hygiene)
14
- and `@rmartz/merge-safety` are:
15
-
16
- 1. **Updates propagate automatically.** Consuming repos pin one reusable workflow
17
- by SHA; Dependabot's `github-actions` ecosystem opens PRs to bump that pin.
14
+ and `@rmartz/merge-safety` are — the package lives here, and consumers reach it
15
+ through a separate action repo:
16
+
17
+ 1. **Updates propagate automatically.** Consuming repos pin
18
+ [`rmartz/bot-automerge-action`](https://github.com/rmartz/bot-automerge-action)
19
+ by SHA; Dependabot's `github-actions` ecosystem opens PRs to bump that pin. The
20
+ action pins this package's version in its own `package.json`, bumped by
21
+ Dependabot's `npm` ecosystem, so a new CLI release reaches consumers as an
22
+ action release.
18
23
  2. **No check-run contract.** bot-automerge is purely an eligibility enabler — it
19
24
  requires no fleet-wide status-check name. See
20
25
  [docs/bot-automerge-contract.md](docs/bot-automerge-contract.md).
21
26
 
22
27
  > **Status: implemented.** The `enable` classification — Dependabot `patch`/`minor`
23
- > bumps and release-please release PRs — and the reusable workflow are complete and
24
- > ship in `v0.1.0`, the first release to GitHub Packages. It was built per
28
+ > bumps and release-please release PRs — shipped in `v0.1.0`, the first release to
29
+ > GitHub Packages. It was built per
25
30
  > [ai-tools#264](https://github.com/rmartz/ai-tools/issues/264); see
26
31
  > [docs/bot-automerge-contract.md](docs/bot-automerge-contract.md) for the
27
32
  > eligibility contract.
28
33
 
29
34
  ## Using it in a consuming repo
30
35
 
31
- Add one caller workflow (this is what Dependabot keeps current). Unlike a
32
- read-only hygiene check, this caller carries the triggers, grants write scopes,
33
- and passes secrets through — because a reusable workflow can't declare its own
34
- triggers and runs with the intersection of granted and declared permissions:
35
-
36
- ```yaml
37
- # .github/workflows/bot-automerge.yml
38
- name: bot-automerge
39
- on:
40
- pull_request_target:
41
- types: [opened, reopened, synchronize, labeled]
42
- permissions:
43
- contents: write
44
- pull-requests: write
45
- packages: read # install the CLI from GitHub Packages
46
- jobs:
47
- bot-automerge:
48
- uses: rmartz/bot-automerge/.github/workflows/bot-automerge.yml@<sha> # vX.Y.Z
49
- with:
50
- pr: ${{ github.event.pull_request.number }}
51
- secrets: inherit
52
- ```
53
-
54
- It uses `pull_request_target` (not `pull_request`) because Dependabot PRs run with
55
- a read-only token, and enabling auto-merge needs base-context write.
36
+ Use [`rmartz/bot-automerge-action`](https://github.com/rmartz/bot-automerge-action)
37
+ — its [consumer guide](https://github.com/rmartz/bot-automerge-action/blob/main/docs/consuming.md)
38
+ has the caller workflow, its permissions, and the `merge-safety` prerequisite.
39
+ This repo no longer ships a reusable workflow; see
40
+ [docs/consuming.md](docs/consuming.md) for what that means for existing callers.
56
41
 
57
42
  > **Require `merge-safety` + your CI checks on the default branch _before_ adopting
58
- > this.** `gh pr merge --auto` merges a PR immediately if the repo has no required
43
+ > it.** `gh pr merge --auto` merges a PR immediately if the repo has no required
59
44
  > status checks — bot-automerge only makes a bot PR _eligible_ to auto-merge; the
60
- > repo's required checks are what it waits on. See the
61
- > [consumer setup guide](docs/consuming.md) for the full prerequisite.
62
-
63
- The public `@rmartz/bot-automerge` package on GitHub Packages is readable with the
64
- built-in `GITHUB_TOKEN`, so no consumer PAT is required.
65
-
66
- > For the full walkthrough — the caller's permissions, why it isn't trigger-free,
67
- > and how the pin stays current — see the
68
- > [consumer setup guide](docs/consuming.md).
45
+ > repo's required checks are what it waits on.
69
46
 
70
47
  ## Requirements
71
48
 
72
49
  - Node.js >= 20.11
73
50
  - pnpm 9 (pinned via `packageManager`)
74
51
 
75
- Consuming repos need neither — the reusable workflow runs the published CLI on a
76
- GitHub-hosted runner.
52
+ Consuming repos need neither — `rmartz/bot-automerge-action` runs the published
53
+ CLI on a GitHub-hosted runner.
77
54
 
78
55
  ## Local development
79
56
 
@@ -90,10 +67,16 @@ pnpm run test # vitest
90
67
 
91
68
  Versioned by [semantic-release](.releaserc.json). A push to `main` analyzes the
92
69
  Conventional-Commit history since the last `bot-automerge-v*` tag and, when a
93
- release is warranted, publishes the package to GitHub Packages (public) and
94
- creates the git tag + GitHub Release — no release PR and no commit-back, so the
70
+ release is warranted, publishes the package to npmjs (public, via OIDC trusted
71
+ publishing with provenance — no npm token) and creates the git tag + GitHub
72
+ Release. Versions up to 0.2.1 were published to GitHub Packages and stay there for
73
+ existing pins; new versions go to npmjs only. There is no release PR and no
74
+ commit-back, so the
95
75
  built-in `GITHUB_TOKEN` suffices. `tagFormat` stays `bot-automerge-v${version}`
96
- for continuity with the prior release-please tags.
76
+ for continuity with the prior release-please tags. A `Release dry-run` CI job
77
+ validates the semantic-release config (that the changelog toolchain renders) on
78
+ every PR, so a broken release setup is caught before merge rather than on the
79
+ post-merge release run.
97
80
 
98
81
  ---
99
82
 
@@ -2,7 +2,6 @@
2
2
  import {
3
3
  AUTOMERGE_HANDLED_LABEL,
4
4
  RELEASE_PLEASE_BRANCH_PREFIX,
5
- RELEASE_PLEASE_PENDING_LABEL,
6
5
  isBotAutomergeCommand,
7
6
  isDependabotAuthor,
8
7
  isDependabotUpdateType,
@@ -60,29 +59,38 @@ function isRateLimited(text) {
60
59
  }
61
60
  async function runTransport({ argv, stdin }, cwd) {
62
61
  const [command, ...args] = argv;
63
- if (command === void 0) return { stdout: null, stderr: "empty argv" };
62
+ if (command === void 0) return { stdout: null, failure: { code: null, stderr: "empty argv" } };
64
63
  try {
65
64
  const r = await boundedRun(command, args, { timeoutMs: GH_API_TIMEOUT_MS, cwd, input: stdin });
66
- if (r.code === 0) return { stdout: r.stdout, stderr: "" };
67
- return { stdout: null, stderr: r.stderr || "" };
65
+ if (r.code === 0) return { stdout: r.stdout, failure: { code: 0, stderr: "" } };
66
+ const stderr = r.timedOut ? `timed out after ${GH_API_TIMEOUT_MS}ms` : r.stderr || "";
67
+ return { stdout: null, failure: { code: r.timedOut ? null : r.code, stderr } };
68
68
  } catch (err) {
69
- return { stdout: null, stderr: err instanceof Error ? err.message : String(err) };
69
+ return {
70
+ stdout: null,
71
+ failure: { code: null, stderr: err instanceof Error ? err.message : String(err) }
72
+ };
70
73
  }
71
74
  }
72
- async function ghCall(primary, fallback, opts = {}) {
75
+ async function ghCallDetailed(primary, fallback, opts = {}) {
73
76
  const sleep = opts.sleep ?? realSleep;
77
+ let lastFailure = { code: null, stderr: "no transport attempted" };
74
78
  for (const transport of [primary, fallback]) {
75
79
  if (!transport) continue;
76
80
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
77
- const { stdout, stderr } = await runTransport(transport, opts.cwd);
78
- if (stdout !== null) return stdout;
79
- if (isRateLimited(stderr)) break;
81
+ const { stdout, failure } = await runTransport(transport, opts.cwd);
82
+ if (stdout !== null) return { stdout, failure: null };
83
+ lastFailure = failure;
84
+ if (isRateLimited(failure.stderr)) break;
80
85
  if (attempt < MAX_RETRIES) {
81
86
  await sleep(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
82
87
  }
83
88
  }
84
89
  }
85
- return null;
90
+ return { stdout: null, failure: lastFailure };
91
+ }
92
+ async function ghCall(primary, fallback, opts = {}) {
93
+ return (await ghCallDetailed(primary, fallback, opts)).stdout;
86
94
  }
87
95
  function parseSlugFromRemoteUrl(url) {
88
96
  const trimmed = url.trim();
@@ -131,6 +139,43 @@ async function addLabels(repo, issue, labels, call = {}) {
131
139
  return out !== null ? out.trim() : null;
132
140
  }
133
141
 
142
+ // src/lib/gh-failure.ts
143
+ var MAX_DETAIL_CHARS = 200;
144
+ function classifyGhFailure(stderr) {
145
+ if (/rate limit|HTTP 429|abuse detection/i.test(stderr)) return "rate-limited";
146
+ if (/HTTP 401|Bad credentials|SAML|\bSSO\b|gh auth login/i.test(stderr)) {
147
+ return "token-rejected";
148
+ }
149
+ if (/Resource not accessible|HTTP 403|scope/i.test(stderr)) return "permission-denied";
150
+ if (/Could not resolve to a PullRequest/i.test(stderr)) return "pr-not-found";
151
+ if (/Could not resolve to a Repository/i.test(stderr)) return "repo-not-found";
152
+ if (/HTTP 404|Not Found/i.test(stderr)) return "pr-not-found";
153
+ return "unknown";
154
+ }
155
+ function tokenSource(env = process.env) {
156
+ if (env.GH_TOKEN) return "GH_TOKEN";
157
+ if (env.GITHUB_TOKEN) return "GITHUB_TOKEN";
158
+ return "gh's stored login";
159
+ }
160
+ var SUMMARIES = {
161
+ "rate-limited": () => "rate limited by GitHub \u2014 wait for the limit to reset, then re-run",
162
+ "token-rejected": (source) => `token rejected (from ${source}) \u2014 it is expired, revoked, or SSO-deauthorized; rotate it, or unset it to fall back to the workflow token`,
163
+ "permission-denied": (source) => `token lacks permission (from ${source}) \u2014 it needs contents:write and pull-requests:write on this repo`,
164
+ "pr-not-found": () => "PR not found \u2014 check the PR number the caller passed",
165
+ "repo-not-found": (source) => `repository not found or not visible to the token (from ${source}) \u2014 check --repo and the token's repo access`,
166
+ unknown: () => "gh failed"
167
+ };
168
+ function firstLine(stderr) {
169
+ const line = stderr.split("\n").map((l) => l.trim()).find((l) => l !== "");
170
+ if (!line) return "(no stderr)";
171
+ return line.length > MAX_DETAIL_CHARS ? `${line.slice(0, MAX_DETAIL_CHARS)}\u2026` : line;
172
+ }
173
+ function describeGhFailure(failure, env = process.env) {
174
+ const summary = SUMMARIES[classifyGhFailure(failure.stderr)](tokenSource(env));
175
+ const exit = failure.code === null ? "gh did not exit cleanly" : `gh exited ${failure.code}`;
176
+ return `${summary} (${exit}: ${firstLine(failure.stderr)})`;
177
+ }
178
+
134
179
  // src/lib/automerge-label.ts
135
180
  async function markPrHandled(repo, pr, opts = {}) {
136
181
  const out = await addLabels(repo, pr, [AUTOMERGE_HANDLED_LABEL], opts);
@@ -143,9 +188,7 @@ function isEvaluablePrState(state) {
143
188
  }
144
189
  function detectBotPrType(view) {
145
190
  if (isDependabotAuthor(view.author)) return "dependabot";
146
- if (view.headRefName.startsWith(RELEASE_PLEASE_BRANCH_PREFIX) || view.labels.includes(RELEASE_PLEASE_PENDING_LABEL)) {
147
- return "release-please";
148
- }
191
+ if (view.headRefName.startsWith(RELEASE_PLEASE_BRANCH_PREFIX)) return "release-please";
149
192
  return null;
150
193
  }
151
194
  function semverWord(updateType) {
@@ -220,12 +263,14 @@ function parse(argv) {
220
263
  return args;
221
264
  }
222
265
  async function ghJson(argv, cwd) {
223
- const out = await ghCall({ argv }, null, { cwd });
224
- if (out === null) return null;
266
+ const { stdout, failure } = await ghCallDetailed({ argv }, null, { cwd });
267
+ if (stdout === null) {
268
+ return { ok: false, why: failure ? describeGhFailure(failure) : "gh failed" };
269
+ }
225
270
  try {
226
- return JSON.parse(out);
271
+ return { ok: true, value: JSON.parse(stdout) };
227
272
  } catch {
228
- return null;
273
+ return { ok: false, why: "gh returned output that is not valid JSON" };
229
274
  }
230
275
  }
231
276
  async function fetchPrView(repo, pr, cwd) {
@@ -248,22 +293,23 @@ function emitVerdictJson(verdict2, isError) {
248
293
  if (isError) process.exitCode = 1;
249
294
  }
250
295
  async function enableAutoMerge(repo, pr, cwd) {
251
- const out = await ghCall(
296
+ const { failure } = await ghCallDetailed(
252
297
  { argv: ["gh", "pr", "merge", "--auto", "--squash", String(pr), "--repo", repo] },
253
298
  null,
254
299
  { cwd }
255
300
  );
256
- return out !== null;
301
+ return failure ? describeGhFailure(failure) : null;
257
302
  }
258
303
  async function runEnable(repo, pr, args) {
259
- const view = await fetchPrView(repo, pr, args.cwd);
260
- if (!view) {
261
- const msg = `could not read PR #${pr}`;
304
+ const fetched = await fetchPrView(repo, pr, args.cwd);
305
+ if (!fetched.ok) {
306
+ const msg = `could not read PR #${pr} \u2014 ${fetched.why}`;
262
307
  if (args.json) return emitVerdictJson(errorBotAutomergeVerdict(msg), true);
263
308
  console.error(`#${pr}: ${msg} \u2014 not enabling auto-merge`);
264
309
  process.exitCode = 1;
265
310
  return;
266
311
  }
312
+ const view = fetched.value;
267
313
  const prView = {
268
314
  author: view.author?.login ?? "",
269
315
  headRefName: view.headRefName,
@@ -282,9 +328,11 @@ async function runEnable(repo, pr, args) {
282
328
  console.log(`#${pr}: not eligible \u2014 ${verdict2.reason}`);
283
329
  return;
284
330
  }
285
- const enabled = await enableAutoMerge(repo, pr, args.cwd);
286
- if (!enabled) {
287
- console.error(`#${pr}: eligible (${verdict2.reason}) but failed to enable auto-merge`);
331
+ const mergeFailure = await enableAutoMerge(repo, pr, args.cwd);
332
+ if (mergeFailure !== null) {
333
+ console.error(
334
+ `#${pr}: eligible (${verdict2.reason}) but failed to enable auto-merge \u2014 ${mergeFailure}`
335
+ );
288
336
  process.exitCode = 1;
289
337
  return;
290
338
  }
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * The classification + enablement implementation (and the `ai-bot-automerge` bin)
11
11
  * lives alongside it; this module is intentionally the narrow, frozen surface the
12
- * reusable workflow and consumers depend on. Extracted per rmartz/ai-tools#264.
12
+ * bot-automerge-action and consumers depend on. Extracted per rmartz/ai-tools#264.
13
13
  */
14
14
  /**
15
15
  * The operations the `ai-bot-automerge` CLI dispatches:
@@ -45,11 +45,15 @@ declare const DEPENDABOT_AUTHORS: readonly ["dependabot[bot]", "app/dependabot"]
45
45
  declare function isDependabotAuthor(login: string): boolean;
46
46
  /**
47
47
  * The head-branch prefix release-please gives its release PR (e.g.
48
- * `release-please--branches--main`). Either this prefix OR the pending label
49
- * below marks a PR as a release-please release PR.
48
+ * `release-please--branches--main`). This prefix alone marks a PR as a
49
+ * release-please release PR.
50
50
  */
51
51
  declare const RELEASE_PLEASE_BRANCH_PREFIX = "release-please--";
52
- /** The label release-please applies to its open release PR. */
52
+ /**
53
+ * The label release-please applies to its open release PR. Informational only:
54
+ * it is NOT an eligibility signal, because anyone with triage permission can
55
+ * apply it to any PR (GHSA-4f7f-7fcp-gcm6).
56
+ */
53
57
  declare const RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
54
58
  /**
55
59
  * The Dependabot semver update-type, as emitted verbatim by
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "@rmartz/bot-automerge",
3
- "version": "0.2.1",
4
- "description": "Enables GitHub-native auto-merge for trustworthy bot pull requests (Dependabot patch/minor bumps and release-please release PRs), distributed via a reusable workflow kept current by Dependabot.",
3
+ "version": "1.0.2",
4
+ "description": "Enables GitHub-native auto-merge for trustworthy bot pull requests (Dependabot patch/minor bumps and release-please release PRs), distributed via rmartz/bot-automerge-action.",
5
5
  "keywords": [
6
6
  "ci",
7
7
  "bot-automerge",
8
8
  "auto-merge",
9
9
  "github-actions",
10
- "reusable-workflow",
11
10
  "dependabot"
12
11
  ],
13
12
  "homepage": "https://github.com/rmartz/bot-automerge#readme",
@@ -50,12 +49,12 @@
50
49
  "test:watch": "vitest"
51
50
  },
52
51
  "devDependencies": {
53
- "@types/node": "^26.6.1",
52
+ "@types/node": "^26.6.2",
54
53
  "@typescript-eslint/eslint-plugin": "^8.70.0",
55
54
  "@typescript-eslint/parser": "^8.70.0",
56
55
  "@vitest/coverage-v8": "^5.0.1",
57
56
  "conventional-changelog-conventionalcommits": "^9.1.0",
58
- "eslint": "^10.10.0",
57
+ "eslint": "^10.11.0",
59
58
  "eslint-import-resolver-typescript": "^4.4.5",
60
59
  "eslint-plugin-import": "^2.32.0",
61
60
  "prettier": "^3.9.6",