diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 54cfe3e..35dd3a0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,17 +2,21 @@ name: Publish to PyPI on: push: - branches: - - 'v[0-9]+.[0-9]+' + tags: + - "v*.*.*" workflow_dispatch: jobs: publish: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -22,28 +26,61 @@ jobs: - name: Get version from pyproject.toml id: version run: | + set -euo pipefail + VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Validate release tag + run: | + set -euo pipefail + + if [ "${GITHUB_REF_TYPE}" != "tag" ]; then + echo "Publishing must run from a release tag, not ${GITHUB_REF_TYPE} ${GITHUB_REF_NAME}." + exit 1 + fi + + NORMALIZED_VERSION="$(python scripts/release_version.py verify-tag \ + "${GITHUB_REF_NAME}" \ + "${{ steps.version.outputs.version }}")" + RELEASE_BRANCH="$(python scripts/release_version.py branch "${GITHUB_REF_NAME}")" + git fetch origin \ + "refs/heads/${RELEASE_BRANCH}:refs/remotes/origin/${RELEASE_BRANCH}" + TAG_COMMIT="$(git rev-list -n 1 "${GITHUB_REF_NAME}")" + if ! git merge-base --is-ancestor "${TAG_COMMIT}" "origin/${RELEASE_BRANCH}"; then + echo "Release tag ${GITHUB_REF_NAME} must point to a commit contained in ${RELEASE_BRANCH}." + exit 1 + fi + echo "Release tag ${GITHUB_REF_NAME} matches package ${NORMALIZED_VERSION} and ${RELEASE_BRANCH}." - name: Check if version exists on PyPI id: check run: | + set -euo pipefail + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/genvm-linter/${{ steps.version.outputs.version }}/json") if [ "$STATUS" = "200" ]; then - echo "exists=true" >> $GITHUB_OUTPUT + echo "exists=true" >> "$GITHUB_OUTPUT" else - echo "exists=false" >> $GITHUB_OUTPUT + echo "exists=false" >> "$GITHUB_OUTPUT" fi - - name: Skip if version exists + - name: Fail if version exists if: steps.check.outputs.exists == 'true' - run: echo "Version ${{ steps.version.outputs.version }} already published, skipping" + run: | + echo "Version ${{ steps.version.outputs.version }} is already published on PyPI." + echo "PyPI releases are immutable; bump pyproject.toml and create a new matching tag." + exit 1 - name: Install dependencies if: steps.check.outputs.exists == 'false' run: | python -m pip install --upgrade pip - pip install build twine + pip install -e ".[dev]" build twine + + - name: Run tests + if: steps.check.outputs.exists == 'false' + run: pytest tests/ -q - name: Build package if: steps.check.outputs.exists == 'false' diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md index 1b034cb..1d1a6b2 100644 --- a/docs/BRANCHING.md +++ b/docs/BRANCHING.md @@ -37,11 +37,16 @@ not the release gate. ## Promotion and Release -When an integration train is ready, open a promotion PR from the integration -branch to the matching stable branch, for example `v0.11-dev` to `v0.11`. +Release candidates are cut directly from the active integration branch with a +version such as `0.11.1-rc.1`. The matching tag (`v0.11.1-rc.1`) publishes the +candidate without promoting unfinished train work to the stable branch. + +When an integration train is ready for a final release, open a promotion PR +from the integration branch to the matching stable branch, for example +`v0.11-dev` to `v0.11`. That promotion PR is the release-readiness gate and must pass required -cross-repo `E2E Tests`. The actual package release is cut from the stable branch +cross-repo `E2E Tests`. The final package release is cut from the stable branch using a version tag after the stable branch is ready. ## `main` diff --git a/pyproject.toml b/pyproject.toml index b4c1b21..211d837 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "genvm-linter" -version = "0.10.0" +version = "0.11.1-rc.1" description = "Fast validation and schema extraction for GenLayer intelligent contracts" readme = "README.md" license = "MIT" diff --git a/scripts/release_version.py b/scripts/release_version.py new file mode 100644 index 0000000..22ee5fe --- /dev/null +++ b/scripts/release_version.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Validate linter release versions and select their owning branches.""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass + +_VERSION_PATTERN = re.compile( + r"^v?(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)" + r"(?:(?:-?rc\.?(?P[1-9][0-9]*)))?$", + re.IGNORECASE, +) +_BRANCH_PATTERN = re.compile( + r"^v(?P0|[1-9][0-9]*)\." + r"(?P0|[1-9][0-9]*)(?P-dev)?$" +) + + +@dataclass(frozen=True) +class ReleaseVersion: + major: int + minor: int + patch: int + rc: int | None = None + + @property + def normalized(self) -> str: + suffix = "" if self.rc is None else f"-rc.{self.rc}" + return f"{self.major}.{self.minor}.{self.patch}{suffix}" + + @property + def is_prerelease(self) -> bool: + return self.rc is not None + + @property + def release_branch(self) -> str: + suffix = "-dev" if self.is_prerelease else "" + return f"v{self.major}.{self.minor}{suffix}" + + +def parse_release_version(value: str) -> ReleaseVersion: + match = _VERSION_PATTERN.fullmatch(value.strip()) + if match is None: + raise ValueError( + f"{value!r} is not a supported release version; use X.Y.Z for a " + "final release or X.Y.Z-rc.N for a release candidate" + ) + return ReleaseVersion( + major=int(match.group("major")), + minor=int(match.group("minor")), + patch=int(match.group("patch")), + rc=int(match.group("rc")) if match.group("rc") is not None else None, + ) + + +def validate_branch_version(branch: str, value: str) -> ReleaseVersion: + branch_match = _BRANCH_PATTERN.fullmatch(branch) + if branch_match is None: + raise ValueError(f"{branch!r} is not a release branch; use vX.Y or vX.Y-dev") + + version = parse_release_version(value) + branch_line = (int(branch_match.group("major")), int(branch_match.group("minor"))) + if branch_line != (version.major, version.minor): + raise ValueError( + f"{version.normalized} belongs to v{version.major}.{version.minor}, not {branch}" + ) + + branch_is_dev = branch_match.group("dev") is not None + if branch_is_dev and not version.is_prerelease: + raise ValueError( + f"final release {version.normalized} must be cut from v{version.major}.{version.minor}" + ) + if not branch_is_dev and version.is_prerelease: + raise ValueError( + f"release candidate {version.normalized} must be cut from " + f"v{version.major}.{version.minor}-dev" + ) + return version + + +def _usage() -> str: + return ( + "usage: release_version.py normalize | branch | " + "is-prerelease | validate | " + "verify-tag " + ) + + +def main(argv: list[str]) -> int: + try: + command = argv[1] + if command == "normalize" and len(argv) == 3: + print(parse_release_version(argv[2]).normalized) + elif command == "branch" and len(argv) == 3: + print(parse_release_version(argv[2]).release_branch) + elif command == "is-prerelease" and len(argv) == 3: + print("true" if parse_release_version(argv[2]).is_prerelease else "false") + elif command == "validate" and len(argv) == 4: + print(validate_branch_version(argv[2], argv[3]).normalized) + elif command == "verify-tag" and len(argv) == 4: + tag_version = parse_release_version(argv[2]) + package_version = parse_release_version(argv[3]) + if tag_version != package_version: + raise ValueError( + f"tag {tag_version.normalized} does not match package " + f"version {package_version.normalized}" + ) + print(tag_version.normalized) + else: + raise ValueError(_usage()) + except (IndexError, ValueError) as exc: + print(exc, file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/test_release_version.py b/tests/test_release_version.py new file mode 100644 index 0000000..6e43af1 --- /dev/null +++ b/tests/test_release_version.py @@ -0,0 +1,62 @@ +import importlib.util +import sys +from pathlib import Path + +import pytest + +MODULE_PATH = Path(__file__).parents[1] / "scripts" / "release_version.py" +SPEC = importlib.util.spec_from_file_location("release_version", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +release_version = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = release_version +SPEC.loader.exec_module(release_version) + + +@pytest.mark.parametrize( + ("raw", "normalized", "branch", "is_prerelease"), + [ + ("0.11.1", "0.11.1", "v0.11", False), + ("v0.11.1-rc.1", "0.11.1-rc.1", "v0.11-dev", True), + ("0.11.1rc2", "0.11.1-rc.2", "v0.11-dev", True), + ], +) +def test_release_version_normalizes_pep440_rc_spellings( + raw, normalized, branch, is_prerelease +): + version = release_version.parse_release_version(raw) + + assert version.normalized == normalized + assert version.release_branch == branch + assert version.is_prerelease is is_prerelease + + +@pytest.mark.parametrize( + ("branch", "version", "message"), + [ + ("main", "0.11.1", "not a release branch"), + ("v0.10-dev", "0.11.1-rc.1", "belongs to v0.11"), + ("v0.11", "0.11.1-rc.1", "must be cut from v0.11-dev"), + ("v0.11-dev", "0.11.1", "must be cut from v0.11"), + ], +) +def test_release_version_rejects_wrong_release_route(branch, version, message): + with pytest.raises(ValueError, match=message): + release_version.validate_branch_version(branch, version) + + +def test_release_version_accepts_rc_only_on_owning_dev_line(): + version = release_version.validate_branch_version("v0.11-dev", "0.11.1rc1") + + assert version.normalized == "0.11.1-rc.1" + + +@pytest.mark.parametrize("version", ["0.11.1-alpha.1", "0.11.1-rc.0", "00.11.1"]) +def test_release_version_rejects_non_rc_or_noncanonical_versions(version): + with pytest.raises(ValueError, match="not a supported release version"): + release_version.parse_release_version(version) + + +def test_release_tag_and_package_version_compare_after_normalization(): + assert release_version.main( + ["release_version.py", "verify-tag", "v0.11.1-rc.1", "0.11.1rc1"] + ) == 0 diff --git a/uv.lock b/uv.lock index 92160ad..a99435c 100644 --- a/uv.lock +++ b/uv.lock @@ -41,7 +41,7 @@ wheels = [ [[package]] name = "genvm-linter" -version = "0.10.0" +version = "0.11.1rc1" source = { editable = "." } dependencies = [ { name = "click" },