Repository navigation
364 lines (324 loc) · 16.8 KB
/
Copy pathpush_checking.yml
File metadata and controls
364 lines (324 loc) · 16.8 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
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
name: Run Solidity Tests
# SECURITY ATTENTION
# This workflow uses `pull_request_target` for the on-close cleanup job
# only. `pull_request_target` runs in the BASE branch's workflow context
# with repository secrets and a write-scoped GITHUB_TOKEN. The cleanup
# job is gated on `github.event.action == 'closed'` and does NOT check
# out PR-head code, so no untrusted code executes under that context.
#
# Adding other `pull_request_target` event types (`opened`, `synchronize`,
# `reopened`, etc.) or any step that checks out `pull_request.head.sha`
# inside a `pull_request_target` job would let a malicious PR exfiltrate
# repo secrets. Re-review carefully if this trigger surface widens.
on:
pull_request:
branches: [master]
push:
branches: [master]
paths: ["contracts/**", "test/**", "**.sol"]
pull_request_target:
types: [closed]
permissions:
contents: write
pull-requests: write
issues: write
concurrency:
group: tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
if: github.event.action != 'closed'
name: ${{ matrix.kind.label }}
runs-on: ubuntu-latest
timeout-minutes: ${{ matrix.kind.timeout[github.event_name] }}
strategy:
# Each matrix kind runs on its own runner so neither has to hold the
# other's peak memory. Don't fail-fast: surfacing both shards' results
# is more useful than aborting the slower one when the faster one fails.
fail-fast: false
matrix:
kind:
- id: unit-fuzz
label: Contract Tests (Unit + Fuzz)
forge_args: --no-match-contract Invariant --no-match-path 'test/fork/**'
timeout: { pull_request: 30, push: 30 }
- id: invariant
label: Contract Tests (Invariant)
forge_args: --match-contract Invariant --no-match-path 'test/fork/**'
# Pull requests run 100 invariant runs per campaign (about 30 minutes), master 300
# (about 100 minutes).
timeout: { pull_request: 60, push: 150 }
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: ./.github/actions/setup-foundry
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2.6"
no-cache: true
# `openzeppelin-foundry-upgrades` shells out to `npx @openzeppelin/upgrades-core`
# via `vm.ffi` during tests; bun does not provide `npx`, so install Node here.
- uses: actions/setup-node@v4
with:
node-version: "20"
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/cache@v4
with:
path: node_modules
key: bun-${{ hashFiles('bun.lock') }}
restore-keys: bun-
- run: bun install
# `cache/` and `out/` are forge's own incremental-compile state: it hashes each
# source file and only recompiles what changed since the hash it last saw. A
# restored cache from an unrelated commit is always safe to build on top of.
# Forge just recompiles whatever the content hashes say is dirty, so a partial
# match via `restore-keys` still saves most of a cold `via_ir` build. This has
# to sit after `bun install`: its postinstall (`setup.bash`) force-reclones
# `lib/`'s pinned dependencies, so hashing `lib/` any earlier would key the
# cache off content these steps are about to replace.
#
# Forge also drops persisted fuzz/invariant/test failure state under
# `cache/fuzz`, `cache/invariant`, and `cache/test-failures`, and replays it
# on the next run before exploring anything new (see foundry.toml's
# `[profile.ci.invariant]` comment). That state is cleared below, both before
# this job's `forge test` runs and again right before the job ends, so it
# never rides along in what gets restored or saved here.
- uses: actions/cache@v4
with:
path: |
cache
out
key: foundry-build-${{ runner.os }}-${{ hashFiles('foundry.toml', 'remappings.txt', 'contracts/**/*.sol', 'test/**/*.sol', 'scripts/**/*.sol', 'lib/**/*.sol') }}
restore-keys: |
foundry-build-${{ runner.os }}-
# Only the unit-fuzz job needs the fork adapter, and only when fork tests
# actually exist in the tree. Fork tests are PR-scoped, so `test/fork/` is
# empty on master and `hashFiles` returns '' (step skipped, no docker build).
- name: Start revive-eth-rpc (paseo_local fork target)
if: matrix.kind.id == 'unit-fuzz' && hashFiles('test/fork/**') != ''
run: |
docker compose up -d --build
bash scripts/shell/wait-for-eth-rpc.sh
- name: Run tests
id: run
env:
FOUNDRY_DISABLE_NIGHTLY_WARNING: "1"
FOUNDRY_PROFILE: "ci"
FOUNDRY_FUZZ_GAS_REPORT_SAMPLES: "1000"
FOUNDRY_INVARIANT_GAS_REPORT_SAMPLES: "1000"
run: |
set +e
# Remove any prior build-info before `forge build` so the JSON the OZ
# validator (vm.ffi inside upgrade tests) reads is the complete one
# this build emits. Defence-in-depth: on a fresh `ubuntu-latest`
# runner there is no prior `out/`, but if a future change reuses a
# warm cache (self-hosted runner, docker layer, etc.) a partial
# build-info from another solc invocation would otherwise survive.
# Preserving `cache/` keeps incremental compile fast.
rm -rf out/build-info
# A restored cache can carry a fuzz/invariant/test failure persisted by
# an earlier, unrelated run. Forge replays those before running anything
# new, so a stale one would fail this job even after the real bug behind
# it was fixed. Clear it before building or testing.
rm -rf cache/fuzz cache/invariant cache/test-failures
forge build --sizes > build.log 2>&1
BUILD=$?
if [ $BUILD -ne 0 ]; then
echo "result=Failed - Build error" >> "$GITHUB_OUTPUT"
echo "has_details=false" >> "$GITHUB_OUTPUT"
exit 1
fi
# Pull requests run 100 invariant runs per campaign to keep the check short; pushes to
# master keep the ci profile's 300. No seed is fixed, so every run explores new
# sequences. The unit + fuzz leg runs no invariants, so this only affects the invariant
# leg.
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
export FOUNDRY_INVARIANT_RUNS=100
fi
forge test -vv --summary ${{ matrix.kind.forge_args }} 2>&1 | tee test.log
TEST=${PIPESTATUS[0]}
python3 scripts/reports/test-report.py test.log
if [ "$TEST" -ne 0 ]; then
exit 1
fi
# Fork tests run against the revive-eth-rpc adapter started above, only
# when present. The preceding `forge build` already produced full build-info
# for the OZ upgrade validator, so this reuses it.
- name: Run fork tests
if: matrix.kind.id == 'unit-fuzz' && hashFiles('test/fork/**') != ''
env:
FOUNDRY_DISABLE_NIGHTLY_WARNING: "1"
FOUNDRY_PROFILE: "ci"
run: forge test -vv --match-path 'test/fork/**'
- name: Tear down revive-eth-rpc
if: always() && matrix.kind.id == 'unit-fuzz' && hashFiles('test/fork/**') != ''
run: docker compose down --volumes --remove-orphans
- name: Stage matrix-kind output for the aggregator
if: always()
run: |
mkdir -p shard
# `result.txt` is the contract between this matrix job and the
# report aggregator: one line of `label|result|has_details` so the
# aggregator can rebuild the PR-comment table without re-running
# `test-report.py` or shelling into the test logs.
printf '%s|%s|%s\n' \
"${{ matrix.kind.label }}" \
"${{ steps.run.outputs.result || 'Failed' }}" \
"${{ steps.run.outputs.has_details || 'false' }}" \
> shard/result.txt
for f in test.log build.log test-details.md test-report.html; do
[ -f "$f" ] && cp "$f" "shard/$f"
done
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-shard-${{ matrix.kind.id }}
path: shard
retention-days: 7
- if: steps.run.outputs.result && contains(steps.run.outputs.result, 'Failed')
run: exit 1
# Last step in the job on purpose: `actions/cache`'s save only runs once
# every step above has finished, and it caches whatever is on disk at that
# point, not just what "Run tests" left behind. Clearing here too means
# nothing this job did can leave a persisted failure in the saved cache.
- name: Clear persisted Foundry failure state before cache save
if: always()
run: rm -rf cache/fuzz cache/invariant cache/test-failures
report:
name: Report Test Results
needs: test
# A pull request from a fork gets a read-only token, so publishing the reports and the comment
# would fail.
if: >-
always() && github.event_name == 'pull_request' && github.event.action != 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: test-artifacts
pattern: test-shard-*
- name: Set deploy paths
id: path
run: |
OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
PR_NUM="${{ github.event.pull_request.number }}"
echo "target_base=tests/pr-${PR_NUM}" >> "$GITHUB_OUTPUT"
echo "url_base=https://${OWNER}.github.io/${REPO_NAME}/tests/pr-${PR_NUM}" >> "$GITHUB_OUTPUT"
- name: Deploy per-shard reports to GitHub Pages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p /tmp/deploy
# Each shard's `test-report.html` lands under its kind subdir
# (e.g. tests/pr-123/unit-fuzz/test-report.html) so the two
# parallel uploads cannot stomp each other on gh-pages.
for shard_dir in test-artifacts/test-shard-*/; do
kind="$(basename "$shard_dir" | sed 's/^test-shard-//')"
if [ -f "${shard_dir}test-report.html" ]; then
mkdir -p "/tmp/deploy/${kind}"
cp "${shard_dir}test-report.html" "/tmp/deploy/${kind}/"
fi
done
if [ -n "$(ls -A /tmp/deploy 2>/dev/null)" ]; then
bash scripts/shell/deploy-to-gh-pages.sh /tmp/deploy "${{ steps.path.outputs.target_base }}"
fi
- name: Update PR comment
uses: actions/github-script@v7
env:
RUN_ID: ${{ github.run_id }}
REPO: ${{ github.repository }}
URL_BASE: ${{ steps.path.outputs.url_base }}
with:
script: |
const fs = require('fs');
const path = require('path');
const marker = "<!-- ci-summary -->";
const detailsMarker = "<!-- details-section -->";
const runId = process.env.RUN_ID;
const repoFull = process.env.REPO;
const urlBase = process.env.URL_BASE;
// Read every shard's `result.txt` into a list of sections.
// Each entry = { id, label, result, hasDetails, details, reportUrl }
const artifactsRoot = 'test-artifacts';
const shardEntries = [];
for (const dirent of fs.readdirSync(artifactsRoot, { withFileTypes: true })) {
if (!dirent.isDirectory() || !dirent.name.startsWith('test-shard-')) continue;
const id = dirent.name.replace(/^test-shard-/, '');
const shardPath = path.join(artifactsRoot, dirent.name);
const resultLine = fs.readFileSync(path.join(shardPath, 'result.txt'), 'utf8').trim();
const [label, result, hasDetailsRaw] = resultLine.split('|');
const hasDetails = hasDetailsRaw === 'true';
let details = '';
try { details = fs.readFileSync(path.join(shardPath, 'test-details.md'), 'utf8').trim(); } catch {}
const reportUrl = hasDetails ? `${urlBase}/${id}/test-report.html` : null;
shardEntries.push({ id, label, result, hasDetails, details, reportUrl });
}
// Read existing comment, parse rows + details, then overwrite
// every row this workflow owns. Other workflows' rows survive.
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
const existing = comments.find(c => c.user?.login === "github-actions[bot]" && c.body?.includes(marker));
let rows = {};
let existingDetails = {};
if (existing?.body) {
const parts = existing.body.split(detailsMarker);
for (const line of (parts[0] || '').split('\n')) {
const match = line.match(/^\| ([^|]+) \| ([^|]+) \|$/);
if (match && match[1].trim() !== 'Check' && !match[1].trim().startsWith(':')) rows[match[1].trim()] = match[2].trim();
}
const re = /<details>\s*<summary><strong>([^<]+)<\/strong><\/summary>([\s\S]*?)<\/details>/g;
let m; while ((m = re.exec(existing.body)) !== null) existingDetails[m[1].trim()] = m[0];
}
for (const entry of shardEntries) {
const result = entry.hasDetails && entry.reportUrl ? `${entry.result} - [View Report](${entry.reportUrl})` : entry.result;
rows[entry.label] = result;
if (entry.hasDetails && entry.details) {
const reportLink = entry.reportUrl ? `[View full report](${entry.reportUrl}) | ` : '';
existingDetails[entry.label] = `<details>\n<summary><strong>${entry.label}</strong></summary>\n\n${entry.details}\n\n${reportLink}[View logs](https://github.com/${repoFull}/actions/runs/${runId})\n\n</details>`;
} else {
delete existingDetails[entry.label];
}
}
const order = ['4naly3er Analysis', 'Slither Analysis', 'Contract Tests (Unit + Fuzz)', 'Contract Tests (Invariant)', 'Gas Report', 'Coverage', 'Documentation', 'Format & Lint', 'File Validation', 'Deploy Contracts', 'PR Title', 'Labels'];
const sortedKeys = Object.keys(rows).sort((a, b) => (order.indexOf(a) === -1 ? 999 : order.indexOf(a)) - (order.indexOf(b) === -1 ? 999 : order.indexOf(b)));
let table = `| Check | Result |\n|:------|:-------|\n`;
for (const key of sortedKeys) table += `| ${key} | ${rows[key]} |\n`;
const detailsOrder = ['4naly3er Analysis', 'Slither Analysis', 'Contract Tests (Unit + Fuzz)', 'Contract Tests (Invariant)', 'Gas Report', 'Coverage'];
const sortedDetails = Object.keys(existingDetails).sort((a, b) => (detailsOrder.indexOf(a) === -1 ? 999 : detailsOrder.indexOf(a)) - (detailsOrder.indexOf(b) === -1 ? 999 : detailsOrder.indexOf(b)));
let body = `${marker}\n## CI Summary\n\n${table}`;
if (sortedDetails.length > 0) body += `\n${detailsMarker}\n\n---\n\n${sortedDetails.map(k => existingDetails[k]).join('\n\n')}`;
// GitHub issue/PR comments are capped at 65536 characters. Fall back to
// the summary table only when the cumulative body would overflow; per-
// section reports remain reachable via the `[View Report]` row links.
const MAX_BODY = 65000;
if (body.length > MAX_BODY) {
body = `${marker}\n## CI Summary\n\n${table}\n\n_Per-section details omitted: combined body exceeded the ${MAX_BODY}-char comment limit. Follow the **View Report** links above for each section's full output._`;
}
if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
else await github.rest.issues.createComment({ owner, repo, issue_number, body });
cleanup:
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event.action == 'closed'
steps:
- uses: actions/checkout@v4
with:
ref: gh-pages
fetch-depth: 0
- run: |
DIR="tests/pr-${{ github.event.pull_request.number }}"
[ -d "$DIR" ] || exit 0
rm -rf "$DIR"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "Clean up test report for PR #${{ github.event.pull_request.number }}" || true
git push