Skip to content

Commit cd112d6

Browse files
Merge pull request #33 from actions/cassiomarques/scope-push-pr-lookup
Prevent push coverage from matching fork pull requests
2 parents 9706dd2 + 19923c1 commit cd112d6

5 files changed

Lines changed: 486 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Unreleased
44

5+
### Fixed
6+
7+
- Non-default branch pushes no longer associate coverage with an unrelated fork pull request that uses the same branch name.
8+
- When a pushed branch has multiple open pull requests, the action prefers the pull request targeting the default branch. If no unique default-branch target exists, the upload skips successfully instead of selecting arbitrarily or failing CI.
9+
510
## v1.4.3 - 2026-09-28
611

712
### Fixed

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ The action auto-detects the event type and resolves the correct behavior:
8989
- **Non-default branch `push` with an open PR**: Looks up the PR number and uploads coverage for the pushed commit.
9090
- **Non-default branch `push` without an open PR**: Skips successfully instead of sending an upload request the API cannot associate with a PR.
9191

92+
When a pushed branch has multiple open pull requests in the repository, the action prefers the pull request targeting the default branch because coverage comparisons currently use the default branch as their baseline. If there is no unique default-branch target, the action skips the upload successfully and logs a warning.
93+
9294
Configure workflows to run for pull requests and pushes to the default branch:
9395

9496
```yaml

‎docs/workflow-strategies.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ In a separate-job layout, the test job generates the report and the upload job r
2525
- Pushes with an open pull request upload using the discovered PR number.
2626
- Pushes without an open pull request skip successfully.
2727

28+
If the same pushed branch has multiple open pull requests, the action prefers the pull request targeting the default branch because coverage comparisons currently use the default branch as their baseline. If no unique default-branch target exists, the action skips successfully with a warning rather than choosing arbitrarily or failing CI.
29+
2830
If a branch is pushed before its pull request is opened, coverage is not uploaded until the next push. To upload coverage when a pull request is opened without requiring another push, use a `pull_request` trigger.
2931

3032
Push-only workflows have an existing requirement to grant the upload job `pull-requests: read`:

‎resolve_upload_context.py‎

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env python3
2+
import json
23
import os
34
import subprocess
45
from collections.abc import Mapping
@@ -22,11 +23,92 @@ class UploadContext:
2223
class PullRequestLookup:
2324
number: str = ""
2425
error: str = ""
26+
warning: str = ""
27+
28+
29+
@dataclass(frozen=True)
30+
class PullRequestCandidate:
31+
number: int
32+
base_ref_name: str
33+
34+
35+
def _parse_pull_request_lookup(
36+
output: str,
37+
repository: str,
38+
ref_name: str,
39+
default_branch: str,
40+
) -> PullRequestLookup:
41+
try:
42+
pull_requests = json.loads(output)
43+
except json.JSONDecodeError as error:
44+
return PullRequestLookup(error=f"gh returned invalid JSON: {error.msg}")
45+
46+
if not isinstance(pull_requests, list):
47+
return PullRequestLookup(error="gh returned invalid JSON: expected a list")
48+
49+
matching_pull_requests: list[PullRequestCandidate] = []
50+
for pull_request in pull_requests:
51+
if not isinstance(pull_request, dict):
52+
return PullRequestLookup(
53+
error="gh returned invalid JSON: expected pull request objects"
54+
)
55+
56+
head_repository = pull_request.get("headRepository")
57+
if not isinstance(head_repository, dict):
58+
continue
59+
60+
head_repository_name = head_repository.get("nameWithOwner")
61+
if (
62+
pull_request.get("headRefName") != ref_name
63+
or not isinstance(head_repository_name, str)
64+
or head_repository_name.casefold() != repository.casefold()
65+
):
66+
continue
67+
68+
number = pull_request.get("number")
69+
if not isinstance(number, int):
70+
return PullRequestLookup(
71+
error="gh returned invalid JSON: expected a pull request number"
72+
)
73+
base_ref_name = pull_request.get("baseRefName")
74+
if not isinstance(base_ref_name, str):
75+
return PullRequestLookup(
76+
error="gh returned invalid JSON: expected a pull request base branch"
77+
)
78+
79+
if base_ref_name == default_branch:
80+
return PullRequestLookup(number=str(number))
81+
82+
matching_pull_requests.append(
83+
PullRequestCandidate(number=number, base_ref_name=base_ref_name)
84+
)
85+
86+
if len(matching_pull_requests) == 1:
87+
return PullRequestLookup(number=str(matching_pull_requests[0].number))
88+
89+
if len(matching_pull_requests) > 1:
90+
numbers = ", ".join(
91+
str(candidate.number)
92+
for candidate in sorted(
93+
matching_pull_requests,
94+
key=lambda candidate: candidate.number,
95+
)
96+
)
97+
return PullRequestLookup(
98+
warning=(
99+
f"multiple open pull requests from {repository}:{ref_name} matched: "
100+
f"{numbers}, but none uniquely targets the default branch "
101+
f"{default_branch}"
102+
)
103+
)
104+
105+
return PullRequestLookup()
25106

26107

27108
def _find_open_pull_request(environ: Mapping[str, str]) -> PullRequestLookup:
28109
repository = environ.get("GITHUB_REPOSITORY", "")
29110
ref_name = environ.get("GITHUB_REF_NAME", "")
111+
default_branch = environ.get("COVERAGE_DEFAULT_BRANCH", "")
30112
command_environment = dict(environ)
31113
server_url = environ.get("GITHUB_SERVER_URL", "")
32114
if server_url:
@@ -44,10 +126,10 @@ def _find_open_pull_request(environ: Mapping[str, str]) -> PullRequestLookup:
44126
ref_name,
45127
"--state",
46128
"open",
129+
"--limit",
130+
"1000",
47131
"--json",
48-
"number",
49-
"--jq",
50-
".[0].number // empty",
132+
"number,headRefName,headRepository,baseRefName",
51133
],
52134
capture_output=True,
53135
check=False,
@@ -62,15 +144,22 @@ def _find_open_pull_request(environ: Mapping[str, str]) -> PullRequestLookup:
62144
error=result.stderr.strip() or f"gh exited with status {result.returncode}"
63145
)
64146

65-
return PullRequestLookup(number=result.stdout.strip())
147+
return _parse_pull_request_lookup(
148+
result.stdout,
149+
repository,
150+
ref_name,
151+
default_branch,
152+
)
66153

67154

68155
def resolve_upload_context(
69-
environ: Optional[Mapping[str, str]] = None,
156+
environ: Mapping[str, str],
70157
pull_request_lookup: Optional[Callable[[Mapping[str, str]], PullRequestLookup]] = None,
71158
) -> UploadContext:
72-
env = dict(os.environ if environ is None else environ)
73-
lookup = _find_open_pull_request if pull_request_lookup is None else pull_request_lookup
159+
env = dict(environ)
160+
lookup_pull_request = (
161+
_find_open_pull_request if pull_request_lookup is None else pull_request_lookup
162+
)
74163
event_name = env.get("GITHUB_EVENT_NAME", "")
75164
repository = env.get("GITHUB_REPOSITORY", "")
76165
ref = env.get("GITHUB_REF", "")
@@ -102,13 +191,11 @@ def resolve_upload_context(
102191

103192
if event_name == "push":
104193
default_branch = env.get("COVERAGE_DEFAULT_BRANCH", "")
105-
is_non_default_branch = (
106-
default_branch
107-
and ref.startswith("refs/heads/")
108-
and ref != f"refs/heads/{default_branch}"
109-
)
194+
default_branch_ref = f"refs/heads/{default_branch}"
195+
is_branch_push = ref.startswith("refs/heads/")
196+
is_non_default_branch = default_branch and is_branch_push and ref != default_branch_ref
110197
if is_non_default_branch:
111-
pull_request = lookup(env)
198+
pull_request = lookup_pull_request(env)
112199
if pull_request.number:
113200
return UploadContext(
114201
should_upload=True,
@@ -128,6 +215,17 @@ def resolve_upload_context(
128215
),
129216
)
130217

218+
if pull_request.warning:
219+
return UploadContext(
220+
should_upload=False,
221+
annotation_level="warning",
222+
message=(
223+
f"Skipping coverage upload because {pull_request.warning}. "
224+
"Use a pull_request workflow trigger for unambiguous per-PR "
225+
"uploads."
226+
),
227+
)
228+
131229
return UploadContext(
132230
should_upload=False,
133231
annotation_level="notice",

0 commit comments

Comments
 (0)