Skip to content

Commit 583437f

Browse files
imconnornglCopilot
andauthored
fix: only checkout remote servers directory (#5027)
* fix: only checkout remote servers directory * chore: requested changes * chore: seperate validate/pr comment Co-authored-by: Copilot <copilot@github.com> * chore: requested changes Co-authored-by: Copilot <copilot@github.com> * chore: requested changes --------- Co-authored-by: Copilot <copilot@github.com>
1 parent ce9b9c3 commit 583437f

5 files changed

Lines changed: 287 additions & 77 deletions

File tree

.github/workflows/pr-feedback.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Post validation feedback
2+
3+
on:
4+
workflow_run:
5+
workflows: ["Validate and upload"]
6+
types:
7+
- completed
8+
9+
jobs:
10+
feedback:
11+
name: Post validation feedback
12+
if: github.event.workflow_run.event == 'pull_request'
13+
runs-on: ubuntu-latest
14+
15+
permissions:
16+
pull-requests: write
17+
issues: write
18+
actions: read
19+
contents: read
20+
21+
steps:
22+
- name: Checkout from GitHub
23+
uses: actions/checkout@v4
24+
25+
- name: Setup Python 3.x
26+
uses: actions/setup-python@v5
27+
with:
28+
python-version: "3.x"
29+
30+
- name: Install Python dependencies
31+
run: pip install -r .scripts/requirements.txt
32+
33+
- name: Download validation results
34+
id: download
35+
uses: actions/download-artifact@v4
36+
with:
37+
name: pr-results
38+
run-id: ${{ github.event.workflow_run.id }}
39+
github-token: ${{ secrets.GITHUB_TOKEN }}
40+
continue-on-error: true
41+
42+
- name: Post validation feedback
43+
run: python .scripts/post_pr_feedback.py
44+
env:
45+
BOT_PAT: ${{ secrets.BOT_PAT }}
46+
DOWNLOAD_OUTCOME: ${{ steps.download.outcome }}
47+
WORKFLOW_RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
48+
WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
49+
WORKFLOW_RUN_PULL_REQUESTS: ${{ toJSON(github.event.workflow_run.pull_requests) }}

.github/workflows/validate-upload.yml

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
push:
55
branches:
66
- master
7-
pull_request_target:
7+
pull_request:
88
branches:
99
- "*"
1010
schedule:
@@ -37,16 +37,26 @@ jobs:
3737
validate-servers:
3838
name: Validate Servers
3939
runs-on: ubuntu-latest
40+
4041
permissions:
41-
pull-requests: write
42-
issues: write
42+
contents: read
43+
pull-requests: read
4344

4445
steps:
45-
- name: Checkout from GitHub
46+
# Trusted checkout: scripts and schemas come from the base ref so a PR
47+
# cannot alter the validator (e.g. fabricating pr_results.json as
48+
# "ready") to bypass validation in the trusted feedback workflow.
49+
- name: Checkout trusted base
50+
uses: actions/checkout@v4
51+
with:
52+
ref: ${{ github.event.pull_request.base.ref || github.ref }}
53+
54+
- name: Checkout PR contents (untrusted)
55+
if: github.event_name == 'pull_request'
4656
uses: actions/checkout@v4
4757
with:
48-
repository: ${{ github.event.pull_request.head.repo.full_name }}
49-
ref: ${{ github.event.pull_request.head.ref }}
58+
ref: ${{ github.event.pull_request.head.sha }}
59+
path: pr-contents
5060

5161
- name: Setup Python 3.x
5262
uses: actions/setup-python@v5
@@ -57,18 +67,27 @@ jobs:
5767
run: pip install -r .scripts/requirements.txt
5868

5969
- name: Validate Servers
70+
working-directory: ${{ github.event_name == 'pull_request' && 'pr-contents' || '.' }}
6071
run: |
61-
python .scripts/validate.py \
72+
python "${GITHUB_WORKSPACE}/.scripts/validate.py" \
6273
--servers_dir servers \
63-
--metadata_schema metadata.schema.json \
74+
--metadata_schema "${GITHUB_WORKSPACE}/metadata.schema.json" \
6475
--inactive_file inactive.json \
65-
--inactive_schema inactive.schema.json \
76+
--inactive_schema "${GITHUB_WORKSPACE}/inactive.schema.json" \
6677
--no-validate_inactive
6778
env:
6879
PR_ID: ${{ github.event.pull_request.number }}
69-
BOT_PAT: ${{ secrets.BOT_PAT }}
80+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
7081
USE_ARGS: "true"
7182

83+
- name: Upload validation results
84+
if: always() && github.event_name == 'pull_request'
85+
uses: actions/upload-artifact@v4
86+
with:
87+
name: pr-results
88+
path: pr-contents/pr_results.json
89+
if-no-files-found: ignore
90+
7291
upload-servers:
7392
name: Upload Servers
7493
needs: [validate-servers, changes]
@@ -88,7 +107,7 @@ jobs:
88107
with:
89108
workload_identity_provider: 'projects/824216268633/locations/global/workloadIdentityPools/github-actions/providers/github-actions-oidc'
90109
service_account: "github-actions@mw-lunarclient-cdn.iam.gserviceaccount.com"
91-
110+
92111
- name: Setup Python 3.x
93112
uses: actions/setup-python@v5
94113
with:
@@ -172,3 +191,4 @@ jobs:
172191
-H "Content-Type: application/json" \
173192
-H "Authorization: Bearer $CLOUDFLARE_PURGE_TOKEN" \
174193
--data '{"files":["https://servermappings.lunarclientcdn.com/servers.json"]}'
194+

.scripts/post_pr_feedback.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
Post-validation feedback for ServerMappings PRs.
3+
4+
Triggered by the workflow_run job in the trusted base-repo context. Reads
5+
pr_results.json (uploaded by the validate-servers job that ran in the
6+
untrusted PR context) and either applies a "Ready for review" label or
7+
posts a review with the validation errors.
8+
9+
Because pr_results.json is produced by code controlled by the PR author,
10+
its pr_id field cannot be trusted. The trusted PR number is derived from
11+
the workflow_run payload (with a head-SHA lookup as a fallback for fork
12+
PRs, whose pull_requests array is empty) and the artifact's pr_id is
13+
rejected if it doesn't match one of those numbers.
14+
"""
15+
16+
import json
17+
import os
18+
19+
import requests
20+
21+
API_ROOT = "https://api.github.com"
22+
READY_LABEL = "Ready for review"
23+
RESULTS_FILE = "pr_results.json"
24+
25+
26+
def gh_request(method: str, path: str, token: str, **kwargs) -> requests.Response:
27+
return requests.request(
28+
method,
29+
f"{API_ROOT}{path}",
30+
headers={
31+
"accept": "application/vnd.github+json",
32+
"Authorization": f"Bearer {token}",
33+
"X-GitHub-Api-Version": "2022-11-28",
34+
},
35+
timeout=30,
36+
**kwargs,
37+
)
38+
39+
40+
def resolve_trusted_pr_numbers(
41+
repo: str, head_sha: str, pr_payload: list, token: str
42+
) -> set[int]:
43+
"""Return PR numbers that are safe to act on with the privileged token."""
44+
numbers: set[int] = set()
45+
for pr in pr_payload or []:
46+
if isinstance(pr, dict) and isinstance(pr.get("number"), int):
47+
numbers.add(pr["number"])
48+
49+
if numbers or not head_sha:
50+
return numbers
51+
52+
res = gh_request("GET", f"/repos/{repo}/commits/{head_sha}/pulls", token)
53+
if not res.ok:
54+
print(
55+
f"::warning::Failed to look up PRs for head SHA {head_sha}: "
56+
f"{res.status_code} {res.text}"
57+
)
58+
return numbers
59+
60+
for pr in res.json():
61+
if pr.get("state") == "open":
62+
numbers.add(int(pr["number"]))
63+
return numbers
64+
65+
66+
def remove_ready_label(repo: str, pr_number: int, token: str) -> None:
67+
encoded = requests.utils.quote(READY_LABEL, safe="")
68+
res = gh_request(
69+
"DELETE", f"/repos/{repo}/issues/{pr_number}/labels/{encoded}", token
70+
)
71+
if res.status_code not in (200, 204, 404):
72+
res.raise_for_status()
73+
74+
75+
def add_ready_label(repo: str, pr_number: int, token: str) -> None:
76+
res = gh_request(
77+
"POST",
78+
f"/repos/{repo}/issues/{pr_number}/labels",
79+
token,
80+
json={"labels": [READY_LABEL]},
81+
)
82+
res.raise_for_status()
83+
84+
85+
def request_changes(repo: str, pr_number: int, body: str, token: str) -> None:
86+
res = gh_request(
87+
"POST",
88+
f"/repos/{repo}/pulls/{pr_number}/reviews",
89+
token,
90+
json={"event": "REQUEST_CHANGES", "body": body},
91+
)
92+
res.raise_for_status()
93+
94+
95+
def build_review_body(errors: dict) -> str:
96+
body = ""
97+
for server_id, msgs in (errors or {}).items():
98+
if not msgs:
99+
continue
100+
joined = "\n- ".join(msgs)
101+
body += f"\n\nErrors found for **{server_id}**:\n- {joined}"
102+
return body
103+
104+
105+
def main() -> None:
106+
token = os.environ["BOT_PAT"]
107+
repo = os.environ["GITHUB_REPOSITORY"]
108+
head_sha = os.environ.get("WORKFLOW_RUN_HEAD_SHA", "")
109+
conclusion = os.environ.get("WORKFLOW_RUN_CONCLUSION", "")
110+
download_outcome = os.environ.get("DOWNLOAD_OUTCOME", "")
111+
112+
try:
113+
pr_payload = json.loads(os.environ.get("WORKFLOW_RUN_PULL_REQUESTS") or "[]")
114+
except json.JSONDecodeError:
115+
pr_payload = []
116+
117+
trusted = resolve_trusted_pr_numbers(repo, head_sha, pr_payload, token)
118+
if not trusted:
119+
print("No PR could be associated with this workflow run; nothing to do.")
120+
return
121+
122+
artifact_present = (
123+
conclusion == "success"
124+
and download_outcome == "success"
125+
and os.path.isfile(RESULTS_FILE)
126+
)
127+
128+
if not artifact_present:
129+
for n in trusted:
130+
remove_ready_label(repo, n, token)
131+
print(
132+
"Upstream did not succeed or artifact missing — "
133+
"removed stale ready label."
134+
)
135+
return
136+
137+
with open(RESULTS_FILE, "r", encoding="utf-8") as f:
138+
data = json.load(f)
139+
140+
pr_id = data.get("pr_id")
141+
if not isinstance(pr_id, int) or pr_id not in trusted:
142+
print(
143+
f"::warning::Artifact pr_id ({pr_id!r}) does not match trusted "
144+
f"PR number(s) {sorted(trusted)}; refusing to act on it."
145+
)
146+
return
147+
148+
if data.get("status") == "ready":
149+
add_ready_label(repo, pr_id, token)
150+
return
151+
152+
remove_ready_label(repo, pr_id, token)
153+
154+
body = build_review_body(data.get("errors") or {})
155+
if not body:
156+
return
157+
158+
request_changes(repo, pr_id, body, token)
159+
160+
161+
if __name__ == "__main__":
162+
main()

.scripts/utils.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
"26.*": ["26.1", "26.1.1", "26.1.2"]
2525
}
2626

27-
2827
def _file_hash(path: str) -> str:
2928
"""Compute SHA1 hash of a file's contents."""
3029
with open(path, "rb") as f:
@@ -202,14 +201,18 @@ def get_edited_servers():
202201
print("No pull request id found. Unable to get edited servers")
203202
return edited_server_ids
204203

204+
token = os.getenv("GITHUB_TOKEN")
205205
res = requests.get(
206-
f"https://api.github.com/repos/LunarClient/ServerMappings/pulls/{pull_id}/files",
207-
headers={
208-
"accept": "application/vnd.github+json",
209-
"Authorization": f"Bearer {os.getenv('BOT_PAT')}",
210-
}
211-
)
206+
f"https://api.github.com/repos/LunarClient/ServerMappings/pulls/{pull_id}/files",
207+
headers={
208+
"accept": "application/vnd.github+json",
209+
"Authorization": f"Bearer {token}",
210+
},
211+
timeout=30,
212+
)
212213

214+
res.raise_for_status()
215+
213216
for file in res.json():
214217
file_name: str = file['filename']
215218
if not file_name.startswith("servers/"):

0 commit comments

Comments
 (0)