@rmartz/merge-safety 0.14.0 → 0.16.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
@@ -32,6 +32,13 @@ the verdict expects of a repo and how it behaves.
32
32
  > `rmartz/merge-safety/.github/workflows/merge-safety.yml` keep working and see a
33
33
  > deprecation warning on each run; migrate them to the action.
34
34
 
35
+ ## Using it as a library
36
+
37
+ The package also exports the verdict for in-process use, e.g. from a hosted
38
+ GitHub App with no checkout: `gatherMergeSafetyFacts` with the API-backed
39
+ `makeApiGitRunner` fact source, then `evaluateMergeSafety`. See
40
+ [docs/fact-sources.md](docs/fact-sources.md).
41
+
35
42
  ## Requirements
36
43
 
37
44
  - Node.js >= 20.11
@@ -74,8 +81,8 @@ split-prefix convention (rmartz/ai#82): a **production** bump arrives as
74
81
  **github-actions** (`chore(github-actions):`) bumps are release-less — as a
75
82
  zero-runtime-dependency package, all Dependabot bumps are dev-only in practice.
76
83
 
77
- Three guards back the automatic flow: [`pr-title-lint.yml`](.github/workflows/pr-title-lint.yml)
78
- (pre-merge title format), [`commit-convention.yml`](.github/workflows/commit-convention.yml)
84
+ Three guards back the automatic flow: pr-policy's `title` check, part of the
85
+ `pr-policy` check run by [`pr-policy.yml`](.github/workflows/pr-policy.yml) (pre-merge title format), [`commit-convention.yml`](.github/workflows/commit-convention.yml)
79
86
  (post-merge tripwire for a non-conventional subject that would make semantic-release
80
87
  silently skip), the shared [`release-check.yml`](.github/workflows/release-check.yml)
81
88
  (renders the notes through the shared
@@ -1,47 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { MergeSafetyCommand } from '../index.js';
3
-
4
- /**
5
- * Base-health classification — "is the base branch's own CI failing?", the
6
- * PR-independent axis of the merge-safety verdict.
7
- *
8
- * Extracted from `merge-safety.ts` when the self-derived breaking-change signal
9
- * (#53) pushed that file past its 480-line cap. It is a genuinely separate
10
- * concern: every other clause of the verdict reads *this PR* against *its base*,
11
- * while these functions read only the base tip's check-runs and know nothing
12
- * about any PR. Pure — the network boundary stays in the bin's probes.
13
- */
14
- /**
15
- * A base-tip check-run reduced to what base-health classification needs: its name
16
- * (matched against the base branch's required status checks), its conclusion, and
17
- * the slug of the producing GitHub App (used by the fallback heuristic to tell a
18
- * GitHub Actions CI run from an external deploy integration).
19
- */
20
- interface BaseCheckRun {
21
- /** The check-run name (e.g. the workflow / job name), matched to a required context. */
22
- name: string;
23
- /** The check-run conclusion, or `null` while still in progress. */
24
- conclusion: string | null;
25
- /** The producing GitHub App's slug (e.g. `github-actions`, `vercel`), or `null`. */
26
- appSlug: string | null;
27
- }
28
-
29
- /**
30
- * Fetches the base branch tip's check-runs (deduped to the latest per name) so
31
- * base-health can be judged. Injected like {@link GitRunner} so the assembly logic
32
- * stays testable with a fake and the network boundary lives in the bin. Soft-fails
33
- * to `[]` (an unreadable base is treated as *not* failing) so a transient `gh`
34
- * error never wedges the whole merge queue on a base-health false positive.
35
- */
36
- type BaseChecksProbe = (baseSha: string) => Promise<readonly BaseCheckRun[]>;
37
- /**
38
- * Fetches the base branch's **required status check** contexts — the set base-health
39
- * scopes failures to (#40). Returns `null` when protection can't be read (transient
40
- * `gh` error, no ruleset, missing scope); a `null` or empty set means base-health
41
- * reports no failure, so a probe hiccup never wedges the merge queue. Injected like
42
- * {@link BaseChecksProbe}, with the network boundary in the bin.
43
- */
44
- type RequiredChecksProbe = (baseBranch: string) => Promise<readonly string[] | null>;
2
+ import { MergeSafetyCommand, BaseChecksProbe, RequiredChecksProbe } from '../index.js';
45
3
 
46
4
  interface Args {
47
5
  mode: MergeSafetyCommand;
@@ -1,68 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  MERGE_SAFETY_CHECK_NAME,
4
- isMergeSafetyCommand
5
- } from "../chunk-ITPKHZGC.js";
4
+ boundedRun,
5
+ errorMergeSafetyDecision,
6
+ evaluateMergeSafety,
7
+ gatherMergeSafetyFacts,
8
+ isEvaluablePrState,
9
+ isMergeSafetyCommand,
10
+ makeGitRunner
11
+ } from "../chunk-2QWRQ4Q5.js";
6
12
 
7
13
  // src/bin/merge-safety.ts
8
14
  import { realpathSync } from "fs";
9
15
  import { fileURLToPath } from "url";
10
16
 
11
- // src/lib/bounded-subprocess.ts
12
- import { spawn } from "child_process";
13
- function boundedRun(command, args, options) {
14
- return new Promise((resolve, reject) => {
15
- const child = spawn(command, args, {
16
- cwd: options.cwd,
17
- env: options.env,
18
- detached: true
19
- // new process group, so we can kill the whole tree
20
- });
21
- let stdout = "";
22
- let stderr = "";
23
- let timedOut = false;
24
- const timer = setTimeout(() => {
25
- timedOut = true;
26
- try {
27
- process.kill(-child.pid, "SIGKILL");
28
- } catch {
29
- }
30
- }, options.timeoutMs);
31
- if (options.input !== void 0 && child.stdin) {
32
- child.stdin.on("error", () => {
33
- });
34
- child.stdin.write(options.input);
35
- child.stdin.end();
36
- }
37
- child.stdout.on("data", (chunk) => stdout += chunk.toString());
38
- child.stderr.on("data", (chunk) => stderr += chunk.toString());
39
- child.on("error", (err) => {
40
- clearTimeout(timer);
41
- reject(err);
42
- });
43
- child.on("close", (code) => {
44
- clearTimeout(timer);
45
- resolve({ stdout, stderr, code, timedOut });
46
- });
47
- });
48
- }
49
-
50
17
  // src/lib/github.ts
51
18
  var GH_API_TIMEOUT_MS = 3e4;
52
19
  var MAX_RETRIES = 2;
53
20
  var BACKOFF_BASE_MS = 1e3;
54
21
  var BACKOFF_CAP_MS = 8e3;
55
22
  var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23
+ var GhTransientError = class extends Error {
24
+ constructor(stderr) {
25
+ super(`transient GitHub API failure: ${stderr}`);
26
+ this.stderr = stderr;
27
+ this.name = "GhTransientError";
28
+ }
29
+ stderr;
30
+ };
56
31
  function isRateLimited(text) {
57
32
  const t = (text ?? "").toLowerCase();
58
33
  return t.includes("rate limit") || t.includes("rate-limit");
59
34
  }
35
+ var TRANSIENT_PATTERNS = [
36
+ /\bHTTP 5\d\d\b/,
37
+ /bad gateway|service unavailable|gateway time-?out|internal server error/i,
38
+ /timed out|timeout/i,
39
+ /connection (?:reset|refused)/i,
40
+ /could not resolve host|no such host|tls handshake|unexpected eof/i
41
+ ];
42
+ function isTransientGhFailure(stderr) {
43
+ return isRateLimited(stderr) || TRANSIENT_PATTERNS.some((p) => p.test(stderr));
44
+ }
60
45
  async function runTransport({ argv, stdin }, cwd) {
61
46
  const [command, ...args] = argv;
62
47
  if (command === void 0) return { stdout: null, stderr: "empty argv" };
63
48
  try {
64
49
  const r = await boundedRun(command, args, { timeoutMs: GH_API_TIMEOUT_MS, cwd, input: stdin });
65
50
  if (r.code === 0) return { stdout: r.stdout, stderr: "" };
51
+ if (r.timedOut) return { stdout: null, stderr: `gh timed out after ${GH_API_TIMEOUT_MS}ms` };
66
52
  return { stdout: null, stderr: r.stderr || "" };
67
53
  } catch (err) {
68
54
  return { stdout: null, stderr: err instanceof Error ? err.message : String(err) };
@@ -70,17 +56,22 @@ async function runTransport({ argv, stdin }, cwd) {
70
56
  }
71
57
  async function ghCall(primary, fallback, opts = {}) {
72
58
  const sleep = opts.sleep ?? realSleep;
59
+ let lastStderr = "";
73
60
  for (const transport of [primary, fallback]) {
74
61
  if (!transport) continue;
75
62
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
76
63
  const { stdout, stderr } = await runTransport(transport, opts.cwd);
77
64
  if (stdout !== null) return stdout;
65
+ lastStderr = stderr;
78
66
  if (isRateLimited(stderr)) break;
79
67
  if (attempt < MAX_RETRIES) {
80
68
  await sleep(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
81
69
  }
82
70
  }
83
71
  }
72
+ if (opts.throwOnTransient && isTransientGhFailure(lastStderr)) {
73
+ throw new GhTransientError(lastStderr.trim());
74
+ }
84
75
  return null;
85
76
  }
86
77
  function parseSlugFromRemoteUrl(url) {
@@ -225,389 +216,38 @@ async function postCheckRun(repo, headSha, output, conclusion, cwd) {
225
216
  );
226
217
  }
227
218
 
228
- // src/conventional-commits.ts
229
- var BREAKING_SUBJECT_RE = /^[a-z]+(?:!(?:\([^)]*\))?|(?:\([^)]*\))?!):/;
230
- var BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:/m;
231
- var CI_SUBJECT_RE = /^ci(\([^)]*\))?!?:/;
232
- var DOCS_SUBJECT_RE = /^docs(\([^)]*\))?!?:/;
233
- function firstLine(message) {
234
- return message.split("\n", 1)[0] ?? "";
235
- }
236
- function isBreakingCommitMessage(message) {
237
- return BREAKING_SUBJECT_RE.test(firstLine(message)) || BREAKING_FOOTER_RE.test(message);
238
- }
239
- function isCiCommitMessage(message) {
240
- return CI_SUBJECT_RE.test(firstLine(message));
241
- }
242
- function isDocsCommitMessage(message) {
243
- return DOCS_SUBJECT_RE.test(firstLine(message));
244
- }
245
- function isBreakingTitle(title) {
246
- return BREAKING_SUBJECT_RE.test(title.trim());
247
- }
248
- function isCiTitle(title) {
249
- return CI_SUBJECT_RE.test(title.trim());
250
- }
251
- function isDocsTitle(title) {
252
- return DOCS_SUBJECT_RE.test(title.trim());
253
- }
254
-
255
- // src/reasons.ts
256
- var BREAKING_DIFF_REASONS = {
257
- "major-version-bump": "dependency major bump",
258
- "sensitive-package-bump": "CI-sensitive package version change",
259
- "material-test-changes": "existing test file modified"
260
- };
261
- function nestedBullets(items) {
262
- return items.map((item) => ` - ${item}`).join("\n");
263
- }
264
- function formatBaseCommit(commit) {
265
- return `${commit.sha.slice(0, 7)} ${commit.subject}`;
266
- }
267
- function withDetail(sentence, detail) {
268
- return detail.length ? `${sentence}
269
- ${nestedBullets(detail)}` : sentence;
270
- }
271
- function breakingSignalDetail(signals) {
272
- return signals.flatMap((s) => s.detail.map((d) => `${BREAKING_DIFF_REASONS[s.kind]}: ${d}`));
273
- }
274
-
275
- // src/staleness.ts
276
- function evaluateStaleness(facts) {
277
- const reasons = [];
278
- if (facts.isCurrent) return { needsUpdate: false, reasons };
279
- const baseBreaking = facts.baseBreakingSinceMergeBase && !(facts.prIsDocs && !facts.prIsBreaking);
280
- const prBreaking = facts.prIsBreaking && !facts.baseOnlyDocsSinceMergeBase;
281
- const prCi = facts.prIsCi && !facts.prIsHotfix;
282
- if (baseBreaking) {
283
- reasons.push(
284
- withDetail(
285
- "A breaking change landed on the base since merge-base \u2014 rebase and re-run CI:",
286
- facts.baseBreakingCommits.map(formatBaseCommit)
287
- )
288
- );
289
- }
290
- if (prBreaking) {
291
- reasons.push(
292
- withDetail(
293
- "This PR is a breaking change \u2014 it must be current with the base before merge.",
294
- breakingSignalDetail(facts.prBreakingDiffSignals)
295
- )
296
- );
297
- }
298
- if (prCi) {
299
- reasons.push(
300
- "This PR is a CI change \u2014 it must be current with the base before merge, so it is re-tested against the latest base and cannot green a guard against a pattern that has since regressed."
301
- );
302
- }
303
- if (facts.baseCiSinceMergeBase) {
304
- reasons.push(
305
- withDetail(
306
- "A CI change landed on the base since merge-base \u2014 rebase to re-test under it:",
307
- facts.baseCiCommits.map(formatBaseCommit)
308
- )
309
- );
310
- }
311
- if (facts.fileOverlap) {
312
- reasons.push(
313
- withDetail(
314
- "This PR changes files the base also changed since merge-base \u2014 sync with base and re-run CI before merging to ensure the changes are compatible:",
315
- facts.overlappingFiles
316
- )
317
- );
318
- }
319
- const needsUpdate = baseBreaking || facts.baseCiSinceMergeBase || prBreaking || prCi || facts.fileOverlap;
320
- return { needsUpdate, reasons };
321
- }
322
-
323
- // src/base-health.ts
324
- var FAILING_CI_CONCLUSIONS = ["failure", "timed_out", "startup_failure"];
325
- var GITHUB_ACTIONS_APP_SLUG = "github-actions";
326
- var NON_BUILD_PLATFORM_CHECKS = /* @__PURE__ */ new Set(["dependabot", "dependabot updates"]);
327
- function isGitHubActionsCheck(check) {
328
- return check.appSlug === GITHUB_ACTIONS_APP_SLUG;
329
- }
330
- function isNonBuildPlatformCheck(name) {
331
- return NON_BUILD_PLATFORM_CHECKS.has(name.trim().toLowerCase());
332
- }
333
- function isFailingCiConclusion(conclusion) {
334
- return conclusion !== null && FAILING_CI_CONCLUSIONS.includes(conclusion);
335
- }
336
- function failingRequiredBaseChecks(checks, requiredContexts) {
337
- if (!requiredContexts || requiredContexts.length === 0) return [];
338
- const required = new Set(requiredContexts);
339
- return checks.filter((c) => required.has(c.name) && isFailingCiConclusion(c.conclusion)).map((c) => c.name);
340
- }
341
- function failingFallbackBaseChecks(checks) {
342
- return checks.filter(
343
- (c) => isGitHubActionsCheck(c) && isFailingCiConclusion(c.conclusion) && !isNonBuildPlatformCheck(c.name)
344
- ).map((c) => c.name);
345
- }
346
-
347
- // src/merge-safety.ts
348
- var MERGE_SAFETY_LABELS = ["update required", "merge conflict"];
349
- function isEvaluablePrState(state) {
350
- return state === "OPEN";
351
- }
352
- var HOTFIX_LABEL = "hotfix";
353
- function overlappingFiles(prFiles, baseFiles) {
354
- if (!prFiles.length || !baseFiles.length) return [];
355
- const base = new Set(baseFiles);
356
- return prFiles.filter((f) => base.has(f));
357
- }
358
- function evaluateMergeSafety(facts) {
359
- const reasons = [];
360
- if (facts.hasConflict) {
361
- reasons.push("Git reports a merge conflict against the base \u2014 resolve it before merging.");
362
- }
363
- const baseUnhealthy = facts.baseCiFailing && !facts.prIsHotfix;
364
- if (baseUnhealthy) {
365
- reasons.push(
366
- withDetail(
367
- "The base branch's CI is failing \u2014 only hotfix PRs may merge until it is green (label this PR `hotfix` to override):",
368
- facts.failingBaseChecks
369
- )
370
- );
371
- }
372
- const { needsUpdate, reasons: stalenessReasons } = evaluateStaleness(facts);
373
- reasons.push(...stalenessReasons);
374
- const needsAction = facts.hasConflict || baseUnhealthy;
375
- const conclusion = needsAction ? "failure" : needsUpdate ? "pending" : "success";
376
- const summary = conclusion === "success" ? "No update required: current, or no breaking/overlapping changes on the base, no conflict, and the base CI is green." : firstLine(reasons[0] ?? "Not safe to merge as-is.");
377
- const title = facts.hasConflict ? "Merge conflict" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : "No update required";
378
- const add = [];
379
- if (needsUpdate) add.push("update required");
380
- if (facts.hasConflict) add.push("merge conflict");
381
- const remove = MERGE_SAFETY_LABELS.filter((l) => !add.includes(l));
382
- return {
383
- conclusion,
384
- needsUpdate,
385
- hasConflict: facts.hasConflict,
386
- baseUnhealthy,
387
- title,
388
- reasons,
389
- summary,
390
- labels: { add, remove }
391
- };
392
- }
393
- function errorMergeSafetyDecision(message) {
394
- return {
395
- conclusion: "failure",
396
- needsUpdate: false,
397
- hasConflict: false,
398
- baseUnhealthy: false,
399
- title: "Could not evaluate",
400
- reasons: [message],
401
- summary: `Could not evaluate merge safety: ${message}`,
402
- labels: { add: [], remove: [] }
403
- };
404
- }
405
-
406
- // src/breaking-diff.ts
407
- var CI_SENSITIVE_PACKAGES = /* @__PURE__ */ new Set(["eslint", "black", "pylint", "ruff", "prettier"]);
408
- var NON_DEPENDENCY_NPM_KEYS = /* @__PURE__ */ new Set(["version"]);
409
- var NPM_DEP_RE = /"([^"]+)"\s*:\s*"[\^~]?(\d+)\.(\d+)\.(\d+)/;
410
- var PIP_DEP_RE = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:==|~=|>=|<=|!=|<|>)\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/;
411
- var TEST_FILE_RE = /(\.test\.tsx?|\.spec\.tsx?|test_[^/]*\.py|[^/]*_test\.py)$/;
412
- var DIFF_HEADER_PATH_RE = / b\/(.+)$/;
413
- function normalizePackageName(name) {
414
- return name.trim().toLowerCase().replace(/[-_.]+/g, "-");
415
- }
416
- function dependencyFileKind(diffGitLine) {
417
- if (diffGitLine.includes("package.json")) return "npm";
418
- if (/requirements[\w./-]*\.txt/.test(diffGitLine)) return "pip";
419
- return null;
420
- }
421
- function isFileHeader(line) {
422
- return line.startsWith("---") || line.startsWith("+++");
423
- }
424
- function dependencyChanges(diffText) {
425
- const changes = [];
426
- let kind = null;
427
- for (const line of diffText.split("\n")) {
428
- if (line.startsWith("diff --git")) {
429
- kind = dependencyFileKind(line);
430
- continue;
431
- }
432
- if (kind === null) continue;
433
- if (isFileHeader(line)) continue;
434
- if (!line.startsWith("-") && !line.startsWith("+")) continue;
435
- const sign = line[0] === "-" ? "-" : "+";
436
- const match = (kind === "npm" ? NPM_DEP_RE : PIP_DEP_RE).exec(line.slice(1));
437
- if (!match) continue;
438
- const name = match[1] ?? "";
439
- if (kind === "npm" && NON_DEPENDENCY_NPM_KEYS.has(name.trim().toLowerCase())) continue;
440
- const major = Number(match[2] ?? 0);
441
- const minor = Number(match[3] ?? 0);
442
- const patch = Number(match[4] ?? 0);
443
- changes.push({ kind, sign, name, major, version: `${major}.${minor}.${patch}` });
444
- }
445
- return changes;
446
- }
447
- function indexChanges(changes) {
448
- const removed = /* @__PURE__ */ new Map();
449
- const added = /* @__PURE__ */ new Map();
450
- for (const change of changes) {
451
- const key = `${change.kind}:${normalizePackageName(change.name)}`;
452
- (change.sign === "-" ? removed : added).set(key, change);
453
- }
454
- return { removed, added };
455
- }
456
- function majorVersionBumps(diffText) {
457
- const { removed, added } = indexChanges(dependencyChanges(diffText));
458
- const bumps = [];
459
- for (const [key, addedChange] of added) {
460
- const removedChange = removed.get(key);
461
- if (removedChange && addedChange.major > removedChange.major) {
462
- bumps.push(`${addedChange.name} ${removedChange.version} \u2192 ${addedChange.version}`);
463
- }
464
- }
465
- return bumps;
466
- }
467
- function sensitivePackageBumps(diffText) {
468
- const { removed, added } = indexChanges(dependencyChanges(diffText));
469
- const bumps = [];
470
- for (const [key, addedChange] of added) {
471
- if (!CI_SENSITIVE_PACKAGES.has(normalizePackageName(addedChange.name))) continue;
472
- const removedChange = removed.get(key);
473
- if (removedChange && removedChange.version !== addedChange.version) {
474
- bumps.push(`${addedChange.name} ${removedChange.version} \u2192 ${addedChange.version}`);
475
- }
476
- }
477
- return bumps;
478
- }
479
- function materialTestChanges(diffText) {
480
- const changed = [];
481
- let file = null;
482
- let hasAdds = false;
483
- let hasRemoves = false;
484
- const flush = () => {
485
- if (file && hasAdds && hasRemoves && TEST_FILE_RE.test(file)) changed.push(file);
486
- };
487
- for (const line of diffText.split("\n")) {
488
- if (line.startsWith("diff --git")) {
489
- flush();
490
- file = DIFF_HEADER_PATH_RE.exec(line)?.[1] ?? null;
491
- hasAdds = false;
492
- hasRemoves = false;
493
- continue;
494
- }
495
- if (isFileHeader(line)) continue;
496
- if (line.startsWith("+")) hasAdds = true;
497
- else if (line.startsWith("-")) hasRemoves = true;
498
- }
499
- flush();
500
- return changed;
501
- }
502
- function breakingDiffSignals(diffText) {
503
- const signals = [];
504
- const major = majorVersionBumps(diffText);
505
- if (major.length) signals.push({ kind: "major-version-bump", detail: major });
506
- const sensitive = sensitivePackageBumps(diffText);
507
- if (sensitive.length) signals.push({ kind: "sensitive-package-bump", detail: sensitive });
508
- const tests = materialTestChanges(diffText);
509
- if (tests.length) signals.push({ kind: "material-test-changes", detail: tests });
510
- return signals;
511
- }
512
-
513
- // src/merge-safety-facts.ts
514
- var GIT_TIMEOUT_MS = 3e4;
515
- function baseBranchName(baseRef) {
516
- return baseRef.replace(/^refs\/heads\//, "").replace(/^origin\//, "");
517
- }
518
- function makeGitRunner(cwd) {
519
- return async (args) => {
520
- const r = await boundedRun("git", args, { timeoutMs: GIT_TIMEOUT_MS, cwd });
521
- return r.code === 0 ? r.stdout : null;
522
- };
523
- }
524
- function splitLines(out) {
525
- return (out ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
526
- }
527
- function parseBaseCommits(logOut) {
528
- return logOut.split("\0").map((record) => record.replace(/^\n+/, "")).filter((record) => record.trim().length > 0).map((record) => {
529
- const newline = record.indexOf("\n");
530
- const sha = (newline === -1 ? record : record.slice(0, newline)).trim();
531
- const message = newline === -1 ? "" : record.slice(newline + 1).trim();
532
- return { sha, message };
533
- });
534
- }
535
- var CI_PATH_RE = /^\.github\/(?:workflows|actions)\//;
536
- function isCiPath(path) {
537
- return CI_PATH_RE.test(path);
538
- }
539
- function parseCiPathShas(logOut) {
540
- const shas = /* @__PURE__ */ new Set();
541
- for (const record of logOut.split("\0")) {
542
- const [sha, ...files] = splitLines(record);
543
- if (sha && files.some(isCiPath)) shas.add(sha);
544
- }
545
- return shas;
546
- }
547
- function selectCommits(commits, predicate) {
548
- return commits.filter(predicate).map((c) => ({ sha: c.sha, subject: c.message.split("\n", 1)[0] ?? "" }));
219
+ // src/lib/cancel-run.ts
220
+ var EXIT_TRANSIENT = 75;
221
+ var CANCEL_GRACE_MS = 12e4;
222
+ async function requestRunCancel(opts = {}) {
223
+ const { GITHUB_ACTIONS, GITHUB_REPOSITORY, GITHUB_RUN_ID } = opts.env ?? process.env;
224
+ if (GITHUB_ACTIONS !== "true" || !GITHUB_REPOSITORY || !GITHUB_RUN_ID) return false;
225
+ const out = await ghCall(
226
+ {
227
+ argv: [
228
+ "gh",
229
+ "api",
230
+ "-X",
231
+ "POST",
232
+ `repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel`
233
+ ]
234
+ },
235
+ null,
236
+ { cwd: opts.cwd }
237
+ );
238
+ return out !== null;
549
239
  }
550
- async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, baseChecks, requiredChecks }) {
551
- const mergeBase = (await git(["merge-base", meta.headSha, baseRef]))?.trim();
552
- const baseTip = (await git(["rev-parse", baseRef]))?.trim();
553
- if (!mergeBase || !baseTip) {
554
- throw new Error(`could not resolve merge-base/base tip for ${meta.headSha}..${baseRef}`);
555
- }
556
- const isCurrent = mergeBase === baseTip;
557
- const baseBranch = baseBranchName(baseRef);
558
- const checks = await baseChecks(baseTip);
559
- const required = await requiredChecks(baseBranch);
560
- const failingBaseChecks = required && required.length > 0 ? failingRequiredBaseChecks(checks, required) : failingFallbackBaseChecks(checks);
561
- const logOut = await git(["log", "-z", "--format=%H%n%B", `${mergeBase}..${baseRef}`]);
562
- if (logOut === null) throw new Error(`git log failed for ${mergeBase}..${baseRef}`);
563
- const commits = parseBaseCommits(logOut);
564
- const baseBreakingCommits = selectCommits(commits, (c) => isBreakingCommitMessage(c.message));
565
- const fileLogOut = await git([
566
- "log",
567
- "--format=%x00%H",
568
- "--name-only",
569
- `${mergeBase}..${baseRef}`
570
- ]);
571
- if (fileLogOut === null) throw new Error(`git log failed for ${mergeBase}..${baseRef}`);
572
- const ciPathShas = parseCiPathShas(fileLogOut);
573
- const baseCiCommits = selectCommits(
574
- commits,
575
- (c) => isCiCommitMessage(c.message) || ciPathShas.has(c.sha)
240
+ async function abandonRunAsCancelled(err, opts = {}) {
241
+ console.warn(
242
+ `::warning title=merge-safety cancelled::${err.message} \u2014 cancelling this run; the next event re-evaluates.`
576
243
  );
577
- const baseFilesOut = await git(["diff", "--name-only", mergeBase, baseRef]);
578
- if (baseFilesOut === null) throw new Error(`git diff failed for ${mergeBase}..${baseRef}`);
579
- const baseFiles = splitLines(baseFilesOut);
580
- const prFilesOut = await git(["diff", "--name-only", mergeBase, meta.headSha]);
581
- if (prFilesOut === null) throw new Error(`git diff failed for ${mergeBase}..${meta.headSha}`);
582
- const prFiles = splitLines(prFilesOut);
583
- const overlaps = overlappingFiles(prFiles, baseFiles);
584
- const prDiff = await git(["diff", "--unified=0", mergeBase, meta.headSha]);
585
- if (prDiff === null) throw new Error(`git diff failed for ${mergeBase}..${meta.headSha}`);
586
- const diffSignals = breakingDiffSignals(prDiff);
587
- const labels = meta.labels.map((l) => l.toLowerCase());
588
- return {
589
- isCurrent,
590
- // Each boolean is derived from its detail list — one computation, two views.
591
- baseBreakingSinceMergeBase: baseBreakingCommits.length > 0,
592
- baseCiSinceMergeBase: baseCiCommits.length > 0,
593
- // The title's `!` is the only declared input (#82) — pr-policy's title check
594
- // keeps it in step with the `breaking change` label. The diff can only add a
595
- // reason to re-test the PR against a moved base (#53).
596
- prIsBreaking: isBreakingTitle(meta.title) || diffSignals.length > 0,
597
- prIsDocs: isDocsTitle(meta.title),
598
- baseOnlyDocsSinceMergeBase: commits.length > 0 && commits.every((c) => isDocsCommitMessage(c.message)),
599
- // Title or path (#67): a release-typed PR that changes CI is still re-tested.
600
- prIsCi: isCiTitle(meta.title) || prFiles.some(isCiPath),
601
- fileOverlap: overlaps.length > 0,
602
- hasConflict: meta.mergeable.toUpperCase() === "CONFLICTING",
603
- baseCiFailing: failingBaseChecks.length > 0,
604
- prIsHotfix: labels.includes(HOTFIX_LABEL),
605
- baseBreakingCommits,
606
- baseCiCommits,
607
- overlappingFiles: overlaps,
608
- failingBaseChecks,
609
- prBreakingDiffSignals: diffSignals
610
- };
244
+ process.exitCode = EXIT_TRANSIENT;
245
+ if (!await requestRunCancel(opts)) {
246
+ console.error("could not request cancellation of this run; exiting as failed");
247
+ return;
248
+ }
249
+ const wait = opts.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
250
+ await wait(CANCEL_GRACE_MS);
611
251
  }
612
252
 
613
253
  // src/bin/merge-safety.ts
@@ -644,7 +284,7 @@ function parse(argv) {
644
284
  return args;
645
285
  }
646
286
  async function ghJson(argv, cwd) {
647
- const out = await ghCall({ argv }, null, { cwd });
287
+ const out = await ghCall({ argv }, null, { cwd, throwOnTransient: true });
648
288
  if (out === null) return null;
649
289
  try {
650
290
  return JSON.parse(out);
@@ -835,8 +475,18 @@ async function main() {
835
475
  console.error("error: could not resolve target repo (pass --repo <owner/repo>)");
836
476
  process.exit(2);
837
477
  }
838
- if (args.mode === "evaluate") await runEvaluate(repo, args.pr, args);
839
- else await runInvalidate(repo, args);
478
+ try {
479
+ if (args.mode === "evaluate") await runEvaluate(repo, args.pr, args);
480
+ else await runInvalidate(repo, args);
481
+ } catch (err) {
482
+ if (!(err instanceof GhTransientError)) throw err;
483
+ if (args.json) {
484
+ console.error(`error: ${err.message}`);
485
+ process.exitCode = EXIT_TRANSIENT;
486
+ } else {
487
+ await abandonRunAsCancelled(err, { cwd: args.cwd });
488
+ }
489
+ }
840
490
  }
841
491
  function isDirectRun() {
842
492
  const entry = process.argv[1];