Skip to content
Open
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
39 changes: 39 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
88 changes: 88 additions & 0 deletions scripts/validate_publish_ref.py
Original file line number Diff line number Diff line change
@@ -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())
52 changes: 52 additions & 0 deletions tests/test_validate_publish_ref.py
Original file line number Diff line number Diff line change
@@ -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,
)