diff --git a/hermes_cli/source_releases.py b/hermes_cli/source_releases.py index e6c149ec34..ef11d7c536 100644 --- a/hermes_cli/source_releases.py +++ b/hermes_cli/source_releases.py @@ -10,7 +10,7 @@ import subprocess import urllib.error import urllib.request -from hermes_cli.update_channel import is_canary_tag +from hermes_cli.update_channel import STABLE_TAG_RE, is_canary_tag logger = logging.getLogger(__name__) _PUBLIC_BASE = "https://hermes-assets.nousresearch.com" @@ -19,7 +19,6 @@ _GITHUB_ORIGIN = re.compile( r"^(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)" r"([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?/?$", re.IGNORECASE, ) -_STABLE_TAG = re.compile(r"v(?:0|[1-9]\d{0,2})\.\d+\.\d+") _SHA = re.compile(r"[0-9a-f]{40}") @@ -175,7 +174,7 @@ class _BuildMetadata(HTMLParser): def _valid_tag(tag, channel: str) -> bool: if not isinstance(tag, str): return False - return bool(_STABLE_TAG.fullmatch(tag)) if channel == "stable" else ( + return bool(STABLE_TAG_RE.fullmatch(tag)) if channel == "stable" else ( tag == tag.strip() and is_canary_tag(tag) ) diff --git a/hermes_cli/update_channel.py b/hermes_cli/update_channel.py index d70074e914..cdb4a9fcc3 100644 --- a/hermes_cli/update_channel.py +++ b/hermes_cli/update_channel.py @@ -56,6 +56,13 @@ CHANNEL_CANARY = "canary" # patch is accepted here. _CANARY_TAG_RE = re.compile(r"^v(?:0|[1-9]\d*)\.\d+\.\d+-canary\.20\d{6}(?:\d{6})?$") +# A stable release tag: v.., no suffix. The major is +# capped at three digits so the historical CalVer tags (v2026.7.20) can never +# pass as SemVer and reach a stable feed, Docker publish, or the source +# updater. THIS is the single authority for the stable shape; every stable +# selector imports it rather than re-typing the rule. +STABLE_TAG_RE = re.compile(r"^v(?:0|[1-9]\d{0,2})\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$") + def is_canary_tag(tag: Any) -> bool: """True when ``tag`` is a canary release tag.""" diff --git a/scripts/release.py b/scripts/release.py index 3eb27850ca..09bda2796d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -36,7 +36,7 @@ from pathlib import Path # is import-light: only os/sys + version constants). sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from hermes_cli.update_channel import _CANARY_TAG_RE, canary_tag_for_date # noqa: E402 +from hermes_cli.update_channel import _CANARY_TAG_RE, STABLE_TAG_RE, canary_tag_for_date # noqa: E402 REPO_ROOT = Path(__file__).resolve().parent.parent VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py" @@ -2163,8 +2163,7 @@ def dispatch_desktop_build(tag: str, gh_repo: str | None) -> bool: Explicit dispatch also works for tags created by GITHUB_TOKEN. """ canary = _CANARY_TAG_RE.fullmatch(tag) is not None - from scripts.releases.semver import STABLE_TAG - if not canary and not STABLE_TAG.fullmatch(tag): + if not canary and not STABLE_TAG_RE.fullmatch(tag): raise ValueError("Expected an exact stable or canary release tag") workflow = "desktop-bundled-release.yml" if canary else "stable-release.yml" cmd = ["gh", "workflow", "run", workflow, "--ref", "main" if canary else tag, @@ -2249,15 +2248,12 @@ def remote_github_repo(remote: str) -> str | None: return match.group(1) if match else None -# Cap the major at three digits, as in scripts/write_install_stamp.py. -# The legacy CalVer tags (v2026.7.20) must never match as SemVer. -_SEMVER_TAG_RE = re.compile(r"v(?:0|[1-9]\d{0,2})\.\d+\.\d+$") -_LEGACY_CALVER_TAG_RE = re.compile(r"v20\d{2}\.\d+\.\d+(?:\.\d+)?$") -# Canary prerelease tags are matched with _CANARY_TAG_RE, imported from -# hermes_cli.update_channel — the single authority for the canary tag -# shape (v..-canary., plus the -# legacy date-only form). The suffix keeps them out of every stable -# selector (all of which require the no-suffix SemVer shape above). +# Stable tags are matched with STABLE_TAG_RE and canary prerelease tags +# with _CANARY_TAG_RE, both imported from hermes_cli.update_channel — the +# single authority for both tag shapes (the stable major is capped at three +# digits so legacy CalVer tags like v2026.7.20 never match; the canary shape +# is v..-canary., plus the legacy +# date-only form). The suffix keeps canaries out of every stable selector. # Second precision so manual fires can publish several canaries per day; # the identifier is pure numeric and fixed-length, so semver prerelease # comparison (numeric) and lexical sort both order it chronologically. @@ -2274,7 +2270,7 @@ def get_last_tag(): if tags: tag_list = tags.split("\n") for tag in tag_list: - if _SEMVER_TAG_RE.fullmatch(tag) and not _LEGACY_CALVER_TAG_RE.fullmatch(tag): + if STABLE_TAG_RE.fullmatch(tag): return tag legacy_tags = git("tag", "--list", "v20*", "--sort=-v:refname") diff --git a/scripts/releases/docker.py b/scripts/releases/docker.py index 0ca6c06afb..965a4e2def 100644 --- a/scripts/releases/docker.py +++ b/scripts/releases/docker.py @@ -10,7 +10,7 @@ import sys MANIFEST_SCHEMA = 1 SHA256 = re.compile(r"[a-f0-9]{64}") GIT_SHA = re.compile(r"[a-f0-9]{40}") -from scripts.releases.semver import STABLE_TAG +from hermes_cli.update_channel import STABLE_TAG_RE ARCHES = ("amd64", "arm64") class DockerReleaseError(ValueError): @@ -18,7 +18,7 @@ class DockerReleaseError(ValueError): def require_stable_tag(tag: str) -> str: - if not isinstance(tag, str) or not STABLE_TAG.fullmatch(tag or ""): + if not isinstance(tag, str) or not STABLE_TAG_RE.fullmatch(tag or ""): raise DockerReleaseError(f"Not a stable release tag: {tag!r}") return tag diff --git a/scripts/releases/semver.py b/scripts/releases/semver.py index 3abbed9f00..a48a1e796f 100644 --- a/scripts/releases/semver.py +++ b/scripts/releases/semver.py @@ -1,10 +1,7 @@ """Compare the stable and canary versions accepted by release feeds.""" from __future__ import annotations -import re -from hermes_cli.update_channel import _CANARY_TAG_RE - -STABLE_TAG = re.compile(r"v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)") +from hermes_cli.update_channel import _CANARY_TAG_RE, STABLE_TAG_RE def is_valid_version(version: str) -> bool: @@ -14,10 +11,10 @@ def is_valid_version(version: str) -> bool: if not isinstance(version, str): return False core, sep, tail = version.partition("-") - if sep and not STABLE_TAG.fullmatch("v" + core): + if sep and not STABLE_TAG_RE.fullmatch("v" + core): return False if not sep: - return bool(STABLE_TAG.fullmatch("v" + version)) + 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. diff --git a/scripts/releases/stable.py b/scripts/releases/stable.py index f3a25a5f70..f371e7bc2b 100644 --- a/scripts/releases/stable.py +++ b/scripts/releases/stable.py @@ -13,7 +13,7 @@ import urllib.request from pathlib import Path from urllib.parse import unquote, urlsplit -from scripts.releases.semver import STABLE_TAG +from hermes_cli.update_channel import STABLE_TAG_RE SHA = re.compile(r"[a-f0-9]{40}") DIGEST = re.compile(r"[a-f0-9]{64}") DESKTOP_TARGETS = ("windows/x64", "windows/arm64", "macos/x64", "macos/arm64") @@ -25,7 +25,7 @@ SMOKE_JOBS = { def require_stable_identity(tag: str, commit: str, ref: str) -> None: - if not isinstance(tag, str) or not STABLE_TAG.fullmatch(tag) or not SHA.fullmatch(commit or "") or ref != f"refs/tags/{tag}": + if not isinstance(tag, str) or not STABLE_TAG_RE.fullmatch(tag) or not SHA.fullmatch(commit or "") or ref != f"refs/tags/{tag}": raise ValueError("Stable release must run on its exact stable tag and commit") diff --git a/tests/scripts/test_release_darwin.py b/tests/scripts/test_release_darwin.py index 863aee3d53..0c4cee7cdd 100644 --- a/tests/scripts/test_release_darwin.py +++ b/tests/scripts/test_release_darwin.py @@ -127,7 +127,8 @@ def test_semver_grammar_rejects_non_release_versions(): from scripts.releases.semver import compare, is_valid_version assert is_valid_version("0.28.0") and is_valid_version("0.28.0-canary.20260904101010") - assert not is_valid_version("0.28") and is_valid_version("2026.7.20") + # Legacy CalVer (four-digit major) is not a release version anywhere. + assert not is_valid_version("0.28") and not is_valid_version("2026.7.20") assert not is_valid_version("0.28.0-beta.1") assert compare("0.28.0", "0.27.9") == 1 assert compare("0.28.0-canary.20260904101010", "0.28.0") == -1 # prerelease < release diff --git a/tests/scripts/test_release_tags.py b/tests/scripts/test_release_tags.py index 3ddad8d322..543f906629 100644 --- a/tests/scripts/test_release_tags.py +++ b/tests/scripts/test_release_tags.py @@ -18,6 +18,23 @@ def test_release_tag_uses_the_semver_version(): assert release.release_tag_for_version("0.20.0") == "v0.20.0" +def test_every_stable_selector_rejects_legacy_calver_tags(): + """One shared stable grammar: a CalVer tag (v2026.9.21) must be refused by + every stable admission path, or a workflow_call carrying the old GitHub + 'latest' tag would be admitted for docker/stable publication.""" + from hermes_cli.source_releases import _valid_tag + from scripts.releases.docker import DockerReleaseError, require_stable_tag + from scripts.releases.semver import compare + + for tag in ("v2026.9.21", "v1000.0.0", "v1.01.0", "v1.0.0-canary.20260921000000"): + assert not _valid_tag(tag, "stable") + with pytest.raises(DockerReleaseError): + require_stable_tag(tag) + assert _valid_tag("v1.0.0", "stable") and require_stable_tag("v999.0.0") == "v999.0.0" + with pytest.raises(ValueError): + compare("1.0.0", "2026.9.21") + + @pytest.fixture def release_repo(tmp_path, monkeypatch): from tests.scripts.test_release_build_commit import git diff --git a/tests/scripts/test_termux_deb_version.py b/tests/scripts/test_termux_deb_version.py index 9e66beb44a..90d394a323 100644 --- a/tests/scripts/test_termux_deb_version.py +++ b/tests/scripts/test_termux_deb_version.py @@ -54,11 +54,13 @@ def test_canary_shape_matches_the_stable_shape_on_every_component(): from hermes_cli.update_channel import _CANARY_TAG_RE from scripts.releases.semver import is_valid_version - assert _CANARY_TAG_RE.fullmatch("v2026.9.15-canary.20260916120000") - assert is_valid_version("2026.9.15-canary.20260916120000") + assert _CANARY_TAG_RE.fullmatch("v1.9.15-canary.20260916120000") + assert is_valid_version("1.9.15-canary.20260916120000") + # A legacy CalVer core is refused as a stable, so its canary is refused too. + assert not is_valid_version("2026.9.15") and not is_valid_version("2026.9.15-canary.20260916120000") # And the malformed-tag negatives stay malformed. - assert not is_valid_version("2026.9.15-canary.2026091612") - assert not is_valid_version("2026.9.15-canary.20260916120000123") + assert not is_valid_version("1.9.15-canary.2026091612") + assert not is_valid_version("1.9.15-canary.20260916120000123") @pytest.mark.parametrize("tag", [