feat(release): derive the next version from the release family
The family is the published stable head, then outstanding -rc claims, then the seed 0.21.4 when both are empty, so the first release is 0.21.5. CalVer tags are excluded rather than sorted: v2026.9.21 is a valid three-component version and would win every max(). A +canary identity compares equal to its stable; the legacy -canary prerelease still sorts before its stable so feeds published before the migration keep their order.
This commit is contained in:
@@ -1,51 +1,70 @@
|
||||
"""Compare the stable and canary versions accepted by release feeds."""
|
||||
"""Compare the stable and canary versions accepted by release feeds.
|
||||
|
||||
Two canary shapes are release versions. The current one is build metadata
|
||||
(``X.Y.Z+canary.<stamp>``) and compares equal to its stable, which is the
|
||||
point: "newer?" comes from the channel record. The legacy prerelease shape
|
||||
(``X.Y.Z-canary.<14 digits>``) sorts before its stable, so a feed published
|
||||
before the migration still orders correctly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from hermes_cli.update_channel import _CANARY_TAG_RE, STABLE_TAG_RE
|
||||
|
||||
_CANARY_VERSION_RE = re.compile(r"^[0-9.]+[+]canary[.]20\d{6}T\d{6}Z$")
|
||||
|
||||
|
||||
def is_canary_version(version: str) -> bool:
|
||||
"""True for the build-metadata canary identity and nothing else."""
|
||||
if not isinstance(version, str) or not _CANARY_VERSION_RE.fullmatch(version):
|
||||
return False
|
||||
return bool(STABLE_TAG_RE.fullmatch("v" + version.split("+", 1)[0]))
|
||||
|
||||
|
||||
def _is_legacy_canary(version: str) -> bool:
|
||||
return isinstance(version, str) and bool(_CANARY_TAG_RE.fullmatch("v" + version)) and len(
|
||||
version.partition("-")[2].split(".", 1)[1]) == 14
|
||||
|
||||
|
||||
def is_valid_version(version: str) -> bool:
|
||||
"""semver.valid restricted to our release grammar (no build metadata,
|
||||
no alphanumeric prerelease identifiers — those never appear in
|
||||
generated tags)."""
|
||||
"""A stable version or a legacy canary prerelease — the grammar the
|
||||
existing feeds and gates were published with."""
|
||||
if not isinstance(version, str):
|
||||
return False
|
||||
core, sep, tail = version.partition("-")
|
||||
if sep and not STABLE_TAG_RE.fullmatch("v" + core):
|
||||
return False
|
||||
if not sep:
|
||||
return bool(STABLE_TAG_RE.fullmatch("v" + version))
|
||||
if not _CANARY_TAG_RE.fullmatch("v" + version):
|
||||
return False
|
||||
# Canary timestamp is a fixed-length 14-digit numeric stamp.
|
||||
stamp = tail.split(".", 1)[1]
|
||||
return len(stamp) == 14
|
||||
core, sep, _tail = version.partition("-")
|
||||
if sep:
|
||||
return _is_legacy_canary(version) and bool(STABLE_TAG_RE.fullmatch("v" + core))
|
||||
return bool(STABLE_TAG_RE.fullmatch("v" + version))
|
||||
|
||||
|
||||
def _prerelease_key(tail: str) -> list[int]:
|
||||
"""Numeric sort key for a canary suffix — 'canary.<14 digits>'."""
|
||||
return [int(tail.split(".", 1)[1])]
|
||||
def is_release_version(version: str) -> bool:
|
||||
"""True for a stable version, a legacy canary, or a build-metadata canary."""
|
||||
return is_valid_version(version) or is_canary_version(version)
|
||||
|
||||
|
||||
def _core(version: str) -> list[int]:
|
||||
return [int(part) for part in version.split("+", 1)[0].split("-", 1)[0].split(".")]
|
||||
|
||||
|
||||
def compare(a: str, b: str) -> int:
|
||||
"""semver.compare for our grammar. Both sides must be valid release
|
||||
versions (ValueError otherwise — feed publication fails loudly).
|
||||
Semver ordering: stable 0.28.0 > any 0.28.0-canary.<stamp>; stamps
|
||||
compare numerically."""
|
||||
if not is_valid_version(a) or not is_valid_version(b):
|
||||
"""Compare two release versions.
|
||||
|
||||
Build metadata is ignored, so a ``+canary`` identity compares equal to the
|
||||
stable it was built from. A legacy ``-canary`` prerelease sorts before the
|
||||
stable of the same core, stamps compared numerically. ValueError on
|
||||
anything that is not a release version — feed publication fails loudly.
|
||||
"""
|
||||
if not is_release_version(a) or not is_release_version(b):
|
||||
raise ValueError(f"invalid release version(s): {a!r}, {b!r}")
|
||||
a_core, _, a_tail = a.partition("-")
|
||||
b_core, _, b_tail = b.partition("-")
|
||||
if a_core != b_core:
|
||||
ka = [int(p) for p in a_core.split(".")]
|
||||
kb = [int(p) for p in b_core.split(".")]
|
||||
ka, kb = _core(a), _core(b)
|
||||
if ka != kb:
|
||||
return -1 if ka < kb else 1
|
||||
# Same core: a prerelease (canary) sorts BEFORE the stable release.
|
||||
if a_tail and b_tail:
|
||||
ka, kb = _prerelease_key(a_tail), _prerelease_key(b_tail)
|
||||
return -1 if ka < kb else (1 if ka > kb else 0)
|
||||
if a_tail:
|
||||
return -1
|
||||
if b_tail:
|
||||
return 1
|
||||
return 0
|
||||
a_pre, b_pre = "-" in a.split("+", 1)[0], "-" in b.split("+", 1)[0]
|
||||
if a_pre == b_pre:
|
||||
if not a_pre:
|
||||
return 0
|
||||
a_stamp = int(a.split("+", 1)[0].split("-", 1)[1].split(".", 1)[1])
|
||||
b_stamp = int(b.split("+", 1)[0].split("-", 1)[1].split(".", 1)[1])
|
||||
return -1 if a_stamp < b_stamp else (1 if a_stamp > b_stamp else 0)
|
||||
return -1 if a_pre else 1
|
||||
|
||||
52
scripts/releases/versioning.py
Normal file
52
scripts/releases/versioning.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Ref name to version, and the next version from the release family.
|
||||
|
||||
The family is the published stable head, then outstanding ``-rc`` claims, then
|
||||
the seed when both are empty. CalVer tags and receipt tags are excluded: a
|
||||
CalVer tag is a valid three-component version and would win every ``max()``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_cli.update_channel import STABLE_TAG_RE
|
||||
|
||||
SEED = "0.21.4"
|
||||
BUMPS = ("major", "minor", "patch")
|
||||
|
||||
|
||||
def version_from_tag(ref: str) -> str | None:
|
||||
"""The version a final release tag names, or None for anything else.
|
||||
|
||||
``-rc`` claims, build-metadata identities and non-``v`` receipt namespaces
|
||||
are not final tags, and a 4-digit major is a CalVer label.
|
||||
"""
|
||||
if not isinstance(ref, str) or not STABLE_TAG_RE.fullmatch(ref):
|
||||
return None
|
||||
return ref[1:]
|
||||
|
||||
|
||||
def _claim_version(tag: str) -> str | None:
|
||||
if isinstance(tag, str) and tag.endswith("-rc"):
|
||||
return version_from_tag(tag[:-3])
|
||||
return None
|
||||
|
||||
|
||||
def _bump(version: str, bump: str) -> str:
|
||||
if bump not in BUMPS:
|
||||
raise ValueError(f"unknown bump {bump!r}")
|
||||
major, minor, patch = (int(part) for part in version.split("."))
|
||||
if bump == "major":
|
||||
return f"{major + 1}.0.0"
|
||||
if bump == "minor":
|
||||
return f"{major}.{minor + 1}.0"
|
||||
return f"{major}.{minor}.{patch + 1}"
|
||||
|
||||
|
||||
def derive_next_version(*, published: str | None, claims: list[str], bump: str) -> str:
|
||||
"""The next version: max of the family, then the bump.
|
||||
|
||||
``claims`` may contain anything a tag list contains. Only ``v<semver>-rc``
|
||||
entries count; CalVer labels and receipt tags are ignored, not errors.
|
||||
"""
|
||||
family = [published] if published else []
|
||||
family.extend(version for version in (_claim_version(tag) for tag in claims) if version)
|
||||
base = max(family, key=lambda version: [int(part) for part in version.split(".")]) if family else SEED
|
||||
return _bump(base, bump)
|
||||
56
tests/scripts/test_release_version_from_ref.py
Normal file
56
tests/scripts/test_release_version_from_ref.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""A ref names a version, and the next version is derived, never read from the tree.
|
||||
|
||||
Derivation reads the release family in order: the published stable head, then
|
||||
outstanding ``-rc`` claims, then the seed ``0.21.4`` when both are empty. CalVer
|
||||
tags and receipt tags are not inputs — a CalVer tag would win every ``max()``.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from scripts.releases.versioning import derive_next_version, version_from_tag
|
||||
|
||||
SEED = "0.21.4"
|
||||
|
||||
|
||||
def test_final_tag_is_its_version():
|
||||
assert version_from_tag("v0.21.5") == "0.21.5"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ref", [
|
||||
"v0.21.5-rc",
|
||||
"v0.21.4+canary.20260922T001400Z",
|
||||
"v2026.9.21",
|
||||
"canary-0.21.4+canary.20260922T001400Z",
|
||||
"channel-preview-20260922T001400Z",
|
||||
"commit-abcdef1-20260922T001400Z",
|
||||
])
|
||||
def test_non_final_refs_are_not_versions(ref):
|
||||
assert version_from_tag(ref) is None
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_canary_compares_equal_to_its_stable():
|
||||
from scripts.releases.semver import compare, is_canary_version
|
||||
assert is_canary_version("0.21.4+canary.20260922T001400Z")
|
||||
assert compare("0.21.4+canary.20260922T001400Z", "0.21.4") == 0
|
||||
|
||||
|
||||
def test_calver_tag_is_never_a_derivation_input():
|
||||
assert derive_next_version(published=None, claims=["v2026.9.21"], bump="patch") == "0.21.5"
|
||||
|
||||
|
||||
def test_empty_family_seeds_the_line():
|
||||
assert derive_next_version(published=None, claims=[], bump="patch") == "0.21.5"
|
||||
assert derive_next_version(published=None, claims=[], bump="minor") == "0.22.0"
|
||||
assert derive_next_version(published=None, claims=[], bump="major") == "1.0.0"
|
||||
|
||||
|
||||
def test_published_head_beats_the_seed():
|
||||
assert derive_next_version(published="0.21.5", claims=[], bump="patch") == "0.21.6"
|
||||
|
||||
|
||||
def test_claim_beats_a_lower_published_head():
|
||||
assert derive_next_version(published="0.21.5", claims=["v0.21.7-rc"], bump="patch") == "0.21.8"
|
||||
Reference in New Issue
Block a user