feat(release): parse attempt refs and abandon marker refs
An attempt ref is rc.<N>-vX.Y.Z and its abandon marker is abandoned-rc.<N>-vX.Y.Z. The ref grammar only anchors: version_from_tag still owns the version shape, so CalVer and canary identities stay out.
This commit is contained in:
@@ -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<attempt>[1-9][0-9]*)-(?P<tag>v[^/]+)")
|
||||
_MARKER_PREFIX = "abandoned-"
|
||||
|
||||
|
||||
def parse_attempt_ref(ref: str) -> tuple[str, int] | None:
|
||||
"""``rc.<N>-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.<N>-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])
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user