diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..7e35521ea --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,39 @@ +name: Publish package + +on: + push: + branches: [main, release] + tags: ["v*.*.*"] + workflow_dispatch: + inputs: + version: + description: Package version to publish + required: true + type: string + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Validate protected release ref + run: | + python3 scripts/validate_publish_ref.py + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install uv + uses: astral-sh/setup-uv@v3 + - name: Build package + run: uv build + - name: Publish package + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: uv publish diff --git a/scripts/validate_publish_ref.py b/scripts/validate_publish_ref.py new file mode 100644 index 000000000..d093f15df --- /dev/null +++ b/scripts/validate_publish_ref.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Validate package publishing is running from an approved release ref.""" + +import argparse +import os +import re +import subprocess +import sys + +RELEASE_BRANCHES = {"main", "release"} +RELEASE_TAG = re.compile(r"^refs/tags/v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") + + +def is_release_tag(ref: str) -> bool: + return bool(RELEASE_TAG.match(ref)) + + +def is_protected_publish_ref( + ref: str, + ref_protected: str, + event_name: str, + tag_signed: bool = False, +) -> bool: + if is_release_tag(ref): + return tag_signed + + if event_name not in {"push", "workflow_dispatch"}: + return False + + branch_prefix = "refs/heads/" + if not ref.startswith(branch_prefix): + return False + + branch = ref[len(branch_prefix):] + return branch in RELEASE_BRANCHES and ref_protected.lower() == "true" + + +def verify_tag_signature(ref: str) -> bool: + tag = ref.removeprefix("refs/tags/") + completed = subprocess.run( + ["git", "verify-tag", tag], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return completed.returncode == 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Fail before registry authentication on unsafe refs." + ) + parser.add_argument("--ref", default=os.getenv("GITHUB_REF", "")) + parser.add_argument( + "--ref-protected", + default=os.getenv("GITHUB_REF_PROTECTED", "false"), + ) + parser.add_argument("--event", default=os.getenv("GITHUB_EVENT_NAME", "")) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + tag_signed = ( + verify_tag_signature(args.ref) + if is_release_tag(args.ref) + else False + ) + if is_protected_publish_ref( + args.ref, + args.ref_protected, + args.event, + tag_signed=tag_signed, + ): + print(f"publish ref accepted: {args.ref}") + return 0 + + print( + "ref is not eligible for package publishing before registry auth: " + f"ref={args.ref!r} protected={args.ref_protected!r} " + f"event={args.event!r}", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_validate_publish_ref.py b/tests/test_validate_publish_ref.py new file mode 100644 index 000000000..7bcc80571 --- /dev/null +++ b/tests/test_validate_publish_ref.py @@ -0,0 +1,52 @@ +from scripts.validate_publish_ref import is_protected_publish_ref + + +def test_accepts_protected_main_branch_for_manual_publish(): + assert is_protected_publish_ref( + "refs/heads/main", + "true", + "workflow_dispatch", + ) + + +def test_rejects_unprotected_manual_branch_before_auth(): + assert not is_protected_publish_ref( + "refs/heads/feature/package-test", + "false", + "workflow_dispatch", + ) + + +def test_manual_inputs_cannot_override_ref_protection(): + assert not is_protected_publish_ref( + "refs/heads/main", + "false", + "workflow_dispatch", + ) + + +def test_accepts_signed_release_tag_shape(): + assert is_protected_publish_ref( + "refs/tags/v2.4.1", + "false", + "push", + tag_signed=True, + ) + + +def test_rejects_unsigned_release_tag_shape(): + assert not is_protected_publish_ref( + "refs/tags/v2.4.1", + "false", + "push", + tag_signed=False, + ) + + +def test_rejects_non_release_tag_shape(): + assert not is_protected_publish_ref( + "refs/tags/latest", + "false", + "push", + tag_signed=True, + )