diff --git a/.github/workflows/release/release.py b/.github/workflows/release/release.py index be6f1069..5694ea12 100644 --- a/.github/workflows/release/release.py +++ b/.github/workflows/release/release.py @@ -207,8 +207,10 @@ def cmd_check_commit_exists(args: argparse.Namespace) -> None: def cmd_check_tag_free(args: argparse.Namespace) -> None: + # /git/ref/ (singular) is an EXACT-match lookup that 404s when the tag is + # free. try: - gh_api("GET", f"/git/refs/tags/{args.tag}") + gh_api("GET", f"/git/ref/tags/{args.tag}") print(f"❌ Tag {args.tag} already exists. Pick a different version.") sys.exit(1) except GhApiError: diff --git a/.github/workflows/release/test_release.py b/.github/workflows/release/test_release.py index 9943b247..efc98435 100644 --- a/.github/workflows/release/test_release.py +++ b/.github/workflows/release/test_release.py @@ -408,6 +408,23 @@ def test_tag_exists(self, capsys): cmd_check_tag_free(_ns(tag="v1.2.3")) assert exc.value.code == 1 + def test_uses_exact_match_endpoint(self): + # Regression: the check MUST use /git/ref/ (singular, exact match). + # The plural /git/refs/ endpoint does PREFIX matching on GitHub's + # side, so existing v1.2.3-rc* tags made v1.2.3 read as taken and + # the rc -> final release flow always failed validation. The mock + # can't reproduce GitHub's prefix semantics, so the load-bearing + # assertion is the endpoint path itself. + with patch("release.gh_api") as mock_gh: + mock_gh.side_effect = GhApiError("not found") + with pytest.raises(SystemExit): + cmd_check_tag_free(_ns(tag="v1.2.3")) + path = mock_gh.call_args.args[1] + assert path == "/git/ref/tags/v1.2.3", ( + f"tag-free check must use the exact-match singular /git/ref/ " + f"endpoint, got: {path}" + ) + # ── create-tag ─────────────────────────────────────────────────────────────── @@ -504,5 +521,3 @@ def _git_rev(path: Path, ref: str) -> str: cwd=str(path), capture_output=True, text=True, ) return r.stdout.strip() - -