From c8aa306894ad43d1eab73984b85dd546f309504d Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 05:48:31 -0500 Subject: [PATCH 01/18] feat: add all-in-one foundry-cicd reusable workflow Single workflow that handles: - CI: build, test, format check - Upgrade safety validation - Testnet deploy on PR (optional) - Mainnet deploy on merge to main (optional) - 3-tier snapshot rotation after deploy Configurable via inputs: - deploy-on-pr: enable testnet deploy on pull requests - deploy-on-main: enable mainnet deploy on push to main - run-upgrade-safety: enable/disable upgrade validation - check-formatting: enable/disable format check --- .github/workflows/_foundry-cicd.yml | 401 ++++++++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 .github/workflows/_foundry-cicd.yml diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml new file mode 100644 index 0000000..6ab3989 --- /dev/null +++ b/.github/workflows/_foundry-cicd.yml @@ -0,0 +1,401 @@ +# All-in-one Foundry CI/CD reusable workflow +# Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_foundry-cicd.yml@main +name: Foundry CI/CD + +on: + workflow_call: + inputs: + # CI Options + 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' + + # Upgrade Safety Options + run-upgrade-safety: + description: 'Run upgrade safety validation' + type: boolean + default: true + baseline-path: + 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: + description: 'Deploy to testnet on pull request' + type: boolean + default: false + deploy-on-main: + description: 'Deploy to mainnet on push to main' + type: boolean + default: false + deploy-script: + description: 'Path to deployment script' + type: string + default: 'script/Deploy.s.sol:Deploy' + network-config-path: + description: 'Path to network configuration JSON' + type: string + default: '.github/deploy-networks.json' + indexing-wait: + description: 'Seconds to wait for indexer before verification' + type: number + default: 60 + flatten-contracts: + description: 'Flatten and commit contract snapshots after mainnet deploy' + type: boolean + default: true + upgrades-path: + description: 'Path for flattened contract snapshots' + type: string + default: 'test/upgrades' + + secrets: + PRIVATE_KEY: + description: 'Deployer wallet private key' + required: false + RPC_URL: + description: 'Network RPC endpoint' + required: false + GH_TOKEN: + description: 'GitHub token for pushing commits' + required: false + +jobs: + ci: + name: Build & Test + runs-on: ubuntu-latest + 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 }} + + upgrade-safety: + name: Upgrade Safety + runs-on: ubuntu-latest + needs: [ci] + if: 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 + + deploy-testnet: + name: Deploy Testnet + runs-on: ubuntu-latest + needs: [ci, upgrade-safety] + if: | + always() && + needs.ci.result == 'success' && + (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 + + deploy-mainnet: + name: Deploy Mainnet + runs-on: ubuntu-latest + needs: [ci, upgrade-safety] + if: | + always() && + needs.ci.result == 'success' && + (needs.upgrade-safety.result == 'success' || needs.upgrade-safety.result == 'skipped') && + 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] + 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 From be6eb8ba4d7ca5c3837da5f432708867a96efbfa Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 06:06:32 -0500 Subject: [PATCH 02/18] fix: verification step passes RPC_URL and fails properly - Add --rpc-url to forge verify-contract (required for --guess-constructor-args) - Use process substitution instead of pipe to properly propagate exit codes - Track verification failures and exit 1 if any contract fails after 3 attempts - Add GitHub Actions ::error:: annotations for visibility --- .github/workflows/_foundry-cicd.yml | 44 +++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index 6ab3989..e2f90d8 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -186,30 +186,44 @@ jobs: - name: Verify contracts on Blockscout env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + RPC_URL: ${{ secrets.RPC_URL }} run: | + set -euo pipefail BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE" | while read -r tx; do + 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" \ --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)) - else - echo "⚠ Verification pending for $CONTRACT_NAME after 3 attempts" fi fi done - 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: @@ -286,30 +300,44 @@ jobs: - name: Verify contracts on Blockscout env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + RPC_URL: ${{ secrets.RPC_URL }} run: | + set -euo pipefail BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - jq -c '.transactions[] | select(.transactionType == "CREATE")' "$BROADCAST_FILE" | while read -r tx; do + 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" \ --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)) - else - echo "⚠ Verification pending for $CONTRACT_NAME after 3 attempts" fi fi done - 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: From a02d53b3cd2179cd5a3b019bba8376838c106e69 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 06:47:03 -0500 Subject: [PATCH 03/18] fix: use fully qualified contract names for verification Resolves 'Multiple contracts found with the name' error by finding the source file path and using format 'src/Contract.sol:Contract' to disambiguate from lib/ dependencies. --- .github/workflows/_foundry-cicd.yml | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index e2f90d8..307edab 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -195,10 +195,19 @@ jobs: 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..." + + # 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" "$CONTRACT_NAME" \ + if forge verify-contract "$CONTRACT_ADDR" "$FULL_CONTRACT" \ --verifier blockscout \ --verifier-url "${BLOCKSCOUT_URL}/api" \ --rpc-url "$RPC_URL" \ @@ -309,10 +318,19 @@ jobs: 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..." + + # 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" "$CONTRACT_NAME" \ + if forge verify-contract "$CONTRACT_ADDR" "$FULL_CONTRACT" \ --verifier blockscout \ --verifier-url "${BLOCKSCOUT_URL}/api" \ --rpc-url "$RPC_URL" \ From 7edff3542ddf395eaab8135104d56e63318722ff Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 06:48:46 -0500 Subject: [PATCH 04/18] fix: use upgrades-path for fallback to fix path resolution The previous fallback path '$BASELINE/../previous' fails when the baseline directory doesn't exist because '../' can't resolve through a non-existent directory. Using '$upgrades-path/previous' directly avoids this issue. --- .github/workflows/_foundry-cicd.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index 307edab..859abf4 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -115,7 +115,8 @@ jobs: forge clean && forge build BASELINE="${{ inputs.baseline-path }}" - FALLBACK="${{ inputs.baseline-path }}/../previous" + # Use upgrades-path for fallback to avoid path resolution issues when baseline dir doesn't exist + FALLBACK="${{ inputs.upgrades-path }}/previous" if [ -d "$BASELINE" ] && ls ${BASELINE}/*.sol 1> /dev/null 2>&1; then echo "Baseline contracts found in $BASELINE, running upgrade validation..." From 89122db37219be5ff24f96db3484031ee6106212 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 15:14:18 -0500 Subject: [PATCH 05/18] refactor: run upgrade validation via OZ CLI instead of script - Remove validation-script input (no longer needed) - Use npx @openzeppelin/upgrades-core validate directly - Loop through all baseline contracts automatically - Consuming repos don't need ValidateUpgrade.s.sol anymore --- .github/workflows/_foundry-cicd.yml | 50 ++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index 859abf4..66c6cd4 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -24,10 +24,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: @@ -112,23 +108,61 @@ jobs: - name: Run upgrade safety validation run: | + set -euo pipefail forge clean && forge build BASELINE="${{ inputs.baseline-path }}" # Use upgrades-path for fallback to avoid path resolution issues when baseline dir doesn't exist FALLBACK="${{ inputs.upgrades-path }}/previous" + # Determine which baseline directory to use 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 + BASELINE_DIR="$BASELINE" 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 + 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" + 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 + + # Derive reference contract path (relative to out/) + BASELINE_RELATIVE="${BASELINE_DIR}/${CONTRACT_NAME}.sol" + + echo "Validating upgrade: ${CURRENT_FILE}:${CONTRACT_NAME} <- ${BASELINE_RELATIVE}:${CONTRACT_NAME}" + + if npx @openzeppelin/upgrades-core@^1.37.0 validate out/build-info \ + --contract "${CURRENT_FILE}:${CONTRACT_NAME}" \ + --reference "${BASELINE_RELATIVE}:${CONTRACT_NAME}" \ + --requireReference; then + echo "✓ ${CONTRACT_NAME} upgrade is safe" + else + echo "::error::${CONTRACT_NAME} upgrade validation failed" + FAILED=1 + fi + done + + 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" + deploy-testnet: name: Deploy Testnet runs-on: ubuntu-latest From cb2c8dabfd169aa482d588fc52d09cf2d005a110 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 15:17:41 -0500 Subject: [PATCH 06/18] refactor: use forge script for upgrade validation instead of npx - Generate ValidateUpgrades.s.sol dynamically at runtime - Use forge script with --sig to pass contract names - Cleanup generated script after validation - More consistent with Foundry toolchain --- .github/workflows/_foundry-cicd.yml | 52 +++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index 66c6cd4..6164d2f 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -107,17 +107,17 @@ jobs: 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 - forge clean && forge build - - BASELINE="${{ inputs.baseline-path }}" - # Use upgrades-path for fallback to avoid path resolution issues when baseline dir doesn't exist - FALLBACK="${{ inputs.upgrades-path }}/previous" # Determine which baseline directory to use - if [ -d "$BASELINE" ] && ls ${BASELINE}/*.sol 1> /dev/null 2>&1; then - BASELINE_DIR="$BASELINE" + 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 @@ -127,6 +127,28 @@ jobs: 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 @@ -140,15 +162,14 @@ jobs: continue fi - # Derive reference contract path (relative to out/) - BASELINE_RELATIVE="${BASELINE_DIR}/${CONTRACT_NAME}.sol" + CURRENT_CONTRACT="${CURRENT_FILE}:${CONTRACT_NAME}" + REFERENCE_CONTRACT="${BASELINE_DIR}/${CONTRACT_NAME}.sol:${CONTRACT_NAME}" - echo "Validating upgrade: ${CURRENT_FILE}:${CONTRACT_NAME} <- ${BASELINE_RELATIVE}:${CONTRACT_NAME}" + echo "Validating upgrade: $CURRENT_CONTRACT <- $REFERENCE_CONTRACT" - if npx @openzeppelin/upgrades-core@^1.37.0 validate out/build-info \ - --contract "${CURRENT_FILE}:${CONTRACT_NAME}" \ - --reference "${BASELINE_RELATIVE}:${CONTRACT_NAME}" \ - --requireReference; then + 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" @@ -156,6 +177,9 @@ jobs: 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 From 7d62ff63c6ad3575c61400749f7de897badcbfd9 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 15:50:56 -0500 Subject: [PATCH 07/18] fix: allow baseline init even when deploy-on-main is disabled flatten-snapshots now runs on push to main when: - deploy-mainnet succeeds (normal post-deploy flattening) - deploy-mainnet is skipped (baseline init without deployment) This enables repos to establish their initial baseline without requiring mainnet deployment to be enabled. --- .github/workflows/_foundry-cicd.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index 6164d2f..dddb4fc 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -433,11 +433,15 @@ jobs: flatten-snapshots: name: Flatten Snapshots runs-on: ubuntu-latest - needs: [deploy-mainnet] + needs: [ci, upgrade-safety, deploy-mainnet] if: | always() && - needs.deploy-mainnet.result == 'success' && - inputs.flatten-contracts + 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.flatten-contracts && + github.event_name == 'push' && + github.ref == 'refs/heads/main' steps: - name: Checkout repository uses: actions/checkout@v4 From 36739d2042fe6660f7c10869fbfe8bc244c2cb22 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 15:55:58 -0500 Subject: [PATCH 08/18] feat: add init-baseline-without-deploy flag for explicit baseline init control New input 'init-baseline-without-deploy' (default: false) controls whether flatten-snapshots runs when deploy-mainnet is skipped (deploy-on-main: false). This gives repos explicit control over baseline initialization behavior: - false (default): baseline only created after successful mainnet deploy - true: baseline can be initialized on merge even without deployment --- .github/workflows/_foundry-cicd.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index dddb4fc..9b22c0c 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -50,6 +50,10 @@ 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 @@ -438,7 +442,8 @@ jobs: always() && needs.ci.result == 'success' && (needs.upgrade-safety.result == 'success' || needs.upgrade-safety.result == 'skipped') && - (needs.deploy-mainnet.result == 'success' || needs.deploy-mainnet.result == 'skipped') && + (needs.deploy-mainnet.result == 'success' || + (needs.deploy-mainnet.result == 'skipped' && inputs.init-baseline-without-deploy)) && inputs.flatten-contracts && github.event_name == 'push' && github.ref == 'refs/heads/main' From 42452da35b6fcb928757dc6da1a6429a9e2a78cf Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 16:10:06 -0500 Subject: [PATCH 09/18] feat: add GitHub releases for testnet/mainnet deployments Mainnet deployments (on merge to main): - Creates GitHub Release with tag 'mainnet-{sha}' - Includes contract addresses, Blockscout links, tx hashes - Attaches flattened contract sources as assets - Includes commit details (message, author) Testnet deployments (on PRs): - Posts deployment info as PR comment - Creates GitHub Pre-release with tag 'testnet-{sha}' - Includes PR reference and commit info New inputs: - create-release: boolean (default: true) - release-prefix: string for custom tag prefixes --- .github/workflows/_foundry-cicd.yml | 182 ++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index 9b22c0c..a86c55f 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -59,6 +59,16 @@ on: 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: '' + secrets: PRIVATE_KEY: description: 'Deployer wallet private key' @@ -311,6 +321,108 @@ jobs: echo "| $CONTRACT | \`$ADDRESS\` | [View](${BLOCKSCOUT_URL}/address/$ADDRESS) |" >> $GITHUB_STEP_SUMMARY done < deployment-summary.txt + - name: Comment on PR with deployment info + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + NETWORK_NAME: ${{ steps.network.outputs.network_name }} + run: | + set -euo pipefail + + SHORT_SHA="${{ github.event.pull_request.head.sha }}" + SHORT_SHA="${SHORT_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 ${{ github.event.pull_request.number }} --body-file pr-comment.md + + - name: Create testnet pre-release + if: inputs.create-release && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + NETWORK_NAME: ${{ steps.network.outputs.network_name }} + run: | + set -euo pipefail + + SHORT_SHA="${{ github.event.pull_request.head.sha }}" + SHORT_SHA="${SHORT_SHA:0:7}" + TAG="${{ inputs.release-prefix }}testnet-${SHORT_SHA}" + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + DATE=$(date -u +"%Y-%m-%d %H:%M UTC") + PR_NUM="${{ github.event.pull_request.number }}" + PR_TITLE="${{ github.event.pull_request.title }}" + + # Build release body + cat > release-notes.md << EOF + ## Testnet Deployment + + **Network:** ${NETWORK_NAME} + **Date:** ${DATE} + **PR:** [#${PR_NUM}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUM}) - ${PR_TITLE} + **Commit:** [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${{ github.event.pull_request.head.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}) |" >> release-notes.md + done < deployment-summary.txt + + # Add transaction details + 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 + + # Create pre-release + gh release create "$TAG" \ + --title "Testnet Deployment - PR #${PR_NUM}" \ + --notes-file release-notes.md \ + --prerelease + + echo "::notice::Created pre-release $TAG" + deploy-mainnet: name: Deploy Mainnet runs-on: ubuntu-latest @@ -434,6 +546,76 @@ jobs: echo "| $CONTRACT | \`$ADDRESS\` | [View](${BLOCKSCOUT_URL}/address/$ADDRESS) |" >> $GITHUB_STEP_SUMMARY done < deployment-summary.txt + - name: Create GitHub Release + if: inputs.create-release + env: + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + NETWORK_NAME: ${{ steps.network.outputs.network_name }} + 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 + 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 + + # Add transaction details + 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 + + # 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 "${{ inputs.upgrades-path }}/current" ]]; then + for f in ${{ inputs.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" + flatten-snapshots: name: Flatten Snapshots runs-on: ubuntu-latest From 4f57a8a7b67f1ff573b3588c1cdedd0fe34598b0 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 16:38:31 -0500 Subject: [PATCH 10/18] refactor: only create releases on merge to main Removed testnet pre-release creation on PRs. Releases are now only created for mainnet deployments (merge to main). PR deployments still get: - Deployment summary in workflow - PR comment with contract addresses and tx links --- .github/workflows/_foundry-cicd.yml | 55 ----------------------------- 1 file changed, 55 deletions(-) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index a86c55f..5f7eef5 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -368,61 +368,6 @@ jobs: # Post comment to PR gh pr comment ${{ github.event.pull_request.number }} --body-file pr-comment.md - - name: Create testnet pre-release - if: inputs.create-release && github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} - BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} - NETWORK_NAME: ${{ steps.network.outputs.network_name }} - run: | - set -euo pipefail - - SHORT_SHA="${{ github.event.pull_request.head.sha }}" - SHORT_SHA="${SHORT_SHA:0:7}" - TAG="${{ inputs.release-prefix }}testnet-${SHORT_SHA}" - BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - DATE=$(date -u +"%Y-%m-%d %H:%M UTC") - PR_NUM="${{ github.event.pull_request.number }}" - PR_TITLE="${{ github.event.pull_request.title }}" - - # Build release body - cat > release-notes.md << EOF - ## Testnet Deployment - - **Network:** ${NETWORK_NAME} - **Date:** ${DATE} - **PR:** [#${PR_NUM}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUM}) - ${PR_TITLE} - **Commit:** [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${{ github.event.pull_request.head.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}) |" >> release-notes.md - done < deployment-summary.txt - - # Add transaction details - 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 - - # Create pre-release - gh release create "$TAG" \ - --title "Testnet Deployment - PR #${PR_NUM}" \ - --notes-file release-notes.md \ - --prerelease - - echo "::notice::Created pre-release $TAG" - deploy-mainnet: name: Deploy Mainnet runs-on: ubuntu-latest From 1cca5eabcc3aefcb2622b989bf08472f8ca15814 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 16:46:06 -0500 Subject: [PATCH 11/18] refactor: replace network config JSON with direct inputs Replaced network-config-path JSON file requirement with direct inputs: - testnet-blockscout-url (default: Sepolia) - testnet-name (default: Sepolia) - mainnet-blockscout-url (default: Sepolia) - mainnet-name (default: Sepolia) This eliminates the need for deploy-networks.json in consuming repos. Repos can override these inputs if deploying to different networks. --- .github/workflows/_foundry-cicd.yml | 36 +++++++++++++++++------------ 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index 5f7eef5..bf5a3fe 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -38,10 +38,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: '.github/deploy-networks.json' + 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: 'Sepolia' indexing-wait: description: 'Seconds to wait for indexer before verification' type: number @@ -220,13 +232,11 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 - - name: Read network config + - name: Set 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 + echo "blockscout_url=${{ inputs.testnet-blockscout-url }}" >> $GITHUB_OUTPUT + echo "network_name=${{ inputs.testnet-name }}" >> $GITHUB_OUTPUT - name: Deploy contracts env: @@ -388,15 +398,11 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 - - name: Read network config + - name: Set 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 + echo "blockscout_url=${{ inputs.mainnet-blockscout-url }}" >> $GITHUB_OUTPUT + echo "network_name=${{ inputs.mainnet-name }}" >> $GITHUB_OUTPUT - name: Deploy contracts env: From 3e6e9301a64e90010fa63ab5bd448ce05b5595b0 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sun, 30 Nov 2025 17:20:07 -0500 Subject: [PATCH 12/18] feat: add compiler config validation Validates that foundry.toml has required settings for deterministic bytecode: - bytecode_hash = "none" - cbor_metadata = false Fails CI with clear error message if settings are missing. --- .github/workflows/_foundry-cicd.yml | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index bf5a3fe..3968324 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -108,6 +108,39 @@ jobs: - name: Show Forge version run: forge --version + - name: 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 From 056062680299ea03a60e4d2b486890544402d064 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Thu, 4 Dec 2025 18:22:06 -0500 Subject: [PATCH 13/18] feat: add deployment artifacts JSON Creates deployments/{network}/deployment.json with schema: { "contracts": [ { "sourcePathAndName": "src/Contract.sol:Contract", "address": "0x..." } ] } - Testnet: deployments/testnet/deployment.json - Mainnet: deployments/{network-name}/deployment.json Artifacts uploaded via actions/upload-artifact@v4 for downstream consumption. --- .github/workflows/_foundry-cicd.yml | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index 3968324..6fe03ee 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -299,6 +299,27 @@ jobs: 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 + run: | + set -euo pipefail + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + mkdir -p deployments/testnet + + # Create deployment.json with spec schema + jq '[.transactions[] | select(.transactionType == "CREATE") | { + sourcePathAndName: "src/\(.contractName).sol:\(.contractName)", + address: .contractAddress + }]' "$BROADCAST_FILE" | jq '{contracts: .}' > deployments/testnet/deployment.json + + echo "Created deployments/testnet/deployment.json:" + cat deployments/testnet/deployment.json + + - name: Upload deployment artifact + uses: actions/upload-artifact@v4 + with: + name: deployment-testnet + path: deployments/testnet/deployment.json + - name: Verify contracts on Blockscout env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} @@ -465,6 +486,31 @@ jobs: 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 + env: + NETWORK_NAME: ${{ inputs.mainnet-name }} + run: | + set -euo pipefail + BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" + NETWORK_DIR=$(echo "$NETWORK_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-') + mkdir -p "deployments/${NETWORK_DIR}" + + # Create deployment.json with spec schema + jq '[.transactions[] | select(.transactionType == "CREATE") | { + sourcePathAndName: "src/\(.contractName).sol:\(.contractName)", + address: .contractAddress + }]' "$BROADCAST_FILE" | jq '{contracts: .}' > "deployments/${NETWORK_DIR}/deployment.json" + + echo "Created deployments/${NETWORK_DIR}/deployment.json:" + cat "deployments/${NETWORK_DIR}/deployment.json" + echo "artifact_path=deployments/${NETWORK_DIR}/deployment.json" >> $GITHUB_OUTPUT + + - name: Upload deployment artifact + uses: actions/upload-artifact@v4 + with: + name: deployment-mainnet + path: deployments/*/deployment.json + - name: Verify contracts on Blockscout env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} From 4a61900a31e2e1d9e10ea8aef5ec9e1a7ff851a8 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sat, 20 Dec 2025 18:00:33 -0400 Subject: [PATCH 14/18] refactor: split workflow into modular sub-workflows Split the monolithic _foundry-cicd.yml into focused, reusable workflows: - _foundry-detect-changes.yml: Smart contract change detection - _foundry-ci.yml: Build, test, format check, compiler validation - _foundry-upgrade-safety.yml: Upgrade safety validation - _foundry-deploy.yml: Deploy with verification (testnet/mainnet) - _foundry-post-mainnet.yml: Flatten snapshots and create releases The main _foundry-cicd.yml now acts as an orchestrator that calls these sub-workflows, providing the same one-line import experience for consumers while enabling granular imports for advanced use cases. --- .github/workflows/_foundry-ci.yml | 79 ++ .github/workflows/_foundry-cicd.yml | 721 ++---------------- .github/workflows/_foundry-deploy.yml | 245 ++++++ .github/workflows/_foundry-detect-changes.yml | 71 ++ .github/workflows/_foundry-post-mainnet.yml | 211 +++++ .github/workflows/_foundry-upgrade-safety.yml | 109 +++ 6 files changed, 793 insertions(+), 643 deletions(-) create mode 100644 .github/workflows/_foundry-ci.yml create mode 100644 .github/workflows/_foundry-deploy.yml create mode 100644 .github/workflows/_foundry-detect-changes.yml create mode 100644 .github/workflows/_foundry-post-mainnet.yml create mode 100644 .github/workflows/_foundry-upgrade-safety.yml diff --git a/.github/workflows/_foundry-ci.yml b/.github/workflows/_foundry-ci.yml new file mode 100644 index 0000000..7b9d49c --- /dev/null +++ b/.github/workflows/_foundry-ci.yml @@ -0,0 +1,79 @@ +# 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 + +jobs: + ci: + name: Build & Test + runs-on: ubuntu-latest + 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 fdfa7f0..f8a5b9c 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: @@ -109,208 +117,43 @@ 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 }} + + # ───────────────────────────────────────────────────────────────────────────── + # 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: 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 }} - + uses: BreadchainCoop/etherform/.github/workflows/_foundry-ci.yml@main + with: + check-formatting: ${{ inputs.check-formatting }} + test-verbosity: ${{ inputs.test-verbosity }} + + # ───────────────────────────────────────────────────────────────────────────── + # 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 - 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 - - CURRENT_CONTRACT="${CURRENT_FILE}:${CONTRACT_NAME}" - REFERENCE_CONTRACT="${BASELINE_DIR}/${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" - + uses: BreadchainCoop/etherform/.github/workflows/_foundry-upgrade-safety.yml@main + with: + baseline-path: ${{ inputs.baseline-path }} + upgrades-path: ${{ inputs.upgrades-path }} + + # ───────────────────────────────────────────────────────────────────────────── + # Testnet Deployment (on PR) + # ───────────────────────────────────────────────────────────────────────────── deploy-testnet: - name: Deploy Testnet - runs-on: ubuntu-latest needs: [detect-changes, ci, upgrade-safety] if: | always() && @@ -319,185 +162,24 @@ 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: Set network config - id: network - run: | - echo "blockscout_url=${{ inputs.testnet-blockscout-url }}" >> $GITHUB_OUTPUT - echo "network_name=${{ inputs.testnet-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: Create deployment artifact - run: | - set -euo pipefail - BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - mkdir -p deployments/testnet - - # Create deployment.json with spec schema - jq '[.transactions[] | select(.transactionType == "CREATE") | { - sourcePathAndName: "src/\(.contractName).sol:\(.contractName)", - address: .contractAddress - }]' "$BROADCAST_FILE" | jq '{contracts: .}' > deployments/testnet/deployment.json - - echo "Created deployments/testnet/deployment.json:" - cat deployments/testnet/deployment.json - - - name: Upload deployment artifact - uses: actions/upload-artifact@v4 - with: - name: deployment-testnet - path: deployments/testnet/deployment.json - - - name: Verify contracts on Blockscout - env: - BLOCKSCOUT_URL: ${{ steps.network.outputs.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: ${{ 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 - - - name: Comment on PR with deployment info - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} - BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} - NETWORK_NAME: ${{ steps.network.outputs.network_name }} - run: | - set -euo pipefail - - SHORT_SHA="${{ github.event.pull_request.head.sha }}" - SHORT_SHA="${SHORT_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 ${{ github.event.pull_request.number }} --body-file pr-comment.md + 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 }} + 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() && @@ -507,212 +189,22 @@ 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: Set network config - id: network - run: | - echo "blockscout_url=${{ inputs.mainnet-blockscout-url }}" >> $GITHUB_OUTPUT - echo "network_name=${{ inputs.mainnet-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: Create deployment artifact - env: - NETWORK_NAME: ${{ inputs.mainnet-name }} - run: | - set -euo pipefail - BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - NETWORK_DIR=$(echo "$NETWORK_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-') - mkdir -p "deployments/${NETWORK_DIR}" - - # Create deployment.json with spec schema - jq '[.transactions[] | select(.transactionType == "CREATE") | { - sourcePathAndName: "src/\(.contractName).sol:\(.contractName)", - address: .contractAddress - }]' "$BROADCAST_FILE" | jq '{contracts: .}' > "deployments/${NETWORK_DIR}/deployment.json" - - echo "Created deployments/${NETWORK_DIR}/deployment.json:" - cat "deployments/${NETWORK_DIR}/deployment.json" - echo "artifact_path=deployments/${NETWORK_DIR}/deployment.json" >> $GITHUB_OUTPUT - - - name: Upload deployment artifact - uses: actions/upload-artifact@v4 - with: - name: deployment-mainnet - path: deployments/*/deployment.json - - - name: Verify contracts on Blockscout - env: - BLOCKSCOUT_URL: ${{ steps.network.outputs.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: ${{ 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 - - - name: Create GitHub Release - if: inputs.create-release - env: - GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} - BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} - NETWORK_NAME: ${{ steps.network.outputs.network_name }} - 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 - 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 - - # Add transaction details - 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 - - # 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 "${{ inputs.upgrades-path }}/current" ]]; then - for f in ${{ inputs.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" - - flatten-snapshots: - name: Flatten Snapshots - runs-on: ubuntu-latest + 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 }} + 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() && @@ -721,72 +213,15 @@ jobs: (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)) && - inputs.flatten-contracts && github.event_name == 'push' && github.ref == 'refs/heads/main' - 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 + 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 }} + 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..4e5ae36 --- /dev/null +++ b/.github/workflows/_foundry-deploy.yml @@ -0,0 +1,245 @@ +# 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: '' + 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 + 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..38fd4d1 --- /dev/null +++ b/.github/workflows/_foundry-detect-changes.yml @@ -0,0 +1,71 @@ +# 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 + 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..5c1b765 --- /dev/null +++ b/.github/workflows/_foundry-post-mainnet.yml @@ -0,0 +1,211 @@ +# 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' + 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 + 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') + 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..d612ca4 --- /dev/null +++ b/.github/workflows/_foundry-upgrade-safety.yml @@ -0,0 +1,109 @@ +# 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' + +jobs: + validate: + name: Upgrade Safety + runs-on: ubuntu-latest + 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 + + CURRENT_CONTRACT="${CURRENT_FILE}:${CONTRACT_NAME}" + REFERENCE_CONTRACT="${BASELINE_DIR}/${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" From 83fb1d1baa91d504ffb1f4a5b4fd39b447e1459d Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sat, 20 Dec 2025 18:47:45 -0400 Subject: [PATCH 15/18] feat: add integration testing with example repo submodule - Add foundry-upgradeable-counter-example as submodule at examples/foundry-counter - Add working-directory input to all sub-workflows for flexible testing - Create integration-test.yml that validates workflow changes against the example project - Pass working-directory through orchestrator to all sub-workflows Integration tests run automatically when workflow files change, validating changes before they affect external consumers. --- .github/workflows/_foundry-ci.yml | 7 ++++ .github/workflows/_foundry-cicd.yml | 12 ++++++ .github/workflows/_foundry-deploy.yml | 7 ++++ .github/workflows/_foundry-detect-changes.yml | 4 ++ .github/workflows/_foundry-post-mainnet.yml | 10 +++++ .github/workflows/_foundry-upgrade-safety.yml | 7 ++++ .github/workflows/integration-test.yml | 41 +++++++++++++++++++ .gitmodules | 3 ++ examples/foundry-counter | 1 + 9 files changed, 92 insertions(+) create mode 100644 .github/workflows/integration-test.yml create mode 100644 .gitmodules create mode 160000 examples/foundry-counter diff --git a/.github/workflows/_foundry-ci.yml b/.github/workflows/_foundry-ci.yml index 7b9d49c..2ecb648 100644 --- a/.github/workflows/_foundry-ci.yml +++ b/.github/workflows/_foundry-ci.yml @@ -17,11 +17,18 @@ on: 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 diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index f8a5b9c..95ff272 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -105,6 +105,12 @@ on: type: string default: '' + # Working Directory + working-directory: + description: 'Working directory for Foundry commands' + type: string + default: '.' + secrets: PRIVATE_KEY: description: 'Deployer wallet private key' @@ -125,6 +131,7 @@ jobs: with: skip-if-no-changes: ${{ inputs.skip-if-no-changes }} contract-paths: ${{ inputs.contract-paths }} + working-directory: ${{ inputs.working-directory }} # ───────────────────────────────────────────────────────────────────────────── # CI: Build & Test @@ -136,6 +143,7 @@ jobs: with: check-formatting: ${{ inputs.check-formatting }} test-verbosity: ${{ inputs.test-verbosity }} + working-directory: ${{ inputs.working-directory }} # ───────────────────────────────────────────────────────────────────────────── # Upgrade Safety Validation @@ -149,6 +157,7 @@ jobs: with: baseline-path: ${{ inputs.baseline-path }} upgrades-path: ${{ inputs.upgrades-path }} + working-directory: ${{ inputs.working-directory }} # ───────────────────────────────────────────────────────────────────────────── # Testnet Deployment (on PR) @@ -171,6 +180,7 @@ jobs: 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 }} @@ -196,6 +206,7 @@ jobs: 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 }} @@ -223,5 +234,6 @@ jobs: 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 index 4e5ae36..4b43491 100644 --- a/.github/workflows/_foundry-deploy.yml +++ b/.github/workflows/_foundry-deploy.yml @@ -33,6 +33,10 @@ on: 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' @@ -55,6 +59,9 @@ 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 }} diff --git a/.github/workflows/_foundry-detect-changes.yml b/.github/workflows/_foundry-detect-changes.yml index 38fd4d1..2cefab6 100644 --- a/.github/workflows/_foundry-detect-changes.yml +++ b/.github/workflows/_foundry-detect-changes.yml @@ -19,6 +19,10 @@ on: 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' diff --git a/.github/workflows/_foundry-post-mainnet.yml b/.github/workflows/_foundry-post-mainnet.yml index 5c1b765..6df9d74 100644 --- a/.github/workflows/_foundry-post-mainnet.yml +++ b/.github/workflows/_foundry-post-mainnet.yml @@ -29,6 +29,10 @@ on: 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' @@ -39,6 +43,9 @@ jobs: 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 @@ -111,6 +118,9 @@ jobs: 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 diff --git a/.github/workflows/_foundry-upgrade-safety.yml b/.github/workflows/_foundry-upgrade-safety.yml index d612ca4..fa2efe0 100644 --- a/.github/workflows/_foundry-upgrade-safety.yml +++ b/.github/workflows/_foundry-upgrade-safety.yml @@ -13,11 +13,18 @@ on: 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 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..4e29f39 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,41 @@ +# 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: + baseline-path: 'examples/foundry-counter/test/upgrades/baseline' + upgrades-path: 'examples/foundry-counter/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/examples/foundry-counter b/examples/foundry-counter new file mode 160000 index 0000000..47aec37 --- /dev/null +++ b/examples/foundry-counter @@ -0,0 +1 @@ +Subproject commit 47aec3785372f2cc4a2a59b67bff115cdf554587 From 5213ede1522c19a16975a2be54e2451717413df5 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sat, 20 Dec 2025 19:08:58 -0400 Subject: [PATCH 16/18] fix: correct upgrade safety paths to be relative to working-directory --- .github/workflows/integration-test.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 4e29f39..66b933f 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -36,6 +36,7 @@ jobs: needs: [test-ci] uses: ./.github/workflows/_foundry-upgrade-safety.yml with: - baseline-path: 'examples/foundry-counter/test/upgrades/baseline' - upgrades-path: 'examples/foundry-counter/test/upgrades' + # Paths are relative to working-directory + baseline-path: 'test/upgrades/baseline' + upgrades-path: 'test/upgrades' working-directory: 'examples/foundry-counter' From 25ed9b3968f2a0a09d8f527eefae16acd5d9229d Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sat, 20 Dec 2025 19:31:12 -0400 Subject: [PATCH 17/18] docs: add development workflow and update submodule - Update README with comprehensive documentation: - Quick start for _foundry-cicd.yml orchestrator - All workflow inputs and secrets reference - Development section explaining integration testing - How local ./ refs test current branch before affecting consumers - Working with the submodule - Architecture decisions - Update foundry-counter submodule to latest (6c84d40): - Clean ERC-7201 namespaced storage pattern - Upgrade-safe storage layout --- README.md | 311 ++++++++++++++++++++++++++++----------- examples/foundry-counter | 2 +- 2 files changed, 225 insertions(+), 88 deletions(-) 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 index 47aec37..6c84d40 160000 --- a/examples/foundry-counter +++ b/examples/foundry-counter @@ -1 +1 @@ -Subproject commit 47aec3785372f2cc4a2a59b67bff115cdf554587 +Subproject commit 6c84d40d0a1824d31296d11ef7b9c9fa560bf835 From 44f66bdb871986366e1cbc682dbca566a79e15bf Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Sat, 20 Dec 2025 19:39:37 -0400 Subject: [PATCH 18/18] fix: correct artifact path resolution for upgrade safety validation Forge output structure strips src/ and test/upgrades/ prefixes: - src/Counter.sol -> out/Counter.sol/Counter.json - test/upgrades/baseline/Counter.sol -> out/baseline/Counter.sol/Counter.json Updated contract path format to match Forge's output structure: - Use Counter.sol:Counter instead of src/Counter.sol:Counter - Use baseline/Counter.sol:Counter instead of test/upgrades/baseline/Counter.sol:Counter This fixes the "No such file or directory" error when OZ Foundry Upgrades tries to read contract artifacts. --- .github/workflows/_foundry-upgrade-safety.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/_foundry-upgrade-safety.yml b/.github/workflows/_foundry-upgrade-safety.yml index fa2efe0..fd03892 100644 --- a/.github/workflows/_foundry-upgrade-safety.yml +++ b/.github/workflows/_foundry-upgrade-safety.yml @@ -90,8 +90,15 @@ jobs: continue fi - CURRENT_CONTRACT="${CURRENT_FILE}:${CONTRACT_NAME}" - REFERENCE_CONTRACT="${BASELINE_DIR}/${CONTRACT_NAME}.sol:${CONTRACT_NAME}" + # 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"