diff --git a/.github/workflows/_foundry-ci.yml b/.github/workflows/_foundry-ci.yml new file mode 100644 index 0000000..2ecb648 --- /dev/null +++ b/.github/workflows/_foundry-ci.yml @@ -0,0 +1,86 @@ +# Foundry CI - Build, Test, Format Check, Compiler Validation +# Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_foundry-ci.yml@main +name: Foundry CI + +on: + workflow_call: + inputs: + check-formatting: + description: 'Run forge fmt --check' + type: boolean + default: true + test-verbosity: + description: 'Test verbosity level (v, vv, vvv, vvvv)' + type: string + default: 'vvv' + validate-compiler-config: + description: 'Validate bytecode_hash and cbor_metadata settings' + type: boolean + default: true + working-directory: + description: 'Working directory for Foundry commands' + type: string + default: '.' + +jobs: + ci: + name: Build & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Show Forge version + run: forge --version + + - name: Validate compiler config + if: inputs.validate-compiler-config + run: | + set -euo pipefail + + ERRORS=0 + + # Check bytecode_hash = "none" + if grep -q 'bytecode_hash\s*=\s*"none"' foundry.toml; then + echo "✓ bytecode_hash = \"none\"" + else + echo "::error::foundry.toml must set bytecode_hash = \"none\" for deterministic bytecode" + ERRORS=1 + fi + + # Check cbor_metadata = false + if grep -q 'cbor_metadata\s*=\s*false' foundry.toml; then + echo "✓ cbor_metadata = false" + else + echo "::error::foundry.toml must set cbor_metadata = false for deterministic bytecode" + ERRORS=1 + fi + + if [[ $ERRORS -eq 1 ]]; then + echo "" + echo "Required foundry.toml settings for CI/CD:" + echo " [profile.default]" + echo " bytecode_hash = \"none\"" + echo " cbor_metadata = false" + exit 1 + fi + + echo "Compiler config validated successfully" + + - name: Check formatting + if: inputs.check-formatting + run: forge fmt --check + + - name: Build contracts + run: forge build + + - name: Run tests + run: forge test -${{ inputs.test-verbosity }} diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index 637aa49..95ff272 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -1,5 +1,13 @@ -# All-in-one Foundry CI/CD reusable workflow +# All-in-one Foundry CI/CD reusable workflow (orchestrator) # Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_foundry-cicd.yml@main +# +# This is the main entry point that orchestrates all sub-workflows. +# For more granular control, you can import individual workflows: +# - _foundry-detect-changes.yml - Change detection +# - _foundry-ci.yml - Build, test, format check +# - _foundry-upgrade-safety.yml - Upgrade safety validation +# - _foundry-deploy.yml - Deploy with verification +# - _foundry-post-mainnet.yml - Flatten snapshots and create release name: Foundry CI/CD on: @@ -40,10 +48,6 @@ on: description: 'Path to baseline contracts for upgrade comparison' type: string default: 'test/upgrades/baseline' - validation-script: - description: 'Path to upgrade validation script' - type: string - default: 'script/upgrades/ValidateUpgrade.s.sol' # Deploy Options deploy-on-pr: @@ -58,10 +62,22 @@ on: description: 'Path to deployment script' type: string default: 'script/Deploy.s.sol:Deploy' - network-config-path: - description: 'Path to network configuration JSON' + testnet-blockscout-url: + description: 'Blockscout URL for testnet verification and explorer links' + type: string + default: 'https://eth-sepolia.blockscout.com' + testnet-name: + description: 'Testnet network name for display' + type: string + default: 'Sepolia' + mainnet-blockscout-url: + description: 'Blockscout URL for mainnet verification and explorer links' + type: string + default: 'https://eth-sepolia.blockscout.com' + mainnet-name: + description: 'Mainnet network name for display' type: string - default: '.github/deploy-networks.json' + default: 'Sepolia' indexing-wait: description: 'Seconds to wait for indexer before verification' type: number @@ -70,11 +86,31 @@ on: description: 'Flatten and commit contract snapshots after mainnet deploy' type: boolean default: true + init-baseline-without-deploy: + description: 'Initialize baseline on merge to main even if deploy-on-main is disabled' + type: boolean + default: false upgrades-path: description: 'Path for flattened contract snapshots' type: string default: 'test/upgrades' + # Release Options + create-release: + description: 'Create GitHub release after deployment' + type: boolean + default: true + release-prefix: + description: 'Prefix for release tags (e.g., "v" for v1.0.0 style)' + type: string + default: '' + + # Working Directory + working-directory: + description: 'Working directory for Foundry commands' + type: string + default: '.' + secrets: PRIVATE_KEY: description: 'Deployer wallet private key' @@ -87,112 +123,46 @@ on: required: false jobs: + # ───────────────────────────────────────────────────────────────────────────── + # Change Detection + # ───────────────────────────────────────────────────────────────────────────── detect-changes: - name: Detect Changes - runs-on: ubuntu-latest - outputs: - contracts-changed: ${{ steps.filter.outputs.contracts }} - should-run: ${{ steps.decide.outputs.should-run }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Build paths filter - id: paths - run: | - # Convert newline-separated paths to YAML list format - YAML_PATHS=$(echo "${{ inputs.contract-paths }}" | sed '/^$/d' | sed 's/^/ - /') - echo "filter<> $GITHUB_OUTPUT - echo "contracts:" >> $GITHUB_OUTPUT - echo "$YAML_PATHS" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Check for contract changes - id: filter - uses: dorny/paths-filter@v3 - with: - filters: ${{ steps.paths.outputs.filter }} - - - name: Decide whether to run - id: decide - run: | - if [[ "${{ inputs.skip-if-no-changes }}" == "false" ]]; then - echo "Change detection disabled, workflow will run" - echo "should-run=true" >> $GITHUB_OUTPUT - elif [[ "${{ steps.filter.outputs.contracts }}" == "true" ]]; then - echo "Contract changes detected, workflow will run" - echo "should-run=true" >> $GITHUB_OUTPUT - else - echo "No contract changes detected, skipping workflow" - echo "should-run=false" >> $GITHUB_OUTPUT - fi - + uses: BreadchainCoop/etherform/.github/workflows/_foundry-detect-changes.yml@main + with: + skip-if-no-changes: ${{ inputs.skip-if-no-changes }} + contract-paths: ${{ inputs.contract-paths }} + working-directory: ${{ inputs.working-directory }} + + # ───────────────────────────────────────────────────────────────────────────── + # CI: Build & Test + # ───────────────────────────────────────────────────────────────────────────── ci: - name: Build & Test - runs-on: ubuntu-latest needs: [detect-changes] if: needs.detect-changes.outputs.should-run == 'true' - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Show Forge version - run: forge --version - - - name: Check formatting - if: inputs.check-formatting - run: forge fmt --check - - - name: Build contracts - run: forge build - - - name: Run tests - run: forge test -${{ inputs.test-verbosity }} - + uses: BreadchainCoop/etherform/.github/workflows/_foundry-ci.yml@main + with: + check-formatting: ${{ inputs.check-formatting }} + test-verbosity: ${{ inputs.test-verbosity }} + working-directory: ${{ inputs.working-directory }} + + # ───────────────────────────────────────────────────────────────────────────── + # Upgrade Safety Validation + # ───────────────────────────────────────────────────────────────────────────── upgrade-safety: - name: Upgrade Safety - runs-on: ubuntu-latest needs: [detect-changes, ci] if: | needs.detect-changes.outputs.should-run == 'true' && inputs.run-upgrade-safety - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Run upgrade safety validation - run: | - forge clean && forge build - - BASELINE="${{ inputs.baseline-path }}" - FALLBACK="${{ inputs.baseline-path }}/../previous" - - if [ -d "$BASELINE" ] && ls ${BASELINE}/*.sol 1> /dev/null 2>&1; then - echo "Baseline contracts found in $BASELINE, running upgrade validation..." - forge script ${{ inputs.validation-script }} -vvv - elif [ -d "$FALLBACK" ] && ls ${FALLBACK}/*.sol 1> /dev/null 2>&1; then - echo "Baseline contracts found in $FALLBACK (fallback path), running upgrade validation..." - forge script ${{ inputs.validation-script }} -vvv - else - echo "::warning::Baseline missing — will auto-init on first merge to main" - echo "No baseline contracts found, skipping upgrade validation (initial deployment)" - fi - + uses: BreadchainCoop/etherform/.github/workflows/_foundry-upgrade-safety.yml@main + with: + baseline-path: ${{ inputs.baseline-path }} + upgrades-path: ${{ inputs.upgrades-path }} + working-directory: ${{ inputs.working-directory }} + + # ───────────────────────────────────────────────────────────────────────────── + # Testnet Deployment (on PR) + # ───────────────────────────────────────────────────────────────────────────── deploy-testnet: - name: Deploy Testnet - runs-on: ubuntu-latest needs: [detect-changes, ci, upgrade-safety] if: | always() && @@ -201,96 +171,25 @@ jobs: (needs.upgrade-safety.result == 'success' || needs.upgrade-safety.result == 'skipped') && inputs.deploy-on-pr && github.event_name == 'pull_request' - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Read network config - id: network - run: | - 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 - - - name: Deploy contracts - env: - PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} - RPC_URL: ${{ secrets.RPC_URL }} - run: | - if [[ "$PRIVATE_KEY" != 0x* ]]; then - export PRIVATE_KEY="0x$PRIVATE_KEY" - fi - forge script ${{ inputs.deploy-script }} \ - --rpc-url "$RPC_URL" \ - --broadcast \ - --slow \ - -vvvv - - - name: Wait for indexing - run: sleep ${{ inputs.indexing-wait }} - - - name: Parse deployment addresses - id: parse - run: | - BROADCAST_FILE=$(find broadcast -name "run-latest.json" -type f | head -1) - if [[ -z "$BROADCAST_FILE" ]]; then - echo "No broadcast file found" - exit 1 - fi - echo "broadcast_file=$BROADCAST_FILE" >> $GITHUB_OUTPUT - jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName): \(.contractAddress)"' "$BROADCAST_FILE" | tee deployment-summary.txt - - - name: Verify contracts on Blockscout - env: - BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} - run: | - BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE" | 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..." - for attempt in 1 2 3; do - if forge verify-contract "$CONTRACT_ADDR" "$CONTRACT_NAME" \ - --verifier blockscout \ - --verifier-url "${BLOCKSCOUT_URL}/api" \ - --guess-constructor-args \ - --watch; then - echo "✓ Verified $CONTRACT_NAME" - 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" - fi - fi - done - done - - - name: Create deployment summary - env: - BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} - run: | - 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 + uses: BreadchainCoop/etherform/.github/workflows/_foundry-deploy.yml@main + with: + network-type: testnet + deploy-script: ${{ inputs.deploy-script }} + blockscout-url: ${{ inputs.testnet-blockscout-url }} + network-name: ${{ inputs.testnet-name }} + indexing-wait: ${{ inputs.indexing-wait }} + pr-number: ${{ github.event.pull_request.number }} + commit-sha: ${{ github.event.pull_request.head.sha }} + working-directory: ${{ inputs.working-directory }} + secrets: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + RPC_URL: ${{ secrets.RPC_URL }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} + # ───────────────────────────────────────────────────────────────────────────── + # Mainnet Deployment (on merge to main) + # ───────────────────────────────────────────────────────────────────────────── deploy-mainnet: - name: Deploy Mainnet - runs-on: ubuntu-latest needs: [detect-changes, ci, upgrade-safety] if: | always() && @@ -300,166 +199,41 @@ jobs: inputs.deploy-on-main && github.event_name == 'push' && github.ref == 'refs/heads/main' - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Read network config - id: network - run: | - 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 - echo "network_name=$NETWORK_NAME" >> $GITHUB_OUTPUT - echo "environment=$ENVIRONMENT" >> $GITHUB_OUTPUT - - - name: Deploy contracts - env: - PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} - RPC_URL: ${{ secrets.RPC_URL }} - run: | - if [[ "$PRIVATE_KEY" != 0x* ]]; then - export PRIVATE_KEY="0x$PRIVATE_KEY" - fi - forge script ${{ inputs.deploy-script }} \ - --rpc-url "$RPC_URL" \ - --broadcast \ - --slow \ - -vvvv - - - name: Wait for indexing - run: sleep ${{ inputs.indexing-wait }} - - - name: Parse deployment addresses - id: parse - run: | - BROADCAST_FILE=$(find broadcast -name "run-latest.json" -type f | head -1) - if [[ -z "$BROADCAST_FILE" ]]; then - echo "No broadcast file found" - exit 1 - fi - echo "broadcast_file=$BROADCAST_FILE" >> $GITHUB_OUTPUT - jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName): \(.contractAddress)"' "$BROADCAST_FILE" | tee deployment-summary.txt - - - name: Verify contracts on Blockscout - env: - BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} - run: | - BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE" | 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..." - for attempt in 1 2 3; do - if forge verify-contract "$CONTRACT_ADDR" "$CONTRACT_NAME" \ - --verifier blockscout \ - --verifier-url "${BLOCKSCOUT_URL}/api" \ - --guess-constructor-args \ - --watch; then - echo "✓ Verified $CONTRACT_NAME" - 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" - fi - fi - done - done - - - name: Create deployment summary - env: - BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} - run: | - 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 - - flatten-snapshots: - name: Flatten Snapshots - runs-on: ubuntu-latest - needs: [deploy-mainnet] + uses: BreadchainCoop/etherform/.github/workflows/_foundry-deploy.yml@main + with: + network-type: mainnet + deploy-script: ${{ inputs.deploy-script }} + blockscout-url: ${{ inputs.mainnet-blockscout-url }} + network-name: ${{ inputs.mainnet-name }} + indexing-wait: ${{ inputs.indexing-wait }} + working-directory: ${{ inputs.working-directory }} + secrets: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + RPC_URL: ${{ secrets.RPC_URL }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} + + # ───────────────────────────────────────────────────────────────────────────── + # Post-Mainnet: Flatten Snapshots & Create Release + # ───────────────────────────────────────────────────────────────────────────── + post-mainnet: + needs: [detect-changes, ci, upgrade-safety, deploy-mainnet] if: | always() && - needs.deploy-mainnet.result == 'success' && - inputs.flatten-contracts - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - token: ${{ secrets.GH_TOKEN || github.token }} - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Build contracts - run: forge build - - - name: Extract and flatten contracts - env: - UPGRADES_PATH: ${{ inputs.upgrades-path }} - run: | - mkdir -p "${UPGRADES_PATH}/current" - - CONTRACTS=$(find broadcast -name "run-latest.json" -type f -exec \ - jq -r '.transactions[] | select(.transactionType == "CREATE") | "src/\(.contractName).sol"' {} \; 2>/dev/null | sort -u) - - if [[ -z "$CONTRACTS" ]]; then - CONTRACTS=$(find src -maxdepth 1 -type f -name "*.sol") - fi - - echo "$CONTRACTS" | while read -r contract; do - if [[ -f "$contract" ]]; then - name=$(basename "$contract") - echo "Flattening $contract..." - forge flatten "$contract" > "${UPGRADES_PATH}/current/$name" - fi - done - - - name: Promote snapshots (3-tier rotation) - env: - UPGRADES_PATH: ${{ inputs.upgrades-path }} - run: | - if [[ -d "${UPGRADES_PATH}/baseline" ]] && ls ${UPGRADES_PATH}/baseline/*.sol 1> /dev/null 2>&1; then - echo "Baseline exists, performing 3-tier rotation..." - rm -rf "${UPGRADES_PATH}/previous" - cp -r "${UPGRADES_PATH}/baseline" "${UPGRADES_PATH}/previous" - rm -rf "${UPGRADES_PATH}/baseline" - cp -r "${UPGRADES_PATH}/current" "${UPGRADES_PATH}/baseline" - COMMIT_MSG="chore: auto-flatten contracts after deploy [skip ci]" - else - echo "No baseline found, initializing..." - mkdir -p "${UPGRADES_PATH}/baseline" - cp -r "${UPGRADES_PATH}/current/"* "${UPGRADES_PATH}/baseline/" - COMMIT_MSG="chore: init upgrade baseline [skip ci]" - fi - echo "commit_msg=$COMMIT_MSG" >> $GITHUB_ENV - - - name: Commit flattened contracts - env: - UPGRADES_PATH: ${{ inputs.upgrades-path }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add "${UPGRADES_PATH}/" || true - if git diff --staged --quiet; then - echo "No snapshot changes to commit" - else - git commit -m "$commit_msg" - git push - fi + needs.detect-changes.outputs.should-run == 'true' && + needs.ci.result == 'success' && + (needs.upgrade-safety.result == 'success' || needs.upgrade-safety.result == 'skipped') && + (needs.deploy-mainnet.result == 'success' || + (needs.deploy-mainnet.result == 'skipped' && inputs.init-baseline-without-deploy)) && + github.event_name == 'push' && + github.ref == 'refs/heads/main' + uses: BreadchainCoop/etherform/.github/workflows/_foundry-post-mainnet.yml@main + with: + flatten-contracts: ${{ inputs.flatten-contracts }} + create-release: ${{ inputs.create-release && needs.deploy-mainnet.result == 'success' }} + release-prefix: ${{ inputs.release-prefix }} + upgrades-path: ${{ inputs.upgrades-path }} + network-name: ${{ inputs.mainnet-name }} + blockscout-url: ${{ inputs.mainnet-blockscout-url }} + working-directory: ${{ inputs.working-directory }} + secrets: + GH_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/_foundry-deploy.yml b/.github/workflows/_foundry-deploy.yml new file mode 100644 index 0000000..4b43491 --- /dev/null +++ b/.github/workflows/_foundry-deploy.yml @@ -0,0 +1,252 @@ +# Foundry Deploy - Deploy contracts with verification +# Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_foundry-deploy.yml@main +name: Deploy + +on: + workflow_call: + inputs: + network-type: + description: 'Network type (testnet or mainnet)' + type: string + required: true + deploy-script: + description: 'Path to deployment script' + type: string + default: 'script/Deploy.s.sol:Deploy' + blockscout-url: + description: 'Blockscout URL for verification and explorer links' + type: string + default: 'https://eth-sepolia.blockscout.com' + network-name: + description: 'Network name for display' + type: string + default: 'Sepolia' + indexing-wait: + description: 'Seconds to wait for indexer before verification' + type: number + default: 60 + pr-number: + description: 'PR number for commenting (testnet only)' + type: string + default: '' + commit-sha: + description: 'Commit SHA for PR comment' + type: string + default: '' + working-directory: + description: 'Working directory for Foundry commands' + type: string + default: '.' + outputs: + broadcast-file: + description: 'Path to broadcast file' + value: ${{ jobs.deploy.outputs.broadcast-file }} + deployment-artifact: + description: 'Path to deployment artifact' + value: ${{ jobs.deploy.outputs.deployment-artifact }} + secrets: + PRIVATE_KEY: + description: 'Deployer wallet private key' + required: true + RPC_URL: + description: 'Network RPC endpoint' + required: true + GH_TOKEN: + description: 'GitHub token for PR comments' + required: false + +jobs: + deploy: + name: Deploy to ${{ inputs.network-name }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ inputs.working-directory }} + outputs: + broadcast-file: ${{ steps.parse.outputs.broadcast_file }} + deployment-artifact: ${{ steps.artifact.outputs.artifact_path }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Deploy contracts + env: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + RPC_URL: ${{ secrets.RPC_URL }} + run: | + if [[ "$PRIVATE_KEY" != 0x* ]]; then + export PRIVATE_KEY="0x$PRIVATE_KEY" + fi + forge script ${{ inputs.deploy-script }} \ + --rpc-url "$RPC_URL" \ + --broadcast \ + --slow \ + -vvvv + + - name: Wait for indexing + run: sleep ${{ inputs.indexing-wait }} + + - name: Parse deployment addresses + id: parse + run: | + BROADCAST_FILE=$(find broadcast -name "run-latest.json" -type f | head -1) + if [[ -z "$BROADCAST_FILE" ]]; then + echo "No broadcast file found" + exit 1 + fi + echo "broadcast_file=$BROADCAST_FILE" >> $GITHUB_OUTPUT + jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName): \(.contractAddress)"' "$BROADCAST_FILE" | tee deployment-summary.txt + + - name: Create deployment artifact + id: artifact + env: + NETWORK_TYPE: ${{ inputs.network-type }} + NETWORK_NAME: ${{ inputs.network-name }} + run: | + set -euo pipefail + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + + if [[ "$NETWORK_TYPE" == "testnet" ]]; then + ARTIFACT_DIR="deployments/testnet" + else + NETWORK_DIR=$(echo "$NETWORK_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-') + ARTIFACT_DIR="deployments/${NETWORK_DIR}" + fi + + mkdir -p "$ARTIFACT_DIR" + + # Create deployment.json with spec schema + jq '[.transactions[] | select(.transactionType == "CREATE") | { + sourcePathAndName: "src/\(.contractName).sol:\(.contractName)", + address: .contractAddress + }]' "$BROADCAST_FILE" | jq '{contracts: .}' > "${ARTIFACT_DIR}/deployment.json" + + echo "Created ${ARTIFACT_DIR}/deployment.json:" + cat "${ARTIFACT_DIR}/deployment.json" + echo "artifact_path=${ARTIFACT_DIR}/deployment.json" >> $GITHUB_OUTPUT + + - name: Upload deployment artifact + uses: actions/upload-artifact@v4 + with: + name: deployment-${{ inputs.network-type }} + path: deployments/*/deployment.json + + - name: Verify contracts on Blockscout + env: + BLOCKSCOUT_URL: ${{ inputs.blockscout-url }} + RPC_URL: ${{ secrets.RPC_URL }} + run: | + set -euo pipefail + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + FAILED=0 + + while read -r tx; do + CONTRACT_NAME=$(echo "$tx" | jq -r '.contractName') + CONTRACT_ADDR=$(echo "$tx" | jq -r '.contractAddress') + + # Find source file to create fully qualified name (disambiguates from lib/ contracts) + SOURCE_FILE=$(find src script -name "${CONTRACT_NAME}.sol" -type f 2>/dev/null | head -1) + if [[ -n "$SOURCE_FILE" ]]; then + FULL_CONTRACT="${SOURCE_FILE}:${CONTRACT_NAME}" + else + FULL_CONTRACT="$CONTRACT_NAME" + fi + + echo "Verifying $CONTRACT_NAME at $CONTRACT_ADDR (using $FULL_CONTRACT)..." + VERIFIED=0 + for attempt in 1 2 3; do + if forge verify-contract "$CONTRACT_ADDR" "$FULL_CONTRACT" \ + --verifier blockscout \ + --verifier-url "${BLOCKSCOUT_URL}/api" \ + --rpc-url "$RPC_URL" \ + --guess-constructor-args \ + --watch; then + echo "✓ Verified $CONTRACT_NAME" + VERIFIED=1 + break + else + if [[ $attempt -lt 3 ]]; then + echo "Attempt $attempt failed, retrying in $((attempt * 30))s..." + sleep $((attempt * 30)) + fi + fi + done + if [[ $VERIFIED -eq 0 ]]; then + echo "::error::Failed to verify $CONTRACT_NAME after 3 attempts" + FAILED=1 + fi + 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: ${{ inputs.blockscout-url }} + NETWORK_TYPE: ${{ inputs.network-type }} + run: | + TITLE=$(echo "$NETWORK_TYPE" | sed 's/.*/\u&/')net + echo "## ${TITLE} 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 + + - name: Comment on PR with deployment info + if: inputs.network-type == 'testnet' && inputs.pr-number != '' + env: + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + BLOCKSCOUT_URL: ${{ inputs.blockscout-url }} + NETWORK_NAME: ${{ inputs.network-name }} + PR_NUMBER: ${{ inputs.pr-number }} + COMMIT_SHA: ${{ inputs.commit-sha }} + run: | + set -euo pipefail + + SHORT_SHA="${COMMIT_SHA:0:7}" + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + + # Build comment body + cat > pr-comment.md << EOF + ## Testnet Deployment + + **Network:** ${NETWORK_NAME} + **Commit:** \`${SHORT_SHA}\` + + ### Deployed Contracts + + | Contract | Address | Explorer | + |----------|---------|----------| + EOF + + # Add contract rows + 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}) |" >> pr-comment.md + done < deployment-summary.txt + + # Add transaction links + echo "" >> pr-comment.md + echo "
" >> pr-comment.md + echo "Deployment Transactions" >> pr-comment.md + echo "" >> pr-comment.md + echo "| Contract | Transaction |" >> pr-comment.md + echo "|----------|-------------|" >> pr-comment.md + jq -r '.transactions[] | select(.transactionType == "CREATE") | "| \(.contractName) | [\(.hash)]('"${BLOCKSCOUT_URL}"'/tx/\(.hash)) |"' "$BROADCAST_FILE" >> pr-comment.md + echo "" >> pr-comment.md + echo "
" >> pr-comment.md + + # Post comment to PR + gh pr comment "$PR_NUMBER" --body-file pr-comment.md diff --git a/.github/workflows/_foundry-detect-changes.yml b/.github/workflows/_foundry-detect-changes.yml new file mode 100644 index 0000000..2cefab6 --- /dev/null +++ b/.github/workflows/_foundry-detect-changes.yml @@ -0,0 +1,75 @@ +# Detect smart contract changes +# Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_foundry-detect-changes.yml@main +name: Detect Changes + +on: + workflow_call: + inputs: + skip-if-no-changes: + description: 'Skip workflow if no smart contract files changed' + type: boolean + default: true + contract-paths: + description: 'Paths to check for changes (newline-separated glob patterns)' + type: string + default: | + src/** + script/** + test/** + lib/** + foundry.toml + remappings.txt + working-directory: + description: 'Working directory for change detection' + type: string + default: '.' + outputs: + should-run: + description: 'Whether the workflow should run' + value: ${{ jobs.detect.outputs.should-run }} + contracts-changed: + description: 'Whether contracts changed' + value: ${{ jobs.detect.outputs.contracts-changed }} + +jobs: + detect: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + should-run: ${{ steps.decide.outputs.should-run }} + contracts-changed: ${{ steps.filter.outputs.contracts }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build paths filter + id: paths + run: | + # Convert newline-separated paths to YAML list format + YAML_PATHS=$(echo "${{ inputs.contract-paths }}" | sed '/^$/d' | sed 's/^/ - /') + echo "filter<> $GITHUB_OUTPUT + echo "contracts:" >> $GITHUB_OUTPUT + echo "$YAML_PATHS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Check for contract changes + id: filter + uses: dorny/paths-filter@v3 + with: + filters: ${{ steps.paths.outputs.filter }} + + - name: Decide whether to run + id: decide + run: | + if [[ "${{ inputs.skip-if-no-changes }}" == "false" ]]; then + echo "Change detection disabled, workflow will run" + echo "should-run=true" >> $GITHUB_OUTPUT + elif [[ "${{ steps.filter.outputs.contracts }}" == "true" ]]; then + echo "Contract changes detected, workflow will run" + echo "should-run=true" >> $GITHUB_OUTPUT + else + echo "No contract changes detected, skipping workflow" + echo "should-run=false" >> $GITHUB_OUTPUT + fi diff --git a/.github/workflows/_foundry-post-mainnet.yml b/.github/workflows/_foundry-post-mainnet.yml new file mode 100644 index 0000000..6df9d74 --- /dev/null +++ b/.github/workflows/_foundry-post-mainnet.yml @@ -0,0 +1,221 @@ +# Foundry Post-Mainnet - Flatten snapshots and create release +# Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_foundry-post-mainnet.yml@main +name: Post Mainnet + +on: + workflow_call: + inputs: + flatten-contracts: + description: 'Flatten and commit contract snapshots' + type: boolean + default: true + create-release: + description: 'Create GitHub release' + type: boolean + default: true + release-prefix: + description: 'Prefix for release tags' + type: string + default: '' + upgrades-path: + description: 'Path for flattened contract snapshots' + type: string + default: 'test/upgrades' + network-name: + description: 'Network name for release notes' + type: string + default: 'Sepolia' + blockscout-url: + description: 'Blockscout URL for explorer links' + type: string + default: 'https://eth-sepolia.blockscout.com' + working-directory: + description: 'Working directory for Foundry commands' + type: string + default: '.' + secrets: + GH_TOKEN: + description: 'GitHub token for pushing commits and creating releases' + required: false + +jobs: + flatten: + name: Flatten Snapshots + runs-on: ubuntu-latest + if: inputs.flatten-contracts + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.GH_TOKEN || github.token }} + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Build contracts + run: forge build + + - name: Extract and flatten contracts + env: + UPGRADES_PATH: ${{ inputs.upgrades-path }} + run: | + mkdir -p "${UPGRADES_PATH}/current" + + CONTRACTS=$(find broadcast -name "run-latest.json" -type f -exec \ + jq -r '.transactions[] | select(.transactionType == "CREATE") | "src/\(.contractName).sol"' {} \; 2>/dev/null | sort -u) + + if [[ -z "$CONTRACTS" ]]; then + CONTRACTS=$(find src -maxdepth 1 -type f -name "*.sol") + fi + + echo "$CONTRACTS" | while read -r contract; do + if [[ -f "$contract" ]]; then + name=$(basename "$contract") + echo "Flattening $contract..." + forge flatten "$contract" > "${UPGRADES_PATH}/current/$name" + fi + done + + - name: Promote snapshots (3-tier rotation) + env: + UPGRADES_PATH: ${{ inputs.upgrades-path }} + run: | + if [[ -d "${UPGRADES_PATH}/baseline" ]] && ls ${UPGRADES_PATH}/baseline/*.sol 1> /dev/null 2>&1; then + echo "Baseline exists, performing 3-tier rotation..." + rm -rf "${UPGRADES_PATH}/previous" + cp -r "${UPGRADES_PATH}/baseline" "${UPGRADES_PATH}/previous" + rm -rf "${UPGRADES_PATH}/baseline" + cp -r "${UPGRADES_PATH}/current" "${UPGRADES_PATH}/baseline" + COMMIT_MSG="chore: auto-flatten contracts after deploy [skip ci]" + else + echo "No baseline found, initializing..." + mkdir -p "${UPGRADES_PATH}/baseline" + cp -r "${UPGRADES_PATH}/current/"* "${UPGRADES_PATH}/baseline/" + COMMIT_MSG="chore: init upgrade baseline [skip ci]" + fi + echo "commit_msg=$COMMIT_MSG" >> $GITHUB_ENV + + - name: Commit flattened contracts + env: + UPGRADES_PATH: ${{ inputs.upgrades-path }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "${UPGRADES_PATH}/" || true + if git diff --staged --quiet; then + echo "No snapshot changes to commit" + else + git commit -m "$commit_msg" + git push + fi + + release: + name: Create Release + runs-on: ubuntu-latest + needs: [flatten] + if: always() && inputs.create-release && (needs.flatten.result == 'success' || needs.flatten.result == 'skipped') + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Download deployment artifact + uses: actions/download-artifact@v4 + with: + name: deployment-mainnet + path: deployments/ + + - name: Parse broadcast file + id: parse + run: | + BROADCAST_FILE=$(find broadcast -name "run-latest.json" -type f | head -1) + if [[ -z "$BROADCAST_FILE" ]]; then + echo "No broadcast file found, checking for deployment artifact..." + # Fall back to deployment artifact + if [[ -f "deployments/*/deployment.json" ]]; then + echo "Using deployment artifact" + fi + fi + echo "broadcast_file=$BROADCAST_FILE" >> $GITHUB_OUTPUT + jq -r '.transactions[] | select(.transactionType == "CREATE") | "\(.contractName): \(.contractAddress)"' "$BROADCAST_FILE" 2>/dev/null | tee deployment-summary.txt || true + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + BLOCKSCOUT_URL: ${{ inputs.blockscout-url }} + NETWORK_NAME: ${{ inputs.network-name }} + UPGRADES_PATH: ${{ inputs.upgrades-path }} + run: | + set -euo pipefail + + SHORT_SHA="${GITHUB_SHA:0:7}" + TAG="${{ inputs.release-prefix }}mainnet-${SHORT_SHA}" + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + DATE=$(date -u +"%Y-%m-%d %H:%M UTC") + + # Build release body + cat > release-notes.md << EOF + ## Mainnet Deployment + + **Network:** ${NETWORK_NAME} + **Date:** ${DATE} + **Commit:** [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}) + + ### Deployed Contracts + + | Contract | Address | Explorer | + |----------|---------|----------| + EOF + + # Add contract rows + if [[ -f deployment-summary.txt ]] && [[ -s deployment-summary.txt ]]; then + 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}) |" >> release-notes.md + done < deployment-summary.txt + fi + + # Add transaction details if broadcast file exists + if [[ -n "$BROADCAST_FILE" ]] && [[ -f "$BROADCAST_FILE" ]]; then + echo "" >> release-notes.md + echo "### Deployment Transactions" >> release-notes.md + echo "" >> release-notes.md + echo "| Contract | Transaction |" >> release-notes.md + echo "|----------|-------------|" >> release-notes.md + jq -r '.transactions[] | select(.transactionType == "CREATE") | "| \(.contractName) | [\(.hash)]('"${BLOCKSCOUT_URL}"'/tx/\(.hash)) |"' "$BROADCAST_FILE" >> release-notes.md + fi + + # Add commit info + echo "" >> release-notes.md + echo "### Commit Details" >> release-notes.md + echo "" >> release-notes.md + echo "**Message:** $(git log -1 --pretty=%s)" >> release-notes.md + echo "" >> release-notes.md + echo "**Author:** $(git log -1 --pretty='%an <%ae>')" >> release-notes.md + + # Collect flattened contracts as assets if they exist + ASSETS="" + if [[ -d "${UPGRADES_PATH}/current" ]]; then + for f in ${UPGRADES_PATH}/current/*.sol; do + if [[ -f "$f" ]]; then + ASSETS="$ASSETS $f" + fi + done + fi + + # Create release + gh release create "$TAG" \ + --title "Mainnet Deployment - ${DATE}" \ + --notes-file release-notes.md \ + $ASSETS + + echo "::notice::Created release $TAG" diff --git a/.github/workflows/_foundry-upgrade-safety.yml b/.github/workflows/_foundry-upgrade-safety.yml new file mode 100644 index 0000000..fd03892 --- /dev/null +++ b/.github/workflows/_foundry-upgrade-safety.yml @@ -0,0 +1,123 @@ +# Foundry Upgrade Safety Validation +# Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_foundry-upgrade-safety.yml@main +name: Upgrade Safety + +on: + workflow_call: + inputs: + baseline-path: + description: 'Path to baseline contracts for upgrade comparison' + type: string + default: 'test/upgrades/baseline' + upgrades-path: + description: 'Path for flattened contract snapshots' + type: string + default: 'test/upgrades' + working-directory: + description: 'Working directory for Foundry commands' + type: string + default: '.' + +jobs: + validate: + name: Upgrade Safety + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Run upgrade safety validation + env: + BASELINE_PATH: ${{ inputs.baseline-path }} + UPGRADES_PATH: ${{ inputs.upgrades-path }} + run: | + set -euo pipefail + + # Determine which baseline directory to use + FALLBACK="${UPGRADES_PATH}/previous" + + if [ -d "$BASELINE_PATH" ] && ls ${BASELINE_PATH}/*.sol 1> /dev/null 2>&1; then + BASELINE_DIR="$BASELINE_PATH" + elif [ -d "$FALLBACK" ] && ls ${FALLBACK}/*.sol 1> /dev/null 2>&1; then + BASELINE_DIR="$FALLBACK" + else + echo "::warning::Baseline missing — will auto-init on first merge to main" + echo "No baseline contracts found, skipping upgrade validation (initial deployment)" + exit 0 + fi + + echo "Using baseline directory: $BASELINE_DIR" + + # Generate validation script dynamically + mkdir -p script/generated + cat > script/generated/ValidateUpgrades.s.sol << 'SOLEOF' + // SPDX-License-Identifier: MIT + pragma solidity ^0.8.13; + + import {Script} from "forge-std/Script.sol"; + import {Options} from "openzeppelin-foundry-upgrades/Options.sol"; + import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; + + contract ValidateUpgrades is Script { + function run(string calldata currentContract, string calldata referenceContract) external { + Options memory opts; + opts.referenceContract = referenceContract; + Upgrades.validateUpgrade(currentContract, opts); + } + } + SOLEOF + + forge build + + FAILED=0 + + # Validate each contract in the baseline + for baseline_file in ${BASELINE_DIR}/*.sol; do + CONTRACT_NAME=$(basename "$baseline_file" .sol) + + # Find the current source file + CURRENT_FILE=$(find src -name "${CONTRACT_NAME}.sol" -type f 2>/dev/null | head -1) + if [[ -z "$CURRENT_FILE" ]]; then + echo "::warning::No current source found for $CONTRACT_NAME, skipping" + continue + fi + + # Forge output structure strips src/ and test/upgrades/ prefixes from artifact paths: + # src/Counter.sol -> out/Counter.sol/Counter.json + # test/upgrades/baseline/Counter.sol -> out/baseline/Counter.sol/Counter.json + # OZ Foundry Upgrades uses the contract path to find artifacts, so we must match this structure + CURRENT_CONTRACT="${CONTRACT_NAME}.sol:${CONTRACT_NAME}" + + # Get path relative to test/upgrades/ for baseline + BASELINE_RELATIVE=$(echo "$BASELINE_DIR" | sed 's|^test/upgrades/||') + REFERENCE_CONTRACT="${BASELINE_RELATIVE}/${CONTRACT_NAME}.sol:${CONTRACT_NAME}" + + echo "Validating upgrade: $CURRENT_CONTRACT <- $REFERENCE_CONTRACT" + + if forge script script/generated/ValidateUpgrades.s.sol \ + --sig "run(string,string)" "$CURRENT_CONTRACT" "$REFERENCE_CONTRACT" \ + -vvv; then + echo "✓ ${CONTRACT_NAME} upgrade is safe" + else + echo "::error::${CONTRACT_NAME} upgrade validation failed" + FAILED=1 + fi + done + + # Cleanup generated script + rm -rf script/generated + + if [[ $FAILED -eq 1 ]]; then + echo "::error::One or more contracts failed upgrade safety validation" + exit 1 + fi + + echo "All contracts passed upgrade safety validation" diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..66b933f --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,42 @@ +# Integration Tests - Validate workflow changes against example project +# Runs when workflow files change to catch issues before affecting external consumers +name: Integration Tests + +on: + push: + paths: + - '.github/workflows/_foundry-*.yml' + - '.github/workflows/integration-test.yml' + - 'examples/foundry-counter/**' + pull_request: + paths: + - '.github/workflows/_foundry-*.yml' + - '.github/workflows/integration-test.yml' + - 'examples/foundry-counter/**' + workflow_dispatch: + +jobs: + # ───────────────────────────────────────────────────────────────────────────── + # Test CI Workflow (build, test, format, compiler validation) + # ───────────────────────────────────────────────────────────────────────────── + test-ci: + name: Test CI Workflow + uses: ./.github/workflows/_foundry-ci.yml + with: + check-formatting: true + test-verbosity: 'vvv' + validate-compiler-config: true + working-directory: 'examples/foundry-counter' + + # ───────────────────────────────────────────────────────────────────────────── + # Test Upgrade Safety Workflow + # ───────────────────────────────────────────────────────────────────────────── + test-upgrade-safety: + name: Test Upgrade Safety Workflow + needs: [test-ci] + uses: ./.github/workflows/_foundry-upgrade-safety.yml + with: + # Paths are relative to working-directory + baseline-path: 'test/upgrades/baseline' + upgrades-path: 'test/upgrades' + working-directory: 'examples/foundry-counter' diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ec83cda --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "examples/foundry-counter"] + path = examples/foundry-counter + url = https://github.com/BreadchainCoop/foundry-upgradeable-counter-example.git diff --git a/README.md b/README.md index 63ed1e3..8f51192 100644 --- a/README.md +++ b/README.md @@ -2,124 +2,261 @@ Reusable GitHub Actions workflows for Foundry smart contract CI/CD with upgrade safety validation. +## Quick Start + +For most projects, use the all-in-one orchestrator workflow: + +```yaml +# .github/workflows/cicd.yml +name: CI/CD + +on: + push: + branches: [main] + pull_request: + +jobs: + cicd: + 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 + secrets: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + RPC_URL: ${{ secrets.RPC_URL }} +``` + ## Workflows +### All-in-One Orchestrator + | Workflow | Description | |----------|-------------| -| `_ci.yml` | Build, test, and format check | -| `_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` | Complete CI/CD pipeline orchestrating all sub-workflows | + +### Modular Sub-Workflows + +For granular control, import individual workflows: + +| Workflow | Description | +|----------|-------------| +| `_foundry-detect-changes.yml` | Smart contract change detection | +| `_foundry-ci.yml` | Build, test, format check, compiler config validation | +| `_foundry-upgrade-safety.yml` | OpenZeppelin upgrade safety validation | +| `_foundry-deploy.yml` | Deploy with Blockscout verification | +| `_foundry-post-mainnet.yml` | Flatten snapshots and create GitHub release | + +## Configuration + +### Orchestrator Inputs (`_foundry-cicd.yml`) + +| Input | Type | Default | Description | +|-------|------|---------|-------------| +| `skip-if-no-changes` | boolean | `true` | Skip if no contract files changed | +| `contract-paths` | string | `src/**\nscript/**\n...` | Paths to check for changes | +| `check-formatting` | boolean | `true` | Run `forge fmt --check` | +| `test-verbosity` | string | `'vvv'` | Test verbosity level | +| `run-upgrade-safety` | boolean | `true` | Run upgrade safety validation | +| `baseline-path` | string | `'test/upgrades/baseline'` | Baseline contracts path | +| `deploy-on-pr` | boolean | `false` | Deploy to testnet on PRs | +| `deploy-on-main` | boolean | `false` | Deploy to mainnet on merge | +| `deploy-script` | string | `'script/Deploy.s.sol:Deploy'` | Deployment script | +| `testnet-blockscout-url` | string | `'https://eth-sepolia.blockscout.com'` | Testnet explorer | +| `mainnet-blockscout-url` | string | `'https://eth-sepolia.blockscout.com'` | Mainnet explorer | +| `flatten-contracts` | boolean | `true` | Flatten snapshots after deploy | +| `create-release` | boolean | `true` | Create GitHub release | +| `working-directory` | string | `'.'` | Working directory for commands | + +### Secrets + +| Secret | Required | Description | +|--------|----------|-------------| +| `PRIVATE_KEY` | For deploy | Deployer wallet private key | +| `RPC_URL` | For deploy | Network RPC endpoint | +| `GH_TOKEN` | For commits | Token for pushing commits/releases | + +## Upgrade Safety + +The upgrade safety workflow validates that contract upgrades don't break storage layout: + +1. **Baseline Detection**: Looks for flattened contracts in `test/upgrades/baseline/` +2. **Comparison**: Compares current contracts against baseline using OpenZeppelin's Foundry Upgrades +3. **3-Tier Rotation**: After mainnet deploy: `current` → `baseline` → `previous` + +If no baseline exists, the check is skipped gracefully. + +## Example Project + +See [examples/foundry-counter](examples/foundry-counter) for a complete working example. + +--- + +# Development + +This section explains how to develop and test changes to etherform's workflows. + +## Repository Structure + +``` +etherform/ +├── .github/workflows/ +│ ├── _foundry-cicd.yml # All-in-one orchestrator +│ ├── _foundry-ci.yml # Build, test, format +│ ├── _foundry-upgrade-safety.yml # Upgrade validation +│ ├── _foundry-detect-changes.yml # Change detection +│ ├── _foundry-deploy.yml # Deployment +│ ├── _foundry-post-mainnet.yml # Post-deploy tasks +│ └── integration-test.yml # Integration tests +├── examples/ +│ └── foundry-counter/ # Test fixture (git submodule) +└── docs/ + └── specs/ # Design specifications +``` + +## How Integration Testing Works -## Usage +The key insight: **workflow changes are tested BEFORE they affect external consumers**. -Reference the reusable workflows in your Foundry project: +### The Problem +When consumers import workflows via `@main`: ```yaml -# .github/workflows/ci.yml -name: CI +uses: BreadchainCoop/etherform/.github/workflows/_foundry-ci.yml@main +``` + +Any breaking change pushed to `main` immediately affects all downstream projects. + +### The Solution -on: [push, pull_request] +The `integration-test.yml` workflow uses **local references** (`./`) instead of `@main`: +```yaml jobs: - ci: - uses: BreadchainCoop/etherform/.github/workflows/_ci.yml@main + test-ci: + uses: ./.github/workflows/_foundry-ci.yml # Uses current branch! with: - check-formatting: true - test-verbosity: 'vvv' + working-directory: 'examples/foundry-counter' ``` -```yaml -# .github/workflows/deploy.yml -name: Deploy +This means: +1. On a PR branch, tests run against the **PR's workflow files** +2. The `examples/foundry-counter` submodule provides a real Foundry project to test against +3. If tests pass, the workflow changes are validated before merging to `main` -on: - push: - branches: [main] +### What Gets Tested -jobs: - ci: - uses: BreadchainCoop/etherform/.github/workflows/_ci.yml@main +| Test | Validates | +|------|-----------| +| Build | Contract compilation works | +| Format Check | `forge fmt --check` execution | +| Unit Tests | `forge test` runs successfully | +| Compiler Config | `bytecode_hash`/`cbor_metadata` validation | +| Upgrade Safety | Baseline detection and storage layout validation | - deploy: - needs: [ci] - uses: BreadchainCoop/etherform/.github/workflows/_deploy-mainnet.yml@main - secrets: - PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} - RPC_URL: ${{ secrets.RPC_URL }} +**Not tested**: Deployment (requires real RPC/keys and costs gas) + +### When Integration Tests Run + +Tests trigger on changes to: +- `.github/workflows/_foundry-*.yml` - Any workflow file +- `.github/workflows/integration-test.yml` - The test workflow itself +- `examples/foundry-counter/**` - The test fixture + +## Development Workflow + +### Making Workflow Changes + +1. **Create a branch**: + ```bash + git checkout -b feature/my-workflow-change + ``` + +2. **Make changes** to workflow files in `.github/workflows/` + +3. **Push and create PR**: + ```bash + git push -u origin feature/my-workflow-change + ``` + +4. **Integration tests run automatically** against your branch's workflows + +5. **If tests pass**, your changes are validated and safe to merge + +### Working with the Submodule + +The `examples/foundry-counter` directory is a git submodule pointing to: +`https://github.com/BreadchainCoop/foundry-upgradeable-counter-example` + +**Clone with submodules**: +```bash +git clone --recursive https://github.com/BreadchainCoop/etherform.git ``` -## Configuration +**Update submodule**: +```bash +git submodule update --remote examples/foundry-counter +``` -### Network Configuration - -Create `.github/deploy-networks.json` in your repository: - -```json -{ - "testnets": [ - { - "name": "sepolia", - "chain_id": 11155111, - "blockscout_url": "https://eth-sepolia.blockscout.com", - "environment": "testnet" - } - ], - "mainnets": [ - { - "name": "ethereum", - "chain_id": 1, - "blockscout_url": "https://eth.blockscout.com", - "environment": "production-ethereum" - } - ] -} +**Make changes to the test fixture**: +```bash +cd examples/foundry-counter +# Make changes, commit, push to the submodule repo +cd .. +git add examples/foundry-counter +git commit -m "chore: update test fixture submodule" ``` -### Secrets Required +### Testing Upgrade Safety Detection -| Secret | Description | -|--------|-------------| -| `PRIVATE_KEY` | Deployer wallet private key | -| `RPC_URL` | Network RPC endpoint | +To verify upgrade safety detection works: -## Workflow Inputs +1. In the submodule, add a storage variable BEFORE an existing one: + ```solidity + contract Counter { + address public owner; // NEW - breaks storage layout! + uint256 public number; // Was at slot 0, now at slot 1 + } + ``` -### `_ci.yml` +2. Push and the integration test should FAIL (expected behavior) -| Input | Type | Default | Description | -|-------|------|---------|-------------| -| `check-formatting` | boolean | `true` | Run `forge fmt --check` | -| `test-verbosity` | string | `'vvv'` | Test verbosity (`v`, `vv`, `vvv`, `vvvv`) | +3. Revert the change to restore passing tests -### `_upgrade-safety.yml` +## Architecture Decisions -| Input | Type | Default | Description | -|-------|------|---------|-------------| -| `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 | +### Why `working-directory` Input? -### `_deploy-testnet.yml` +All sub-workflows accept a `working-directory` input: +```yaml +inputs: + working-directory: + type: string + default: '.' -| Input | Type | Default | Description | -|-------|------|---------|-------------| -| `deploy-script` | string | `'script/Deploy.s.sol:Deploy'` | Deployment script | -| `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 | +jobs: + build: + defaults: + run: + working-directory: ${{ inputs.working-directory }} +``` -### `_deploy-mainnet.yml` +This allows: +- Testing against the submodule at `examples/foundry-counter` +- Consumers with non-root Foundry projects (monorepos) -| Input | Type | Default | Description | -|-------|------|---------|-------------| -| `deploy-script` | string | `'script/Deploy.s.sol:Deploy'` | Deployment script | -| `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 | -| `flatten-contracts` | boolean | `true` | Flatten and commit snapshots | -| `upgrades-path` | string | `'test/upgrades'` | Path for flattened snapshots | +### Why Local `./` References in Integration Tests? -## Example Project +Using `./` instead of `@main` means: +```yaml +# Tests current branch's workflow files +uses: ./.github/workflows/_foundry-ci.yml + +# Would test main branch (not useful for validation) +# uses: BreadchainCoop/etherform/.github/workflows/_foundry-ci.yml@main +``` + +### Why a Git Submodule? -See the [examples/foundry-counter](examples/foundry-counter) submodule for a complete working example. +- **Real project**: Tests run against actual Foundry code, not mocks +- **Isolation**: Test fixture lives in its own repo +- **Versioning**: Can pin to specific commits if needed diff --git a/examples/foundry-counter b/examples/foundry-counter new file mode 160000 index 0000000..6c84d40 --- /dev/null +++ b/examples/foundry-counter @@ -0,0 +1 @@ +Subproject commit 6c84d40d0a1824d31296d11ef7b9c9fa560bf835