From bfe2da1afc2f05f455fb7a796595e78e193c2383 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Thu, 12 Feb 2026 14:16:45 -0500 Subject: [PATCH] fix: resolve all 13 reported issues --- .github/workflows/_ci.yml | 52 +++++- .github/workflows/_deploy-mainnet.yml | 114 ++++++++++-- .github/workflows/_deploy-testnet.yml | 109 +++++++++-- .github/workflows/_foundry-cicd.yml | 255 ++++++++++++++++++++++---- .github/workflows/_upgrade-safety.yml | 8 +- README.md | 144 +++++++++++++-- tests/run-all.sh | 29 +++ tests/test-baseline-detection.sh | 38 ++++ tests/test-blockscout-verification.sh | 84 +++++++++ 9 files changed, 755 insertions(+), 78 deletions(-) create mode 100755 tests/run-all.sh create mode 100755 tests/test-baseline-detection.sh create mode 100755 tests/test-blockscout-verification.sh diff --git a/.github/workflows/_ci.yml b/.github/workflows/_ci.yml index 562ea4e..aa882b5 100644 --- a/.github/workflows/_ci.yml +++ b/.github/workflows/_ci.yml @@ -1,6 +1,6 @@ # Reusable CI workflow for Foundry projects # Usage: jobs..uses: ./.github/workflows/_ci.yml -name: CI (Reusable) +name: Build & Test (Reusable) on: workflow_call: @@ -13,6 +13,22 @@ on: description: 'Test verbosity level (v, vv, vvv, vvvv)' type: string default: 'vvv' + foundry-profile: + description: 'Foundry profile to use for building/testing (leave empty for default)' + type: string + default: '' + run-slither: + description: 'Run Slither static analysis' + type: boolean + default: false + slither-fail-on: + description: 'Slither severity level to fail on (low, medium, high)' + type: string + default: 'medium' + slither-config: + description: 'Path to Slither configuration file' + type: string + default: 'slither.config.json' jobs: check: @@ -36,6 +52,40 @@ jobs: - name: Build contracts run: forge build + env: + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} - name: Run tests run: forge test -${{ inputs.test-verbosity }} + env: + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} + + slither: + name: Slither Analysis + runs-on: ubuntu-latest + if: inputs.run-slither + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Build with Forge + run: forge build + + - name: Run Slither + uses: crytic/slither-action@v0.4.0 + with: + fail-on: ${{ inputs.slither-fail-on }} + slither-config: ${{ inputs.slither-config }} + sarif: results.sarif + + - name: Upload SARIF results + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif + continue-on-error: true diff --git a/.github/workflows/_deploy-mainnet.yml b/.github/workflows/_deploy-mainnet.yml index 9558b16..08be004 100644 --- a/.github/workflows/_deploy-mainnet.yml +++ b/.github/workflows/_deploy-mainnet.yml @@ -1,6 +1,6 @@ # Reusable mainnet deployment workflow with matrix support # Usage: jobs..uses: ./.github/workflows/_deploy-mainnet.yml -name: Deploy Mainnet (Reusable) +name: Deploy & Verify Mainnet (Reusable) on: workflow_call: @@ -21,6 +21,10 @@ on: description: 'Seconds to wait for indexer before verification' type: number default: 60 + verify-contracts: + description: 'Run Blockscout contract verification after deployment' + type: boolean + default: true flatten-contracts: description: 'Flatten and commit contract snapshots after deploy' type: boolean @@ -29,11 +33,18 @@ on: description: 'Path to store flattened contract snapshots' type: string default: 'test/upgrades' + foundry-profile: + description: 'Foundry profile to use for deployment (leave empty for default)' + type: string + default: '' secrets: PRIVATE_KEY: required: true RPC_URL: required: true + ADMIN_ADDRESS: + description: 'Optional admin address for upgradeable contract deploy scripts' + required: false GH_TOKEN: description: 'GitHub token for pushing commits (defaults to GITHUB_TOKEN)' required: false @@ -77,10 +88,27 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 + - name: Validate Foundry profile + if: inputs.foundry-profile != '' + run: | + if ! grep -q "\[profile\.${{ inputs.foundry-profile }}\]" foundry.toml 2>/dev/null; then + echo "::error::Foundry profile '${{ inputs.foundry-profile }}' not found in foundry.toml" + exit 1 + fi + + - name: Validate foundry.toml configuration + run: | + # Detect etherscan config that references unavailable env vars + if grep -q 'ETHERSCAN_API_KEY' foundry.toml 2>/dev/null; then + echo "::warning::foundry.toml references ETHERSCAN_API_KEY which is not available in this workflow. Blockscout is used for verification." + fi + - name: Deploy contracts env: PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} RPC_URL: ${{ secrets.RPC_URL }} + ADMIN_ADDRESS: ${{ secrets.ADMIN_ADDRESS }} + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} run: | # Ensure PRIVATE_KEY has 0x prefix if [[ "$PRIVATE_KEY" != 0x* ]]; then @@ -110,37 +138,69 @@ jobs: jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName): \(.contractAddress)"' "$BROADCAST_FILE" | tee deployment-summary.txt - name: Verify contracts on Blockscout + if: inputs.verify-contracts env: BLOCKSCOUT_URL: ${{ matrix.blockscout_url }} + RPC_URL: ${{ secrets.RPC_URL }} run: | BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - # Extract and verify each deployed contract with retry logic - jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE" | while read -r tx; do + # Skip if no Blockscout URL configured + if [[ -z "$BLOCKSCOUT_URL" || "$BLOCKSCOUT_URL" == "null" ]]; then + echo "::warning::No Blockscout URL configured for ${{ matrix.name }}, skipping verification" + exit 0 + fi + + # Validate Blockscout URL format + if ! echo "$BLOCKSCOUT_URL" | grep -qE '^https?://[a-zA-Z0-9.-]+'; then + echo "::error::Invalid Blockscout URL format: $BLOCKSCOUT_URL" + exit 1 + fi + + FAILED=0 + + # Use process substitution to propagate failures + while read -r tx; do CONTRACT_NAME=$(echo "$tx" | jq -r '.contractName') CONTRACT_ADDR=$(echo "$tx" | jq -r '.contractAddress') echo "Verifying $CONTRACT_NAME at $CONTRACT_ADDR..." - # Retry up to 3 times with exponential backoff + VERIFIED=0 for attempt in 1 2 3; do - if forge verify-contract "$CONTRACT_ADDR" "$CONTRACT_NAME" \ + # Capture output and use timeout to prevent hanging + VERIFY_OUTPUT=$(timeout 120 forge verify-contract "$CONTRACT_ADDR" "$CONTRACT_NAME" \ --verifier blockscout \ --verifier-url "${BLOCKSCOUT_URL}/api" \ --guess-constructor-args \ - --watch; then + --watch 2>&1) || true + echo "$VERIFY_OUTPUT" + + # Check for actual verification success (not just exit code) + if echo "$VERIFY_OUTPUT" | grep -qiE "Contract successfully verified|Pass - Verified"; then echo "✓ Verified $CONTRACT_NAME" + VERIFIED=1 + break + elif echo "$VERIFY_OUTPUT" | grep -qi "Already Verified"; then + echo "✓ $CONTRACT_NAME already verified" + VERIFIED=1 break else if [[ $attempt -lt 3 ]]; then echo "Attempt $attempt failed, retrying in $((attempt * 30))s..." sleep $((attempt * 30)) else - echo "⚠ Verification pending for $CONTRACT_NAME after 3 attempts" + echo "::warning::Verification failed for $CONTRACT_NAME after 3 attempts" + FAILED=1 fi fi done - done + done < <(jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE") + + if [[ $FAILED -eq 1 ]]; then + echo "::error::One or more contracts failed verification" + exit 1 + fi - name: Save deployment artifacts run: | @@ -161,16 +221,37 @@ jobs: env: BLOCKSCOUT_URL: ${{ matrix.blockscout_url }} run: | + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + echo "## ${{ matrix.name }} Deployment Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "| Contract | Address | Explorer |" >> $GITHUB_STEP_SUMMARY - echo "|----------|---------|----------|" >> $GITHUB_STEP_SUMMARY + echo "| Contract | Address | Constructor Args | Explorer |" >> $GITHUB_STEP_SUMMARY + echo "|----------|---------|------------------|----------|" >> $GITHUB_STEP_SUMMARY + + jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName)|\(.contractAddress)|\(.arguments // [] | join(", "))"' "$BROADCAST_FILE" | while IFS='|' read -r CONTRACT ADDRESS ARGS; do + if [[ -n "$BLOCKSCOUT_URL" && "$BLOCKSCOUT_URL" != "null" ]]; then + EXPLORER="[View](${BLOCKSCOUT_URL}/address/$ADDRESS)" + else + EXPLORER="-" + fi + echo "| $CONTRACT | \`$ADDRESS\` | ${ARGS:--} | $EXPLORER |" >> $GITHUB_STEP_SUMMARY + done - while read -r line; do - CONTRACT=$(echo "$line" | cut -d: -f1) - ADDRESS=$(echo "$line" | cut -d: -f2 | tr -d ' ') - echo "| $CONTRACT | \`$ADDRESS\` | [View](${BLOCKSCOUT_URL}/address/$ADDRESS) |" >> $GITHUB_STEP_SUMMARY - done < deployment-summary.txt + # Method calls section + CALLS=$(jq -r '.transactions[] | select(.transactionType == "CALL") | "\(.contractName)|\(.function // "unknown")|\(.arguments // [] | join(", "))"' "$BROADCAST_FILE" 2>/dev/null) + if [[ -n "$CALLS" ]]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "Method Calls" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Contract | Method | Arguments |" >> $GITHUB_STEP_SUMMARY + echo "|----------|--------|-----------|" >> $GITHUB_STEP_SUMMARY + echo "$CALLS" | while IFS='|' read -r CONTRACT METHOD ARGS; do + echo "| $CONTRACT | \`$METHOD\` | ${ARGS:--} |" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + fi flatten-snapshots: name: Flatten & Commit Snapshots @@ -194,6 +275,8 @@ jobs: - name: Build contracts run: forge build + env: + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} - name: Extract contract list from broadcast artifacts id: contracts @@ -216,6 +299,7 @@ jobs: - name: Flatten contracts to current snapshot env: UPGRADES_PATH: ${{ inputs.upgrades-path }} + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} run: | mkdir -p "${UPGRADES_PATH}/current" diff --git a/.github/workflows/_deploy-testnet.yml b/.github/workflows/_deploy-testnet.yml index cd41ed4..52b1765 100644 --- a/.github/workflows/_deploy-testnet.yml +++ b/.github/workflows/_deploy-testnet.yml @@ -1,6 +1,6 @@ # Reusable testnet deployment workflow # Usage: jobs..uses: ./.github/workflows/_deploy-testnet.yml -name: Deploy Testnet (Reusable) +name: Deploy & Verify Testnet (Reusable) on: workflow_call: @@ -21,11 +21,22 @@ on: description: 'Seconds to wait for indexer before verification' type: number default: 60 + verify-contracts: + description: 'Run Blockscout contract verification after deployment' + type: boolean + default: true + foundry-profile: + description: 'Foundry profile to use for deployment (leave empty for default)' + type: string + default: '' secrets: PRIVATE_KEY: required: true RPC_URL: required: true + ADMIN_ADDRESS: + description: 'Optional admin address for upgradeable contract deploy scripts' + required: false jobs: deploy-testnet: @@ -43,10 +54,18 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 + - name: Validate Foundry profile + if: inputs.foundry-profile != '' + run: | + if ! grep -q "\[profile\.${{ inputs.foundry-profile }}\]" foundry.toml 2>/dev/null; then + echo "::error::Foundry profile '${{ inputs.foundry-profile }}' not found in foundry.toml" + exit 1 + fi + - name: Read network config id: network run: | - BLOCKSCOUT_URL=$(jq -r '.testnets[${{ inputs.network-index }}].blockscout_url' ${{ inputs.network-config-path }}) + BLOCKSCOUT_URL=$(jq -r '.testnets[${{ inputs.network-index }}].blockscout_url // ""' ${{ inputs.network-config-path }}) NETWORK_NAME=$(jq -r '.testnets[${{ inputs.network-index }}].name' ${{ inputs.network-config-path }}) echo "blockscout_url=$BLOCKSCOUT_URL" >> $GITHUB_OUTPUT echo "network_name=$NETWORK_NAME" >> $GITHUB_OUTPUT @@ -55,6 +74,8 @@ jobs: env: PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} RPC_URL: ${{ secrets.RPC_URL }} + ADMIN_ADDRESS: ${{ secrets.ADMIN_ADDRESS }} + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} run: | # Ensure PRIVATE_KEY has 0x prefix if [[ "$PRIVATE_KEY" != 0x* ]]; then @@ -84,38 +105,69 @@ jobs: jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName): \(.contractAddress)"' "$BROADCAST_FILE" | tee deployment-summary.txt - name: Verify contracts on Blockscout + if: inputs.verify-contracts env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + RPC_URL: ${{ secrets.RPC_URL }} run: | BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - # Extract and verify each deployed contract with retry logic - jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE" | while read -r tx; do + # Skip if no Blockscout URL configured + if [[ -z "$BLOCKSCOUT_URL" || "$BLOCKSCOUT_URL" == "null" ]]; then + echo "::warning::No Blockscout URL configured, skipping verification" + exit 0 + fi + + # Validate Blockscout URL format + if ! echo "$BLOCKSCOUT_URL" | grep -qE '^https?://[a-zA-Z0-9.-]+'; then + echo "::error::Invalid Blockscout URL format: $BLOCKSCOUT_URL" + exit 1 + fi + + FAILED=0 + + # Use process substitution to propagate failures + while read -r tx; do CONTRACT_NAME=$(echo "$tx" | jq -r '.contractName') CONTRACT_ADDR=$(echo "$tx" | jq -r '.contractAddress') echo "Verifying $CONTRACT_NAME at $CONTRACT_ADDR..." - # Retry up to 3 times with exponential backoff - # Use --guess-constructor-args to auto-detect from on-chain bytecode + VERIFIED=0 for attempt in 1 2 3; do - if forge verify-contract "$CONTRACT_ADDR" "$CONTRACT_NAME" \ + # Capture output and use timeout to prevent hanging + VERIFY_OUTPUT=$(timeout 120 forge verify-contract "$CONTRACT_ADDR" "$CONTRACT_NAME" \ --verifier blockscout \ --verifier-url "${BLOCKSCOUT_URL}/api" \ --guess-constructor-args \ - --watch; then + --watch 2>&1) || true + echo "$VERIFY_OUTPUT" + + # Check for actual verification success (not just exit code) + if echo "$VERIFY_OUTPUT" | grep -qiE "Contract successfully verified|Pass - Verified"; then echo "✓ Verified $CONTRACT_NAME" + VERIFIED=1 + break + elif echo "$VERIFY_OUTPUT" | grep -qi "Already Verified"; then + echo "✓ $CONTRACT_NAME already verified" + VERIFIED=1 break else if [[ $attempt -lt 3 ]]; then echo "Attempt $attempt failed, retrying in $((attempt * 30))s..." sleep $((attempt * 30)) else - echo "⚠ Verification pending for $CONTRACT_NAME after 3 attempts" + echo "::warning::Verification failed for $CONTRACT_NAME after 3 attempts" + FAILED=1 fi fi done - done + done < <(jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE") + + if [[ $FAILED -eq 1 ]]; then + echo "::error::One or more contracts failed verification" + exit 1 + fi - name: Save deployment artifacts run: | @@ -136,13 +188,34 @@ jobs: env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} run: | + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + echo "## Testnet Deployment Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "| Contract | Address | Explorer |" >> $GITHUB_STEP_SUMMARY - echo "|----------|---------|----------|" >> $GITHUB_STEP_SUMMARY - - while read -r line; do - CONTRACT=$(echo "$line" | cut -d: -f1) - ADDRESS=$(echo "$line" | cut -d: -f2 | tr -d ' ') - echo "| $CONTRACT | \`$ADDRESS\` | [View](${BLOCKSCOUT_URL}/address/$ADDRESS) |" >> $GITHUB_STEP_SUMMARY - done < deployment-summary.txt + echo "| Contract | Address | Constructor Args | Explorer |" >> $GITHUB_STEP_SUMMARY + echo "|----------|---------|------------------|----------|" >> $GITHUB_STEP_SUMMARY + + jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName)|\(.contractAddress)|\(.arguments // [] | join(", "))"' "$BROADCAST_FILE" | while IFS='|' read -r CONTRACT ADDRESS ARGS; do + if [[ -n "$BLOCKSCOUT_URL" && "$BLOCKSCOUT_URL" != "null" ]]; then + EXPLORER="[View](${BLOCKSCOUT_URL}/address/$ADDRESS)" + else + EXPLORER="-" + fi + echo "| $CONTRACT | \`$ADDRESS\` | ${ARGS:--} | $EXPLORER |" >> $GITHUB_STEP_SUMMARY + done + + # Method calls section + CALLS=$(jq -r '.transactions[] | select(.transactionType == "CALL") | "\(.contractName)|\(.function // "unknown")|\(.arguments // [] | join(", "))"' "$BROADCAST_FILE" 2>/dev/null) + if [[ -n "$CALLS" ]]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "Method Calls" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Contract | Method | Arguments |" >> $GITHUB_STEP_SUMMARY + echo "|----------|--------|-----------|" >> $GITHUB_STEP_SUMMARY + echo "$CALLS" | while IFS='|' read -r CONTRACT METHOD ARGS; do + echo "| $CONTRACT | \`$METHOD\` | ${ARGS:--} |" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index 637aa49..e5e5086 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -1,6 +1,6 @@ # All-in-one Foundry CI/CD reusable workflow # Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_foundry-cicd.yml@main -name: Foundry CI/CD +name: CI/CD Pipeline on: workflow_call: @@ -30,6 +30,24 @@ on: description: 'Test verbosity level (v, vv, vvv, vvvv)' type: string default: 'vvv' + foundry-profile: + description: 'Foundry profile to use for deployment/build (leave empty for default)' + type: string + default: '' + + # Slither Options + run-slither: + description: 'Run Slither static analysis' + type: boolean + default: false + slither-fail-on: + description: 'Slither severity level to fail on (low, medium, high)' + type: string + default: 'medium' + slither-config: + description: 'Path to Slither configuration file' + type: string + default: 'slither.config.json' # Upgrade Safety Options run-upgrade-safety: @@ -66,6 +84,10 @@ on: description: 'Seconds to wait for indexer before verification' type: number default: 60 + verify-contracts: + description: 'Run Blockscout contract verification after deployment' + type: boolean + default: true flatten-contracts: description: 'Flatten and commit contract snapshots after mainnet deploy' type: boolean @@ -82,13 +104,16 @@ on: RPC_URL: description: 'Network RPC endpoint' required: false + ADMIN_ADDRESS: + description: 'Optional admin address for upgradeable contract deploy scripts' + required: false GH_TOKEN: description: 'GitHub token for pushing commits' required: false jobs: detect-changes: - name: Detect Changes + name: Detect Contract Changes runs-on: ubuntu-latest outputs: contracts-changed: ${{ steps.filter.outputs.contracts }} @@ -146,18 +171,69 @@ jobs: - name: Show Forge version run: forge --version + - name: Validate Foundry profile + if: inputs.foundry-profile != '' + run: | + if ! grep -q "\[profile\.${{ inputs.foundry-profile }}\]" foundry.toml 2>/dev/null; then + echo "::error::Foundry profile '${{ inputs.foundry-profile }}' not found in foundry.toml" + exit 1 + fi + + - name: Validate foundry.toml configuration + run: | + if grep -q 'ETHERSCAN_API_KEY' foundry.toml 2>/dev/null; then + echo "::warning::foundry.toml references ETHERSCAN_API_KEY which is not available in this workflow. Blockscout is used for verification." + fi + - name: Check formatting if: inputs.check-formatting run: forge fmt --check - name: Build contracts run: forge build + env: + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} - name: Run tests run: forge test -${{ inputs.test-verbosity }} + env: + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} + + slither: + name: Slither Analysis + runs-on: ubuntu-latest + needs: [detect-changes] + if: | + needs.detect-changes.outputs.should-run == 'true' && + inputs.run-slither + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Build with Forge + run: forge build + + - name: Run Slither + uses: crytic/slither-action@v0.4.0 + with: + fail-on: ${{ inputs.slither-fail-on }} + slither-config: ${{ inputs.slither-config }} + sarif: results.sarif + + - name: Upload SARIF results + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif + continue-on-error: true upgrade-safety: - name: Upgrade Safety + name: Upgrade Safety Check runs-on: ubuntu-latest needs: [detect-changes, ci] if: | @@ -173,6 +249,8 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 - name: Run upgrade safety validation + env: + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} run: | forge clean && forge build @@ -191,7 +269,7 @@ jobs: fi deploy-testnet: - name: Deploy Testnet + name: Deploy & Verify Testnet runs-on: ubuntu-latest needs: [detect-changes, ci, upgrade-safety] if: | @@ -213,7 +291,7 @@ jobs: - name: Read network config id: network run: | - BLOCKSCOUT_URL=$(jq -r '.testnets[0].blockscout_url' ${{ inputs.network-config-path }}) + BLOCKSCOUT_URL=$(jq -r '.testnets[0].blockscout_url // ""' ${{ inputs.network-config-path }}) NETWORK_NAME=$(jq -r '.testnets[0].name' ${{ inputs.network-config-path }}) echo "blockscout_url=$BLOCKSCOUT_URL" >> $GITHUB_OUTPUT echo "network_name=$NETWORK_NAME" >> $GITHUB_OUTPUT @@ -222,6 +300,8 @@ jobs: env: PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} RPC_URL: ${{ secrets.RPC_URL }} + ADMIN_ADDRESS: ${{ secrets.ADMIN_ADDRESS }} + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} run: | if [[ "$PRIVATE_KEY" != 0x* ]]; then export PRIVATE_KEY="0x$PRIVATE_KEY" @@ -247,49 +327,103 @@ jobs: jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName): \(.contractAddress)"' "$BROADCAST_FILE" | tee deployment-summary.txt - name: Verify contracts on Blockscout + if: inputs.verify-contracts env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + RPC_URL: ${{ secrets.RPC_URL }} run: | BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE" | while read -r tx; do + + # Skip if no Blockscout URL configured + if [[ -z "$BLOCKSCOUT_URL" || "$BLOCKSCOUT_URL" == "null" ]]; then + echo "::warning::No Blockscout URL configured, skipping verification" + exit 0 + fi + + # Validate Blockscout URL format + if ! echo "$BLOCKSCOUT_URL" | grep -qE '^https?://[a-zA-Z0-9.-]+'; then + echo "::error::Invalid Blockscout URL format: $BLOCKSCOUT_URL" + exit 1 + fi + + FAILED=0 + + while read -r tx; do CONTRACT_NAME=$(echo "$tx" | jq -r '.contractName') CONTRACT_ADDR=$(echo "$tx" | jq -r '.contractAddress') echo "Verifying $CONTRACT_NAME at $CONTRACT_ADDR..." + + VERIFIED=0 for attempt in 1 2 3; do - if forge verify-contract "$CONTRACT_ADDR" "$CONTRACT_NAME" \ + VERIFY_OUTPUT=$(timeout 120 forge verify-contract "$CONTRACT_ADDR" "$CONTRACT_NAME" \ --verifier blockscout \ --verifier-url "${BLOCKSCOUT_URL}/api" \ --guess-constructor-args \ - --watch; then + --watch 2>&1) || true + echo "$VERIFY_OUTPUT" + + if echo "$VERIFY_OUTPUT" | grep -qiE "Contract successfully verified|Pass - Verified"; then echo "✓ Verified $CONTRACT_NAME" + VERIFIED=1 + break + elif echo "$VERIFY_OUTPUT" | grep -qi "Already Verified"; then + echo "✓ $CONTRACT_NAME already verified" + VERIFIED=1 break else if [[ $attempt -lt 3 ]]; then echo "Attempt $attempt failed, retrying in $((attempt * 30))s..." sleep $((attempt * 30)) else - echo "⚠ Verification pending for $CONTRACT_NAME after 3 attempts" + echo "::warning::Verification failed for $CONTRACT_NAME after 3 attempts" + FAILED=1 fi fi done - done + done < <(jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE") + + if [[ $FAILED -eq 1 ]]; then + echo "::error::One or more contracts failed verification" + exit 1 + fi - name: Create deployment summary env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} run: | + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + echo "## Testnet Deployment Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "| Contract | Address | Explorer |" >> $GITHUB_STEP_SUMMARY - echo "|----------|---------|----------|" >> $GITHUB_STEP_SUMMARY - while read -r line; do - CONTRACT=$(echo "$line" | cut -d: -f1) - ADDRESS=$(echo "$line" | cut -d: -f2 | tr -d ' ') - echo "| $CONTRACT | \`$ADDRESS\` | [View](${BLOCKSCOUT_URL}/address/$ADDRESS) |" >> $GITHUB_STEP_SUMMARY - done < deployment-summary.txt + echo "| Contract | Address | Constructor Args | Explorer |" >> $GITHUB_STEP_SUMMARY + echo "|----------|---------|------------------|----------|" >> $GITHUB_STEP_SUMMARY + + jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName)|\(.contractAddress)|\(.arguments // [] | join(", "))"' "$BROADCAST_FILE" | while IFS='|' read -r CONTRACT ADDRESS ARGS; do + if [[ -n "$BLOCKSCOUT_URL" && "$BLOCKSCOUT_URL" != "null" ]]; then + EXPLORER="[View](${BLOCKSCOUT_URL}/address/$ADDRESS)" + else + EXPLORER="-" + fi + echo "| $CONTRACT | \`$ADDRESS\` | ${ARGS:--} | $EXPLORER |" >> $GITHUB_STEP_SUMMARY + done + + CALLS=$(jq -r '.transactions[] | select(.transactionType == "CALL") | "\(.contractName)|\(.function // "unknown")|\(.arguments // [] | join(", "))"' "$BROADCAST_FILE" 2>/dev/null) + if [[ -n "$CALLS" ]]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "Method Calls" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Contract | Method | Arguments |" >> $GITHUB_STEP_SUMMARY + echo "|----------|--------|-----------|" >> $GITHUB_STEP_SUMMARY + echo "$CALLS" | while IFS='|' read -r CONTRACT METHOD ARGS; do + echo "| $CONTRACT | \`$METHOD\` | ${ARGS:--} |" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + fi deploy-mainnet: - name: Deploy Mainnet + name: Deploy & Verify Mainnet runs-on: ubuntu-latest needs: [detect-changes, ci, upgrade-safety] if: | @@ -312,7 +446,7 @@ jobs: - name: Read network config id: network run: | - BLOCKSCOUT_URL=$(jq -r '.mainnets[0].blockscout_url' ${{ inputs.network-config-path }}) + BLOCKSCOUT_URL=$(jq -r '.mainnets[0].blockscout_url // ""' ${{ inputs.network-config-path }}) NETWORK_NAME=$(jq -r '.mainnets[0].name' ${{ inputs.network-config-path }}) ENVIRONMENT=$(jq -r '.mainnets[0].environment' ${{ inputs.network-config-path }}) echo "blockscout_url=$BLOCKSCOUT_URL" >> $GITHUB_OUTPUT @@ -323,6 +457,8 @@ jobs: env: PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} RPC_URL: ${{ secrets.RPC_URL }} + ADMIN_ADDRESS: ${{ secrets.ADMIN_ADDRESS }} + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} run: | if [[ "$PRIVATE_KEY" != 0x* ]]; then export PRIVATE_KEY="0x$PRIVATE_KEY" @@ -348,49 +484,101 @@ jobs: jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName): \(.contractAddress)"' "$BROADCAST_FILE" | tee deployment-summary.txt - name: Verify contracts on Blockscout + if: inputs.verify-contracts env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + RPC_URL: ${{ secrets.RPC_URL }} run: | BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE" | while read -r tx; do + + if [[ -z "$BLOCKSCOUT_URL" || "$BLOCKSCOUT_URL" == "null" ]]; then + echo "::warning::No Blockscout URL configured, skipping verification" + exit 0 + fi + + if ! echo "$BLOCKSCOUT_URL" | grep -qE '^https?://[a-zA-Z0-9.-]+'; then + echo "::error::Invalid Blockscout URL format: $BLOCKSCOUT_URL" + exit 1 + fi + + FAILED=0 + + while read -r tx; do CONTRACT_NAME=$(echo "$tx" | jq -r '.contractName') CONTRACT_ADDR=$(echo "$tx" | jq -r '.contractAddress') echo "Verifying $CONTRACT_NAME at $CONTRACT_ADDR..." + + VERIFIED=0 for attempt in 1 2 3; do - if forge verify-contract "$CONTRACT_ADDR" "$CONTRACT_NAME" \ + VERIFY_OUTPUT=$(timeout 120 forge verify-contract "$CONTRACT_ADDR" "$CONTRACT_NAME" \ --verifier blockscout \ --verifier-url "${BLOCKSCOUT_URL}/api" \ --guess-constructor-args \ - --watch; then + --watch 2>&1) || true + echo "$VERIFY_OUTPUT" + + if echo "$VERIFY_OUTPUT" | grep -qiE "Contract successfully verified|Pass - Verified"; then echo "✓ Verified $CONTRACT_NAME" + VERIFIED=1 + break + elif echo "$VERIFY_OUTPUT" | grep -qi "Already Verified"; then + echo "✓ $CONTRACT_NAME already verified" + VERIFIED=1 break else if [[ $attempt -lt 3 ]]; then echo "Attempt $attempt failed, retrying in $((attempt * 30))s..." sleep $((attempt * 30)) else - echo "⚠ Verification pending for $CONTRACT_NAME after 3 attempts" + echo "::warning::Verification failed for $CONTRACT_NAME after 3 attempts" + FAILED=1 fi fi done - done + done < <(jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE") + + if [[ $FAILED -eq 1 ]]; then + echo "::error::One or more contracts failed verification" + exit 1 + fi - name: Create deployment summary env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} run: | + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + echo "## Mainnet Deployment Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "| Contract | Address | Explorer |" >> $GITHUB_STEP_SUMMARY - echo "|----------|---------|----------|" >> $GITHUB_STEP_SUMMARY - while read -r line; do - CONTRACT=$(echo "$line" | cut -d: -f1) - ADDRESS=$(echo "$line" | cut -d: -f2 | tr -d ' ') - echo "| $CONTRACT | \`$ADDRESS\` | [View](${BLOCKSCOUT_URL}/address/$ADDRESS) |" >> $GITHUB_STEP_SUMMARY - done < deployment-summary.txt + echo "| Contract | Address | Constructor Args | Explorer |" >> $GITHUB_STEP_SUMMARY + echo "|----------|---------|------------------|----------|" >> $GITHUB_STEP_SUMMARY + + jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName)|\(.contractAddress)|\(.arguments // [] | join(", "))"' "$BROADCAST_FILE" | while IFS='|' read -r CONTRACT ADDRESS ARGS; do + if [[ -n "$BLOCKSCOUT_URL" && "$BLOCKSCOUT_URL" != "null" ]]; then + EXPLORER="[View](${BLOCKSCOUT_URL}/address/$ADDRESS)" + else + EXPLORER="-" + fi + echo "| $CONTRACT | \`$ADDRESS\` | ${ARGS:--} | $EXPLORER |" >> $GITHUB_STEP_SUMMARY + done + + CALLS=$(jq -r '.transactions[] | select(.transactionType == "CALL") | "\(.contractName)|\(.function // "unknown")|\(.arguments // [] | join(", "))"' "$BROADCAST_FILE" 2>/dev/null) + if [[ -n "$CALLS" ]]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "Method Calls" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Contract | Method | Arguments |" >> $GITHUB_STEP_SUMMARY + echo "|----------|--------|-----------|" >> $GITHUB_STEP_SUMMARY + echo "$CALLS" | while IFS='|' read -r CONTRACT METHOD ARGS; do + echo "| $CONTRACT | \`$METHOD\` | ${ARGS:--} |" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + fi flatten-snapshots: - name: Flatten Snapshots + name: Post-Deploy Snapshots runs-on: ubuntu-latest needs: [deploy-mainnet] if: | @@ -409,10 +597,13 @@ jobs: - name: Build contracts run: forge build + env: + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} - name: Extract and flatten contracts env: UPGRADES_PATH: ${{ inputs.upgrades-path }} + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} run: | mkdir -p "${UPGRADES_PATH}/current" diff --git a/.github/workflows/_upgrade-safety.yml b/.github/workflows/_upgrade-safety.yml index c746efb..6f25c58 100644 --- a/.github/workflows/_upgrade-safety.yml +++ b/.github/workflows/_upgrade-safety.yml @@ -1,6 +1,6 @@ # Reusable upgrade safety validation workflow # Usage: jobs..uses: ./.github/workflows/_upgrade-safety.yml -name: Upgrade Safety (Reusable) +name: Upgrade Safety Check (Reusable) on: workflow_call: @@ -17,6 +17,10 @@ on: description: 'Path to validation script' type: string default: 'script/upgrades/ValidateUpgrade.s.sol' + foundry-profile: + description: 'Foundry profile to use (leave empty for default)' + type: string + default: '' jobs: upgrade-safety: @@ -32,6 +36,8 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 - name: Run upgrade safety validation + env: + FOUNDRY_PROFILE: ${{ inputs.foundry-profile || 'default' }} run: | forge clean && forge build diff --git a/README.md b/README.md index 63ed1e3..3c3e5de 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,42 @@ Reusable GitHub Actions workflows for Foundry smart contract CI/CD with upgrade | Workflow | Description | |----------|-------------| -| `_ci.yml` | Build, test, and format check | +| `_ci.yml` | Build, test, format check, and optional Slither analysis | | `_upgrade-safety.yml` | OpenZeppelin upgrade safety validation | | `_deploy-testnet.yml` | Testnet deployment with Blockscout verification | | `_deploy-mainnet.yml` | Mainnet deployment with matrix support and 3-tier snapshot rotation | +| `_foundry-cicd.yml` | All-in-one CI/CD pipeline (recommended) | -## Usage +## Quick Start -Reference the reusable workflows in your Foundry project: +### Option 1: All-in-one workflow (recommended) + +```yaml +# .github/workflows/cicd.yml +name: CI/CD + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pipeline: + uses: BreadchainCoop/etherform/.github/workflows/_foundry-cicd.yml@main + with: + deploy-on-pr: true # Deploy to testnet on PRs + deploy-on-main: true # Deploy to mainnet on merge + verify-contracts: true # Verify on Blockscout + run-slither: true # Optional static analysis + foundry-profile: '' # Use specific Foundry profile (optional) + secrets: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + RPC_URL: ${{ secrets.RPC_URL }} + ADMIN_ADDRESS: ${{ secrets.ADMIN_ADDRESS }} # Optional +``` + +### Option 2: Individual workflows ```yaml # .github/workflows/ci.yml @@ -27,6 +55,8 @@ jobs: with: check-formatting: true test-verbosity: 'vvv' + run-slither: true + slither-fail-on: 'medium' ``` ```yaml @@ -44,9 +74,13 @@ jobs: deploy: needs: [ci] uses: BreadchainCoop/etherform/.github/workflows/_deploy-mainnet.yml@main + with: + verify-contracts: true + foundry-profile: 'production' secrets: PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} RPC_URL: ${{ secrets.RPC_URL }} + ADMIN_ADDRESS: ${{ secrets.ADMIN_ADDRESS }} ``` ## Configuration @@ -76,14 +110,55 @@ Create `.github/deploy-networks.json` in your repository: } ``` -### Secrets Required +> **Tip:** To skip Blockscout verification for a specific network, omit the `blockscout_url` field or set it to `""`. + +### Deployment Script Convention + +CI expects a canonical entry point: + +``` +script/Deploy.s.sol:Deploy +``` + +Your deploy script can read these environment variables (provided by the workflow): +- `PRIVATE_KEY` — deployer wallet private key +- `RPC_URL` — network RPC endpoint +- `ADMIN_ADDRESS` — optional admin address for upgradeable contracts + +### Foundry Profiles -| Secret | Description | -|--------|-------------| -| `PRIVATE_KEY` | Deployer wallet private key | -| `RPC_URL` | Network RPC endpoint | +Use the `foundry-profile` input to specify a Foundry profile for deployment builds. This is useful when your test profile uses different optimizer settings than production: -## Workflow Inputs +```toml +# foundry.toml +[profile.default] +optimizer = false + +[profile.production] +optimizer = true +optimizer_runs = 200 +bytecode_hash = "none" +cbor_metadata = false +``` + +```yaml +jobs: + pipeline: + uses: BreadchainCoop/etherform/.github/workflows/_foundry-cicd.yml@main + with: + foundry-profile: 'production' +``` + +### Secrets + +| Secret | Required | Description | +|--------|----------|-------------| +| `PRIVATE_KEY` | Yes (for deploy) | Deployer wallet private key | +| `RPC_URL` | Yes (for deploy) | Network RPC endpoint | +| `ADMIN_ADDRESS` | No | Admin address for upgradeable contract deploy scripts | +| `GH_TOKEN` | No | GitHub token for pushing snapshot commits (defaults to `GITHUB_TOKEN`) | + +## Workflow Inputs Reference ### `_ci.yml` @@ -91,6 +166,10 @@ Create `.github/deploy-networks.json` in your repository: |-------|------|---------|-------------| | `check-formatting` | boolean | `true` | Run `forge fmt --check` | | `test-verbosity` | string | `'vvv'` | Test verbosity (`v`, `vv`, `vvv`, `vvvv`) | +| `foundry-profile` | string | `''` | Foundry profile for build/test | +| `run-slither` | boolean | `false` | Run Slither static analysis | +| `slither-fail-on` | string | `'medium'` | Slither severity to fail on | +| `slither-config` | string | `'slither.config.json'` | Slither config file path | ### `_upgrade-safety.yml` @@ -99,6 +178,7 @@ Create `.github/deploy-networks.json` in your repository: | `baseline-path` | string | `'test/upgrades/baseline'` | Path to baseline contracts | | `fallback-path` | string | `'test/upgrades/previous'` | Fallback path if baseline missing | | `validation-script` | string | `'script/upgrades/ValidateUpgrade.s.sol'` | Validation script path | +| `foundry-profile` | string | `''` | Foundry profile | ### `_deploy-testnet.yml` @@ -108,6 +188,8 @@ Create `.github/deploy-networks.json` in your repository: | `network-config-path` | string | `'.github/deploy-networks.json'` | Network config path | | `network-index` | number | `0` | Index in testnets array | | `indexing-wait` | number | `60` | Seconds to wait before verification | +| `verify-contracts` | boolean | `true` | Run Blockscout verification | +| `foundry-profile` | string | `''` | Foundry profile for deployment | ### `_deploy-mainnet.yml` @@ -117,9 +199,49 @@ Create `.github/deploy-networks.json` in your repository: | `network-config-path` | string | `'.github/deploy-networks.json'` | Network config path | | `network` | string | `''` | Specific network (empty = all) | | `indexing-wait` | number | `60` | Seconds to wait before verification | +| `verify-contracts` | boolean | `true` | Run Blockscout verification | | `flatten-contracts` | boolean | `true` | Flatten and commit snapshots | | `upgrades-path` | string | `'test/upgrades'` | Path for flattened snapshots | +| `foundry-profile` | string | `''` | Foundry profile for deployment | -## Example Project +### `_foundry-cicd.yml` (all-in-one) + +Combines all the above plus: + +| Input | Type | Default | Description | +|-------|------|---------|-------------| +| `skip-if-no-changes` | boolean | `true` | Skip if no contract files changed | +| `contract-paths` | string | `src/** script/** ...` | Paths to watch for changes | +| `deploy-on-pr` | boolean | `false` | Deploy to testnet on PR | +| `deploy-on-main` | boolean | `false` | Deploy to mainnet on push to main | +| `run-slither` | boolean | `false` | Run Slither static analysis | +| `run-upgrade-safety` | boolean | `true` | Run upgrade safety validation | + +## Blockscout Verification + +Verification uses `forge verify-contract` with Blockscout. The workflow: + +1. **Validates the Blockscout URL** — fails fast on malformed URLs instead of looping +2. **Checks actual verification status** — parses output for "Contract successfully verified" or "Pass - Verified" instead of trusting the exit code (which can return 0 even on failure) +3. **Retries with backoff** — 3 attempts with 30s/60s/90s delays +4. **Times out** — each attempt has a 120s timeout to prevent hanging +5. **Is optional** — set `verify-contracts: false` to skip, or omit `blockscout_url` from network config + +## Deployment Summary + +After deployment, the workflow generates a GitHub Step Summary containing: + +- **Contract table** with name, address, constructor arguments, and explorer link +- **Method calls** (collapsible) showing all `initialize()`, `grantRoles()`, etc. calls with their arguments + +## Testing + +Run the workflow logic tests locally: + +```bash +./tests/run-all.sh +``` -See the [examples/foundry-counter](examples/foundry-counter) submodule for a complete working example. +Tests validate: +- Blockscout verification output parsing (false positive detection, URL validation) +- Baseline detection logic for upgrade safety diff --git a/tests/run-all.sh b/tests/run-all.sh new file mode 100755 index 0000000..1fcccd6 --- /dev/null +++ b/tests/run-all.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Run all etherform workflow logic tests +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PASS=0 +FAIL=0 + +run_test() { + local test_file="$1" + echo "Running $(basename "$test_file")..." + if bash "$test_file"; then + echo " ✓ PASSED" + PASS=$((PASS + 1)) + else + echo " ✗ FAILED" + FAIL=$((FAIL + 1)) + fi +} + +for test_file in "$SCRIPT_DIR"/test-*.sh; do + if [[ -f "$test_file" ]]; then + run_test "$test_file" + fi +done + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]] diff --git a/tests/test-baseline-detection.sh b/tests/test-baseline-detection.sh new file mode 100755 index 0000000..2b1433d --- /dev/null +++ b/tests/test-baseline-detection.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Test baseline detection logic used in upgrade safety +set -euo pipefail + +ERRORS=0 +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +check_baseline() { + local baseline_path="$1" + local fallback_path="$2" + local expected="$3" # "baseline", "fallback", or "skip" + local desc="$4" + + local result="skip" + if [ -d "$baseline_path" ] && ls "$baseline_path"/*.sol 1>/dev/null 2>&1; then + result="baseline" + elif [ -d "$fallback_path" ] && ls "$fallback_path"/*.sol 1>/dev/null 2>&1; then + result="fallback" + fi + + if [[ "$result" != "$expected" ]]; then + echo " FAIL: $desc (expected=$expected, got=$result)" + ((ERRORS++)) + fi +} + +# Setup test dirs +mkdir -p "$TMPDIR/baseline" "$TMPDIR/previous" "$TMPDIR/empty" +echo "// SPDX" > "$TMPDIR/baseline/Test.sol" +echo "// SPDX" > "$TMPDIR/previous/Test.sol" + +check_baseline "$TMPDIR/baseline" "$TMPDIR/previous" "baseline" "Baseline exists" +check_baseline "$TMPDIR/nonexistent" "$TMPDIR/previous" "fallback" "Fallback when no baseline" +check_baseline "$TMPDIR/nonexistent" "$TMPDIR/nonexistent2" "skip" "Skip when neither exists" +check_baseline "$TMPDIR/empty" "$TMPDIR/previous" "fallback" "Fallback when baseline empty" + +[[ $ERRORS -eq 0 ]] diff --git a/tests/test-blockscout-verification.sh b/tests/test-blockscout-verification.sh new file mode 100755 index 0000000..bde8123 --- /dev/null +++ b/tests/test-blockscout-verification.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Test Blockscout verification output parsing logic +set -euo pipefail + +ERRORS=0 + +check_verification_output() { + local output="$1" + local expected="$2" # "pass" or "fail" + local desc="$3" + + local result="fail" + if echo "$output" | grep -qiE "Contract successfully verified|Pass - Verified"; then + result="pass" + elif echo "$output" | grep -qi "Already Verified"; then + result="pass" + fi + + if [[ "$result" != "$expected" ]]; then + echo " FAIL: $desc (expected=$expected, got=$result)" + ((ERRORS++)) + fi +} + +# Test: False positive — Blockscout returns "Fail - Unable to verify" but exit code 0 +check_verification_output \ + "Contract verification status:\nResponse: \`OK\`\nDetails: \`Fail - Unable to verify\`" \ + "fail" \ + "False positive: 'Fail - Unable to verify' should not pass" + +# Test: Real success +check_verification_output \ + "Contract successfully verified" \ + "pass" \ + "Real success should pass" + +# Test: Already verified +check_verification_output \ + "Contract source code already verified" \ + "pass" \ + "Already Verified should pass" + +# Test: Pass - Verified +check_verification_output \ + "Response: \`OK\`\nDetails: \`Pass - Verified\`" \ + "pass" \ + "Pass - Verified should pass" + +# Test: Network error +check_verification_output \ + "Error: connection refused" \ + "fail" \ + "Network error should fail" + +# Test: Empty output +check_verification_output \ + "" \ + "fail" \ + "Empty output should fail" + +# Test URL validation +validate_url() { + local url="$1" + echo "$url" | grep -qE '^https?://[a-zA-Z0-9.-]+' +} + +if validate_url "https://eth-sepolia.blockscout.com"; then + true +else + echo " FAIL: Valid URL rejected" + ((ERRORS++)) +fi + +if validate_url "not-a-url"; then + echo " FAIL: Invalid URL accepted" + ((ERRORS++)) +fi + +if validate_url ""; then + echo " FAIL: Empty URL accepted" + ((ERRORS++)) +fi + +[[ $ERRORS -eq 0 ]]