diff --git a/commitizen/version_schemes.py b/commitizen/version_schemes.py index 14a55d655..4edd1a081 100644 --- a/commitizen/version_schemes.py +++ b/commitizen/version_schemes.py @@ -121,7 +121,10 @@ def __ne__(self, other: object) -> bool: def bump( self, increment: Increment | None, - prerelease: Prerelease | None = None, + # str instead of Prerelease to support arbitrary semver pre-release labels + # (e.g., "release", "SNAPSHOT") parsed from existing tags. The CLI still + # restricts user input to alpha/beta/rc via argparse choices. + prerelease: str | None = None, prerelease_offset: int = 0, devrelease: int | None = None, is_local_version: bool = False, @@ -145,6 +148,52 @@ def bump( VersionScheme: TypeAlias = type[VersionProtocol] +# Custom version pattern for SemVer schemes that extends packaging's PEP 440 +# regex to support arbitrary semver pre-release labels (e.g., -release, -SNAPSHOT, +# -pre-release). Python's packaging library does not use semver; it predates it. +# We cannot fully rely on packaging.version for semver-compatible parsing. +# This pattern is NOT applied to Pep440 scheme, which retains strict PEP 440 parsing. +# See: https://github.com/pypa/packaging/blob/14b83e15dbb9caa87c63646ba7808b2b5e460ce6/src/packaging/version.py#L117 +_SEMVER_VERSION_PATTERN = r"""^\s* + v? + (?: + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?P
                                          # pre-release
+            [-_\.]?
+            (?P
+                (?!                                       # negative lookahead to prevent
+                    [-_\.]?                               # matching post, rev, r, dev
+                    (post|rev|r|dev)                      # (reserved PEP 440 segments)
+                    [-_\.]?
+                    ([0-9]+)?
+                (\+|$))                                   # terminated by local segment or EOL
+                [a-z]+(?:-[a-z]+)*                        # letters with optional hyphen-separated parts
+            )
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+\s*$"""
+
+
 class BaseVersion(_BaseVersion):
     """
     A base class implementing the `VersionProtocol` for PEP440-like versions.
@@ -184,8 +233,26 @@ def generate_prerelease(
         # https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-releases
         # https://semver.org/#spec-item-11
         if self.is_prerelease and self.pre:
-            prerelease = max(prerelease, self.pre[0])
-            if prerelease.startswith(self.pre[0]):
+            current_pre_label = self.pre[0]
+            # packaging normalizes "alpha"→"a", "beta"→"b", "rc"→"rc"
+            _LABEL_TO_NORMALIZED = {"alpha": "a", "beta": "b", "rc": "rc"}
+            _KNOWN_PRE_LABELS = {"a", "b", "rc"}
+            normalized_prerelease = _LABEL_TO_NORMALIZED.get(
+                prerelease, prerelease.lower()
+            )
+
+            # The ordering logic (max) only makes sense for the known PEP 440
+            # labels where "a" < "b" < "rc" lexicographically. For arbitrary
+            # semver labels (e.g., "release", "SNAPSHOT"), we use strict equality
+            # since there's no defined ordering between them.
+            if (
+                current_pre_label in _KNOWN_PRE_LABELS
+                and normalized_prerelease in _KNOWN_PRE_LABELS
+            ):
+                prerelease = max(normalized_prerelease, current_pre_label)
+                if prerelease == current_pre_label:
+                    offset = self.pre[1] + 1
+            elif normalized_prerelease == current_pre_label:
                 offset = self.pre[1] + 1
 
         return f"{prerelease}{offset}"
@@ -232,7 +299,7 @@ def increment_base(self, increment: Increment | None = None) -> str:
     def bump(
         self,
         increment: Increment | None,
-        prerelease: Prerelease | None = None,
+        prerelease: str | None = None,  # str to support arbitrary semver labels
         prerelease_offset: int = 0,
         devrelease: int | None = None,
         is_local_version: bool = False,
@@ -300,6 +367,12 @@ class SemVer(BaseVersion):
     See: https://semver.org/spec/v1.0.0.html
     """
 
+    # Override the PEP 440 regex to accept arbitrary semver pre-release labels
+    # (e.g., -release, -SNAPSHOT, -pre-release). SemVer2 inherits this.
+    _regex: re.Pattern = re.compile(
+        _SEMVER_VERSION_PATTERN, re.VERBOSE | re.IGNORECASE
+    )
+
     def __str__(self) -> str:
         parts: list[str] = []
 
diff --git a/tests/test_version_scheme_pep440.py b/tests/test_version_scheme_pep440.py
index 479c2f775..1453df5cd 100644
--- a/tests/test_version_scheme_pep440.py
+++ b/tests/test_version_scheme_pep440.py
@@ -1320,3 +1320,21 @@ def test_pep440_scheme_property():
 
 def test_pep440_implement_version_protocol():
     assert isinstance(Pep440("0.0.1"), VersionProtocol)
+
+
+def test_pep440_rejects_arbitrary_prerelease_labels():
+    """Pep440 scheme must NOT accept arbitrary semver pre-release labels.
+
+    Only SemVer/SemVer2 schemes accept labels like 'release' or 'SNAPSHOT'.
+    This ensures the relaxed regex is scoped to SemVer schemes only.
+    """
+    from packaging.version import InvalidVersion
+
+    with pytest.raises(InvalidVersion):
+        Pep440("1.0.0-release")
+
+    with pytest.raises(InvalidVersion):
+        Pep440("1.0.0-SNAPSHOT")
+
+    with pytest.raises(InvalidVersion):
+        Pep440("1.0.0-pre-release")
diff --git a/tests/test_version_scheme_semver.py b/tests/test_version_scheme_semver.py
index b5a275e98..02a872966 100644
--- a/tests/test_version_scheme_semver.py
+++ b/tests/test_version_scheme_semver.py
@@ -250,6 +250,70 @@
             ),
             "1.0.0",
         ),
+        # arbitrary semver pre-release labels (issue #950)
+        (
+            VersionSchemeTestArgs(
+                current_version="1.0.0-reallyweird",
+                increment="PATCH",
+                prerelease="reallyweird",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "1.0.0-reallyweird1",
+        ),
+        (
+            VersionSchemeTestArgs(
+                current_version="v0.7.1-release",
+                increment="PATCH",
+                prerelease="release",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "0.7.1-release1",
+        ),
+        (
+            VersionSchemeTestArgs(
+                current_version="v0.0.1-SNAPSHOT",
+                increment="PATCH",
+                prerelease="SNAPSHOT",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "0.0.1-snapshot1",
+        ),
+        # hyphenated pre-release label (issue #950)
+        (
+            VersionSchemeTestArgs(
+                current_version="1.0.0-pre-release",
+                increment="PATCH",
+                prerelease="pre-release",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "1.0.0-pre-release1",
+        ),
+        # arbitrary label with local segment (lookahead fix)
+        (
+            VersionSchemeTestArgs(
+                current_version="1.0.0-release+local123",
+                increment="PATCH",
+                prerelease="release",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "1.0.0-release1",
+        ),
+        # transition from arbitrary label to standard prerelease
+        (
+            VersionSchemeTestArgs(
+                current_version="1.0.0-weird",
+                increment="PATCH",
+                prerelease="alpha",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "1.0.0-a0",
+        ),
         # simple flow
         (
             VersionSchemeTestArgs(
diff --git a/tests/test_version_scheme_semver2.py b/tests/test_version_scheme_semver2.py
index ddd975bf7..1cca245a4 100644
--- a/tests/test_version_scheme_semver2.py
+++ b/tests/test_version_scheme_semver2.py
@@ -240,6 +240,70 @@
             ),
             "1.0.0",
         ),
+        # arbitrary semver pre-release labels (issue #950)
+        (
+            VersionSchemeTestArgs(
+                current_version="1.0.0-reallyweird",
+                increment="PATCH",
+                prerelease="reallyweird",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "1.0.0-reallyweird.1",
+        ),
+        (
+            VersionSchemeTestArgs(
+                current_version="v0.7.1-release",
+                increment="PATCH",
+                prerelease="release",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "0.7.1-release.1",
+        ),
+        (
+            VersionSchemeTestArgs(
+                current_version="v0.0.1-SNAPSHOT",
+                increment="PATCH",
+                prerelease="SNAPSHOT",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "0.0.1-snapshot.1",
+        ),
+        # hyphenated pre-release label (issue #950)
+        (
+            VersionSchemeTestArgs(
+                current_version="1.0.0-pre-release",
+                increment="PATCH",
+                prerelease="pre-release",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "1.0.0-pre-release.1",
+        ),
+        # arbitrary label with local segment (lookahead fix)
+        (
+            VersionSchemeTestArgs(
+                current_version="1.0.0-release+local123",
+                increment="PATCH",
+                prerelease="release",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "1.0.0-release.1",
+        ),
+        # transition from arbitrary label to standard prerelease
+        (
+            VersionSchemeTestArgs(
+                current_version="1.0.0-weird",
+                increment="PATCH",
+                prerelease="alpha",
+                prerelease_offset=0,
+                devrelease=None,
+            ),
+            "1.0.0-alpha.0",
+        ),
         # simple_flow
         (
             VersionSchemeTestArgs(
diff --git a/tests/utils.py b/tests/utils.py
index bca565f78..ddae45824 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -18,13 +18,13 @@
     from freezegun.api import FrozenDateTimeFactory
     from pytest_mock import MockerFixture
 
-    from commitizen.version_schemes import Increment, Prerelease
+    from commitizen.version_schemes import Increment
 
 
 class VersionSchemeTestArgs(NamedTuple):
     current_version: str
     increment: Increment | None
-    prerelease: Prerelease | None
+    prerelease: str | None
     prerelease_offset: int
     devrelease: int | None