@rmartz/merge-safety 0.6.0 → 0.10.1
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 +13 -7
- package/dist/bin/merge-safety.d.ts +6 -0
- package/dist/bin/merge-safety.js +119 -45
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -32,10 +32,12 @@ triggers and runs with the intersection of granted and declared permissions:
|
|
|
32
32
|
# .github/workflows/merge-safety.yml
|
|
33
33
|
name: merge-safety
|
|
34
34
|
on:
|
|
35
|
-
|
|
35
|
+
pull_request_target:
|
|
36
36
|
types: [opened, synchronize, reopened, edited, labeled, unlabeled]
|
|
37
37
|
push:
|
|
38
38
|
branches: [main]
|
|
39
|
+
check_suite:
|
|
40
|
+
types: [completed]
|
|
39
41
|
workflow_dispatch:
|
|
40
42
|
inputs:
|
|
41
43
|
pr:
|
|
@@ -43,6 +45,7 @@ on:
|
|
|
43
45
|
required: true
|
|
44
46
|
permissions:
|
|
45
47
|
checks: write
|
|
48
|
+
statuses: write
|
|
46
49
|
pull-requests: write
|
|
47
50
|
contents: read
|
|
48
51
|
actions: write
|
|
@@ -58,9 +61,11 @@ jobs:
|
|
|
58
61
|
Then require the `merge-safety` status check on the default branch. Both the
|
|
59
62
|
caller and the Dependabot entry are seeded once by
|
|
60
63
|
[`@rmartz/bootstrap`](https://github.com/rmartz/ai-tools)
|
|
61
|
-
(`ai-ensure-project-config`); after that Dependabot maintains the pin. The
|
|
62
|
-
`@rmartz/merge-safety`
|
|
63
|
-
|
|
64
|
+
(`ai-ensure-project-config`); after that Dependabot maintains the pin. The
|
|
65
|
+
`@rmartz/merge-safety` CLI is public on npmjs, so installing it needs no token.
|
|
66
|
+
(Pins at v0.6.0 or earlier install from GitHub Packages instead, which is what the
|
|
67
|
+
`packages: read` permission above is for; it can be dropped once your pin is past
|
|
68
|
+
v0.6.0.)
|
|
64
69
|
|
|
65
70
|
> For the full walkthrough — the caller's permissions, why it isn't trigger-free,
|
|
66
71
|
> and how to verify the setup — see the
|
|
@@ -91,9 +96,10 @@ Fully automatic via [`semantic-release`](https://semantic-release.gitbook.io)
|
|
|
91
96
|
manual `pnpm version` step. Every push to `main` runs
|
|
92
97
|
[`release.yml`](.github/workflows/release.yml): it analyzes the conventional-commit
|
|
93
98
|
subjects since the last `vX.Y.Z` tag (the squash-merged **PR title** is that subject),
|
|
94
|
-
computes the next version, builds and publishes the package to
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
computes the next version, builds and publishes the package to npmjs (public, via
|
|
100
|
+
OIDC trusted publishing with provenance — no npm token), and creates the git tag +
|
|
101
|
+
GitHub Release with generated notes — using only the built-in `GITHUB_TOKEN`, no
|
|
102
|
+
release-please and no PAT. There is deliberately **no
|
|
97
103
|
`@semantic-release/git`**: `package.json`'s `version` is a frozen `0.0.0` placeholder
|
|
98
104
|
and is never committed back. The version installed by the reusable workflow is
|
|
99
105
|
resolved at runtime from the **release tag at its own pinned commit**, so it lives
|
|
@@ -77,6 +77,12 @@ interface Args {
|
|
|
77
77
|
* than holding every PR.
|
|
78
78
|
*/
|
|
79
79
|
defaultBranch?: string;
|
|
80
|
+
/**
|
|
81
|
+
* `invalidate` only: the branch that moved, whose open PRs are fanned out over.
|
|
82
|
+
* 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.
|
|
84
|
+
*/
|
|
85
|
+
baseBranch?: string;
|
|
80
86
|
/** Labels on a base PR that exempt its children from the barrier (#54). */
|
|
81
87
|
exemptBaseLabels: readonly string[];
|
|
82
88
|
/** Decision-only: print the verdict as JSON and perform no side effects. */
|
package/dist/bin/merge-safety.js
CHANGED
|
@@ -167,7 +167,34 @@ async function openCheckRunIds(repo, headSha, cwd) {
|
|
|
167
167
|
if (out === null) return null;
|
|
168
168
|
return out.split("\n").map((l) => Number(l.trim())).filter((id) => Number.isInteger(id) && id > 0);
|
|
169
169
|
}
|
|
170
|
+
var STATUS_DESCRIPTION_MAX = 140;
|
|
171
|
+
async function postStatus(repo, headSha, output, conclusion, cwd) {
|
|
172
|
+
const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = process.env;
|
|
173
|
+
const runUrl = GITHUB_SERVER_URL && GITHUB_REPOSITORY && GITHUB_RUN_ID ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` : void 0;
|
|
174
|
+
const out = await ghCall(
|
|
175
|
+
{
|
|
176
|
+
argv: ["gh", "api", "-X", "POST", `repos/${repo}/statuses/${headSha}`, "--input", "-"],
|
|
177
|
+
stdin: JSON.stringify({
|
|
178
|
+
state: conclusion,
|
|
179
|
+
context: MERGE_SAFETY_CHECK_NAME,
|
|
180
|
+
description: output.title.slice(0, STATUS_DESCRIPTION_MAX),
|
|
181
|
+
...runUrl ? { target_url: runUrl } : {}
|
|
182
|
+
})
|
|
183
|
+
},
|
|
184
|
+
null,
|
|
185
|
+
{ cwd }
|
|
186
|
+
);
|
|
187
|
+
if (out === null) {
|
|
188
|
+
console.warn(
|
|
189
|
+
`::warning::could not set the ${MERGE_SAFETY_CHECK_NAME} commit status on ${headSha} \u2014 does the caller workflow grant \`statuses: write\`? (rmartz/merge-safety#73)`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
170
193
|
async function postCheck(repo, headSha, output, conclusion, cwd) {
|
|
194
|
+
await postCheckRun(repo, headSha, output, conclusion, cwd);
|
|
195
|
+
await postStatus(repo, headSha, output, conclusion, cwd);
|
|
196
|
+
}
|
|
197
|
+
async function postCheckRun(repo, headSha, output, conclusion, cwd) {
|
|
171
198
|
const state = conclusion === "pending" ? { status: "in_progress", output } : { status: "completed", conclusion, completed_at: (/* @__PURE__ */ new Date()).toISOString(), output };
|
|
172
199
|
let updated = false;
|
|
173
200
|
for (const id of await openCheckRunIds(repo, headSha, cwd) ?? []) {
|
|
@@ -202,6 +229,7 @@ async function postCheck(repo, headSha, output, conclusion, cwd) {
|
|
|
202
229
|
var BREAKING_SUBJECT_RE = /^[a-z]+(?:!(?:\([^)]*\))?|(?:\([^)]*\))?!):/;
|
|
203
230
|
var BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:/m;
|
|
204
231
|
var CI_SUBJECT_RE = /^ci(\([^)]*\))?!?:/;
|
|
232
|
+
var DOCS_SUBJECT_RE = /^docs(\([^)]*\))?!?:/;
|
|
205
233
|
var FUNCTIONAL_TYPES = /* @__PURE__ */ new Set(["feat", "fix", "perf", "revert"]);
|
|
206
234
|
var CONVENTIONAL_TYPE_RE = /^([a-z]+)(?:!|\([^)]*\))*:/;
|
|
207
235
|
function firstLine(message) {
|
|
@@ -213,12 +241,18 @@ function isBreakingCommitMessage(message) {
|
|
|
213
241
|
function isCiCommitMessage(message) {
|
|
214
242
|
return CI_SUBJECT_RE.test(firstLine(message));
|
|
215
243
|
}
|
|
244
|
+
function isDocsCommitMessage(message) {
|
|
245
|
+
return DOCS_SUBJECT_RE.test(firstLine(message));
|
|
246
|
+
}
|
|
216
247
|
function isBreakingTitle(title) {
|
|
217
248
|
return BREAKING_SUBJECT_RE.test(title.trim());
|
|
218
249
|
}
|
|
219
250
|
function isCiTitle(title) {
|
|
220
251
|
return CI_SUBJECT_RE.test(title.trim());
|
|
221
252
|
}
|
|
253
|
+
function isDocsTitle(title) {
|
|
254
|
+
return DOCS_SUBJECT_RE.test(title.trim());
|
|
255
|
+
}
|
|
222
256
|
function mayCarryBreakingMarker(title) {
|
|
223
257
|
const type = CONVENTIONAL_TYPE_RE.exec(title.trim())?.[1];
|
|
224
258
|
return type !== void 0 && FUNCTIONAL_TYPES.has(type);
|
|
@@ -266,6 +300,54 @@ function breakingSignalDetail(signals) {
|
|
|
266
300
|
return signals.flatMap((s) => s.detail.map((d) => `${BREAKING_DIFF_REASONS[s.kind]}: ${d}`));
|
|
267
301
|
}
|
|
268
302
|
|
|
303
|
+
// src/staleness.ts
|
|
304
|
+
function evaluateStaleness(facts) {
|
|
305
|
+
const reasons = [];
|
|
306
|
+
if (facts.isCurrent) return { needsUpdate: false, reasons };
|
|
307
|
+
const baseBreaking = facts.baseBreakingSinceMergeBase && !(facts.prIsDocs && !facts.prIsBreaking);
|
|
308
|
+
const prBreaking = facts.prIsBreaking && !facts.baseOnlyDocsSinceMergeBase;
|
|
309
|
+
const prCi = facts.prIsCi && !facts.prIsHotfix;
|
|
310
|
+
if (baseBreaking) {
|
|
311
|
+
reasons.push(
|
|
312
|
+
withDetail(
|
|
313
|
+
"A breaking change landed on the base since merge-base \u2014 rebase and re-run CI:",
|
|
314
|
+
facts.baseBreakingCommits.map(formatBaseCommit)
|
|
315
|
+
)
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (prBreaking) {
|
|
319
|
+
reasons.push(
|
|
320
|
+
withDetail(
|
|
321
|
+
"This PR is a breaking change \u2014 it must be current with the base before merge.",
|
|
322
|
+
breakingSignalDetail(facts.prBreakingDiffSignals)
|
|
323
|
+
)
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (prCi) {
|
|
327
|
+
reasons.push(
|
|
328
|
+
"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."
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (facts.baseCiSinceMergeBase) {
|
|
332
|
+
reasons.push(
|
|
333
|
+
withDetail(
|
|
334
|
+
"A CI change landed on the base since merge-base \u2014 rebase to re-test under it:",
|
|
335
|
+
facts.baseCiCommits.map(formatBaseCommit)
|
|
336
|
+
)
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (facts.fileOverlap) {
|
|
340
|
+
reasons.push(
|
|
341
|
+
withDetail(
|
|
342
|
+
"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:",
|
|
343
|
+
facts.overlappingFiles
|
|
344
|
+
)
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
const needsUpdate = baseBreaking || facts.baseCiSinceMergeBase || prBreaking || prCi || facts.fileOverlap;
|
|
348
|
+
return { needsUpdate, reasons };
|
|
349
|
+
}
|
|
350
|
+
|
|
269
351
|
// src/breaking-diff.ts
|
|
270
352
|
var CI_SENSITIVE_PACKAGES = /* @__PURE__ */ new Set(["eslint", "black", "pylint", "ruff", "prettier"]);
|
|
271
353
|
var NON_DEPENDENCY_NPM_KEYS = /* @__PURE__ */ new Set(["version"]);
|
|
@@ -432,46 +514,9 @@ function evaluateMergeSafety(facts) {
|
|
|
432
514
|
)
|
|
433
515
|
);
|
|
434
516
|
}
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
reasons.push(
|
|
438
|
-
withDetail(
|
|
439
|
-
"A breaking change landed on the base since merge-base \u2014 rebase and re-run CI:",
|
|
440
|
-
facts.baseBreakingCommits.map(formatBaseCommit)
|
|
441
|
-
)
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
if (stale && facts.prIsBreaking) {
|
|
445
|
-
reasons.push(
|
|
446
|
-
withDetail(
|
|
447
|
-
"This PR is a breaking change \u2014 it must be current with the base before merge.",
|
|
448
|
-
breakingSignalDetail(facts.prBreakingDiffSignals)
|
|
449
|
-
)
|
|
450
|
-
);
|
|
451
|
-
}
|
|
452
|
-
if (stale && facts.prIsCi && !facts.prIsHotfix) {
|
|
453
|
-
reasons.push(
|
|
454
|
-
"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."
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
if (stale && facts.baseCiSinceMergeBase) {
|
|
458
|
-
reasons.push(
|
|
459
|
-
withDetail(
|
|
460
|
-
"A CI change landed on the base since merge-base \u2014 rebase to re-test under it:",
|
|
461
|
-
facts.baseCiCommits.map(formatBaseCommit)
|
|
462
|
-
)
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
if (stale && facts.fileOverlap) {
|
|
466
|
-
reasons.push(
|
|
467
|
-
withDetail(
|
|
468
|
-
"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:",
|
|
469
|
-
facts.overlappingFiles
|
|
470
|
-
)
|
|
471
|
-
);
|
|
472
|
-
}
|
|
517
|
+
const { needsUpdate, reasons: stalenessReasons } = evaluateStaleness(facts);
|
|
518
|
+
reasons.push(...stalenessReasons);
|
|
473
519
|
const needsCiRetitle = hasSignal(facts.prBreakingDiffSignals, "sensitive-package-bump") ? !facts.prIsCi : false;
|
|
474
|
-
const needsUpdate = stale && (facts.baseBreakingSinceMergeBase || facts.baseCiSinceMergeBase || facts.prIsBreaking || facts.prIsCi && !facts.prIsHotfix || facts.fileOverlap);
|
|
475
520
|
if (needsCiRetitle) {
|
|
476
521
|
reasons.push(
|
|
477
522
|
withDetail(
|
|
@@ -541,8 +586,20 @@ function parseBaseCommits(logOut) {
|
|
|
541
586
|
return { sha, message };
|
|
542
587
|
});
|
|
543
588
|
}
|
|
589
|
+
var CI_PATH_RE = /^\.github\/(?:workflows|actions)\//;
|
|
590
|
+
function isCiPath(path) {
|
|
591
|
+
return CI_PATH_RE.test(path);
|
|
592
|
+
}
|
|
593
|
+
function parseCiPathShas(logOut) {
|
|
594
|
+
const shas = /* @__PURE__ */ new Set();
|
|
595
|
+
for (const record of logOut.split("\0")) {
|
|
596
|
+
const [sha, ...files] = splitLines(record);
|
|
597
|
+
if (sha && files.some(isCiPath)) shas.add(sha);
|
|
598
|
+
}
|
|
599
|
+
return shas;
|
|
600
|
+
}
|
|
544
601
|
function selectCommits(commits, predicate) {
|
|
545
|
-
return commits.filter(
|
|
602
|
+
return commits.filter(predicate).map((c) => ({ sha: c.sha, subject: c.message.split("\n", 1)[0] ?? "" }));
|
|
546
603
|
}
|
|
547
604
|
async function gatherMergeSafetyFacts(meta, {
|
|
548
605
|
baseRef = "origin/main",
|
|
@@ -566,8 +623,19 @@ async function gatherMergeSafetyFacts(meta, {
|
|
|
566
623
|
const logOut = await git(["log", "-z", "--format=%H%n%B", `${mergeBase}..${baseRef}`]);
|
|
567
624
|
if (logOut === null) throw new Error(`git log failed for ${mergeBase}..${baseRef}`);
|
|
568
625
|
const commits = parseBaseCommits(logOut);
|
|
569
|
-
const baseBreakingCommits = selectCommits(commits, isBreakingCommitMessage);
|
|
570
|
-
const
|
|
626
|
+
const baseBreakingCommits = selectCommits(commits, (c) => isBreakingCommitMessage(c.message));
|
|
627
|
+
const fileLogOut = await git([
|
|
628
|
+
"log",
|
|
629
|
+
"--format=%x00%H",
|
|
630
|
+
"--name-only",
|
|
631
|
+
`${mergeBase}..${baseRef}`
|
|
632
|
+
]);
|
|
633
|
+
if (fileLogOut === null) throw new Error(`git log failed for ${mergeBase}..${baseRef}`);
|
|
634
|
+
const ciPathShas = parseCiPathShas(fileLogOut);
|
|
635
|
+
const baseCiCommits = selectCommits(
|
|
636
|
+
commits,
|
|
637
|
+
(c) => isCiCommitMessage(c.message) || ciPathShas.has(c.sha)
|
|
638
|
+
);
|
|
571
639
|
const baseFilesOut = await git(["diff", "--name-only", mergeBase, baseRef]);
|
|
572
640
|
if (baseFilesOut === null) throw new Error(`git diff failed for ${mergeBase}..${baseRef}`);
|
|
573
641
|
const baseFiles = splitLines(baseFilesOut);
|
|
@@ -594,7 +662,10 @@ async function gatherMergeSafetyFacts(meta, {
|
|
|
594
662
|
// Additive by construction: the title marker and the label remain inputs, and
|
|
595
663
|
// the diff can only ever add a reason to treat the PR as breaking (#53).
|
|
596
664
|
prIsBreaking: isBreakingTitle(meta.title) || labels.includes(BREAKING_LABEL) || diffSignals.length > 0,
|
|
597
|
-
|
|
665
|
+
prIsDocs: isDocsTitle(meta.title),
|
|
666
|
+
baseOnlyDocsSinceMergeBase: commits.length > 0 && commits.every((c) => isDocsCommitMessage(c.message)),
|
|
667
|
+
// Title or path (#67): a release-typed PR that changes CI is still re-tested.
|
|
668
|
+
prIsCi: isCiTitle(meta.title) || prFiles.some(isCiPath),
|
|
598
669
|
fileOverlap: overlaps.length > 0,
|
|
599
670
|
hasConflict: meta.mergeable.toUpperCase() === "CONFLICTING",
|
|
600
671
|
baseCiFailing: failingBaseChecks.length > 0,
|
|
@@ -614,7 +685,7 @@ async function gatherMergeSafetyFacts(meta, {
|
|
|
614
685
|
var DEFAULT_CALLER_WORKFLOW = "merge-safety.yml";
|
|
615
686
|
function usage() {
|
|
616
687
|
console.error(
|
|
617
|
-
"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 [--workflow <file>] [--exclude <n>]
|
|
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>]"
|
|
618
689
|
);
|
|
619
690
|
process.exit(2);
|
|
620
691
|
}
|
|
@@ -637,6 +708,7 @@ function parse(argv) {
|
|
|
637
708
|
else if (a === "--workflow") args.workflow = argv[++i] ?? args.workflow;
|
|
638
709
|
else if (a === "--cwd") args.cwd = argv[++i];
|
|
639
710
|
else if (a === "--default-branch") args.defaultBranch = argv[++i];
|
|
711
|
+
else if (a === "--base-branch") args.baseBranch = argv[++i];
|
|
640
712
|
else if (a === "--exempt-base-labels")
|
|
641
713
|
args.exemptBaseLabels = (argv[++i] ?? "").split(",").map((l) => l.trim()).filter(Boolean);
|
|
642
714
|
else if (a === "--json" || a === "--dry-run") args.json = true;
|
|
@@ -825,6 +897,8 @@ function emitDecisionJson(decision, isError) {
|
|
|
825
897
|
if (isError) process.exitCode = 1;
|
|
826
898
|
}
|
|
827
899
|
async function runInvalidate(repo, args) {
|
|
900
|
+
const baseBranch = await resolveDefaultBranch(repo, args.baseBranch, args.cwd);
|
|
901
|
+
if (!baseBranch) throw new Error("could not resolve the base branch to fan out over");
|
|
828
902
|
const prs = await ghJson(
|
|
829
903
|
[
|
|
830
904
|
"gh",
|
|
@@ -835,7 +909,7 @@ async function runInvalidate(repo, args) {
|
|
|
835
909
|
"--state",
|
|
836
910
|
"open",
|
|
837
911
|
"--base",
|
|
838
|
-
|
|
912
|
+
baseBranch,
|
|
839
913
|
"--limit",
|
|
840
914
|
"1000",
|
|
841
915
|
"--json",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmartz/merge-safety",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"verify:release-notes": "node scripts/verify-changelog-render.mjs"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
+
"@semantic-release/exec": "^7.1.0",
|
|
54
55
|
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
55
56
|
"@types/node": "^26.6.2",
|
|
56
57
|
"@typescript-eslint/eslint-plugin": "^8.70.0",
|