@rmartz/merge-safety 0.13.0 → 0.15.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/README.md CHANGED
@@ -6,11 +6,13 @@ breaking-change, and conflict facts for a PR, posts them as the `merge-safety`
6
6
  check-run, and re-holds every open PR when the base branch moves — so a PR only
7
7
  auto-merges once it is genuinely safe against the current base.
8
8
 
9
- It is distributed the same way [`@rmartz/repo-hygiene`](https://github.com/rmartz/repo-hygiene)
10
- is:
9
+ Consuming repos adopt it through the composite action
10
+ [**`rmartz/merge-safety-action`**](https://github.com/rmartz/merge-safety-action),
11
+ distributed the same way as the fleet's other `-action` repos:
11
12
 
12
- 1. **Updates propagate automatically.** Consuming repos pin one reusable workflow
13
- by SHA; Dependabot's `github-actions` ecosystem opens PRs to bump that pin.
13
+ 1. **Updates propagate automatically.** Consuming repos pin the action by SHA;
14
+ Dependabot's `github-actions` ecosystem opens PRs to bump that pin, and each
15
+ action release pins a specific version of this CLI.
14
16
  2. **The check-run name is a fleet contract.** Every consumer requires a status
15
17
  check named exactly `merge-safety`; see
16
18
  [docs/check-run-contract.md](docs/check-run-contract.md).
@@ -20,60 +22,22 @@ is:
20
22
 
21
23
  ## Using it in a consuming repo
22
24
 
23
- Add one caller workflow (this is what Dependabot keeps current). Unlike a
24
- read-only hygiene check, this caller carries the triggers, grants write scopes,
25
- and passes secrets through — because a reusable workflow can't declare its own
26
- triggers and runs with the intersection of granted and declared permissions:
27
-
28
- ```yaml
29
- # .github/workflows/merge-safety.yml
30
- name: merge-safety
31
- on:
32
- pull_request_target:
33
- types: [opened, synchronize, reopened, edited, labeled, unlabeled]
34
- push:
35
- branches: [main]
36
- check_suite:
37
- types: [completed]
38
- workflow_dispatch:
39
- inputs:
40
- pr:
41
- description: PR number to evaluate
42
- required: true
43
- permissions:
44
- checks: write
45
- statuses: write
46
- pull-requests: write
47
- contents: read
48
- actions: write
49
- packages: read
50
- jobs:
51
- merge-safety:
52
- uses: rmartz/merge-safety/.github/workflows/merge-safety.yml@<sha> # vX.Y.Z
53
- with:
54
- pr: ${{ inputs.pr }}
55
- secrets: inherit
56
- ```
57
-
58
- Then require the `merge-safety` status check on the default branch. Both the
59
- caller and the Dependabot entry are seeded once by
60
- [`@rmartz/bootstrap`](https://github.com/rmartz/ai-tools)
61
- (`ai-ensure-project-config`); after that Dependabot maintains the pin. The
62
- `@rmartz/merge-safety` CLI is public on npmjs, so installing it needs no token.
63
- (Pins at v0.6.0 or earlier install from GitHub Packages instead, which is what the
64
- `packages: read` permission above is for; it can be dropped once your pin is past
65
- v0.6.0.)
25
+ Add the caller workflow from the action's
26
+ [consumer guide](https://github.com/rmartz/merge-safety-action/blob/main/docs/consuming.md),
27
+ create the labels merge-safety manages, and require the `merge-safety` status
28
+ check on the default branch. See [docs/consuming.md](docs/consuming.md) for what
29
+ the verdict expects of a repo and how it behaves.
66
30
 
67
- > For the full walkthrough — the caller's permissions, why it isn't trigger-free,
68
- > and how to verify the setup — see the
69
- > [consumer setup guide](docs/consuming.md).
31
+ > **The reusable workflow in this repo is deprecated.** Repos pinned to
32
+ > `rmartz/merge-safety/.github/workflows/merge-safety.yml` keep working and see a
33
+ > deprecation warning on each run; migrate them to the action.
70
34
 
71
35
  ## Requirements
72
36
 
73
37
  - Node.js >= 20.11
74
38
  - pnpm 9 (pinned via `packageManager`)
75
39
 
76
- Consuming repos need neither — the reusable workflow runs the published CLI on a
40
+ Consuming repos need neither — the action runs the published CLI on a
77
41
  GitHub-hosted runner.
78
42
 
79
43
  ## Local development
@@ -98,9 +62,9 @@ OIDC trusted publishing with provenance — no npm token), and creates the git t
98
62
  GitHub Release with generated notes — using only the built-in `GITHUB_TOKEN`, no
99
63
  release-please and no PAT. There is deliberately **no
100
64
  `@semantic-release/git`**: `package.json`'s `version` is a frozen `0.0.0` placeholder
101
- and is never committed back. The version installed by the reusable workflow is
102
- resolved at runtime from the **release tag at its own pinned commit**, so it lives
103
- only in the git tag. Config: [`.releaserc.json`](.releaserc.json).
65
+ and is never committed back. The version installed by the deprecated reusable
66
+ workflow is resolved at runtime from the **release tag at its own pinned commit**,
67
+ so it lives only in the git tag; the action pins the version in its own lockfile. Config: [`.releaserc.json`](.releaserc.json).
104
68
 
105
69
  **Version mapping (v0):** `feat:` → minor; `fix:`/`perf:` → patch. Pre-1.0, a
106
70
  breaking change (`!`) is **capped at minor** so it can't auto-jump to `1.0.0`;
@@ -53,16 +53,35 @@ var MAX_RETRIES = 2;
53
53
  var BACKOFF_BASE_MS = 1e3;
54
54
  var BACKOFF_CAP_MS = 8e3;
55
55
  var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
56
+ var GhTransientError = class extends Error {
57
+ constructor(stderr) {
58
+ super(`transient GitHub API failure: ${stderr}`);
59
+ this.stderr = stderr;
60
+ this.name = "GhTransientError";
61
+ }
62
+ stderr;
63
+ };
56
64
  function isRateLimited(text) {
57
65
  const t = (text ?? "").toLowerCase();
58
66
  return t.includes("rate limit") || t.includes("rate-limit");
59
67
  }
68
+ var TRANSIENT_PATTERNS = [
69
+ /\bHTTP 5\d\d\b/,
70
+ /bad gateway|service unavailable|gateway time-?out|internal server error/i,
71
+ /timed out|timeout/i,
72
+ /connection (?:reset|refused)/i,
73
+ /could not resolve host|no such host|tls handshake|unexpected eof/i
74
+ ];
75
+ function isTransientGhFailure(stderr) {
76
+ return isRateLimited(stderr) || TRANSIENT_PATTERNS.some((p) => p.test(stderr));
77
+ }
60
78
  async function runTransport({ argv, stdin }, cwd) {
61
79
  const [command, ...args] = argv;
62
80
  if (command === void 0) return { stdout: null, stderr: "empty argv" };
63
81
  try {
64
82
  const r = await boundedRun(command, args, { timeoutMs: GH_API_TIMEOUT_MS, cwd, input: stdin });
65
83
  if (r.code === 0) return { stdout: r.stdout, stderr: "" };
84
+ if (r.timedOut) return { stdout: null, stderr: `gh timed out after ${GH_API_TIMEOUT_MS}ms` };
66
85
  return { stdout: null, stderr: r.stderr || "" };
67
86
  } catch (err) {
68
87
  return { stdout: null, stderr: err instanceof Error ? err.message : String(err) };
@@ -70,17 +89,22 @@ async function runTransport({ argv, stdin }, cwd) {
70
89
  }
71
90
  async function ghCall(primary, fallback, opts = {}) {
72
91
  const sleep = opts.sleep ?? realSleep;
92
+ let lastStderr = "";
73
93
  for (const transport of [primary, fallback]) {
74
94
  if (!transport) continue;
75
95
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
76
96
  const { stdout, stderr } = await runTransport(transport, opts.cwd);
77
97
  if (stdout !== null) return stdout;
98
+ lastStderr = stderr;
78
99
  if (isRateLimited(stderr)) break;
79
100
  if (attempt < MAX_RETRIES) {
80
101
  await sleep(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
81
102
  }
82
103
  }
83
104
  }
105
+ if (opts.throwOnTransient && isTransientGhFailure(lastStderr)) {
106
+ throw new GhTransientError(lastStderr.trim());
107
+ }
84
108
  return null;
85
109
  }
86
110
  function parseSlugFromRemoteUrl(url) {
@@ -225,6 +249,40 @@ async function postCheckRun(repo, headSha, output, conclusion, cwd) {
225
249
  );
226
250
  }
227
251
 
252
+ // src/lib/cancel-run.ts
253
+ var EXIT_TRANSIENT = 75;
254
+ var CANCEL_GRACE_MS = 12e4;
255
+ async function requestRunCancel(opts = {}) {
256
+ const { GITHUB_ACTIONS, GITHUB_REPOSITORY, GITHUB_RUN_ID } = opts.env ?? process.env;
257
+ if (GITHUB_ACTIONS !== "true" || !GITHUB_REPOSITORY || !GITHUB_RUN_ID) return false;
258
+ const out = await ghCall(
259
+ {
260
+ argv: [
261
+ "gh",
262
+ "api",
263
+ "-X",
264
+ "POST",
265
+ `repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel`
266
+ ]
267
+ },
268
+ null,
269
+ { cwd: opts.cwd }
270
+ );
271
+ return out !== null;
272
+ }
273
+ async function abandonRunAsCancelled(err, opts = {}) {
274
+ console.warn(
275
+ `::warning title=merge-safety cancelled::${err.message} \u2014 cancelling this run; the next event re-evaluates.`
276
+ );
277
+ process.exitCode = EXIT_TRANSIENT;
278
+ if (!await requestRunCancel(opts)) {
279
+ console.error("could not request cancellation of this run; exiting as failed");
280
+ return;
281
+ }
282
+ const wait = opts.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
283
+ await wait(CANCEL_GRACE_MS);
284
+ }
285
+
228
286
  // src/conventional-commits.ts
229
287
  var BREAKING_SUBJECT_RE = /^[a-z]+(?:!(?:\([^)]*\))?|(?:\([^)]*\))?!):/;
230
288
  var BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:/m;
@@ -644,7 +702,7 @@ function parse(argv) {
644
702
  return args;
645
703
  }
646
704
  async function ghJson(argv, cwd) {
647
- const out = await ghCall({ argv }, null, { cwd });
705
+ const out = await ghCall({ argv }, null, { cwd, throwOnTransient: true });
648
706
  if (out === null) return null;
649
707
  try {
650
708
  return JSON.parse(out);
@@ -835,8 +893,18 @@ async function main() {
835
893
  console.error("error: could not resolve target repo (pass --repo <owner/repo>)");
836
894
  process.exit(2);
837
895
  }
838
- if (args.mode === "evaluate") await runEvaluate(repo, args.pr, args);
839
- else await runInvalidate(repo, args);
896
+ try {
897
+ if (args.mode === "evaluate") await runEvaluate(repo, args.pr, args);
898
+ else await runInvalidate(repo, args);
899
+ } catch (err) {
900
+ if (!(err instanceof GhTransientError)) throw err;
901
+ if (args.json) {
902
+ console.error(`error: ${err.message}`);
903
+ process.exitCode = EXIT_TRANSIENT;
904
+ } else {
905
+ await abandonRunAsCancelled(err, { cwd: args.cwd });
906
+ }
907
+ }
840
908
  }
841
909
  function isDirectRun() {
842
910
  const entry = process.argv[1];
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@rmartz/merge-safety",
3
- "version": "0.13.0",
4
- "description": "The pre-auto-merge safety verdict for a PR (base-currency, breaking-change, and conflict facts), posted as the `merge-safety` check-run and distributed via a reusable workflow kept current by Dependabot.",
3
+ "version": "0.15.0",
4
+ "description": "The pre-auto-merge safety verdict for a PR (base-currency, breaking-change, and conflict facts), posted as the `merge-safety` check-run and distributed through the `rmartz/merge-safety-action` composite action kept current by Dependabot.",
5
5
  "keywords": [
6
6
  "ci",
7
7
  "merge-safety",
8
8
  "auto-merge",
9
9
  "github-actions",
10
- "reusable-workflow",
10
+ "composite-action",
11
11
  "check-run"
12
12
  ],
13
13
  "homepage": "https://github.com/rmartz/merge-safety#readme",