diff --git a/scripts/releases/versioning.py b/scripts/releases/versioning.py index f90c986023..aacd559fef 100644 --- a/scripts/releases/versioning.py +++ b/scripts/releases/versioning.py @@ -67,6 +67,42 @@ def version_from_tag(ref: str) -> str | None: return ref[1:] +# The ref grammar only anchors; ``version_from_tag`` owns the version shape. +_ATTEMPT_REF_RE = re.compile(r"rc\.(?P[1-9][0-9]*)-(?Pv[^/]+)") +_MARKER_PREFIX = "abandoned-" + + +def parse_attempt_ref(ref: str) -> tuple[str, int] | None: + """``rc.-vX.Y.Z`` to ``(version, N)``, or None for anything else. + + The attempt comes first so the ref can never read as a SemVer prerelease + of the version it claims. + """ + match = _ATTEMPT_REF_RE.fullmatch(ref) if isinstance(ref, str) else None + if match is None: + return None + version = version_from_tag(match.group("tag")) + return (version, int(match.group("attempt"))) if version else None + + +def parse_marker_ref(ref: str) -> tuple[str, int] | None: + """``abandoned-rc.-vX.Y.Z``, the record that clears one attempt.""" + if not isinstance(ref, str) or not ref.startswith(_MARKER_PREFIX): + return None + return parse_attempt_ref(ref[len(_MARKER_PREFIX):]) + + +def attempt_ref(version: str, attempt: int) -> str: + ref = f"rc.{attempt}-v{version}" + if parse_attempt_ref(ref) != (version, attempt): + raise ValueError(f"invalid attempt ref: {version!r} attempt {attempt!r}") + return ref + + +def marker_ref(version: str, attempt: int) -> str: + return _MARKER_PREFIX + attempt_ref(version, attempt) + + def _claim_version(tag: str) -> str | None: if isinstance(tag, str) and tag.endswith("-rc"): return version_from_tag(tag[:-3]) diff --git a/tests/scripts/test_release_version_from_ref.py b/tests/scripts/test_release_version_from_ref.py index 3151b5a7b3..fa264747f1 100644 --- a/tests/scripts/test_release_version_from_ref.py +++ b/tests/scripts/test_release_version_from_ref.py @@ -27,6 +27,46 @@ def test_non_final_refs_are_not_versions(ref): assert version_from_tag(ref) is None +def test_attempt_ref_carries_version_and_attempt(): + from scripts.releases.versioning import parse_attempt_ref + + assert parse_attempt_ref("rc.1-v0.21.5") == ("0.21.5", 1) + assert parse_attempt_ref("rc.12-v1.0.0") == ("1.0.0", 12) + + +def test_marker_ref_names_the_attempt_it_clears(): + from scripts.releases.versioning import parse_marker_ref + + assert parse_marker_ref("abandoned-rc.1-v0.21.5") == ("0.21.5", 1) + assert parse_marker_ref("rc.1-v0.21.5") is None + + +@pytest.mark.parametrize("ref", [ + "v0.21.5-rc", "v0.21.5-rc.1", "rc.01-v0.21.5", "rc.0-v0.21.5", + "rc.1-v2026.9.21", "v0.21.5", "abandoned-rc.1-v0.21.5", + "rc.1-v0.21.5+canary.20260922T001400Z", "rc.1-v0.21", "rc.-v0.21.5", +]) +def test_attempt_ref_rejects_other_shapes(ref): + from scripts.releases.versioning import parse_attempt_ref + + assert parse_attempt_ref(ref) is None + + +def test_attempt_and_marker_refs_round_trip(): + from scripts.releases.versioning import attempt_ref, marker_ref, parse_attempt_ref, parse_marker_ref + + assert parse_attempt_ref(attempt_ref("0.21.5", 3)) == ("0.21.5", 3) + assert parse_marker_ref(marker_ref("0.21.5", 3)) == ("0.21.5", 3) + + +@pytest.mark.parametrize("version, attempt", [("2026.9.21", 1), ("0.21.5", 0), ("0.21", 1)]) +def test_attempt_ref_refuses_what_it_could_not_parse(version, attempt): + from scripts.releases.versioning import attempt_ref + + with pytest.raises(ValueError): + attempt_ref(version, attempt) + + def test_claim_advances_the_line_but_is_not_a_final_tag(): assert version_from_tag("v0.21.5-rc") is None assert derive_next_version(published=None, claims=["v0.21.5-rc"], bump="patch") == "0.21.6"