diff --git a/scripts/releases/semver.py b/scripts/releases/semver.py index a48a1e796f..06c2b77af4 100644 --- a/scripts/releases/semver.py +++ b/scripts/releases/semver.py @@ -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.``) 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.; 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 diff --git a/scripts/releases/versioning.py b/scripts/releases/versioning.py new file mode 100644 index 0000000000..af17734c9a --- /dev/null +++ b/scripts/releases/versioning.py @@ -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-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) diff --git a/tests/scripts/test_release_version_from_ref.py b/tests/scripts/test_release_version_from_ref.py new file mode 100644 index 0000000000..a37efaa7b8 --- /dev/null +++ b/tests/scripts/test_release_version_from_ref.py @@ -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"