From 0acd8baaa3d50f7e12e0f660a67772dd9f5e76db Mon Sep 17 00:00:00 2001 From: calvinsturm Date: Fri, 3 Jul 2026 19:05:47 -0700 Subject: [PATCH] Require release tag and crate version alignment --- scripts/ci_release_readiness.py | 70 ++++++++++++++++++++++++++-- scripts/test_ci_release_readiness.py | 58 +++++++++++++++++++++++ 2 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 scripts/test_ci_release_readiness.py diff --git a/scripts/ci_release_readiness.py b/scripts/ci_release_readiness.py index d707798..76b1087 100644 --- a/scripts/ci_release_readiness.py +++ b/scripts/ci_release_readiness.py @@ -5,6 +5,7 @@ import re import subprocess import sys +import argparse from pathlib import Path @@ -19,6 +20,7 @@ re.MULTILINE, ) SCHEMA_NOTE_RE = re.compile(r"(?im)^\s{0,3}(##|###)\s+Schema Notes\b") +RELEASE_TAG_RE = re.compile(r"^v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$") def fail(msg: str) -> None: @@ -33,14 +35,60 @@ def read_text(path: Path) -> str: fail(f"missing required file: {path.relative_to(ROOT)}") +def parse_cargo_package_version(content: str) -> str | None: + in_package = False + for line in content.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + section = re.match(r"^\[([^\]]+)\]\s*$", stripped) + if section: + in_package = section.group(1).strip() == "package" + continue + if in_package: + m = re.match(r'^version\s*=\s*"([^"]+)"\s*$', stripped) + if m: + return m.group(1) + return None + + def cargo_version() -> str: - content = read_text(CARGO_TOML) - m = re.search(r'(?m)^version\s*=\s*"([^"]+)"\s*$', content) + version = parse_cargo_package_version(read_text(CARGO_TOML)) + if not version: + fail("could not parse package.version from Cargo.toml") + return version + + +def version_from_release_tag(release_tag: str) -> str: + m = RELEASE_TAG_RE.match(release_tag.strip()) if not m: - fail("could not parse package version from Cargo.toml") + raise ValueError( + "release tag must look like `v1.2.3` or `v1.2.3-alpha.1`" + ) return m.group(1) +def release_tag_mismatch_message(release_tag: str, package_version: str) -> str | None: + tag_version = version_from_release_tag(release_tag) + if tag_version == package_version: + return None + return ( + "release tag version does not match Cargo.toml package.version: " + f"`{release_tag}` -> `{tag_version}`, Cargo.toml -> `{package_version}`" + ) + + +def check_release_tag_matches_cargo(release_tag: str | None, package_version: str) -> None: + if not release_tag: + return + try: + mismatch = release_tag_mismatch_message(release_tag, package_version) + except ValueError as e: + fail(str(e)) + if mismatch: + fail(mismatch) + + def check_version_linkage(version: str) -> None: tag = f"v{version}" release_filename = f"RELEASE_NOTES_{tag}.md" @@ -136,8 +184,22 @@ def check_schema_note(version: str) -> None: ) -def main() -> None: +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate release readiness invariants.") + parser.add_argument( + "--release-tag", + help=( + "Optional official release tag, for example `v0.6.0-alpha.2`. " + "When supplied, its version must match Cargo.toml package.version." + ), + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + args = parse_args(sys.argv[1:] if argv is None else argv) version = cargo_version() + check_release_tag_matches_cargo(args.release_tag, version) check_version_linkage(version) if schema_changed(): check_schema_note(version) diff --git a/scripts/test_ci_release_readiness.py b/scripts/test_ci_release_readiness.py new file mode 100644 index 0000000..505694d --- /dev/null +++ b/scripts/test_ci_release_readiness.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("ci_release_readiness.py") +SPEC = importlib.util.spec_from_file_location("ci_release_readiness", SCRIPT) +assert SPEC and SPEC.loader +ci_release_readiness = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(ci_release_readiness) + + +class ReleaseReadinessTests(unittest.TestCase): + def test_version_from_release_tag_strips_v_prefix(self) -> None: + self.assertEqual( + ci_release_readiness.version_from_release_tag("v0.6.0-alpha.2"), + "0.6.0-alpha.2", + ) + + def test_version_from_release_tag_accepts_build_metadata(self) -> None: + self.assertEqual( + ci_release_readiness.version_from_release_tag("v1.2.3+build.4"), + "1.2.3+build.4", + ) + + def test_version_from_release_tag_rejects_non_version_tag(self) -> None: + with self.assertRaises(ValueError): + ci_release_readiness.version_from_release_tag("release-candidate") + + def test_parse_cargo_package_version_reads_package_section_only(self) -> None: + content = """ +[dependencies] +example = { version = "9.9.9" } + +[package] +name = "localagent" +version = "0.6.0-alpha.2" +""" + self.assertEqual( + ci_release_readiness.parse_cargo_package_version(content), + "0.6.0-alpha.2", + ) + + def test_release_tag_mismatch_message_reports_both_versions(self) -> None: + message = ci_release_readiness.release_tag_mismatch_message( + "v0.6.0-alpha.2", + "0.5.0", + ) + assert message is not None + self.assertIn("0.6.0-alpha.2", message) + self.assertIn("0.5.0", message) + + +if __name__ == "__main__": + unittest.main()