Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 66 additions & 4 deletions scripts/ci_release_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
import subprocess
import sys
import argparse
from pathlib import Path


Expand All @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions scripts/test_ci_release_readiness.py
Original file line number Diff line number Diff line change
@@ -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()
Loading