diff --git a/.github/scripts/select-release-by-tag.py b/.github/scripts/select-release-by-tag.py new file mode 100644 index 0000000..f5b348e --- /dev/null +++ b/.github/scripts/select-release-by-tag.py @@ -0,0 +1,27 @@ +"""Select exactly one release from paginated GitHub API output, including drafts.""" + +from __future__ import annotations + +import argparse +import json +import sys + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tag") + args = parser.parse_args() + pages = json.load(sys.stdin) + if not isinstance(pages, list) or not all(isinstance(page, list) for page in pages): + parser.error("expected paginated release arrays") + releases = [release for page in pages for release in page] + if not all(isinstance(release, dict) for release in releases): + parser.error("expected release objects") + matches = [release for release in releases if release.get("tag_name") == args.tag] + if len(matches) != 1: + parser.error(f"expected exactly one release for tag {args.tag}") + json.dump(matches[0], sys.stdout) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 154e9a6..0791e71 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,6 +24,10 @@ jobs: environment: pypi runs-on: ubuntu-latest steps: + - name: Check out trusted release policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Find matching release candidate id: release shell: bash @@ -36,7 +40,9 @@ jobs: echo "invalid release tag: ${RELEASE_TAG}" >&2 exit 1 fi - if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.draft')" != true ]]; then + release=$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" | + python .github/scripts/select-release-by-tag.py "$RELEASE_TAG") + if [[ "$(jq -r '.draft' <<<"$release")" != true ]]; then echo "GitHub release ${RELEASE_TAG} must still be a draft" >&2 exit 1 fi diff --git a/tests/unit/test_release_selector.py b/tests/unit/test_release_selector.py new file mode 100644 index 0000000..46b12b8 --- /dev/null +++ b/tests/unit/test_release_selector.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parents[2] / ".github/scripts/select-release-by-tag.py" + + +def select(pages: object) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), "v0.7.0"], + input=json.dumps(pages), + text=True, + capture_output=True, + check=False, + ) + + +def test_draft_on_later_page_is_selected() -> None: + draft = {"id": 42, "tag_name": "v0.7.0", "draft": True} + result = select([[{"tag_name": "v0.6.0"}], [draft]]) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == draft + + +@pytest.mark.parametrize( + "pages", + [ + [], + [[]], + [[{"tag_name": "v0.6.0"}]], + [[{"tag_name": "v0.7.0"}], [{"tag_name": "v0.7.0"}]], + {}, + [{"tag_name": "v0.7.0"}], + [[None]], + ], +) +def test_missing_duplicate_or_invalid_release_is_rejected(pages: object) -> None: + result = select(pages) + assert result.returncode != 0 + assert result.stdout == ""