@rmartz/merge-safety 0.10.0 → 0.11.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
@@ -116,9 +116,11 @@ zero-runtime-dependency package, all Dependabot bumps are dev-only in practice.
116
116
  Three guards back the automatic flow: [`pr-title-lint.yml`](.github/workflows/pr-title-lint.yml)
117
117
  (pre-merge title format), [`commit-convention.yml`](.github/workflows/commit-convention.yml)
118
118
  (post-merge tripwire for a non-conventional subject that would make semantic-release
119
- silently skip), and, in [`ci.yml`](.github/workflows/ci.yml), the `Release notes render`
120
- job (renders the notes through the real changelog toolchain so an incompatible
121
- preset/writer pairing fails the PR) and the `Package`
119
+ silently skip), the shared [`release-check.yml`](.github/workflows/release-check.yml)
120
+ (renders the notes through the shared
121
+ [semantic-release-ci](https://github.com/rmartz/semantic-release-ci) toolchain so an
122
+ incompatible release config fails the PR), and, in [`ci.yml`](.github/workflows/ci.yml),
123
+ the `Package`
122
124
  job (packs exactly what `pnpm publish` would upload and fails if the tarball is
123
125
  malformed or missing an `exports`/`bin` entry point) — so both a broken release config
124
126
  and a broken packaging manifest are caught before merge, not on the real release run.
@@ -1,13 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { MergeSafetyCommand } from '../index.js';
3
3
 
4
- /** The open PR that heads a branch, reduced to what the barrier needs. */
5
- interface BasePr {
6
- number: number;
7
- /** The PR's label names, matched case-insensitively against the exempt set. */
8
- labels: readonly string[];
9
- }
10
-
11
4
  /**
12
5
  * Base-health classification — "is the base branch's own CI failing?", the
13
6
  * PR-independent axis of the merge-safety verdict.
@@ -49,14 +42,6 @@ type BaseChecksProbe = (baseSha: string) => Promise<readonly BaseCheckRun[]>;
49
42
  * {@link BaseChecksProbe}, with the network boundary in the bin.
50
43
  */
51
44
  type RequiredChecksProbe = (baseBranch: string) => Promise<readonly string[] | null>;
52
- /**
53
- * Fetches the open PR whose **head** is a given branch — this PR's stacked parent
54
- * (#54) — or `null` when no open PR heads it. A failed probe also yields `null`,
55
- * which deliberately reads as "not stacked": an unreadable lookup must not hold a
56
- * PR that may not be stacked at all, the same never-wedge posture as the check
57
- * probes above. Injected like {@link BaseChecksProbe}.
58
- */
59
- type BasePrProbe = (baseBranch: string) => Promise<BasePr | null>;
60
45
 
61
46
  interface Args {
62
47
  mode: MergeSafetyCommand;
@@ -71,20 +56,13 @@ interface Args {
71
56
  */
72
57
  workflow: string;
73
58
  cwd?: string;
74
- /**
75
- * The repository default branch, used by the stacked-PR barrier (#54). Absent →
76
- * resolved from `gh repo view`; unresolvable → the barrier is disabled rather
77
- * than holding every PR.
78
- */
79
- defaultBranch?: string;
80
59
  /**
81
60
  * `invalidate` only: the branch that moved, whose open PRs are fanned out over.
82
61
  * Any branch, not just the default — a push to a stacked parent's branch must
83
- * invalidate its children (#54). Absent → the resolved default branch.
62
+ * invalidate its children, which may now be stale. Absent → the resolved default
63
+ * branch.
84
64
  */
85
65
  baseBranch?: string;
86
- /** Labels on a base PR that exempt its children from the barrier (#54). */
87
- exemptBaseLabels: readonly string[];
88
66
  /** Decision-only: print the verdict as JSON and perform no side effects. */
89
67
  json: boolean;
90
68
  }
@@ -107,14 +85,7 @@ declare function makeBaseChecksProbe(repo: string, cwd?: string): BaseChecksProb
107
85
  * wedging the queue on an unreadable protection config (#40).
108
86
  */
109
87
  declare function makeRequiredChecksProbe(repo: string, cwd?: string): RequiredChecksProbe;
110
- /**
111
- * A real base-PR probe: the open PR whose **head** is `baseBranch`, i.e. this PR's
112
- * stacked parent (#54). Soft-fails to `null` (not stacked) on any read error or
113
- * malformed payload, so a transient `gh` failure never holds a PR that may not be
114
- * stacked at all. `--limit 1` suffices: a branch heads at most one open PR.
115
- */
116
- declare function makeBasePrProbe(repo: string, cwd?: string): BasePrProbe;
117
88
  declare function runEvaluate(repo: string, pr: number, args: Args): Promise<void>;
118
89
  declare function runInvalidate(repo: string, args: Args): Promise<void>;
119
90
 
120
- export { type Args, makeBaseChecksProbe, makeBasePrProbe, makeRequiredChecksProbe, runEvaluate, runInvalidate };
91
+ export { type Args, makeBaseChecksProbe, makeRequiredChecksProbe, runEvaluate, runInvalidate };
@@ -258,28 +258,6 @@ function mayCarryBreakingMarker(title) {
258
258
  return type !== void 0 && FUNCTIONAL_TYPES.has(type);
259
259
  }
260
260
 
261
- // src/stacked-base.ts
262
- var DEFAULT_EXEMPT_BASE_LABELS = ["release", "epic"];
263
- function isExemptBasePr(basePr, exemptLabels) {
264
- const exempt = new Set(exemptLabels.map((l) => l.trim().toLowerCase()));
265
- return basePr.labels.some((l) => exempt.has(l.trim().toLowerCase()));
266
- }
267
- function stackedParentPr({
268
- baseBranch,
269
- defaultBranch,
270
- basePr,
271
- exemptLabels
272
- }) {
273
- if (!defaultBranch) return null;
274
- if (!baseBranch || baseBranch === defaultBranch) return null;
275
- if (!basePr) return null;
276
- if (isExemptBasePr(basePr, exemptLabels)) return null;
277
- return basePr.number;
278
- }
279
- function stackedBaseReason(baseBranch, parentPr) {
280
- return `This PR targets \`${baseBranch}\`, the head of open PR #${parentPr} \u2014 it is stacked and must not merge until that PR does. Once #${parentPr} merges, GitHub retargets this PR to the default branch and it merges normally. Review and fixes proceed meanwhile; only the merge is held.`;
281
- }
282
-
283
261
  // src/reasons.ts
284
262
  var BREAKING_DIFF_REASONS = {
285
263
  "major-version-bump": "dependency major bump",
@@ -502,9 +480,6 @@ function evaluateMergeSafety(facts) {
502
480
  if (facts.hasConflict) {
503
481
  reasons.push("Git reports a merge conflict against the base \u2014 resolve it before merging.");
504
482
  }
505
- const stackedOnPr = facts.stackedOnPr;
506
- const stackedBarred = stackedOnPr !== null;
507
- if (stackedOnPr !== null) reasons.push(stackedBaseReason(facts.baseBranch, stackedOnPr));
508
483
  const baseUnhealthy = facts.baseCiFailing && !facts.prIsHotfix;
509
484
  if (baseUnhealthy) {
510
485
  reasons.push(
@@ -525,10 +500,10 @@ function evaluateMergeSafety(facts) {
525
500
  )
526
501
  );
527
502
  }
528
- const needsAction = facts.hasConflict || baseUnhealthy || needsCiRetitle || stackedBarred;
503
+ const needsAction = facts.hasConflict || baseUnhealthy || needsCiRetitle;
529
504
  const conclusion = needsAction ? "failure" : needsUpdate ? "pending" : "success";
530
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.");
531
- const title = facts.hasConflict ? "Merge conflict" : stackedBarred ? "Base PR not merged" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : needsCiRetitle ? "Retitle as a CI change" : "No update required";
506
+ const title = facts.hasConflict ? "Merge conflict" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : needsCiRetitle ? "Retitle as a CI change" : "No update required";
532
507
  const add = [];
533
508
  if (needsUpdate) add.push("update required");
534
509
  if (facts.hasConflict) add.push("merge conflict");
@@ -542,7 +517,6 @@ function evaluateMergeSafety(facts) {
542
517
  hasConflict: facts.hasConflict,
543
518
  baseUnhealthy,
544
519
  needsCiRetitle,
545
- stackedBarred,
546
520
  title,
547
521
  reasons,
548
522
  summary,
@@ -556,7 +530,6 @@ function errorMergeSafetyDecision(message) {
556
530
  hasConflict: false,
557
531
  baseUnhealthy: false,
558
532
  needsCiRetitle: false,
559
- stackedBarred: false,
560
533
  title: "Could not evaluate",
561
534
  reasons: [message],
562
535
  summary: `Could not evaluate merge safety: ${message}`,
@@ -601,15 +574,7 @@ function parseCiPathShas(logOut) {
601
574
  function selectCommits(commits, predicate) {
602
575
  return commits.filter(predicate).map((c) => ({ sha: c.sha, subject: c.message.split("\n", 1)[0] ?? "" }));
603
576
  }
604
- async function gatherMergeSafetyFacts(meta, {
605
- baseRef = "origin/main",
606
- git,
607
- baseChecks,
608
- requiredChecks,
609
- basePr = async () => null,
610
- defaultBranch = null,
611
- exemptBaseLabels = DEFAULT_EXEMPT_BASE_LABELS
612
- }) {
577
+ async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, baseChecks, requiredChecks }) {
613
578
  const mergeBase = (await git(["merge-base", meta.headSha, baseRef]))?.trim();
614
579
  const baseTip = (await git(["rev-parse", baseRef]))?.trim();
615
580
  if (!mergeBase || !baseTip) {
@@ -646,13 +611,6 @@ async function gatherMergeSafetyFacts(meta, {
646
611
  const prDiff = await git(["diff", "--unified=0", mergeBase, meta.headSha]);
647
612
  if (prDiff === null) throw new Error(`git diff failed for ${mergeBase}..${meta.headSha}`);
648
613
  const diffSignals = breakingDiffSignals(prDiff);
649
- const parentPr = defaultBranch && baseBranch !== defaultBranch ? await basePr(baseBranch) : null;
650
- const stackedOnPr = stackedParentPr({
651
- baseBranch,
652
- defaultBranch,
653
- basePr: parentPr,
654
- exemptLabels: exemptBaseLabels
655
- });
656
614
  const labels = meta.labels.map((l) => l.toLowerCase());
657
615
  return {
658
616
  isCurrent,
@@ -675,9 +633,7 @@ async function gatherMergeSafetyFacts(meta, {
675
633
  overlappingFiles: overlaps,
676
634
  failingBaseChecks,
677
635
  prBreakingDiffSignals: diffSignals,
678
- prMayCarryBreakingMarker: mayCarryBreakingMarker(meta.title),
679
- baseBranch,
680
- stackedOnPr
636
+ prMayCarryBreakingMarker: mayCarryBreakingMarker(meta.title)
681
637
  };
682
638
  }
683
639
 
@@ -685,7 +641,7 @@ async function gatherMergeSafetyFacts(meta, {
685
641
  var DEFAULT_CALLER_WORKFLOW = "merge-safety.yml";
686
642
  function usage() {
687
643
  console.error(
688
- "usage: ai-merge-safety evaluate --pr <n> [--json] [--repo <o/r>] [--base <ref>] [--cwd <path>]\n [--default-branch <name>] [--exempt-base-labels <csv>]\n ai-merge-safety invalidate [--base-branch <name>] [--workflow <file>] [--exclude <n>]\n [--repo <o/r>] [--cwd <path>]"
644
+ "usage: ai-merge-safety evaluate --pr <n> [--json] [--repo <o/r>] [--base <ref>] [--cwd <path>]\n ai-merge-safety invalidate [--base-branch <name>] [--workflow <file>] [--exclude <n>]\n [--repo <o/r>] [--cwd <path>]"
689
645
  );
690
646
  process.exit(2);
691
647
  }
@@ -696,7 +652,6 @@ function parse(argv) {
696
652
  mode,
697
653
  baseRef: "origin/main",
698
654
  workflow: DEFAULT_CALLER_WORKFLOW,
699
- exemptBaseLabels: DEFAULT_EXEMPT_BASE_LABELS,
700
655
  json: false
701
656
  };
702
657
  for (let i = 1; i < argv.length; i++) {
@@ -707,10 +662,7 @@ function parse(argv) {
707
662
  else if (a === "--base") args.baseRef = argv[++i] ?? args.baseRef;
708
663
  else if (a === "--workflow") args.workflow = argv[++i] ?? args.workflow;
709
664
  else if (a === "--cwd") args.cwd = argv[++i];
710
- else if (a === "--default-branch") args.defaultBranch = argv[++i];
711
665
  else if (a === "--base-branch") args.baseBranch = argv[++i];
712
- else if (a === "--exempt-base-labels")
713
- args.exemptBaseLabels = (argv[++i] ?? "").split(",").map((l) => l.trim()).filter(Boolean);
714
666
  else if (a === "--json" || a === "--dry-run") args.json = true;
715
667
  else usage();
716
668
  }
@@ -778,31 +730,6 @@ function makeRequiredChecksProbe(repo, cwd) {
778
730
  return [...new Set(contexts)];
779
731
  };
780
732
  }
781
- function makeBasePrProbe(repo, cwd) {
782
- return async (baseBranch) => {
783
- const prs = await ghJson(
784
- [
785
- "gh",
786
- "pr",
787
- "list",
788
- "--repo",
789
- repo,
790
- "--state",
791
- "open",
792
- "--head",
793
- baseBranch,
794
- "--limit",
795
- "1",
796
- "--json",
797
- "number,labels"
798
- ],
799
- cwd
800
- );
801
- const pr = prs?.[0];
802
- if (!pr) return null;
803
- return { number: pr.number, labels: (pr.labels ?? []).map((l) => l.name) };
804
- };
805
- }
806
733
  async function resolveDefaultBranch(repo, override, cwd) {
807
734
  if (override) return override;
808
735
  const view = await ghJson(
@@ -851,10 +778,7 @@ async function runEvaluate(repo, pr, args) {
851
778
  baseRef: args.baseRef,
852
779
  git: makeGitRunner(args.cwd),
853
780
  baseChecks: makeBaseChecksProbe(repo, args.cwd),
854
- requiredChecks: makeRequiredChecksProbe(repo, args.cwd),
855
- basePr: makeBasePrProbe(repo, args.cwd),
856
- defaultBranch: await resolveDefaultBranch(repo, args.defaultBranch, args.cwd),
857
- exemptBaseLabels: args.exemptBaseLabels
781
+ requiredChecks: makeRequiredChecksProbe(repo, args.cwd)
858
782
  });
859
783
  decision = evaluateMergeSafety(facts);
860
784
  } catch (err) {
@@ -959,7 +883,6 @@ function isDirectRun() {
959
883
  if (isDirectRun()) void main();
960
884
  export {
961
885
  makeBaseChecksProbe,
962
- makeBasePrProbe,
963
886
  makeRequiredChecksProbe,
964
887
  runEvaluate,
965
888
  runInvalidate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmartz/merge-safety",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
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.",
5
5
  "keywords": [
6
6
  "ci",
@@ -47,21 +47,17 @@
47
47
  "format": "prettier --write .",
48
48
  "format:check": "prettier --check .",
49
49
  "test": "vitest run",
50
- "test:watch": "vitest",
51
- "verify:release-notes": "node scripts/verify-changelog-render.mjs"
50
+ "test:watch": "vitest"
52
51
  },
53
52
  "devDependencies": {
54
- "@semantic-release/release-notes-generator": "^14.1.1",
55
53
  "@types/node": "^26.6.2",
56
54
  "@typescript-eslint/eslint-plugin": "^8.70.0",
57
55
  "@typescript-eslint/parser": "^8.70.0",
58
56
  "@vitest/coverage-v8": "^5.0.1",
59
- "conventional-changelog-conventionalcommits": "^9.3.1",
60
57
  "eslint": "^10.11.0",
61
58
  "eslint-import-resolver-typescript": "^4.4.5",
62
59
  "eslint-plugin-import": "^2.32.0",
63
60
  "prettier": "^3.9.8",
64
- "semantic-release": "^25.0.9",
65
61
  "tsup": "^8.5.1",
66
62
  "typescript": "^6.0.3",
67
63
  "vite": "^8.3.0",