diff --git a/.github/scripts/prune_renders.py b/.github/scripts/prune_renders.py index 59ffd47b..a986944a 100644 --- a/.github/scripts/prune_renders.py +++ b/.github/scripts/prune_renders.py @@ -89,6 +89,33 @@ def collect_paths_under(dir_path: str) -> list[str]: return paths +def _is_git_data_404(exc: RuntimeError) -> bool: + message = str(exc) + return ( + " → 404:" in message + and repo_path("/git/") in message + and (message.startswith("POST ") or message.startswith("PATCH ")) + ) + + +def delete_via_contents_api(paths_to_delete: list[str]) -> None: + for path in paths_to_delete: + current = request_api( + "GET", repo_path(f"/contents/{path}?ref={BRANCH}"), expect_404=True + ) + if current is None: + continue + request_api( + "DELETE", + repo_path(f"/contents/{path}"), + { + "message": f"Prune stale render file {path}", + "branch": BRANCH, + "sha": current["sha"], + }, + ) + + def commit_deletions(paths_to_delete: list[str]) -> None: """Build a new tree omitting the given paths and push a single commit. @@ -115,27 +142,32 @@ def commit_deletions(paths_to_delete: list[str]) -> None: # base_tree is load-bearing: omit it and the new tree contains ONLY the # delete-stubs, which the API resolves to an empty tree → next commit # wipes the branch. Test test_commit_deletions_payload_shape locks this. - new_tree = request_api( - "POST", - repo_path("/git/trees"), - {"base_tree": base_tree_sha, "tree": tree_entries}, - ) + try: + new_tree = request_api( + "POST", + repo_path("/git/trees"), + {"base_tree": base_tree_sha, "tree": tree_entries}, + ) - new_commit = request_api( - "POST", - repo_path("/git/commits"), - { - "message": f"Prune {len(paths_to_delete)} stale render file(s)", - "tree": new_tree["sha"], - "parents": [head_sha], - }, - ) + new_commit = request_api( + "POST", + repo_path("/git/commits"), + { + "message": f"Prune {len(paths_to_delete)} stale render file(s)", + "tree": new_tree["sha"], + "parents": [head_sha], + }, + ) - request_api( - "PATCH", - repo_path(f"/git/refs/heads/{BRANCH}"), - {"sha": new_commit["sha"], "force": False}, - ) + request_api( + "PATCH", + repo_path(f"/git/refs/heads/{BRANCH}"), + {"sha": new_commit["sha"], "force": False}, + ) + except RuntimeError as exc: + if not _is_git_data_404(exc): + raise + delete_via_contents_api(paths_to_delete) def main() -> int: diff --git a/tests/github_scripts/test_prune_renders.py b/tests/github_scripts/test_prune_renders.py index 8f99e68f..cdf30922 100644 --- a/tests/github_scripts/test_prune_renders.py +++ b/tests/github_scripts/test_prune_renders.py @@ -150,6 +150,66 @@ def test_commit_deletions_at_safety_limit_proceeds(): assert m.call_count == 5, "all 5 API calls must occur — no early return at the limit" +def test_commit_deletions_falls_back_to_contents_api_on_git_data_404(): + calls = [] + + def fake_api(method, path, body=None, **kwargs): + calls.append((method, path, body, kwargs)) + if (method, path) == ("GET", "/repos/owner/repo/git/refs/heads/renders"): + return {"object": {"sha": "head_sha"}} + if (method, path) == ("GET", "/repos/owner/repo/git/commits/head_sha"): + return {"tree": {"sha": "base_tree_sha"}} + if (method, path) == ("POST", "/repos/owner/repo/git/trees"): + raise RuntimeError( + 'POST /repos/owner/repo/git/trees → 404: {"message":"Not Found"}' + ) + if (method, path) == ("GET", "/repos/owner/repo/contents/issue-1/a.png?ref=renders"): + return {"sha": "sha-a"} + if (method, path) == ("GET", "/repos/owner/repo/contents/issue-1/b.png?ref=renders"): + return {"sha": "sha-b"} + if (method, path) == ("DELETE", "/repos/owner/repo/contents/issue-1/a.png"): + return {} + if (method, path) == ("DELETE", "/repos/owner/repo/contents/issue-1/b.png"): + return {} + raise AssertionError(f"unexpected API call: {(method, path, body, kwargs)}") + + with patch.object(prune_renders, "request_api", side_effect=fake_api): + prune_renders.commit_deletions(["issue-1/a.png", "issue-1/b.png"]) + + assert calls[3] == ( + "GET", + "/repos/owner/repo/contents/issue-1/a.png?ref=renders", + None, + {"expect_404": True}, + ) + assert calls[4] == ( + "DELETE", + "/repos/owner/repo/contents/issue-1/a.png", + { + "message": "Prune stale render file issue-1/a.png", + "branch": "renders", + "sha": "sha-a", + }, + {}, + ) + assert calls[5] == ( + "GET", + "/repos/owner/repo/contents/issue-1/b.png?ref=renders", + None, + {"expect_404": True}, + ) + assert calls[6] == ( + "DELETE", + "/repos/owner/repo/contents/issue-1/b.png", + { + "message": "Prune stale render file issue-1/b.png", + "branch": "renders", + "sha": "sha-b", + }, + {}, + ) + + def test_list_issue_dirs_ignores_non_issue_entries(): contents = [ {"type": "dir", "name": "issue-1", "path": "issue-1"},