-
Notifications
You must be signed in to change notification settings - Fork 124
264 lines (249 loc) · 11.6 KB
/
coverage-comment.yml
File metadata and controls
264 lines (249 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
name: Coverage comment
# Posts the merged coverage report from `coverage.yml` onto the PR
# (or updates the tracking issue body for nightly / push runs).
#
# This workflow is intentionally split from `coverage.yml` so that
# the build job runs with the default read-only `pull_request` token
# (no privilege when running on an untrusted fork PR), and only this
# narrow workflow holds the write token. There is no checkout, no
# arbitrary code execution from the PR, and no use of any artefact
# beyond the validated JSON.
#
# Trust model:
# - The build (`coverage.yml`) runs untrusted PR code under
# `read-all` permissions; an attacker who compromises the build
# cannot post anywhere.
# - This workflow runs *trusted* code from the default branch
# (workflow_run resolves the workflow file from the default
# branch, not from the PR). The only PR-controlled input is the
# contents of `coverage-merged.json`, which is parsed with
# strict size + structural limits below.
on:
workflow_run:
workflows: [ "Coverage" ]
types: [ completed ]
# Minimum required to post / edit comments and edit the tracking
# issue. No `contents: read` — we never check out the repo.
permissions:
pull-requests: write
issues: write
jobs:
comment:
name: Post coverage report
runs-on: ubuntu-24.04
# Only act on successful Coverage runs that came from a PR or
# from a default-branch schedule/push. Forks do not need this
# filter — `workflow_run` already restricts to runs of the
# `Coverage` workflow as defined on the default branch.
if: >-
github.event.workflow_run.conclusion == 'success' &&
(github.event.workflow_run.event == 'pull_request' ||
((github.event.workflow_run.event == 'schedule' ||
github.event.workflow_run.event == 'push') &&
github.event.workflow_run.head_branch ==
github.event.repository.default_branch))
steps:
- name: Download merged coverage artifact
uses: actions/download-artifact@v4
with:
# Must match the upload name in coverage.yml's merge job.
name: coverage-merged
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: artifact
# Provisional caps; revisit after first week
# of nightlies. download-artifact doesn't enforce a per-file
# cap, so we re-validate explicitly in the next step.
- name: Validate artifact
id: validate
run: |
set -euo pipefail
# Per-file cap: 50 MB. Total cap: 500 MB.
MAX_FILE=$((50 * 1024 * 1024))
MAX_TOTAL=$((500 * 1024 * 1024))
total=0
for f in artifact/*; do
[ -f "$f" ] || continue
sz=$(stat -c%s "$f")
if [ "$sz" -gt "$MAX_FILE" ]; then
echo "::error::artifact file $f too large ($sz > $MAX_FILE)"
exit 1
fi
total=$((total + sz))
done
if [ "$total" -gt "$MAX_TOTAL" ]; then
echo "::error::artifact total $total > cap $MAX_TOTAL"
exit 1
fi
# Structural validation: must parse as JSON with the
# exact top-level shape merge_coverage.py emits.
python3 - <<'PY'
import json, sys
with open("artifact/coverage-merged.json") as f:
m = json.load(f)
for k in ("files", "totals", "platforms"):
if k not in m:
print(f"::error::missing top-level key '{k}'", file=sys.stderr)
sys.exit(1)
if not isinstance(m["files"], dict):
print("::error::'files' is not an object", file=sys.stderr); sys.exit(1)
if not isinstance(m["totals"], dict):
print("::error::'totals' is not an object", file=sys.stderr); sys.exit(1)
for k in ("executable", "covered"):
if k not in m["totals"] or not isinstance(m["totals"][k], int):
print(f"::error::totals.{k} missing or not int", file=sys.stderr)
sys.exit(1)
if m["totals"]["covered"] > m["totals"]["executable"]:
print("::error::covered > executable", file=sys.stderr); sys.exit(1)
print(f"validated: {len(m['files'])} files, "
f"{m['totals']['covered']}/{m['totals']['executable']} lines, "
f"{len(m['platforms'])} platforms")
PY
# Ensure the markdown body carries the bot marker — the
# comment search relies on this, and a missing marker
# would orphan future comments.
if ! grep -qF '<!-- snmalloc-coverage-bot -->' artifact/coverage-merged.md; then
echo "::error::coverage-merged.md missing '<!-- snmalloc-coverage-bot -->' marker"
exit 1
fi
# Stash markdown size & first-line for follow-up steps.
echo "md_bytes=$(stat -c%s artifact/coverage-merged.md)" >> "$GITHUB_OUTPUT"
- name: Resolve PR number
id: pr
uses: actions/github-script@v7
env:
# Pass the event JSON via the environment, not via inline
# `${{ toJson(...) }}` interpolation. Actions resolves
# `${{ ... }}` *before* the shell parses the script, so a
# single quote anywhere in the payload (e.g. an apostrophe
# in a commit message or branch name) would close the
# surrounding `'...'` quoting and corrupt the rest of the
# script.
EVENT_JSON: ${{ toJson(github.event) }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# Two-stage resolution:
# 1. workflow_run.pull_requests[] — populated for PRs
# from branches in the same repository.
# 2. GET /repos/{owner}/{repo}/commits/{sha}/pulls —
# works for fork PRs too, because GitHub indexes the
# PR by the merge head SHA regardless of which repo
# the head ref lives in. This is the only reliable
# way to recover the PR number for a fork-originated
# `workflow_run` payload, since `pull_requests[]` is
# always empty in that case.
# If both fail, fall through to the tracking-issue path.
script: |
const ev = JSON.parse(process.env.EVENT_JSON);
const wr = ev.workflow_run;
let pr = (wr.pull_requests && wr.pull_requests.length)
? wr.pull_requests[0].number : 0;
if (!pr) {
core.info(
`pull_requests[] empty (likely fork PR); ` +
`looking up by head SHA ${wr.head_sha}`);
const resp = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: wr.head_sha,
});
// Prefer an open PR; fall back to any PR if only
// closed ones match (e.g. squash-merged race).
const open = resp.data.find(p => p.state === 'open');
const any = resp.data[0];
if (open) pr = open.number;
else if (any) pr = any.number;
}
if (pr) core.info(`Will comment on PR #${pr}`);
else core.info('No PR resolved; will update tracking issue');
core.setOutput('pr', pr ? String(pr) : '');
# ------------------------------------------------------------
# PR path: find-or-create the bot comment, dual-marker check.
# ------------------------------------------------------------
- name: Comment on PR
if: steps.pr.outputs.pr != ''
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ steps.pr.outputs.pr }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const body = fs.readFileSync('artifact/coverage-merged.md', 'utf8');
const MARKER = '<!-- snmalloc-coverage-bot -->';
if (!body.includes(MARKER)) {
core.setFailed('marker missing from body');
return;
}
const pr = Number(process.env.PR_NUMBER);
// Dual-marker policy: the comment we edit must be both
// authored by github-actions[bot] AND contain the bot
// marker in its body. Either alone is insufficient.
// - login alone: collides with any other bot comment.
// - marker alone: someone could quote the marker in
// a regular comment and have us silently overwrite
// their comment.
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner: context.repo.owner, repo: context.repo.repo,
issue_number: pr, per_page: 100 });
const existing = comments.find(c =>
c.user && c.user.login === 'github-actions[bot]' &&
typeof c.body === 'string' && c.body.includes(MARKER));
// 3-attempt backoff for transient 409/403 (e.g. another
// bot writing concurrently, secondary rate limits).
async function withRetry(op) {
const delays = [0, 2000, 8000];
let lastErr;
for (const d of delays) {
if (d) await new Promise(r => setTimeout(r, d));
try { return await op(); }
catch (e) {
if (e.status !== 409 && e.status !== 403) throw e;
lastErr = e;
}
}
throw lastErr;
}
if (existing) {
core.info(`Updating comment ${existing.id}`);
await withRetry(() => github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body }));
} else {
core.info(`Creating new comment on PR #${pr}`);
await withRetry(() => github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: pr, body }));
}
# ------------------------------------------------------------
# Tracking-issue path: update the body of the issue named by
# the COVERAGE_TRACKING_ISSUE repo variable. Never the
# comments — body keeps history short and avoids notification
# spam on every nightly.
# ------------------------------------------------------------
- name: Update tracking issue
if: steps.pr.outputs.pr == '' && vars.COVERAGE_TRACKING_ISSUE != ''
uses: actions/github-script@v7
env:
ISSUE_NUMBER: ${{ vars.COVERAGE_TRACKING_ISSUE }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const body = fs.readFileSync('artifact/coverage-merged.md', 'utf8');
// Marker must match the constant in merge_coverage.py.
const MARKER = '<!-- snmalloc-coverage-bot -->';
if (!body.includes(MARKER)) {
core.setFailed('marker missing from body');
return;
}
const issue = Number(process.env.ISSUE_NUMBER);
await github.rest.issues.update({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue, body });
core.info(`Updated tracking issue #${issue}`);
- name: No-op summary
if: steps.pr.outputs.pr == '' && vars.COVERAGE_TRACKING_ISSUE == ''
run: |
echo "::warning::no PR and COVERAGE_TRACKING_ISSUE unset; nothing posted"