Skip to content

Commit b45c744

Browse files
authored
Merge pull request rust-lang#8288 from jder/main
Post PR comments from separate workflow with write permissions
2 parents 5653e43 + 6439872 commit b45c744

2 files changed

Lines changed: 169 additions & 52 deletions

File tree

.github/workflows/checks.yml

Lines changed: 15 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ on:
1111

1212
permissions:
1313
contents: read
14-
pull-requests: write
1514

1615
jobs:
1716
Run-Markdown-Checks:
@@ -86,59 +85,23 @@ jobs:
8685
fi
8786
8887
exit 0
89-
- name: Comment on PR check output
88+
# Upload the check output so the workflow_run workflow can fetch it later.
89+
# That follow-up workflow has permission to write PR comments, unlike this
90+
# pull_request workflow which runs on possibly-untrusted forks.
91+
- name: Prepare PR check output metadata
9092
if: ${{ github.event_name == 'pull_request' }}
9193
shell: bash
92-
env:
93-
LINK_OUTPUT_FILE: ${{ runner.temp }}/inspect-links-pr.txt
94-
LINK_FAILED: ${{ steps.inspect_links_pr.outputs.failed }}
95-
MARKDOWN_OUTPUT_FILE: ${{ runner.temp }}/inspect-markdown-pr.txt
96-
MARKDOWN_FAILED: ${{ steps.inspect_markdown_pr.outputs.failed }}
97-
GITHUB_REPOSITORY: ${{ github.repository }}
98-
GH_TOKEN: ${{ github.token }}
99-
PR_NUMBER: ${{ github.event.pull_request.number }}
100-
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
101-
run: |
102-
python3 - <<'PY'
103-
import json
104-
import os
105-
from pathlib import Path
106-
107-
def section(title, path):
108-
output = Path(path).read_text().strip()
109-
if not output:
110-
return None
111-
return (
112-
f"### {title}\n\n"
113-
f"{output}\n"
114-
)
115-
116-
sections = [
117-
section("Link check", os.environ["LINK_OUTPUT_FILE"]),
118-
section("Markdown check", os.environ["MARKDOWN_OUTPUT_FILE"]),
119-
]
120-
sections = [s for s in sections if s]
121-
if sections:
122-
check_output = "\n\n".join(sections)
123-
124-
body = (
125-
f"Thank you for your contribution to This Week in Rust! Our automated checks found some possible issues with these changes:\n\n"
126-
f"{check_output}\n\n"
127-
f"You can see more details here: {os.environ['RUN_URL']}"
128-
)
129-
130-
if len(body) > 60000:
131-
body = body[:59900] + "\n\n... truncated"
132-
133-
Path("comment.json").write_text(json.dumps({"body": body}))
134-
PY
135-
136-
if [[ -s comment.json ]]; then
137-
gh api \
138-
--method POST \
139-
"repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
140-
--input comment.json
141-
fi
94+
run: echo "${{ github.event.pull_request.number }}" > "${{ runner.temp }}/pr-number.txt"
95+
- name: Upload PR check output
96+
if: ${{ github.event_name == 'pull_request' }}
97+
uses: actions/upload-artifact@v4
98+
with:
99+
name: pr-check-output
100+
path: |
101+
${{ runner.temp }}/pr-number.txt
102+
${{ runner.temp }}/inspect-links-pr.txt
103+
${{ runner.temp }}/inspect-markdown-pr.txt
104+
if-no-files-found: ignore
142105
- name: Fail PR check on errors
143106
if: ${{ github.event_name == 'pull_request' && (steps.inspect_links_pr.outputs.failed == 'true' || steps.inspect_markdown_pr.outputs.failed == 'true') }}
144107
run: exit 1
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
name: Comment on PR Check Output
2+
3+
# This workflow runs using the definition from main and can write to pull
4+
# requests. It only reads the untrusted artifact produced by Correctness Checks
5+
# and posts a comment if that artifact contains check output.
6+
on:
7+
workflow_run:
8+
workflows: ["Correctness Checks"]
9+
types: [completed]
10+
11+
permissions:
12+
actions: read
13+
pull-requests: write
14+
15+
jobs:
16+
comment:
17+
name: Comment on PR check output
18+
# Correctness Checks also runs on pushes to main. Only PR runs upload the
19+
# artifact that contains a PR number and check output.
20+
if: ${{ github.event.workflow_run.event == 'pull_request' }}
21+
runs-on: ubuntu-24.04
22+
steps:
23+
- name: Download PR check output
24+
id: download
25+
uses: actions/download-artifact@v5
26+
with:
27+
name: pr-check-output
28+
path: ${{ runner.temp }}/pr-check-output
29+
run-id: ${{ github.event.workflow_run.id }}
30+
github-token: ${{ github.token }}
31+
32+
- name: Comment on PR check output
33+
shell: bash
34+
env:
35+
CHECK_OUTPUT_DIR: ${{ runner.temp }}/pr-check-output
36+
GITHUB_REPOSITORY: ${{ github.repository }}
37+
GH_TOKEN: ${{ github.token }}
38+
RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
39+
RUN_URL: ${{ github.event.workflow_run.html_url }}
40+
run: |
41+
python3 - <<'PY'
42+
import json
43+
import os
44+
import re
45+
import subprocess
46+
import sys
47+
import tempfile
48+
from pathlib import Path
49+
from typing import Optional
50+
51+
MAX_COMMENT_LENGTH = 60000
52+
53+
output_dir = Path(os.environ["CHECK_OUTPUT_DIR"])
54+
repository = os.environ["GITHUB_REPOSITORY"]
55+
run_head_sha = os.environ["RUN_HEAD_SHA"]
56+
run_url = os.environ["RUN_URL"]
57+
58+
def read_pr_number() -> Optional[str]:
59+
path = output_dir / "pr-number.txt"
60+
if not path.exists():
61+
print("No pull request number artifact found for workflow run; skipping comment.")
62+
return None
63+
64+
value = path.read_text().strip()
65+
if not value:
66+
print("No pull request number artifact found for workflow run; skipping comment.")
67+
return None
68+
69+
if not re.fullmatch(r"[0-9]+", value):
70+
raise ValueError(f"invalid pull request number artifact: {value!r}")
71+
return value
72+
73+
def section(title: str, filename: str) -> Optional[str]:
74+
path = output_dir / filename
75+
if not path.exists():
76+
return None
77+
78+
output = path.read_text().strip()
79+
if not output:
80+
return None
81+
82+
return f"### {title}\n\n{output}\n"
83+
84+
def build_comment() -> Optional[str]:
85+
sections = [
86+
section("Link check", "inspect-links-pr.txt"),
87+
section("Markdown check", "inspect-markdown-pr.txt"),
88+
]
89+
sections = [item for item in sections if item]
90+
if not sections:
91+
print("No check output found; skipping comment.")
92+
return None
93+
94+
details = f"\n\nYou can see more details here: {run_url}"
95+
check_output = "\n\n".join(sections)
96+
body = (
97+
"Thank you for your contribution to This Week in Rust! "
98+
"Our automated checks found some possible issues with these changes:\n\n"
99+
f"{check_output}"
100+
)
101+
102+
if len(body) + len(details) > MAX_COMMENT_LENGTH:
103+
body = body[:MAX_COMMENT_LENGTH - len(details)]
104+
105+
return body + details
106+
107+
def run_gh(args: list[str]) -> subprocess.CompletedProcess[str]:
108+
return subprocess.run(
109+
["gh", *args],
110+
check=True,
111+
text=True,
112+
capture_output=True,
113+
)
114+
115+
def validate_pr_head(pr_number: str) -> bool:
116+
result = run_gh(["api", f"repos/{repository}/pulls/{pr_number}", "--jq", ".head.sha"])
117+
pr_head_sha = result.stdout.strip()
118+
if pr_head_sha != run_head_sha:
119+
print("Pull request head SHA no longer matches workflow run; skipping stale comment.")
120+
return False
121+
return True
122+
123+
def post_comment(pr_number: str, body: str) -> None:
124+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".json") as input_file:
125+
json.dump({"body": body}, input_file)
126+
input_file.flush()
127+
run_gh(
128+
[
129+
"api",
130+
"--method",
131+
"POST",
132+
f"repos/{repository}/issues/{pr_number}/comments",
133+
"--input",
134+
input_file.name,
135+
]
136+
)
137+
138+
try:
139+
pr_number = read_pr_number()
140+
if not pr_number:
141+
raise SystemExit(0)
142+
143+
if not validate_pr_head(pr_number):
144+
raise SystemExit(0)
145+
146+
body = build_comment()
147+
if not body:
148+
raise SystemExit(0)
149+
150+
post_comment(pr_number, body)
151+
except subprocess.CalledProcessError as exc:
152+
sys.stderr.write(exc.stderr)
153+
raise
154+
PY

0 commit comments

Comments
 (0)