Skip to content
Merged
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
23 changes: 16 additions & 7 deletions scripts/run_agentic_release_project_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,14 +448,22 @@ def _follow_annotated_tag(


def _canonical_repository(payload: dict, requested: str) -> str:
"""Return the canonical owner/repo from a release API response."""
repo = payload.get("repository") or {}
"""Validate optional release repository metadata and return its identity.

GitHub's REST release representation does not guarantee a ``repository``
member. The endpoint itself is scoped to ``/repos/{owner}/{repo}``, and
``_require_token_access`` has already proved that endpoint's canonical
repository with a separate repository API request. Therefore an omitted
member is normal, not ambiguous. If a member is present, it is still
validated as defense in depth.
"""
repo = payload.get("repository")
if repo is None:
return requested
full = repo.get("full_name") if isinstance(repo, dict) else None
if not isinstance(full, str) or not REPOSITORY_PATTERN.match(full):
raise ReleaseReviewError(
f"release response did not carry a canonical repository; expected {requested!r}"
)
# Reject ambiguity: the canonical repository must match the requested one.
raise ReleaseReviewError("release response carried an invalid repository")
# Reject ambiguity when optional embedded repository metadata is present.
if full.lower() != requested.lower():
raise ReleaseReviewError(
f"release belongs to {full!r}, not the requested {requested!r}"
Expand Down Expand Up @@ -508,7 +516,8 @@ def fetch_release(
raise ReleaseReviewError("release response must be a JSON object")
if payload.get("draft") is True:
raise ReleaseReviewError("draft releases are not reviewed")
# Validate the canonical repository matches the request before returning.
# Release payloads may omit `repository`; endpoint-scoped access was proven
# by _require_token_access before this fetch. Validate it when present.
_canonical_repository(payload, target_repository)
tag = payload.get("tag_name")
if not isinstance(tag, str) or not tag.strip():
Expand Down
36 changes: 36 additions & 0 deletions tests/test_run_agentic_release_project_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,42 @@ def test_resolve_only_fetches_canonical_release(self) -> None:
self.assertIn(f"target_repository={self.TARGET}", out)
self.assertIn("external=false", out)

def test_resolve_only_accepts_github_release_without_repository_member(self) -> None:
"""GitHub's release API does not guarantee embedded repository metadata.

Repository identity is already proven by the target-scoped repository
request, while the release endpoint is scoped to that same repository.
"""
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
metadata_path = tmp_path / "release-metadata.json"
release_payload = {
"id": 42,
"tag_name": "v1.0",
"draft": False,
"target_commitish": self.SHA,
}
with (
mock.patch.object(RUNNER, "github_request", return_value=(release_payload, {})),
mock.patch.object(
RUNNER,
"_require_token_access",
return_value={"full_name": self.TARGET},
),
):
rc = RUNNER.main(
[
"resolve-only",
"--target-repository", self.TARGET,
"--caller-repository", self.TARGET,
"--release-id", "42",
"--target-token", "t",
"--release-metadata", str(metadata_path),
]
)
self.assertEqual(rc, 0)
self.assertEqual(json.loads(metadata_path.read_text())["repository"], self.TARGET)

def test_resolve_only_resolves_branch_target_commitish(self) -> None:
"""A release whose target_commitish is a branch is resolved to a SHA."""
with tempfile.TemporaryDirectory() as tmp:
Expand Down
Loading