diff --git a/.github/workflows/publish-releases.yaml b/.github/workflows/publish-releases.yaml index 58a8378..cde2af2 100644 --- a/.github/workflows/publish-releases.yaml +++ b/.github/workflows/publish-releases.yaml @@ -41,9 +41,18 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ github.event.release.tag_name }} + ref: main fetch-depth: 0 + - name: Validate release tag + id: release + env: + TAG_NAME: ${{ github.event.release.tag_name }} + run: ./scripts/validate-release-tag.sh + + - name: Check out validated release tag + run: git checkout --detach "$(git rev-parse 'refs/tags/${{ steps.release.outputs.tag }}^{}')" + - name: Install uv uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3.4.1 @@ -53,7 +62,12 @@ jobs: python-version: "3.13" - name: Build package - run: uvx --with uv-dynamic-versioning hatchling build -d ./dist/ + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + package_version=$(uvx --with uv-dynamic-versioning hatchling version) + test "$package_version" = "$RELEASE_VERSION" + uvx --with uv-dynamic-versioning hatchling build -d ./dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 diff --git a/scripts/validate-release-tag.sh b/scripts/validate-release-tag.sh new file mode 100755 index 0000000..8a81a88 --- /dev/null +++ b/scripts/validate-release-tag.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${TAG_NAME:?TAG_NAME must be set}" + +if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf 'Release tag %q is not a canonical lowercase tag in vMAJOR.MINOR.PATCH form.\n' "$TAG_NAME" >&2 + exit 1 +fi + +git fetch --no-tags origin main + +tag_commit=$(git rev-parse "refs/tags/$TAG_NAME^{}") +main_commit=$(git rev-parse FETCH_HEAD) + +if [[ "$tag_commit" != "$main_commit" ]]; then + printf 'Release tag %s (%s) does not point to origin/main (%s).\n' "$TAG_NAME" "$tag_commit" "$main_commit" >&2 + exit 1 +fi + +{ + printf 'tag=%s\n' "$TAG_NAME" + printf 'version=%s\n' "${TAG_NAME#v}" +} >> "$GITHUB_OUTPUT" diff --git a/tests/test_release_guard.py b/tests/test_release_guard.py new file mode 100644 index 0000000..2bc5df5 --- /dev/null +++ b/tests/test_release_guard.py @@ -0,0 +1,85 @@ +import os +import subprocess +from pathlib import Path + +import pytest + + +REPOSITORY_ROOT = Path(__file__).parents[1] +GUARD = REPOSITORY_ROOT / "scripts" / "validate-release-tag.sh" + + +def run(command: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, cwd=cwd, check=True, capture_output=True, text=True) + + +@pytest.fixture +def release_repository(tmp_path: Path) -> Path: + remote = tmp_path / "remote.git" + repository = tmp_path / "repository" + + run(["git", "init", "--bare", str(remote)], tmp_path) + run(["git", "clone", str(remote), str(repository)], tmp_path) + run(["git", "config", "user.email", "test@example.com"], repository) + run(["git", "config", "user.name", "Test User"], repository) + (repository / "README").write_text("initial\n") + run(["git", "add", "README"], repository) + run(["git", "commit", "-m", "initial"], repository) + run(["git", "branch", "-M", "main"], repository) + run(["git", "push", "-u", "origin", "main"], repository) + return repository + + +def guard(tag_name: str, repository: Path) -> subprocess.CompletedProcess[str]: + output = repository / "github-output" + return subprocess.run( + [str(GUARD)], + cwd=repository, + env={**os.environ, "TAG_NAME": tag_name, "GITHUB_OUTPUT": str(output)}, + capture_output=True, + text=True, + check=False, + ) + + +def test_accepts_canonical_tag_at_origin_main(release_repository: Path) -> None: + run(["git", "tag", "v0.0.67"], release_repository) + run(["git", "push", "origin", "v0.0.67"], release_repository) + + result = guard("v0.0.67", release_repository) + + assert result.returncode == 0 + + +def test_rejects_noncanonical_uppercase_tag(release_repository: Path) -> None: + run(["git", "tag", "V0.0.67"], release_repository) + run(["git", "push", "origin", "V0.0.67"], release_repository) + + result = guard("V0.0.67", release_repository) + + assert result.returncode != 0 + assert "canonical lowercase tag" in result.stderr + + +def test_rejects_tag_behind_updated_origin_main( + release_repository: Path, tmp_path: Path +) -> None: + run(["git", "tag", "v0.0.67"], release_repository) + run(["git", "push", "origin", "v0.0.67"], release_repository) + + updater = tmp_path / "updater" + run( + ["git", "clone", str(release_repository.parent / "remote.git"), str(updater)], + tmp_path, + ) + run(["git", "config", "user.email", "test@example.com"], updater) + run(["git", "config", "user.name", "Test User"], updater) + run(["git", "checkout", "main"], updater) + (updater / "README").write_text("updated\n") + run(["git", "commit", "-am", "advance main"], updater) + run(["git", "push"], updater) + + result = guard("v0.0.67", release_repository) + + assert result.returncode != 0 + assert "does not point to origin/main" in result.stderr