@rmartz/merge-safety 0.12.0 → 0.14.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`;
@@ -230,8 +230,6 @@ var BREAKING_SUBJECT_RE = /^[a-z]+(?:!(?:\([^)]*\))?|(?:\([^)]*\))?!):/;
230
230
  var BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:/m;
231
231
  var CI_SUBJECT_RE = /^ci(\([^)]*\))?!?:/;
232
232
  var DOCS_SUBJECT_RE = /^docs(\([^)]*\))?!?:/;
233
- var FUNCTIONAL_TYPES = /* @__PURE__ */ new Set(["feat", "fix", "perf", "revert"]);
234
- var CONVENTIONAL_TYPE_RE = /^([a-z]+)(?:!|\([^)]*\))*:/;
235
233
  function firstLine(message) {
236
234
  return message.split("\n", 1)[0] ?? "";
237
235
  }
@@ -253,10 +251,6 @@ function isCiTitle(title) {
253
251
  function isDocsTitle(title) {
254
252
  return DOCS_SUBJECT_RE.test(title.trim());
255
253
  }
256
- function mayCarryBreakingMarker(title) {
257
- const type = CONVENTIONAL_TYPE_RE.exec(title.trim())?.[1];
258
- return type !== void 0 && FUNCTIONAL_TYPES.has(type);
259
- }
260
254
 
261
255
  // src/reasons.ts
262
256
  var BREAKING_DIFF_REASONS = {
@@ -326,6 +320,89 @@ function evaluateStaleness(facts) {
326
320
  return { needsUpdate, reasons };
327
321
  }
328
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
+
329
406
  // src/breaking-diff.ts
330
407
  var CI_SENSITIVE_PACKAGES = /* @__PURE__ */ new Set(["eslint", "black", "pylint", "ruff", "prettier"]);
331
408
  var NON_DEPENDENCY_NPM_KEYS = /* @__PURE__ */ new Set(["version"]);
@@ -432,110 +509,6 @@ function breakingDiffSignals(diffText) {
432
509
  if (tests.length) signals.push({ kind: "material-test-changes", detail: tests });
433
510
  return signals;
434
511
  }
435
- function hasSignal(signals, kind) {
436
- return signals.some((s) => s.kind === kind);
437
- }
438
- function signalDetail(signals, kind) {
439
- return signals.find((s) => s.kind === kind)?.detail ?? [];
440
- }
441
-
442
- // src/base-health.ts
443
- var FAILING_CI_CONCLUSIONS = ["failure", "timed_out", "startup_failure"];
444
- var GITHUB_ACTIONS_APP_SLUG = "github-actions";
445
- var NON_BUILD_PLATFORM_CHECKS = /* @__PURE__ */ new Set(["dependabot", "dependabot updates"]);
446
- function isGitHubActionsCheck(check) {
447
- return check.appSlug === GITHUB_ACTIONS_APP_SLUG;
448
- }
449
- function isNonBuildPlatformCheck(name) {
450
- return NON_BUILD_PLATFORM_CHECKS.has(name.trim().toLowerCase());
451
- }
452
- function isFailingCiConclusion(conclusion) {
453
- return conclusion !== null && FAILING_CI_CONCLUSIONS.includes(conclusion);
454
- }
455
- function failingRequiredBaseChecks(checks, requiredContexts) {
456
- if (!requiredContexts || requiredContexts.length === 0) return [];
457
- const required = new Set(requiredContexts);
458
- return checks.filter((c) => required.has(c.name) && isFailingCiConclusion(c.conclusion)).map((c) => c.name);
459
- }
460
- function failingFallbackBaseChecks(checks) {
461
- return checks.filter(
462
- (c) => isGitHubActionsCheck(c) && isFailingCiConclusion(c.conclusion) && !isNonBuildPlatformCheck(c.name)
463
- ).map((c) => c.name);
464
- }
465
-
466
- // src/merge-safety.ts
467
- var MERGE_SAFETY_LABELS = ["update required", "merge conflict"];
468
- function isEvaluablePrState(state) {
469
- return state === "OPEN";
470
- }
471
- var HOTFIX_LABEL = "hotfix";
472
- var BREAKING_LABEL = "breaking change";
473
- function overlappingFiles(prFiles, baseFiles) {
474
- if (!prFiles.length || !baseFiles.length) return [];
475
- const base = new Set(baseFiles);
476
- return prFiles.filter((f) => base.has(f));
477
- }
478
- function evaluateMergeSafety(facts) {
479
- const reasons = [];
480
- if (facts.hasConflict) {
481
- reasons.push("Git reports a merge conflict against the base \u2014 resolve it before merging.");
482
- }
483
- const baseUnhealthy = facts.baseCiFailing && !facts.prIsHotfix;
484
- if (baseUnhealthy) {
485
- reasons.push(
486
- withDetail(
487
- "The base branch's CI is failing \u2014 only hotfix PRs may merge until it is green (label this PR `hotfix` to override):",
488
- facts.failingBaseChecks
489
- )
490
- );
491
- }
492
- const { needsUpdate, reasons: stalenessReasons } = evaluateStaleness(facts);
493
- reasons.push(...stalenessReasons);
494
- const needsCiRetitle = hasSignal(facts.prBreakingDiffSignals, "sensitive-package-bump") ? !facts.prIsCi : false;
495
- if (needsCiRetitle) {
496
- reasons.push(
497
- withDetail(
498
- "This PR changes a CI-sensitive package version, which can redden the format/lint gate on PRs that never touched the bumped file. Retitle it with the `ci` type (e.g. `ci(deps): \u2026`) so merged siblings are forced to re-test under it \u2014 a `breaking change` label cannot carry this signal on a non-functional type:",
499
- signalDetail(facts.prBreakingDiffSignals, "sensitive-package-bump")
500
- )
501
- );
502
- }
503
- const needsAction = facts.hasConflict || baseUnhealthy || needsCiRetitle;
504
- const conclusion = needsAction ? "failure" : needsUpdate ? "pending" : "success";
505
- 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.");
506
- const title = facts.hasConflict ? "Merge conflict" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : needsCiRetitle ? "Retitle as a CI change" : "No update required";
507
- const add = [];
508
- if (needsUpdate) add.push("update required");
509
- if (facts.hasConflict) add.push("merge conflict");
510
- const remove = MERGE_SAFETY_LABELS.filter((l) => !add.includes(l));
511
- const addOnly = [];
512
- if (hasSignal(facts.prBreakingDiffSignals, "major-version-bump") && facts.prMayCarryBreakingMarker)
513
- addOnly.push(BREAKING_LABEL);
514
- return {
515
- conclusion,
516
- needsUpdate,
517
- hasConflict: facts.hasConflict,
518
- baseUnhealthy,
519
- needsCiRetitle,
520
- title,
521
- reasons,
522
- summary,
523
- labels: { add, remove, addOnly }
524
- };
525
- }
526
- function errorMergeSafetyDecision(message) {
527
- return {
528
- conclusion: "failure",
529
- needsUpdate: false,
530
- hasConflict: false,
531
- baseUnhealthy: false,
532
- needsCiRetitle: false,
533
- title: "Could not evaluate",
534
- reasons: [message],
535
- summary: `Could not evaluate merge safety: ${message}`,
536
- labels: { add: [], remove: [], addOnly: [] }
537
- };
538
- }
539
512
 
540
513
  // src/merge-safety-facts.ts
541
514
  var GIT_TIMEOUT_MS = 3e4;
@@ -617,9 +590,10 @@ async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, base
617
590
  // Each boolean is derived from its detail list — one computation, two views.
618
591
  baseBreakingSinceMergeBase: baseBreakingCommits.length > 0,
619
592
  baseCiSinceMergeBase: baseCiCommits.length > 0,
620
- // Additive by construction: the title marker and the label remain inputs, and
621
- // the diff can only ever add a reason to treat the PR as breaking (#53).
622
- prIsBreaking: isBreakingTitle(meta.title) || labels.includes(BREAKING_LABEL) || diffSignals.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,
623
597
  prIsDocs: isDocsTitle(meta.title),
624
598
  baseOnlyDocsSinceMergeBase: commits.length > 0 && commits.every((c) => isDocsCommitMessage(c.message)),
625
599
  // Title or path (#67): a release-typed PR that changes CI is still re-tested.
@@ -632,8 +606,7 @@ async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, base
632
606
  baseCiCommits,
633
607
  overlappingFiles: overlaps,
634
608
  failingBaseChecks,
635
- prBreakingDiffSignals: diffSignals,
636
- prMayCarryBreakingMarker: mayCarryBreakingMarker(meta.title)
609
+ prBreakingDiffSignals: diffSignals
637
610
  };
638
611
  }
639
612
 
@@ -807,13 +780,7 @@ ${detail}` },
807
780
  decision.conclusion,
808
781
  args.cwd
809
782
  );
810
- await reconcileLabels(
811
- repo,
812
- pr,
813
- [...decision.labels.add, ...decision.labels.addOnly],
814
- decision.labels.remove,
815
- args.cwd
816
- );
783
+ await reconcileLabels(repo, pr, decision.labels.add, decision.labels.remove, args.cwd);
817
784
  console.log(`#${pr}: ${decision.conclusion} \u2014 ${decision.summary}`);
818
785
  }
819
786
  function emitDecisionJson(decision, isError) {
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@rmartz/merge-safety",
3
- "version": "0.12.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.14.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",