@rmartz/merge-safety 0.10.1 → 0.12.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 +7 -8
- package/dist/bin/merge-safety.d.ts +5 -34
- package/dist/bin/merge-safety.js +6 -83
- package/dist/index.d.ts +2 -2
- package/package.json +3 -8
package/README.md
CHANGED
|
@@ -15,11 +15,8 @@ is:
|
|
|
15
15
|
check named exactly `merge-safety`; see
|
|
16
16
|
[docs/check-run-contract.md](docs/check-run-contract.md).
|
|
17
17
|
|
|
18
|
-
> **Status: released.**
|
|
19
|
-
> implementation
|
|
20
|
-
> ready to adopt. It was split out of `@rmartz/pr-review` per
|
|
21
|
-
> [ai-tools#247](https://github.com/rmartz/ai-tools/issues/247); see
|
|
22
|
-
> [docs/migration.md](docs/migration.md) for the record of how it was extracted.
|
|
18
|
+
> **Status: released.** The package ships the full `evaluate` / `invalidate`
|
|
19
|
+
> implementation as the `merge-safety` CLI and is ready to adopt.
|
|
23
20
|
|
|
24
21
|
## Using it in a consuming repo
|
|
25
22
|
|
|
@@ -116,9 +113,11 @@ zero-runtime-dependency package, all Dependabot bumps are dev-only in practice.
|
|
|
116
113
|
Three guards back the automatic flow: [`pr-title-lint.yml`](.github/workflows/pr-title-lint.yml)
|
|
117
114
|
(pre-merge title format), [`commit-convention.yml`](.github/workflows/commit-convention.yml)
|
|
118
115
|
(post-merge tripwire for a non-conventional subject that would make semantic-release
|
|
119
|
-
silently skip),
|
|
120
|
-
|
|
121
|
-
|
|
116
|
+
silently skip), the shared [`release-check.yml`](.github/workflows/release-check.yml)
|
|
117
|
+
(renders the notes through the shared
|
|
118
|
+
[semantic-release-ci](https://github.com/rmartz/semantic-release-ci) toolchain so an
|
|
119
|
+
incompatible release config fails the PR), and, in [`ci.yml`](.github/workflows/ci.yml),
|
|
120
|
+
the `Package`
|
|
122
121
|
job (packs exactly what `pnpm publish` would upload and fails if the tarball is
|
|
123
122
|
malformed or missing an `exports`/`bin` entry point) — so both a broken release config
|
|
124
123
|
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;
|
|
@@ -66,25 +51,18 @@ interface Args {
|
|
|
66
51
|
baseRef: string;
|
|
67
52
|
/**
|
|
68
53
|
* The consumer's caller workflow filename that `invalidate` re-dispatches per PR
|
|
69
|
-
* (
|
|
70
|
-
*
|
|
54
|
+
* (a reusable workflow's fan-out must target the *caller* file, not this
|
|
55
|
+
* reusable file). Defaults to the conventional name.
|
|
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
|
|
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,
|
|
91
|
+
export { type Args, makeBaseChecksProbe, makeRequiredChecksProbe, runEvaluate, runInvalidate };
|
package/dist/bin/merge-safety.js
CHANGED
|
@@ -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
|
|
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" :
|
|
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:
|
|
644
|
+
"usage: merge-safety evaluate --pr <n> [--json] [--repo <o/r>] [--base <ref>] [--cwd <path>]\n 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/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Encodes the package's stable public contract: the `merge-safety` check-run
|
|
5
5
|
* name and the CLI command surface. The evaluate/invalidate implementation
|
|
6
|
-
* (`merge-safety.ts` + `merge-safety-facts.ts` and the `
|
|
6
|
+
* (`merge-safety.ts` + `merge-safety-facts.ts` and the `merge-safety` bin)
|
|
7
7
|
* lives alongside it; this module is intentionally the narrow, frozen surface
|
|
8
8
|
* consumers and the reusable workflow depend on.
|
|
9
9
|
*/
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
declare const MERGE_SAFETY_CHECK_NAME = "merge-safety";
|
|
21
21
|
/**
|
|
22
|
-
* The two operations the `
|
|
22
|
+
* The two operations the `merge-safety` CLI dispatches:
|
|
23
23
|
* - `evaluate` — gather base-currency + breaking-change + conflict facts for one
|
|
24
24
|
* PR, post the `merge-safety` check-run, and reconcile the update-required /
|
|
25
25
|
* merge-conflict labels.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmartz/merge-safety",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"bin": {
|
|
29
|
-
"
|
|
29
|
+
"merge-safety": "./dist/bin/merge-safety.js"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist"
|
|
@@ -47,22 +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/exec": "^7.1.0",
|
|
55
|
-
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
56
53
|
"@types/node": "^26.6.2",
|
|
57
54
|
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
|
58
55
|
"@typescript-eslint/parser": "^8.70.0",
|
|
59
56
|
"@vitest/coverage-v8": "^5.0.1",
|
|
60
|
-
"conventional-changelog-conventionalcommits": "^9.3.1",
|
|
61
57
|
"eslint": "^10.11.0",
|
|
62
58
|
"eslint-import-resolver-typescript": "^4.4.5",
|
|
63
59
|
"eslint-plugin-import": "^2.32.0",
|
|
64
60
|
"prettier": "^3.9.8",
|
|
65
|
-
"semantic-release": "^25.0.9",
|
|
66
61
|
"tsup": "^8.5.1",
|
|
67
62
|
"typescript": "^6.0.3",
|
|
68
63
|
"vite": "^8.3.0",
|