Repository navigation
184 lines (174 loc) · 7.95 KB
/
Copy pathfinal-check.yml
File metadata and controls
184 lines (174 loc) · 7.95 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
name: final-check
# A maintainer adds the `final-check` label to an important PR. The check job
# runs, on the PR merged into its base, everything a push to develop/main
# would: the full-catalog validator, the tests and the site build. TechEngineBot
# leaves ONE comment with the verdict; adding the label again edits that
# comment. This is the only place the TechEngineBot account is used here.
#
# The check job runs PR code, so it gets no secrets and a read-only token.
# Only the comment job sees TECHENGINEBOT_TOKEN, which must be a fine-grained
# token of TechEngineBot with Pull requests: read and write; the job refuses
# any other kind of token or account. Fork PRs never get a comment.
on:
pull_request:
types: [labeled]
branches: [develop, main]
permissions: {}
jobs:
check:
if: github.event.label.name == 'final-check'
runs-on: ubuntu-24.04
# The full validation of 962,384 records took 3 minutes on 2026-10-31.
timeout-minutes: 60
permissions:
contents: read
concurrency:
group: final-check-${{ github.event.pull_request.number }}
cancel-in-progress: true
outputs:
body: ${{ steps.report.outputs.body }}
steps:
# The merge commit, as a push would build it after merging. Full history:
# site/build.py replays every data commit for history.json.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
- name: Validate the whole catalog
id: validate
continue-on-error: true
run: |
status=0
python -m app.validate > "$RUNNER_TEMP/validate.log" 2>&1 || status=$?
tail -n 40 "$RUNNER_TEMP/validate.log"
exit "$status"
- name: Tests
id: tests
continue-on-error: true
run: |
status=0
{ python -m pip install --disable-pip-version-check pytest && python -m pytest -q; } \
> "$RUNNER_TEMP/tests.log" 2>&1 || status=$?
tail -n 40 "$RUNNER_TEMP/tests.log"
exit "$status"
- name: Build the site
id: site
continue-on-error: true
run: |
status=0
python site/build.py > "$RUNNER_TEMP/site.log" 2>&1 || status=$?
cat "$RUNNER_TEMP/site.log"
exit "$status"
# The comment quotes tool output, which can carry text from the PR's
# records: logs stay inside code fences with backticks and @ removed, so
# they can neither break out into markdown nor mention anyone.
- name: Report
id: report
if: always()
env:
VALIDATE: ${{ steps.validate.outcome }}
TESTS: ${{ steps.tests.outcome }}
SITE: ${{ steps.site.outcome }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
clean() { tr -d '`@' | cut -c1-300; }
fence() { # fence <log> <lines>
echo '```text'
tail -n "$2" "$1" 2>/dev/null | clean
echo '```'
}
verdict='✅ Final check passed'
[ "$VALIDATE/$TESTS/$SITE" = success/success/success ] || verdict='❌ Final check failed'
changed=$(git diff --name-only HEAD^1 HEAD -- data/game | grep -c '\.json$' || true)
{
echo '<!-- game-catalog-final-check -->'
echo "### $verdict"
echo
echo "Head \`${HEAD_SHA::7}\`, checked as merged into the base · ${changed:-0} changed record(s) · [run]($RUN_URL)"
echo
echo '| Step | Result |'
echo '| --- | --- |'
echo "| Validator, whole catalog | ${VALIDATE:-skipped} |"
echo "| Tests | ${TESTS:-skipped} |"
echo "| Site build (summary.json, history.json) | ${SITE:-skipped} |"
[ "$VALIDATE" = failure ] && { echo; echo '<details><summary>Validator log</summary>'; echo; fence "$RUNNER_TEMP/validate.log" 30; echo '</details>'; }
[ "$TESTS" = failure ] && { echo; echo '<details><summary>Test log</summary>'; echo; fence "$RUNNER_TEMP/tests.log" 30; echo '</details>'; }
[ "$SITE" = failure ] && { echo; echo '<details><summary>Site build log</summary>'; echo; fence "$RUNNER_TEMP/site.log" 20; echo '</details>'; }
true
} > "$RUNNER_TEMP/body.md"
cat "$RUNNER_TEMP/body.md" >> "$GITHUB_STEP_SUMMARY"
delim="BODY_$(openssl rand -hex 16)"
{ echo "body<<$delim"; cat "$RUNNER_TEMP/body.md"; echo "$delim"; } >> "$GITHUB_OUTPUT"
[ "$verdict" = '✅ Final check passed' ]
comment:
needs: check
if: >-
always() && github.event.label.name == 'final-check' &&
(needs.check.result == 'success' || needs.check.result == 'failure') &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
steps:
- name: Check the token
id: token
env:
TOKEN: ${{ secrets.TECHENGINEBOT_TOKEN }}
run: |
if [ -z "$TOKEN" ]; then
echo "::warning::TECHENGINEBOT_TOKEN is not set; the verdict is in the check job's summary only."
echo "ok=false" >> "$GITHUB_OUTPUT"
exit 0
fi
case "$TOKEN" in
github_pat_*) echo "ok=true" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::TECHENGINEBOT_TOKEN must be a fine-grained token (github_pat_...) of TechEngineBot with Pull requests: read and write."
exit 1 ;;
esac
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
if: steps.token.outputs.ok == 'true'
env:
BODY: ${{ needs.check.outputs.body }}
PR: ${{ github.event.pull_request.number }}
with:
github-token: ${{ secrets.TECHENGINEBOT_TOKEN }}
script: |
const marker = '<!-- game-catalog-final-check -->';
// The check job ran the PR's code, so its report is untrusted here:
// drop every @ and break issue references and issue/PR links, so the
// comment can neither mention anyone nor cross-reference anything.
const zw = String.fromCharCode(0x200b); // zero-width space
const body = (process.env.BODY || '')
.replace(/@/g, '')
.replace(/(?<!&)#(?=\d+\b)/g, `#${zw}`)
.replace(/\bgh-(?=\d+\b)/gi, `$&${zw}`)
.replace(/\/(issues|pull|discussions)\/(?=\d)/g, `/$1/${zw}`)
.slice(0, 65000);
if (!body.startsWith(marker)) {
core.setFailed('The check job produced no report; nothing posted.');
return;
}
const { data: me } = await github.rest.users.getAuthenticated();
if (me.login !== 'TechEngineBot') {
core.setFailed(`TECHENGINEBOT_TOKEN belongs to ${me.login}, not TechEngineBot; nothing posted.`);
return;
}
const { owner, repo } = context.repo;
const issue_number = Number(process.env.PR);
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number, per_page: 100,
});
const mine = comments.find((c) => c.user?.login === me.login && c.body?.startsWith(marker));
if (!mine) {
const { data } = await github.rest.issues.createComment({ owner, repo, issue_number, body });
core.info(`Posted ${data.html_url}`);
} else if (mine.body !== body) {
await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body });
core.info(`Updated ${mine.html_url}`);
} else {
core.info(`Unchanged: ${mine.html_url}`);
}