Repository navigation
201 lines (186 loc) · 9.65 KB
/
Copy pathcoverage-comment.yml
File metadata and controls
201 lines (186 loc) · 9.65 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
name: Coverage Comment
# Posts the coverage the `test` job's `latest` leg measured in unit-tests.yml
# as a pull-request comment. Not from unit-tests.yml itself: that file is also a
# reusable workflow whose callers grant `contents: read`, and a called workflow
# can only narrow its caller's token, so `pull-requests: write` there would
# break every caller, the release gate included.
#
# `workflow_run` runs this file as it is on the default branch, with a token of
# its own, so fork pull requests get a comment too. The price is that the
# triggering run's artifact is untrusted input. So this workflow:
#
# * never checks out or executes anything from the pull request;
# * takes the PR number from the API (the open PR whose head is this run's
# head sha), not from the artifact, and accepts it only if it is all digits;
# * renders its own Markdown from the artifact's numbers, accepting only the
# shapes the `test` job writes, rather than posting text from the run.
#
# A push run has no pull request to comment on, so the `discussion` job posts it
# to its branch's thread in the Discussions category REPORT_CATEGORY names
# (#2030). It reads the run's jobs through the API and the jobs' summaries from
# their `job-summary-*` artifacts, as data the report quotes, runs
# util/check_suite_report.py and util/discussion_report.py from the default
# branch's checkout, never the run's head, and alone holds `discussions: write`.
# ci-reports.yml's `discussion` job runs the same steps for Experimental Tests
# and Python Compatibility.
on:
workflow_run:
workflows: ["Unit Tests"]
types: [completed]
# Each job holds its own scopes (#2030).
permissions: {}
# One comment per PR, so serialise per head branch. A run that finishes after a
# newer push skips itself below, since its sha is no longer the PR's head. A
# push run's group is its own (#2030): a group cancels a pending run when a
# newer one joins it, which would drop that run's report.
concurrency:
group: >-
coverage-comment-${{ github.event.workflow_run.event == 'pull_request' &&
format('{0}-{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) ||
github.event.workflow_run.id }}
cancel-in-progress: false
# #2030: one Discussions category holds every report with no pull request to
# comment on, one thread per job.
env:
REPORT_CATEGORY: CI reports
jobs:
comment:
name: Coverage comment
if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion != 'cancelled' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
pull-requests: write
steps:
- name: Post or update the coverage comment
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
set -eu
case "$HEAD_SHA" in
*[!0-9a-f]*|'') echo "::error::unexpected head sha"; exit 1 ;;
esac
# `workflow_run.pull_requests` is empty for fork PRs, so ask the API.
pr=$(gh api -X GET "repos/$REPO/pulls" -f state=open -f head="$HEAD_OWNER:$HEAD_BRANCH" \
--jq ".[] | select(.head.sha == \"$HEAD_SHA\") | .number" | head -n 1)
case "$pr" in
*[!0-9]*|'') echo "::notice::No open PR has $HEAD_SHA as its head; nothing to comment on."; exit 0 ;;
esac
dir="$RUNNER_TEMP/coverage"
if ! gh run download "$RUN_ID" --repo "$REPO" --name coverage-summary --dir "$dir"; then
echo "::notice::Run $RUN_ID uploaded no coverage-summary artifact (the coverage leg did not get that far)."
exit 0
fi
python3 - "$dir/coverage-summary.json" > "$RUNNER_TEMP/body.md" <<'PY'
import json, os, re, sys
data = json.load(open(sys.argv[1]))
def number(value, ints=False):
ok = isinstance(value, int) if ints else isinstance(value, (int, float))
if isinstance(value, bool) or not ok or not 0 <= value < 10**7:
sys.exit(f'invalid number in artifact: {value!r}')
return value
tests = data['tests']
if tests not in ('success', 'failure', 'cancelled', 'skipped'):
sys.exit(f'invalid test outcome in artifact: {tests!r}')
total = number(data['total'])
# The leg's version, from the artifact; one without it reads as the coverage leg.
python = data.get('python')
if python is not None and not (isinstance(python, str) and re.fullmatch(r'3\.\d+', python)):
sys.exit(f'invalid Python version in artifact: {python!r}')
leg = f'Python {python}' if python else 'the coverage leg'
status = '' if tests == 'success' else f' -- **tests {tests}**, so this is partial'
print(f"**Coverage: {total:.2f}%**{status} (unit tier, {leg}, "
f"`{os.environ['HEAD_SHA'][:9]}`, Unit Tests run {os.environ['RUN_CONCLUSION']})")
print()
print('| Package | Statements | Missed | Branches | Partial | Cover |')
print('|---|--:|--:|--:|--:|--:|')
for name, *counts, cover in data['packages']:
if not isinstance(name, str) or not re.fullmatch(r'pcapkit(/\w+| \(top level\))?', name):
sys.exit(f'invalid package name in artifact: {name!r}')
counts = [number(c, ints=True) for c in counts]
if len(counts) != 4:
sys.exit('invalid row in artifact')
print('| `{}` | {} | {} | {} | {} | {:.2f}% |'.format(name, *counts, number(cover)))
print()
print(f"Per-file detail: the `coverage-html` artifact of [this run]({os.environ['RUN_URL']}).")
print()
print('<!-- pcapkit-coverage-report -->')
PY
# Edit the one comment this workflow posted, found by its marker and
# author, rather than adding one per push.
id=$(gh api --paginate "repos/$REPO/issues/$pr/comments" --jq \
'.[] | select(.user.login == "github-actions[bot]" and (.body | contains("<!-- pcapkit-coverage-report -->"))) | .id' | head -n 1)
if [ -n "$id" ]; then
gh api -X PATCH "repos/$REPO/issues/comments/$id" -F body=@"$RUNNER_TEMP/body.md" > /dev/null
else
gh api -X POST "repos/$REPO/issues/$pr/comments" -F body=@"$RUNNER_TEMP/body.md" > /dev/null
fi
# #2030: a Unit Tests run on a push, posted as a `push` report to the thread
# `unit-tests: <branch> push runs`: a run that failed or warned posts, then one
# green run after it. util/check_suite_report.py writes the report from the
# run's check suite and its jobs' summaries; ci-reports.yml's `discussion` job
# runs the same steps, which tests/project/test_unit_tests_push_reports_2030.py
# holds identical. The job and its steps are `continue-on-error`; both scripts
# warn instead of failing.
discussion:
name: Post the push run report
if: ${{ github.event.workflow_run.event == 'push' }}
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
permissions:
actions: read
checks: read
contents: read
discussions: write
steps:
# The default branch, which is what `workflow_run` checks out, and only
# the scripts; nothing from the run's head.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: |
util/_actions.py
util/check_suite_report.py
util/discussion_report.py
sparse-checkout-cone-mode: false
persist-credentials: false
# The run's fields are data, in `env:`, where the script reads them.
- name: Write the run report
id: report
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW: ${{ github.event.workflow_run.name }}
SUITE: ${{ github.event.workflow_run.check_suite_node_id }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
RUN_EVENT: ${{ github.event.workflow_run.event }}
RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: python3 util/check_suite_report.py --report report/run-report.md
# The script labels the run it links with GITHUB_WORKFLOW, which names
# this workflow, so the command sets the relayed run's for that process.
- name: Post the report to Discussions
if: ${{ steps.report.outputs.post == 'true' }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW: ${{ github.event.workflow_run.name }}
TITLE: ${{ steps.report.outputs.title }}
MODE: ${{ steps.report.outputs.mode }}
STATUS: ${{ steps.report.outputs.status }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
GITHUB_WORKFLOW="$WORKFLOW" python3 util/discussion_report.py --category "$REPORT_CATEGORY" \
--title "$TITLE" --report report/run-report.md \
--mode "$MODE" --status "$STATUS" --run-url "$RUN_URL" --sha "$HEAD_SHA"