@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 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
- pull_request:
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 public
62
- `@rmartz/merge-safety` package on GitHub Packages is readable with the built-in
63
- `GITHUB_TOKEN`, so no consumer PAT is required.
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 GitHub Packages
95
- (public), and creates the git tag + GitHub Release with generated notes — using only
96
- the built-in `GITHUB_TOKEN`, no release-please and no PAT. There is deliberately **no
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. */
@@ -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 stale = !facts.isCurrent;
436
- if (stale && facts.baseBreakingSinceMergeBase) {
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((c) => predicate(c.message)).map((c) => ({ sha: c.sha, subject: c.message.split("\n", 1)[0] ?? "" }));
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 baseCiCommits = selectCommits(commits, isCiCommitMessage);
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
- prIsCi: isCiTitle(meta.title),
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>] [--repo <o/r>] [--cwd <path>]"
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
- "main",
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.6.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",