From c40a0da937aa9670bdf015e0a554c8a254df47ff Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 8 Jun 2026 21:06:36 +0300 Subject: [PATCH] fix(branch-prefix): recognize GitHub PR merge subject under defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit semvertag's branch-prefix strategy gated on the subject containing the literal string "Merge branch" — GitLab's default merge format. GitHub's PR-merge commits use "Merge pull request #N from OWNER/BRANCH" instead, which doesn't contain "Merge branch", so every GitHub Actions consumer of branch-prefix silently no-op'd with status=no_merge_commit even on real merge commits. Surfaced by dogfooding semvertag 0.3.0 on this repo's own CI (PR #6). Fix: rename `merge_mark_text: str` to `merge_mark_texts: tuple[str, ...]` defaulting to `("Merge branch", "Merge pull request")`; strategy now matches if any mark appears in the subject. Out-of-the-box correct for both providers. Breaking change for anyone overriding the old `merge_mark_text` env/config field — the rename to plural-tuple requires updating `SEMVERTAG_BRANCH_PREFIX__MERGE_MARK_TEXT="X"` to `SEMVERTAG_BRANCH_PREFIX__MERGE_MARK_TEXTS='["X"]'`. Acceptable pre-1.0 on an internal config knob nobody has had a chance to override in the wild (the field shipped post-bmad in v0.1.0 with no documented use case). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/strategies/branch-prefix.md | 15 ++++++++------- semvertag/strategies/branch_prefix.py | 7 +++++-- tests/unit/test_branch_prefix_strategy.py | 19 +++++++++++++++++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/strategies/branch-prefix.md b/docs/strategies/branch-prefix.md index 0841e38..450b790 100644 --- a/docs/strategies/branch-prefix.md +++ b/docs/strategies/branch-prefix.md @@ -25,15 +25,16 @@ to a new major version manually, or switch to The strategy only fires on commits whose subject contains the literal string `Merge branch` (the default `git merge` subject). Commits -without that text return `none` regardless of prefix. This means: +without one of those marks return `none` regardless of prefix. This +means: - Standard `git merge feature/foo` → subject `Merge branch 'feature/foo' into main` → bump = minor ✓ -- GitHub's "Merge pull request #N from user/feature/foo" → does NOT - contain `Merge branch` → bump = none under defaults. +- GitHub's `Merge pull request #N from user/feature/foo` → bump = minor ✓ - Direct pushes to the default branch → bump = none. -The `merge_mark_text` is configurable (defaults to `Merge branch`); -adapt it for non-default merge-commit conventions. +The `merge_mark_texts` tuple is configurable (defaults to +`("Merge branch", "Merge pull request")`); adapt it for non-default +merge-commit conventions (e.g. squash-merge prefixes). ## Customizing the prefixes @@ -43,8 +44,8 @@ The strategy reads its prefixes from the application's settings layer: `("feature/",)`). - `patch` — tuple of prefixes that trigger a patch bump (default `("bugfix/", "hotfix/")`). -- `merge_mark_text` — substring that marks a subject as a merge - commit (default `Merge branch`). +- `merge_mark_texts` — tuple of substrings that mark a subject as a + merge commit (default `("Merge branch", "Merge pull request")`). These are set via the same pydantic-settings env-var mechanism used for tokens / endpoints — see the provider docs for the variable diff --git a/semvertag/strategies/branch_prefix.py b/semvertag/strategies/branch_prefix.py index 97b8c79..5266e80 100644 --- a/semvertag/strategies/branch_prefix.py +++ b/semvertag/strategies/branch_prefix.py @@ -15,7 +15,10 @@ class BranchPrefixConfig(pydantic.BaseModel): minor: tuple[_NonEmptyStr, ...] = pydantic.Field(default=("feature/",), min_length=1) patch: tuple[_NonEmptyStr, ...] = pydantic.Field(default=("bugfix/", "hotfix/"), min_length=1) - merge_mark_text: _NonEmptyStr = "Merge branch" + merge_mark_texts: tuple[_NonEmptyStr, ...] = pydantic.Field( + default=("Merge branch", "Merge pull request"), + min_length=1, + ) @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) @@ -27,7 +30,7 @@ class BranchPrefixStrategy: def decide(self, commit: Commit) -> Bump: subject: typing.Final = subject_line(commit.message) - if self.config.merge_mark_text not in subject: + if not any(mark in subject for mark in self.config.merge_mark_texts): return Bump.NONE if any(prefix in subject for prefix in self.config.minor): return Bump.MINOR diff --git a/tests/unit/test_branch_prefix_strategy.py b/tests/unit/test_branch_prefix_strategy.py index a9e0519..8d40a86 100644 --- a/tests/unit/test_branch_prefix_strategy.py +++ b/tests/unit/test_branch_prefix_strategy.py @@ -90,7 +90,7 @@ def test_honors_custom_minor_prefix_when_config_overrides_default() -> None: config=BranchPrefixConfig( minor=("feat/",), patch=("fix/",), - merge_mark_text="Auto-merge:", + merge_mark_texts=("Auto-merge:",), ), ) assert custom.decide(_commit("Auto-merge: feat/new-thing")) is Bump.MINOR @@ -99,10 +99,25 @@ def test_honors_custom_minor_prefix_when_config_overrides_default() -> None: assert custom.decide(_commit("Merge branch 'feat/x' into main")) is Bump.NONE +def test_recognizes_github_pr_merge_subject_under_defaults() -> None: + default: typing.Final = BranchPrefixStrategy(config=BranchPrefixConfig()) + assert default.decide(_commit("Merge pull request #42 from org/feature/new-thing")) is Bump.MINOR + assert default.decide(_commit("Merge pull request #43 from org/bugfix/bug-123")) is Bump.PATCH + assert default.decide(_commit("Merge pull request #44 from org/hotfix/urgent")) is Bump.PATCH + assert default.decide(_commit("Merge pull request #45 from org/chore/cleanup")) is Bump.NONE + + +def test_recognizes_gitlab_merge_branch_subject_under_defaults() -> None: + default: typing.Final = BranchPrefixStrategy(config=BranchPrefixConfig()) + assert default.decide(_commit("Merge branch 'feature/new-thing' into main")) is Bump.MINOR + assert default.decide(_commit("Merge branch 'bugfix/bug-123' into main")) is Bump.PATCH + + _INVALID_CONFIG_CASES: typing.Final = [ {"minor": ()}, {"patch": ()}, - {"merge_mark_text": ""}, + {"merge_mark_texts": ()}, + {"merge_mark_texts": ("",)}, {"minor": ("",)}, {"patch": ("",)}, {"minor": ("feature/", "")},