Skip to content

feat: whitelist names before mainnet (#239) #341

feat: whitelist names before mainnet (#239)

feat: whitelist names before mainnet (#239) #341

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 });
}