-
Notifications
You must be signed in to change notification settings - Fork 1
516 lines (451 loc) · 21.8 KB
/
Copy pathdeploy-contracts.yml
File metadata and controls
516 lines (451 loc) · 21.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
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
name: Deploy Contracts and Verify artifacts
on:
pull_request:
branches: [master]
paths: ["contracts/**", "scripts/**", "**.sol", "package.json", "bun.lock"]
push:
branches: [master]
paths: ["contracts/**", "scripts/**", "**.sol", "package.json", "bun.lock"]
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: deploy-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
# Shared across steps. ACCOUNT_* is anvil test account 7 (public test keys,
# never valid on a real network) used to run the pipeline. FACTORY_DEPLOYER is
# the public address of the single-purpose factory key; PINNED_FACTORY is its
# nonce-0 CREATE address and the one recorded in every manifest. CANONICAL is
# the committed manifest that seeds DEPLOYMENTS.md; MANIFEST is the file this
# CI deploy writes. PRIVATE_KEY is deliberately absent here: it is set only on
# the first deploy to import the keystore, and must not reach the resume run,
# which reuses the already-imported account.
env:
ACCOUNT_NAME: anvil-default-7
ACCOUNT_PASSWORD: anvil-default-password
# Required by the deploy scripts (BaseDeployer.tldLabel). The TLD is passed
# to DotnsProtocolRegistry.initialize as init calldata, which does not affect
# any CREATE3 address, so the reproduction still lands the canonical set.
DOTNS_TLD: dot
WHITELIST_OPERATOR: "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955"
FACTORY_DEPLOYER: "0xd498F7BC5bB3cBdd0068c3deEbbd814b69C3F164"
PINNED_FACTORY: "0x8533c79E058c5a6489CAFeCA86dc600E029D75f5"
CANONICAL: deployments/paseo-assethub/420420417.json
MANIFEST: deployments/paseo-local/420420420.json
steps:
- name: Checkout repo (with submodules)
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 1
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2.6"
no-cache: true
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: stable
- name: Make scripts executable
run: chmod +x setup.bash
- uses: actions/cache@v4
with:
path: node_modules
key: bun-${{ hashFiles('bun.lock') }}
restore-keys: bun-
- name: Install dependencies
run: bun install
- name: Start Anvil
# --block-gas-limit lifted from the 30M default so `forge script` has
# enough per-call gas to handle OZ upgrade-safety validation returns,
# which route multi-MB ABI JSON blobs through cheatcode return bytes
# into EVM memory. Memory expansion gas is quadratic; the default caps
# the deploy with `MemoryOOG` long before any allocation fails.
run: |
anvil --chain-id 420420420 --port 8545 --block-gas-limit 1000000000 > anvil.log 2>&1 &
sleep 3
- name: Wait for RPC to be ready
id: rpc_wait
run: |
set +e
for _ in {1..30}; do
if curl -s -X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' \
http://127.0.0.1:8545 | grep -q "result"; then
echo "RPC ready"
echo "result=RPC ready" >> "$GITHUB_OUTPUT"
exit 0
fi
sleep 2
done
echo "result=Failed - RPC connection timeout" >> "$GITHUB_OUTPUT"
exit 1
- name: Deploy contracts (production flow on a fresh chain)
id: deploy
if: steps.rpc_wait.outcome == 'success'
# Runs the exact production two-key `deploy:all` on a fresh anvil. The
# factory key (a repo secret) deploys the CREATE3 factory at nonce 0,
# landing it at the canonical PINNED_FACTORY, then the pipeline (anvil test
# account 7) deploys everything through it. EXPECTED_CREATE3_FACTORY aborts
# the run if the factory lands anywhere else. This is a genuine new-chain
# deploy, so the addresses it produces are the ones every environment gets.
env:
# Pipeline account: anvil test account 7, imported on first run.
PRIVATE_KEY: "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356"
# Factory account: the single-purpose key that owns the canonical factory
# address, supplied as a repo secret so CI signs the nonce-0 deploy for
# real. Its password is CI-local; the keystore is imported on first run.
FACTORY_ACCOUNT: create3-factory
FACTORY_PASSWORD: anvil-default-password
FACTORY_PRIVATE_KEY: ${{ secrets.FACTORY_DEPLOYER_KEY }}
EXPECTED_CREATE3_FACTORY: ${{ env.PINNED_FACTORY }}
run: |
set +e
if [ -z "$FACTORY_PRIVATE_KEY" ]; then
echo "result=Failed - FACTORY_DEPLOYER_KEY secret unavailable" >> "$GITHUB_OUTPUT"
echo "has_details=true" >> "$GITHUB_OUTPUT"
{
echo "### Factory key secret unavailable"
echo ""
echo "The \`FACTORY_DEPLOYER_KEY\` repo secret is not set, or is withheld on a"
echo "fork PR. The reproduction deploy needs it to sign the nonce-0 factory"
echo "deploy that lands the factory at its canonical address."
} > deploy-error.md
cat deploy-error.md
exit 1
fi
# Fund the factory deployer on anvil; it is not a prefunded dev account.
cast rpc anvil_setBalance "$FACTORY_DEPLOYER" 0x3635C9ADC5DEA00000 \
--rpc-url http://127.0.0.1:8545 > /dev/null
bun run deploy:all > deploy.log 2>&1
DEPLOY_EXIT=$?
# Drop the factory credentials the moment the deploy is done, so nothing
# later in this step (the error extractor that feeds the public PR
# comment, or any future command) inherits the key in its environment.
unset FACTORY_PRIVATE_KEY FACTORY_PASSWORD PRIVATE_KEY
if [ $DEPLOY_EXIT -eq 0 ]; then
echo "result=Deployment successful" >> "$GITHUB_OUTPUT"
echo "has_details=false" >> "$GITHUB_OUTPUT"
else
echo "result=Failed - Deployment error" >> "$GITHUB_OUTPUT"
echo "has_details=true" >> "$GITHUB_OUTPUT"
# Extract error details
python3 << 'EOF'
import re
import os
def extract_errors(log_content):
errors = []
lines = log_content.split('\n')
# Look for common error patterns
error_patterns = [
r'Error:(.+)',
r'error:(.+)',
r'FAIL:(.+)',
r'Revert:(.+)',
r'reverted with reason string:(.+)',
r'Transaction reverted:(.+)',
r'script failed:(.+)',
]
in_stack_trace = False
stack_lines = []
for i, line in enumerate(lines):
# Check for error patterns
for pattern in error_patterns:
match = re.search(pattern, line, re.IGNORECASE)
if match:
error_msg = match.group(1).strip()
if error_msg and len(error_msg) > 5:
errors.append(f"- {error_msg}")
# Capture stack traces
if 'at ' in line and ('(' in line or '.sol' in line):
if not in_stack_trace:
in_stack_trace = True
stack_lines.append("\n**Stack Trace:**\n```")
stack_lines.append(line.strip())
elif in_stack_trace and (line.strip() == '' or 'Error' in line):
stack_lines.append("```")
in_stack_trace = False
if in_stack_trace:
stack_lines.append("```")
return errors, stack_lines
try:
with open('deploy.log', 'r') as f:
log_content = f.read()
errors, stack_trace = extract_errors(log_content)
md_lines = ["### Deployment Failed\n"]
if errors:
md_lines.append("**Errors Found:**\n")
for error in errors[:10]: # Limit to 10 errors
md_lines.append(error)
if len(errors) > 10:
md_lines.append(f"\n*+{len(errors)-10} more errors*")
if stack_trace:
md_lines.extend(stack_trace)
# Add full log excerpt (last 50 lines)
log_lines = log_content.split('\n')
if len(log_lines) > 50:
md_lines.append("\n**Last 50 lines of output:**\n```")
md_lines.extend(log_lines[-50:])
md_lines.append("```")
else:
md_lines.append("\n**Full output:**\n```")
md_lines.extend(log_lines)
md_lines.append("```")
with open('deploy-error.md', 'w') as f:
f.write('\n'.join(md_lines))
except Exception as e:
with open('deploy-error.md', 'w') as f:
f.write(f"### Error parsing logs\n\n{str(e)}")
EOF
cat deploy-error.md
exit 1
fi
- name: Verify deployment artifact exists
id: verify
if: steps.deploy.outcome == 'success'
run: |
set +e
if [ -f "$MANIFEST" ]; then
echo "result=Artifacts verified" >> "$GITHUB_OUTPUT"
echo "Deployment artifact exists"
else
echo "result=Failed - Artifact not found" >> "$GITHUB_OUTPUT"
echo "has_details=true" >> "$GITHUB_OUTPUT"
{
echo "### Missing Deployment Artifact"
echo ""
echo "Expected file \`$MANIFEST\` was not created."
echo ""
echo "**Files in deployments directory:**"
echo "\`\`\`"
} > deploy-error.md
ls -la deployments/ 2>&1 || echo "Directory does not exist" >> deploy-error.md
echo "\`\`\`" >> deploy-error.md
exit 1
fi
# This CI deploy reproduces the published address set. The canonical factory
# was deployed above, and every DotNS address is a pure function of that
# factory plus a fixed salt, so the freshly deployed manifest must equal the
# committed manifest that seeds DEPLOYMENTS.md. Assert that, print the
# expected-vs-actual table, then rerun the pipeline to prove the deploy is
# resumable: a rerun adopts every contract and still lands on the same set.
- name: Verify addresses match DEPLOYMENTS.md
id: verify_addresses
if: steps.verify.outcome == 'success'
run: |
set +e
# Markdown expected-vs-actual table: expected = committed manifest,
# actual = this CI deployment. Any row whose addresses differ is MOVED.
emit_table() {
echo "### Deployed addresses vs DEPLOYMENTS.md"
echo ""
echo "Expected is the committed manifest; actual is this CI deployment of the same pipeline."
echo ""
echo "| Contract | Expected | Actual | Match |"
echo "|:---------|:---------|:-------|:-----:|"
jq -rn --slurpfile e "$CANONICAL" --slurpfile a "$MANIFEST" '
($e[0]) as $exp | ($a[0]) as $act
| $exp | keys_unsorted[] as $k
| "| \($k) | `\($exp[$k])` | `\($act[$k] // "MISSING")` | \(if $exp[$k] == $act[$k] then "match" else "MOVED" end) |"
'
}
# True when the deployed manifest equals the committed one. Sort keys so
# only value differences, never ordering, register as a mismatch.
matches_canonical() {
diff <(jq -S . "$CANONICAL") <(jq -S . "$MANIFEST") > /dev/null
}
# Record a failure (deploy-error.md already written) and stop.
fail() {
echo "result=$1" >> "$GITHUB_OUTPUT"
echo "has_details=true" >> "$GITHUB_OUTPUT"
cat deploy-error.md
exit 1
}
emit_table > table.md
cat table.md
cat table.md >> "$GITHUB_STEP_SUMMARY"
if ! matches_canonical; then
{
echo "### Deployed addresses do not match DEPLOYMENTS.md"
echo ""
echo "The pipeline no longer reproduces the published set. Rows marked MOVED"
echo "below differ from the committed manifest; regenerate DEPLOYMENTS.md and"
echo "the manifest, or restore the salt or label that moved."
echo ""
cat table.md
} > deploy-error.md
fail "Failed - addresses differ from DEPLOYMENTS.md"
fi
# Resume: rerun the same pinned pipeline. It must adopt every contract
# and still land on the published set. No PRIVATE_KEY: the keystore is
# already imported, the real resume path a human takes.
if ! CREATE3_FACTORY="$PINNED_FACTORY" ./scripts/deploy/run.sh > resume.log 2>&1; then
{
echo "### Re-run did not resume"
echo ""
echo "Rerunning the pipeline against the already-deployed chain reverted; an"
echo "adopt path (\`_deployCreate3\` or the proxy-init skip) has regressed."
echo ""
echo "**Last 50 lines of re-run output:**"
echo '```'
tail -50 resume.log
echo '```'
} > deploy-error.md
fail "Failed - re-run did not resume"
fi
if ! matches_canonical; then
{
echo "### Resume moved addresses"
echo ""
echo "The rerun no longer matches DEPLOYMENTS.md, so a resumed deploy would"
echo "relocate contracts."
echo ""
emit_table
} > deploy-error.md
fail "Failed - resume moved addresses"
fi
# Surface the table under the passing row too, so the address set is on
# record every run, not only on failure.
cp table.md deploy-error.md
echo "result=Reproduces DEPLOYMENTS.md; resume verified" >> "$GITHUB_OUTPUT"
echo "has_details=true" >> "$GITHUB_OUTPUT"
- name: Set final result
id: result
if: always()
run: |
if [ "${{ steps.rpc_wait.outcome }}" != "success" ]; then
echo "result=Failed - RPC connection timeout" >> "$GITHUB_OUTPUT"
echo "has_details=false" >> "$GITHUB_OUTPUT"
elif [ "${{ steps.deploy.outcome }}" != "success" ]; then
echo "result=${{ steps.deploy.outputs.result }}" >> "$GITHUB_OUTPUT"
echo "has_details=${{ steps.deploy.outputs.has_details }}" >> "$GITHUB_OUTPUT"
elif [ "${{ steps.verify.outcome }}" != "success" ]; then
echo "result=${{ steps.verify.outputs.result }}" >> "$GITHUB_OUTPUT"
echo "has_details=${{ steps.verify.outputs.has_details }}" >> "$GITHUB_OUTPUT"
else
# Everything up to and including verify passed, so verify_addresses ran.
# Its outputs carry the final verdict and the has_details flag that
# surfaces the expected-vs-actual table.
echo "result=${{ steps.verify_addresses.outputs.result }}" >> "$GITHUB_OUTPUT"
echo "has_details=${{ steps.verify_addresses.outputs.has_details }}" >> "$GITHUB_OUTPUT"
fi
# Defence in depth for the public sinks. deploy-error.md is posted verbatim
# to a public PR comment and *.log is uploaded as a public artifact, and
# GitHub secret masking rewrites only the streamed log view, never file
# contents. No path writes the key to these files today; this strips any
# private-key-shaped hex (32 bytes, optionally 0x-prefixed) so a future
# regression cannot surface it publicly.
- name: Redact key-shaped hex from public outputs
if: always()
run: |
for f in deploy-error.md *.log; do
[ -f "$f" ] && sed -E -i 's/(0x)?[0-9a-fA-F]{64}/[REDACTED]/g' "$f" || true
done
- name: Upload logs
if: always()
uses: actions/upload-artifact@v4
with:
name: deployment-logs
path: |
*.log
deploy-error.md
deployments/
retention-days: 7
- name: Stop Anvil
if: always()
run: pkill -f anvil || true
- name: Update PR comment
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
SECTION: Deploy Contracts
RESULT: ${{ steps.result.outputs.result }}
HAS_DETAILS: ${{ steps.result.outputs.has_details }}
RUN_ID: ${{ github.run_id }}
REPO: ${{ github.repository }}
with:
script: |
const fs = require('fs');
const marker = "<!-- ci-summary -->";
const detailsMarker = "<!-- details-section -->";
const section = process.env.SECTION;
const result = process.env.RESULT || 'Unknown';
const hasDetails = process.env.HAS_DETAILS === 'true';
const runId = process.env.RUN_ID;
const repoFull = process.env.REPO;
let details = '';
try {
details = fs.readFileSync('deploy-error.md', 'utf8').trim();
} catch {}
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);
const tableSection = parts[0] || '';
const lines = tableSection.split('\n');
for (const line of lines) {
const match = line.match(/^\| ([^|]+) \| ([^|]+) \|$/);
if (match) {
const name = match[1].trim();
if (name && name !== 'Check' && !name.startsWith(':')) {
rows[name] = match[2].trim();
}
}
}
const detailsRegex = /<details>\s*<summary><strong>([^<]+)<\/strong><\/summary>([\s\S]*?)<\/details>/g;
let detailMatch;
while ((detailMatch = detailsRegex.exec(existing.body)) !== null) {
existingDetails[detailMatch[1].trim()] = detailMatch[0];
}
}
rows[section] = result;
if (hasDetails && details) {
existingDetails[section] = `<details>\n<summary><strong>${section}</strong></summary>\n\n${details}\n\n[View full logs](https://github.com/${repoFull}/actions/runs/${runId})\n\n</details>`;
} else {
delete existingDetails[section];
}
const order = ['4naly3er Analysis', 'Slither Analysis', 'Contract Tests (Unit + Fuzz)', 'Contract Tests (Invariant)', 'Coverage', 'Documentation', 'Format & Lint', 'Deploy Contracts', 'PR Title', 'Labels'];
const sortedKeys = Object.keys(rows).sort((a, b) => {
const ai = order.indexOf(a), bi = order.indexOf(b);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
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)', 'Coverage', 'Deploy Contracts'];
const sortedDetails = Object.keys(existingDetails).sort((a, b) => {
const ai = detailsOrder.indexOf(a), bi = detailsOrder.indexOf(b);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
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. As the CI
// summary accumulates `<details>` blocks from every workflow, the body
// occasionally trips the limit. Fall back to the summary table only;
// each row already carries a `[View Report]` link to the GH Pages
// artifact, so the details are reachable without inlining them here.
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 });
}