diff --git a/.github/scripts/check_release_tag_version.py b/.github/scripts/check_release_tag_version.py new file mode 100644 index 00000000000..60a479dfca1 --- /dev/null +++ b/.github/scripts/check_release_tag_version.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Fail when a release tag and the pyproject version at that tag disagree. + +Usage +----- + python3 check_release_tag_version.py [path/to/pyproject.toml] + +The tag may carry a leading `v` and a PEP 440 suffix (`rc`, `dev`, `post`, or a +vendor suffix such as `-stable`). Only the X.Y.Z release segment is compared, so +`v1.107.0-rc.2` matches a pyproject version of `1.107.0`. + +Exit code 1 with a `::error::` line on stderr when the tag and the version do +not name the same release. +""" + +from __future__ import annotations + +import pathlib +import re +import sys +from typing import Final + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +_RELEASE: Final = re.compile(r"^(?P[0-9]+\.[0-9]+\.[0-9]+)(?P.*)$") +_ALLOWED_SUFFIX: Final = re.compile(r"^(?:$|[.\-+].*|(?:a|b|rc|dev|post)[0-9].*)$") + + +def _release_segment(value: str, *, leading_v: bool) -> str | None: + candidate: Final = value[1:] if leading_v and value.startswith("v") else value + match: Final = _RELEASE.fullmatch(candidate) + if match is None or _ALLOWED_SUFFIX.fullmatch(match.group("suffix")) is None: + return None + return match.group("release") + + +def read_version(pyproject: pathlib.Path) -> str: + with pyproject.open("rb") as f: + return tomllib.load(f)["project"]["version"] + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + print( # noqa: T201 # the ::error:: line to stderr is the workflow's failure signal + "::error::usage: check_release_tag_version.py [path/to/pyproject.toml]", + file=sys.stderr, + ) + return 1 + + tag: Final = argv[1] + pyproject: Final = pathlib.Path(argv[2]) if len(argv) > 2 else pathlib.Path("pyproject.toml") + tag_release: Final = _release_segment(tag, leading_v=True) + if tag_release is None: + print( # noqa: T201 # the ::error:: line to stderr is the workflow's failure signal + f"::error::{tag} is not a release tag with an X.Y.Z version", file=sys.stderr + ) + return 1 + + version: Final = read_version(pyproject) + version_release: Final = _release_segment(version, leading_v=False) + if version_release is None: + print( # noqa: T201 # the ::error:: line to stderr is the workflow's failure signal + f"::error::pyproject.toml version {version} is not an X.Y.Z version", file=sys.stderr + ) + return 1 + + if tag_release != version_release: + print( # noqa: T201 # the ::error:: line to stderr is the workflow's failure signal + f"::error::{tag} points at a commit whose pyproject.toml version is {version}. " + f"Tag the commit for {tag_release}, or correct the version before tagging.", + file=sys.stderr, + ) + return 1 + + print(f"{tag} matches pyproject.toml version {version}") # noqa: T201 # the workflow shows this line + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/validate-release-tag.yml b/.github/workflows/validate-release-tag.yml new file mode 100644 index 00000000000..9bebbda9848 --- /dev/null +++ b/.github/workflows/validate-release-tag.yml @@ -0,0 +1,24 @@ +name: Validate Release Tag + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + validate: + name: Check the tag against pyproject.toml + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Check the tag against pyproject.toml + env: + RELEASE_TAG: ${{ github.ref_name }} + run: python3 .github/scripts/check_release_tag_version.py "$RELEASE_TAG" diff --git a/tests/unit/test_check_release_tag_version.py b/tests/unit/test_check_release_tag_version.py new file mode 100644 index 00000000000..bc33b122e9d --- /dev/null +++ b/tests/unit/test_check_release_tag_version.py @@ -0,0 +1,83 @@ +"""Tests for .github/scripts/check_release_tag_version.py.""" + +import importlib.util +import sys +from pathlib import Path +from typing import Final + +import pytest + +_REPO_ROOT: Final = Path(__file__).resolve().parents[2] +_MODULE_PATH: Final = _REPO_ROOT / ".github" / "scripts" / "check_release_tag_version.py" +_spec: Final = importlib.util.spec_from_file_location("check_release_tag_version", _MODULE_PATH) +check_release_tag_version: Final = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = check_release_tag_version +_spec.loader.exec_module(check_release_tag_version) + + +def _run(tmp_path: Path, tag: str, version: str, capsys: pytest.CaptureFixture[str]) -> tuple[int, str, str]: + pyproject: Final = tmp_path / "pyproject.toml" + pyproject.write_text(f'[project]\nname = "litellm"\nversion = "{version}"\n', encoding="utf-8") + code: Final = check_release_tag_version.main(["check_release_tag_version.py", tag, str(pyproject)]) + captured: Final = capsys.readouterr() + return code, captured.out, captured.err + + +@pytest.mark.parametrize( + ("tag", "version"), + [ + ("v1.107.0", "1.107.0"), + ("1.107.0", "1.107.0"), + ("v1.107.0-rc.2", "1.107.0"), + ("1.84.0rc1", "1.84.0"), + ("v1.84.0.dev42", "1.84.0.dev42"), + ("v1.107.0.post1", "1.107.0.post1"), + ("v1.83.10-stable", "1.83.10"), + ("v1.83.3-stable.opus-4.7", "1.83.3"), + ], +) +def test_a_tag_that_matches_the_pyproject_version_passes( + tmp_path: Path, capsys: pytest.CaptureFixture[str], tag: str, version: str +) -> None: + code, out, err = _run(tmp_path, tag, version, capsys) + assert (code, err) == (0, "") + assert out == f"{tag} matches pyproject.toml version {version}\n" + + +@pytest.mark.parametrize(("tag", "version"), [("v1.107.0", "1.108.0"), ("v1.106.2", "1.107.0")]) +def test_a_tag_that_does_not_match_the_pyproject_version_fails( + tmp_path: Path, capsys: pytest.CaptureFixture[str], tag: str, version: str +) -> None: + code, out, err = _run(tmp_path, tag, version, capsys) + assert code == 1 + assert out == "" + assert err.startswith(f"::error::{tag} points at a commit whose pyproject.toml version is {version}.") + + +@pytest.mark.parametrize("tag", ["release-1.107.0", "v1.107", "v1.107.0foo"]) +def test_a_tag_that_is_not_an_x_y_z_release_fails(tmp_path: Path, capsys: pytest.CaptureFixture[str], tag: str) -> None: + code, out, err = _run(tmp_path, tag, "1.107.0", capsys) + assert code == 1 + assert out == "" + assert err == f"::error::{tag} is not a release tag with an X.Y.Z version\n" + + +def test_a_malformed_pyproject_version_fails(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + code, out, err = _run(tmp_path, "v1.107.0", "1.107", capsys) + assert code == 1 + assert out == "" + assert err == "::error::pyproject.toml version 1.107 is not an X.Y.Z version\n" + + +def test_a_missing_tag_argument_fails(capsys: pytest.CaptureFixture[str]) -> None: + assert check_release_tag_version.main(["check_release_tag_version.py"]) == 1 + assert capsys.readouterr().err == ("::error::usage: check_release_tag_version.py [path/to/pyproject.toml]\n") + + +def test_the_repo_pyproject_is_read_when_no_path_is_given( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.chdir(_REPO_ROOT) + version: Final = check_release_tag_version.read_version(_REPO_ROOT / "pyproject.toml") + assert check_release_tag_version.main(["check_release_tag_version.py", f"v{version}"]) == 0 + assert capsys.readouterr().out == f"v{version} matches pyproject.toml version {version}\n"