feat: whitelist names before mainnet #332
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | |
| } |