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:
ethernet
2026-09-21 20:22:35 -04:00
parent 9786846da7
commit e10ce050af
3 changed files with 163 additions and 36 deletions

View File

@@ -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

View 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)

View 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"