From 5ff9f27c79058a5fc3b510e0a3a8ce297a4a61e3 Mon Sep 17 00:00:00 2001 From: Ron Turetzky Date: Fri, 3 Jul 2026 11:05:04 -0400 Subject: [PATCH 1/4] Overhaul workflows: dedup via nesting, harden supply chain, fix upgrade-safety and broadcast parsing bugs Bugs: - _foundry-cicd.yml no longer fails for consumers without upgrades.json: upgrade safety is skipped when the config file is absent, and deploys accept a skipped upgrade-safety job (fixes the documented minimum setup) - CREATE2 deployments are now included in summaries, deployment.json, and Blockscout verification; unnamed nested deployments (additionalContracts) are surfaced as a warning instead of silently dropped Structure: - new shared composite action (.github/actions/setup) replaces 9 copies of the Foundry/Node/deps setup preamble, adds Foundry compile caching - _foundry-cicd.yml is now a thin orchestrator calling _ci.yml, _upgrade-safety.yml and _deploy-testnet.yml as nested reusable workflows (581 -> 290 lines), eliminating the drift between copies - unified deploy behavior: chain-id-based network resolution, deployment.json artifact, and --private-key are now consistent (network-index removed) - main-branch input renamed to base-branch to match _upgrade-safety.yml Hardening: - all actions pinned to commit SHAs; foundry-version, halmos-version and oz-upgrades-core-version inputs added; Node default bumped to 22 - workflow inputs routed through env vars instead of run-block interpolation; test-verbosity validated against an allowlist - deploys restricted to same-repo PRs (fork PRs have no secrets) and serialized per ref via job-level concurrency, never cancelled mid-flight - scripts checkout defaults to job.workflow_sha, so the bash scripts always match the workflow commit (fixes the etherform-ref split-brain) - optional protected GitHub Environment support for mainnet deploys Robustness: - parse-broadcast: chain id read from the artifact, DEPLOY_SCRIPT scoping, hard error on ambiguous multiple broadcasts (was: silent head -1) - extract-summary: POSIX-portable parsing (no GNU grep -P; tests now pass on macOS), handles the forge >= 1.7 table format - validate.sh: OZ CLI installed once instead of npx per contract; per-contract "options" passthrough for --unsafeAllow* flags - upgrades.json removal guard unchanged; deployment artifact logic extracted to write-deployment-json.sh with tests Testing & docs: - new e2e workflow runs the real _foundry-cicd.yml against a dependency-free fixture Foundry project on every PR; actionlint added to repo CI; shellcheck now covers tests/ - README updated (mainnet deploys, concurrency guidance, explicit secrets, new inputs, ref-pinning advice); original spec marked partially superseded --- .github/actionlint.yaml | 10 + .github/actions/setup/action.yml | 76 +++ .github/workflows/_ci.yml | 198 ++++--- .github/workflows/_deploy-testnet.yml | 142 ++--- .github/workflows/_foundry-cicd.yml | 527 ++++-------------- .github/workflows/_upgrade-safety.yml | 49 +- .github/workflows/e2e.yml | 36 ++ .github/workflows/test.yml | 60 +- .gitignore | 4 + README.md | 106 +++- docs/specs/ci-cd-automated-deployments.md | 8 + scripts/coverage/extract-summary.sh | 8 +- scripts/deploy/parse-broadcast.sh | 55 +- scripts/deploy/verify-blockscout.sh | 13 +- scripts/deploy/write-deployment-json.sh | 33 ++ scripts/upgrade-safety/validate.sh | 71 ++- .../Deploy.s.sol/31337/run-latest.json | 17 +- tests/fixtures/coverage-raw-forge17.txt | 20 + tests/fixtures/project/foundry.toml | 6 + tests/fixtures/project/src/Counter.sol | 21 + tests/fixtures/project/test/Counter.t.sol | 36 ++ tests/test-extract-summary.sh | 27 +- tests/test-parse-broadcast.sh | 54 +- tests/test-write-deployment-json.sh | 48 ++ 24 files changed, 960 insertions(+), 665 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/actions/setup/action.yml create mode 100644 .github/workflows/e2e.yml create mode 100644 scripts/deploy/write-deployment-json.sh create mode 100644 tests/fixtures/coverage-raw-forge17.txt create mode 100644 tests/fixtures/project/foundry.toml create mode 100644 tests/fixtures/project/src/Counter.sol create mode 100644 tests/fixtures/project/test/Counter.t.sol create mode 100644 tests/test-write-deployment-json.sh diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..928a02d --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,10 @@ +# actionlint's context schema lags behind GitHub: job.workflow_sha / +# job.workflow_ref (the SHA/ref of the reusable workflow file that defines the +# current job) are documented at +# https://docs.github.com/en/actions/reference/workflows-and-actions/contexts +# but not yet in actionlint's `job` context type. +paths: + .github/workflows/**/*.yml: + ignore: + - 'property "workflow_sha" is not defined in object type' + - 'property "workflow_ref" is not defined in object type' diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..4da3e44 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,76 @@ +name: Setup Foundry toolchain +description: >- + Shared setup for etherform jobs: installs Foundry, optionally sets up Node.js + and installs package-manager dependencies, and restores the Foundry compile cache. + The consumer repository must already be checked out. + +inputs: + foundry-version: + description: Foundry version to install (stable, nightly, or a specific version) + required: false + default: stable + package-manager: + description: Package manager for Node.js dependencies (none, npm, yarn, pnpm) + required: false + default: none + node-version: + description: Node.js version for package installation + required: false + default: '22' + working-directory: + description: Directory containing the Foundry project + required: false + default: '.' + cache: + description: Restore/save the Foundry compile cache between runs + required: false + default: 'true' + +runs: + using: composite + steps: + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@c7450ba673e133f5ee30098b3b54f444d3a2ca2d # v1.8.0 + with: + version: ${{ inputs.foundry-version }} + + - name: Setup Node.js + if: inputs.package-manager != 'none' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ inputs.node-version }} + cache: ${{ inputs.package-manager }} + cache-dependency-path: | + ${{ inputs.working-directory }}/package-lock.json + ${{ inputs.working-directory }}/yarn.lock + ${{ inputs.working-directory }}/pnpm-lock.yaml + + - name: Install dependencies + if: inputs.package-manager != 'none' + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + case "$PACKAGE_MANAGER" in + npm) npm ci ;; + yarn) yarn --frozen-lockfile ;; + pnpm) corepack enable && pnpm install --frozen-lockfile ;; + *) + echo "::error::Unsupported package-manager '$PACKAGE_MANAGER' (expected none, npm, yarn, pnpm)" + exit 1 + ;; + esac + + - name: Restore Foundry compile cache + if: inputs.cache == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ inputs.working-directory }}/cache + key: foundry-compile-${{ runner.os }}-${{ hashFiles(format('{0}/foundry.toml', inputs.working-directory), format('{0}/src/**/*.sol', inputs.working-directory)) }} + restore-keys: | + foundry-compile-${{ runner.os }}- + + - name: Show Forge version + shell: bash + run: forge --version diff --git a/.github/workflows/_ci.yml b/.github/workflows/_ci.yml index 145b2b4..9026493 100644 --- a/.github/workflows/_ci.yml +++ b/.github/workflows/_ci.yml @@ -10,7 +10,7 @@ on: type: boolean default: true test-verbosity: - description: 'Test verbosity level (v, vv, vvv, vvvv)' + description: 'Test verbosity level (v, vv, vvv, vvvv, vvvvv)' type: string default: 'vvv' package-manager: @@ -20,7 +20,15 @@ on: node-version: description: 'Node.js version for package installation' type: string - default: '20' + default: '22' + foundry-version: + description: 'Foundry version to install (stable, nightly, or a specific version)' + type: string + default: 'stable' + working-directory: + description: 'Directory containing the Foundry project (for monorepos)' + type: string + default: '.' run-slither: description: 'Run Slither static analysis' type: boolean @@ -57,10 +65,14 @@ on: description: 'Run Halmos symbolic execution' type: boolean default: false + halmos-version: + description: 'Halmos version to install (empty = latest)' + type: string + default: '' etherform-ref: - description: 'Git ref for etherform scripts checkout (default: main)' + description: 'Git ref for etherform scripts checkout (default: the ref this workflow was called at)' type: string - default: 'main' + default: '' secrets: RPC_URL: description: 'RPC endpoint for fork-based tests' @@ -70,33 +82,32 @@ jobs: check: name: Build & Test runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ inputs.working-directory }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: submodules: recursive - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + - name: Checkout etherform + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + repository: BreadchainCoop/etherform + ref: ${{ inputs.etherform-ref || job.workflow_sha }} + path: .etherform + sparse-checkout: | + scripts + .github/actions - - name: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 + - name: Setup toolchain + uses: ./.etherform/.github/actions/setup with: + foundry-version: ${{ inputs.foundry-version }} + package-manager: ${{ inputs.package-manager }} node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac - - - name: Show Forge version - run: forge --version + working-directory: ${{ inputs.working-directory }} - name: Check formatting if: inputs.check-formatting @@ -108,43 +119,55 @@ jobs: - name: Run tests env: RPC_URL: ${{ secrets.RPC_URL }} - run: forge test -${{ inputs.test-verbosity }} + TEST_VERBOSITY: ${{ inputs.test-verbosity }} + run: | + case "$TEST_VERBOSITY" in + v|vv|vvv|vvvv|vvvvv) ;; + *) + echo "::error::Invalid test-verbosity '$TEST_VERBOSITY' (expected v, vv, vvv, vvvv, or vvvvv)" + exit 1 + ;; + esac + forge test -"$TEST_VERBOSITY" slither: name: Slither Analysis runs-on: ubuntu-latest if: inputs.run-slither + defaults: + run: + working-directory: ${{ inputs.working-directory }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: submodules: recursive - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + - name: Checkout etherform + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + repository: BreadchainCoop/etherform + ref: ${{ inputs.etherform-ref || job.workflow_sha }} + path: .etherform + sparse-checkout: | + scripts + .github/actions - - name: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 + - name: Setup toolchain + uses: ./.etherform/.github/actions/setup with: + foundry-version: ${{ inputs.foundry-version }} + package-manager: ${{ inputs.package-manager }} node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac + working-directory: ${{ inputs.working-directory }} - name: Build for Slither run: forge build --build-info --skip '*/test/**' '*/script/**' --force - name: Run Slither - uses: crytic/slither-action@v0.4.0 + uses: crytic/slither-action@b52cc1cbfee9ca3e8722dd5224299d16c9a6b80f # v0.4.2 with: + target: ${{ inputs.working-directory }} slither-config: ${{ inputs.slither-config }} fail-on: ${{ inputs.slither-fail-on }} @@ -153,48 +176,40 @@ jobs: runs-on: ubuntu-latest needs: [check] if: inputs.run-coverage + defaults: + run: + working-directory: ${{ inputs.working-directory }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: submodules: recursive - - name: Checkout etherform scripts - uses: actions/checkout@v4 + - name: Checkout etherform + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: repository: BreadchainCoop/etherform - ref: ${{ inputs.etherform-ref }} + ref: ${{ inputs.etherform-ref || job.workflow_sha }} path: .etherform - sparse-checkout: scripts + sparse-checkout: | + scripts + .github/actions - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 + - name: Setup toolchain + uses: ./.etherform/.github/actions/setup with: + foundry-version: ${{ inputs.foundry-version }} + package-manager: ${{ inputs.package-manager }} node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac - - - name: Show Forge version - run: forge --version + working-directory: ${{ inputs.working-directory }} - name: Run coverage env: RPC_URL: ${{ secrets.RPC_URL }} + COVERAGE_EXCLUDE_PATHS: ${{ inputs.coverage-exclude-paths }} run: | - if [[ -n "${{ inputs.coverage-exclude-paths }}" ]]; then - forge coverage --no-match-path "${{ inputs.coverage-exclude-paths }}" 2>&1 | tee coverage-raw.txt + if [[ -n "$COVERAGE_EXCLUDE_PATHS" ]]; then + forge coverage --no-match-path "$COVERAGE_EXCLUDE_PATHS" 2>&1 | tee coverage-raw.txt else forge coverage 2>&1 | tee coverage-raw.txt fi @@ -203,13 +218,14 @@ jobs: id: summary env: COVERAGE_SOURCE_FILTER: ${{ inputs.coverage-source-filter }} - run: bash .etherform/scripts/coverage/extract-summary.sh + run: bash "$GITHUB_WORKSPACE/.etherform/scripts/coverage/extract-summary.sh" - name: Post coverage comment if: inputs.coverage-post-comment && github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 with: - path: coverage-comment.md + header: coverage + path: ${{ inputs.working-directory }}/coverage-comment.md - name: Check coverage threshold if: inputs.coverage-min-threshold > 0 @@ -219,44 +235,48 @@ jobs: STMTS_PCT: ${{ steps.summary.outputs.stmts_pct }} BRANCH_PCT: ${{ steps.summary.outputs.branch_pct }} FUNCS_PCT: ${{ steps.summary.outputs.funcs_pct }} - run: bash .etherform/scripts/coverage/check-threshold.sh + run: bash "$GITHUB_WORKSPACE/.etherform/scripts/coverage/check-threshold.sh" halmos: name: Symbolic Execution runs-on: ubuntu-latest if: inputs.run-halmos + defaults: + run: + working-directory: ${{ inputs.working-directory }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: submodules: recursive - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + - name: Checkout etherform + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + repository: BreadchainCoop/etherform + ref: ${{ inputs.etherform-ref || job.workflow_sha }} + path: .etherform + sparse-checkout: | + scripts + .github/actions - - name: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 + - name: Setup toolchain + uses: ./.etherform/.github/actions/setup with: + foundry-version: ${{ inputs.foundry-version }} + package-manager: ${{ inputs.package-manager }} node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac + working-directory: ${{ inputs.working-directory }} - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.12' - name: Install Halmos - run: pip install halmos + env: + HALMOS_VERSION: ${{ inputs.halmos-version }} + run: pip install "halmos${HALMOS_VERSION:+==$HALMOS_VERSION}" - name: Run Halmos env: diff --git a/.github/workflows/_deploy-testnet.yml b/.github/workflows/_deploy-testnet.yml index dc82296..c3b3548 100644 --- a/.github/workflows/_deploy-testnet.yml +++ b/.github/workflows/_deploy-testnet.yml @@ -1,6 +1,11 @@ -# Reusable testnet deployment workflow +# Reusable deployment workflow (testnet or mainnet) # Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_deploy-testnet.yml@main -name: Deploy Testnet (Reusable) +# +# The target network is determined by the RPC_URL secret; the chain ID from the +# broadcast artifact is matched against the network config to resolve the +# explorer URL and network name. Set the `environment` input to run the deploy +# under a protected GitHub Environment (e.g. for mainnet deploys on merge). +name: Deploy (Reusable) on: workflow_call: @@ -13,10 +18,6 @@ on: description: 'Path to network configuration JSON' type: string default: '.github/deploy-networks.json' - network-index: - description: 'Index in testnets array to deploy to' - type: number - default: 0 indexing-wait: description: 'Seconds to wait for indexer before verification' type: number @@ -25,6 +26,10 @@ on: description: 'Verify deployed contracts on Blockscout' type: boolean default: true + environment: + description: 'GitHub Environment to deploy under (empty = none); use a protected environment for mainnet' + type: string + default: '' package-manager: description: 'Package manager for Node.js dependencies (none, npm, yarn, pnpm)' type: string @@ -32,11 +37,15 @@ on: node-version: description: 'Node.js version for package installation' type: string - default: '20' + default: '22' + foundry-version: + description: 'Foundry version to install (stable, nightly, or a specific version)' + type: string + default: 'stable' etherform-ref: - description: 'Git ref for etherform scripts checkout (default: main)' + description: 'Git ref for etherform scripts checkout (default: the ref this workflow was called at)' type: string - default: 'main' + default: '' secrets: PRIVATE_KEY: required: true @@ -47,9 +56,14 @@ on: required: false jobs: - deploy-testnet: - name: Deploy to Testnet + deploy: + name: Deploy Contracts runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + # Serialize deploys per ref; never cancel an in-flight deploy + concurrency: + group: etherform-deploy-${{ github.ref }} + cancel-in-progress: false permissions: contents: read pull-requests: write @@ -59,80 +73,74 @@ jobs: broadcast_file: ${{ steps.parse.outputs.broadcast_file }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: submodules: recursive - - name: Checkout etherform scripts - uses: actions/checkout@v4 + - name: Checkout etherform + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: repository: BreadchainCoop/etherform - ref: ${{ inputs.etherform-ref }} + ref: ${{ inputs.etherform-ref || job.workflow_sha }} path: .etherform - sparse-checkout: scripts + sparse-checkout: | + scripts + .github/actions - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 + - name: Setup toolchain + uses: ./.etherform/.github/actions/setup with: + foundry-version: ${{ inputs.foundry-version }} + package-manager: ${{ inputs.package-manager }} node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac - - - name: Read network config - id: network - run: | - BLOCKSCOUT_URL=$(jq -r '.testnets[${{ inputs.network-index }}].blockscout_url' ${{ inputs.network-config-path }}) - NETWORK_NAME=$(jq -r '.testnets[${{ inputs.network-index }}].name' ${{ inputs.network-config-path }}) - echo "blockscout_url=$BLOCKSCOUT_URL" >> $GITHUB_OUTPUT - echo "network_name=$NETWORK_NAME" >> $GITHUB_OUTPUT - name: Deploy contracts env: PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} RPC_URL: ${{ secrets.RPC_URL }} DEPLOY_ENV_VARS: ${{ secrets.DEPLOY_ENV_VARS }} + DEPLOY_SCRIPT: ${{ inputs.deploy-script }} run: | source .etherform/scripts/deploy/prepare-env.sh - forge script ${{ inputs.deploy-script }} \ + forge script "$DEPLOY_SCRIPT" \ --rpc-url "$RPC_URL" \ + --private-key "$PRIVATE_KEY" \ --broadcast \ --slow \ -vvvv - name: Wait for indexing - run: sleep ${{ inputs.indexing-wait }} + env: + INDEXING_WAIT: ${{ inputs.indexing-wait }} + run: sleep "$INDEXING_WAIT" - name: Parse deployment addresses id: parse + env: + DEPLOY_SCRIPT: ${{ inputs.deploy-script }} run: bash .etherform/scripts/deploy/parse-broadcast.sh - - name: Save deployment artifacts - run: | - mkdir -p deployments/${{ steps.network.outputs.network_name }} - BROADCAST_FILE="${{ steps.parse.outputs.broadcast_file }}" - jq '{contracts: [.transactions[] | select(.transactionType == "CREATE") | {sourcePathAndName: "src/\(.contractName).sol:\(.contractName)", address: .contractAddress}]}' "$BROADCAST_FILE" \ - > deployments/${{ steps.network.outputs.network_name }}/deployment.json + - name: Resolve network from chain ID + id: network + env: + CHAIN_ID: ${{ steps.parse.outputs.chain_id }} + NETWORK_CONFIG: ${{ inputs.network-config-path }} + run: bash .etherform/scripts/deploy/resolve-network.sh + + - name: Write deployment artifact + env: + BROADCAST_FILE: ${{ steps.parse.outputs.broadcast_file }} + NETWORK_NAME: ${{ steps.network.outputs.network_name }} + run: bash .etherform/scripts/deploy/write-deployment-json.sh - name: Upload deployment artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: deployment-${{ steps.network.outputs.network_name }} path: deployments/ - name: Upload broadcast artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: broadcast path: broadcast/ @@ -140,7 +148,7 @@ jobs: - name: Create deployment summary env: BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} - SUMMARY_TITLE: Testnet Deployment Summary + SUMMARY_TITLE: Deployment Summary run: bash .etherform/scripts/deploy/deployment-summary.sh - name: Write deployment comment @@ -152,7 +160,7 @@ jobs: - name: Post deployment comment if: github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 with: header: testnet-deployment-${{ steps.network.outputs.network_name }} path: deployment-comment.md @@ -160,34 +168,40 @@ jobs: verify-contracts: name: Verify Contracts runs-on: ubuntu-latest - needs: [deploy-testnet] + needs: [deploy] if: inputs.verify-contracts steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: submodules: recursive - - name: Checkout etherform scripts - uses: actions/checkout@v4 + - name: Checkout etherform + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: repository: BreadchainCoop/etherform - ref: ${{ inputs.etherform-ref }} + ref: ${{ inputs.etherform-ref || job.workflow_sha }} path: .etherform - sparse-checkout: scripts + sparse-checkout: | + scripts + .github/actions - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + - name: Setup toolchain + uses: ./.etherform/.github/actions/setup + with: + foundry-version: ${{ inputs.foundry-version }} + package-manager: ${{ inputs.package-manager }} + node-version: ${{ inputs.node-version }} - name: Download broadcast artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: broadcast path: broadcast/ - name: Verify contracts on Blockscout env: - BLOCKSCOUT_URL: ${{ needs.deploy-testnet.outputs.blockscout_url }} + BLOCKSCOUT_URL: ${{ needs.deploy.outputs.blockscout_url }} ETH_RPC_URL: ${{ secrets.RPC_URL }} - BROADCAST_FILE: ${{ needs.deploy-testnet.outputs.broadcast_file }} - run: bash .etherform/scripts/deploy/verify-blockscout.sh + BROADCAST_FILE: ${{ needs.deploy.outputs.broadcast_file }} + run: bash .etherform/scripts/deploy/verify-blockscout.sh \ No newline at end of file diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index b367cb8..5bd461d 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -1,5 +1,9 @@ # All-in-one Foundry CI/CD reusable workflow # Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_foundry-cicd.yml@main +# +# Thin orchestrator: change detection runs inline; CI, upgrade safety, and +# deploy are delegated to the sibling reusable workflows (_ci.yml, +# _upgrade-safety.yml, _deploy-testnet.yml) so the logic lives in one place. name: Foundry CI/CD on: @@ -27,11 +31,19 @@ on: type: boolean default: true test-verbosity: - description: 'Test verbosity level (v, vv, vvv, vvvv)' + description: 'Test verbosity level (v, vv, vvv, vvvv, vvvvv)' type: string default: 'vvv' + working-directory: + description: 'Directory containing the Foundry project (applies to CI, coverage, Slither, Halmos; upgrade safety and deploy assume the repo root)' + type: string + default: '.' - # Node.js Dependency Options + # Toolchain Options + foundry-version: + description: 'Foundry version to install (stable, nightly, or a specific version)' + type: string + default: 'stable' package-manager: description: 'Package manager for Node.js dependencies (none, npm, yarn, pnpm)' type: string @@ -39,7 +51,7 @@ on: node-version: description: 'Node.js version for package installation' type: string - default: '20' + default: '22' # Slither Options run-slither: @@ -77,15 +89,29 @@ on: type: number default: 0 + # Halmos Options + run-halmos: + description: 'Run Halmos symbolic execution' + type: boolean + default: false + halmos-version: + description: 'Halmos version to install (empty = latest)' + type: string + default: '' + # Upgrade Safety Options upgrades-config: - description: 'Path to upgrade safety configuration (upgrades.json)' + description: 'Path to upgrade safety configuration (upgrades.json); the job is skipped when the file does not exist' type: string default: '.github/upgrades.json' - main-branch: + base-branch: description: 'Base branch for upgrade safety comparison' type: string default: 'main' + oz-upgrades-core-version: + description: 'Version of @openzeppelin/upgrades-core used for validation' + type: string + default: '1.44.2' # Deploy Options deploy-on-pr: @@ -108,17 +134,12 @@ on: description: 'Verify deployed contracts on Blockscout' type: boolean default: true - # Halmos Options - run-halmos: - description: 'Run Halmos symbolic execution' - type: boolean - default: false # Etherform Options etherform-ref: - description: 'Git ref for etherform scripts checkout (default: main)' + description: 'Git ref for etherform scripts checkout (default: the ref this workflow was called at)' type: string - default: 'main' + default: '' secrets: PRIVATE_KEY: @@ -138,444 +159,128 @@ jobs: outputs: contracts-changed: ${{ steps.filter.outputs.contracts }} should-run: ${{ steps.decide.outputs.should-run }} + upgrades-config-exists: ${{ steps.upgrades.outputs.exists }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: - fetch-depth: 0 + persist-credentials: true - name: Build paths filter id: paths + env: + CONTRACT_PATHS: ${{ inputs.contract-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 + YAML_PATHS=$(printf '%s\n' "$CONTRACT_PATHS" | sed '/^$/d' | sed 's/^/ - /') + { + echo "filter<> "$GITHUB_OUTPUT" - name: Check for contract changes id: filter - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 with: filters: ${{ steps.paths.outputs.filter }} - name: Decide whether to run id: decide + env: + SKIP_IF_NO_CHANGES: ${{ inputs.skip-if-no-changes }} + CONTRACTS_CHANGED: ${{ steps.filter.outputs.contracts }} run: | - if [[ "${{ inputs.skip-if-no-changes }}" == "false" ]]; then + if [[ "$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 "should-run=true" >> "$GITHUB_OUTPUT" + elif [[ "$CONTRACTS_CHANGED" == "true" ]]; then echo "Contract changes detected, workflow will run" - echo "should-run=true" >> $GITHUB_OUTPUT + echo "should-run=true" >> "$GITHUB_OUTPUT" else echo "No contract changes detected, skipping workflow" - echo "should-run=false" >> $GITHUB_OUTPUT + echo "should-run=false" >> "$GITHUB_OUTPUT" fi - 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: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac - - - 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 + - name: Check for upgrades config + id: upgrades env: - RPC_URL: ${{ secrets.RPC_URL }} - run: forge test -${{ inputs.test-verbosity }} - - slither: - name: Slither Analysis - runs-on: ubuntu-latest - needs: [detect-changes] - if: | - needs.detect-changes.outputs.should-run == 'true' && - inputs.run-slither - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac - - - name: Build for Slither - run: forge build --build-info --skip '*/test/**' '*/script/**' --force - - - name: Run Slither - uses: crytic/slither-action@v0.4.0 - with: - slither-config: ${{ inputs.slither-config }} - fail-on: ${{ inputs.slither-fail-on }} - - coverage: - name: Coverage Report - runs-on: ubuntu-latest - needs: [detect-changes, ci] - if: | - always() && - needs.detect-changes.outputs.should-run == 'true' && - needs.ci.result == 'success' && - inputs.run-coverage - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Checkout etherform scripts - uses: actions/checkout@v4 - with: - repository: BreadchainCoop/etherform - ref: ${{ inputs.etherform-ref }} - path: .etherform - sparse-checkout: scripts - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac - - - name: Show Forge version - run: forge --version - - - name: Run coverage - env: - RPC_URL: ${{ secrets.RPC_URL }} + UPGRADES_CONFIG: ${{ inputs.upgrades-config }} run: | - if [[ -n "${{ inputs.coverage-exclude-paths }}" ]]; then - forge coverage --no-match-path "${{ inputs.coverage-exclude-paths }}" 2>&1 | tee coverage-raw.txt + if [[ -f "$UPGRADES_CONFIG" ]]; then + echo "exists=true" >> "$GITHUB_OUTPUT" else - forge coverage 2>&1 | tee coverage-raw.txt + echo "No $UPGRADES_CONFIG found — upgrade safety will be skipped" + echo "exists=false" >> "$GITHUB_OUTPUT" fi - - name: Extract summary table - id: summary - env: - COVERAGE_SOURCE_FILTER: ${{ inputs.coverage-source-filter }} - run: bash .etherform/scripts/coverage/extract-summary.sh - - - name: Post coverage comment - if: inputs.coverage-post-comment && github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v2 - with: - path: coverage-comment.md - - - name: Check coverage threshold - if: inputs.coverage-min-threshold > 0 - env: - THRESHOLD: ${{ inputs.coverage-min-threshold }} - LINES_PCT: ${{ steps.summary.outputs.lines_pct }} - STMTS_PCT: ${{ steps.summary.outputs.stmts_pct }} - BRANCH_PCT: ${{ steps.summary.outputs.branch_pct }} - FUNCS_PCT: ${{ steps.summary.outputs.funcs_pct }} - run: bash .etherform/scripts/coverage/check-threshold.sh - - halmos: - name: Symbolic Execution - runs-on: ubuntu-latest + ci: + name: CI needs: [detect-changes] - if: | - needs.detect-changes.outputs.should-run == 'true' && - inputs.run-halmos - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install Halmos - run: pip install halmos - - - name: Run Halmos - env: - RPC_URL: ${{ secrets.RPC_URL }} - run: halmos - + if: needs.detect-changes.outputs.should-run == 'true' + uses: ./.github/workflows/_ci.yml + with: + check-formatting: ${{ inputs.check-formatting }} + test-verbosity: ${{ inputs.test-verbosity }} + working-directory: ${{ inputs.working-directory }} + foundry-version: ${{ inputs.foundry-version }} + package-manager: ${{ inputs.package-manager }} + node-version: ${{ inputs.node-version }} + run-slither: ${{ inputs.run-slither }} + slither-fail-on: ${{ inputs.slither-fail-on }} + slither-config: ${{ inputs.slither-config }} + run-coverage: ${{ inputs.run-coverage }} + coverage-exclude-paths: ${{ inputs.coverage-exclude-paths }} + coverage-source-filter: ${{ inputs.coverage-source-filter }} + coverage-post-comment: ${{ inputs.coverage-post-comment }} + coverage-min-threshold: ${{ inputs.coverage-min-threshold }} + run-halmos: ${{ inputs.run-halmos }} + halmos-version: ${{ inputs.halmos-version }} + etherform-ref: ${{ inputs.etherform-ref }} + secrets: + RPC_URL: ${{ secrets.RPC_URL }} upgrade-safety: name: Upgrade Safety - runs-on: ubuntu-latest needs: [detect-changes, ci] - if: needs.detect-changes.outputs.should-run == 'true' - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - fetch-depth: 0 - - - name: Checkout etherform scripts - uses: actions/checkout@v4 - with: - repository: BreadchainCoop/etherform - ref: ${{ inputs.etherform-ref }} - path: .etherform - sparse-checkout: scripts - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac - - - name: Build contracts - run: forge build --build-info --extra-output storageLayout - - - name: Validate upgrades config - env: - UPGRADES_CONFIG: ${{ inputs.upgrades-config }} - BASE_BRANCH: ${{ inputs.main-branch }} - run: bash .etherform/scripts/upgrade-safety/validate-config.sh - - - name: Validate upgrade safety - env: - UPGRADES_CONFIG: ${{ inputs.upgrades-config }} - BASE_BRANCH: ${{ inputs.main-branch }} - PACKAGE_MANAGER: ${{ inputs.package-manager }} - run: bash .etherform/scripts/upgrade-safety/validate.sh + if: | + needs.detect-changes.outputs.should-run == 'true' && + needs.detect-changes.outputs.upgrades-config-exists == 'true' + uses: ./.github/workflows/_upgrade-safety.yml + with: + upgrades-config: ${{ inputs.upgrades-config }} + base-branch: ${{ inputs.base-branch }} + oz-upgrades-core-version: ${{ inputs.oz-upgrades-core-version }} + package-manager: ${{ inputs.package-manager }} + node-version: ${{ inputs.node-version }} + foundry-version: ${{ inputs.foundry-version }} + etherform-ref: ${{ inputs.etherform-ref }} deploy-testnet: name: Deploy Testnet - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write needs: [detect-changes, ci, upgrade-safety] + # Deploys only on same-repo pull requests: fork PRs have no secrets, and + # a skipped upgrade-safety job (no upgrades.json) does not block deploys. if: | always() && needs.detect-changes.outputs.should-run == 'true' && needs.ci.result == 'success' && - needs.upgrade-safety.result == 'success' && + (needs.upgrade-safety.result == 'success' || needs.upgrade-safety.result == 'skipped') && inputs.deploy-on-pr && - github.event_name == 'pull_request' - outputs: - blockscout_url: ${{ steps.network.outputs.blockscout_url }} - broadcast_file: ${{ steps.parse.outputs.broadcast_file }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Checkout etherform scripts - uses: actions/checkout@v4 - with: - repository: BreadchainCoop/etherform - ref: ${{ inputs.etherform-ref }} - path: .etherform - sparse-checkout: scripts - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Setup Node.js - if: inputs.package-manager != 'none' - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac - - - name: Deploy contracts - env: - PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} - RPC_URL: ${{ secrets.RPC_URL }} - DEPLOY_ENV_VARS: ${{ secrets.DEPLOY_ENV_VARS }} - run: | - source .etherform/scripts/deploy/prepare-env.sh - forge script "${{ inputs.deploy-script }}" \ - --rpc-url "$RPC_URL" \ - --private-key "$PRIVATE_KEY" \ - --broadcast \ - --slow \ - -vvvv - - - name: Wait for indexing - run: sleep ${{ inputs.indexing-wait }} - - - name: Parse deployment addresses - id: parse - run: bash .etherform/scripts/deploy/parse-broadcast.sh - - - name: Upload broadcast artifacts - uses: actions/upload-artifact@v4 - with: - name: broadcast - path: broadcast/ - - - name: Resolve Blockscout URL from chain ID - id: network - env: - CHAIN_ID: ${{ steps.parse.outputs.chain_id }} - NETWORK_CONFIG: ${{ inputs.network-config-path }} - run: bash .etherform/scripts/deploy/resolve-network.sh - - - name: Create deployment summary - env: - BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} - SUMMARY_TITLE: Testnet Deployment Summary - run: bash .etherform/scripts/deploy/deployment-summary.sh - - - name: Write deployment comment - if: github.event_name == 'pull_request' - env: - BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} - NETWORK_NAME: ${{ steps.network.outputs.network_name }} - run: bash .etherform/scripts/deploy/deployment-comment.sh - - - name: Post deployment comment - if: github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: testnet-deployment-${{ steps.network.outputs.network_name }} - path: deployment-comment.md - - verify-contracts: - name: Verify Contracts - runs-on: ubuntu-latest - needs: [deploy-testnet] - if: | - always() && - needs.deploy-testnet.result == 'success' && - inputs.verify-contracts - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Checkout etherform scripts - uses: actions/checkout@v4 - with: - repository: BreadchainCoop/etherform - ref: ${{ inputs.etherform-ref }} - path: .etherform - sparse-checkout: scripts - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Download broadcast artifacts - uses: actions/download-artifact@v4 - with: - name: broadcast - path: broadcast/ - - - name: Verify contracts on Blockscout - env: - BLOCKSCOUT_URL: ${{ needs.deploy-testnet.outputs.blockscout_url }} - ETH_RPC_URL: ${{ secrets.RPC_URL }} - BROADCAST_FILE: ${{ needs.deploy-testnet.outputs.broadcast_file }} - run: bash .etherform/scripts/deploy/verify-blockscout.sh + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/workflows/_deploy-testnet.yml + with: + deploy-script: ${{ inputs.deploy-script }} + network-config-path: ${{ inputs.network-config-path }} + indexing-wait: ${{ inputs.indexing-wait }} + verify-contracts: ${{ inputs.verify-contracts }} + package-manager: ${{ inputs.package-manager }} + node-version: ${{ inputs.node-version }} + foundry-version: ${{ inputs.foundry-version }} + etherform-ref: ${{ inputs.etherform-ref }} + secrets: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + RPC_URL: ${{ secrets.RPC_URL }} + DEPLOY_ENV_VARS: ${{ secrets.DEPLOY_ENV_VARS }} diff --git a/.github/workflows/_upgrade-safety.yml b/.github/workflows/_upgrade-safety.yml index 4fc21dc..89f9985 100644 --- a/.github/workflows/_upgrade-safety.yml +++ b/.github/workflows/_upgrade-safety.yml @@ -21,6 +21,10 @@ on: description: 'Base branch to compare against when no explicit reference is provided' type: string default: 'main' + oz-upgrades-core-version: + description: 'Version of @openzeppelin/upgrades-core used for validation' + type: string + default: '1.44.2' package-manager: description: 'Package manager for Node.js dependencies (none, npm, yarn, pnpm)' type: string @@ -28,47 +32,49 @@ on: node-version: description: 'Node.js version for package installation' type: string - default: '20' + default: '22' + foundry-version: + description: 'Foundry version to install (stable, nightly, or a specific version)' + type: string + default: 'stable' etherform-ref: - description: 'Git ref for etherform scripts checkout (default: main)' + description: 'Git ref for etherform scripts checkout (default: the ref this workflow was called at)' type: string - default: 'main' + default: '' jobs: upgrade-safety: name: Validate Upgrade Safety runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: submodules: recursive fetch-depth: 0 + persist-credentials: true - - name: Checkout etherform scripts - uses: actions/checkout@v4 + - name: Checkout etherform + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: repository: BreadchainCoop/etherform - ref: ${{ inputs.etherform-ref }} + ref: ${{ inputs.etherform-ref || job.workflow_sha }} path: .etherform - sparse-checkout: scripts + sparse-checkout: | + scripts + .github/actions - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + - name: Setup toolchain + uses: ./.etherform/.github/actions/setup + with: + foundry-version: ${{ inputs.foundry-version }} + package-manager: ${{ inputs.package-manager }} + node-version: ${{ inputs.node-version }} - name: Setup Node.js - uses: actions/setup-node@v4 + if: inputs.package-manager == 'none' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ inputs.node-version }} - cache: ${{ inputs.package-manager }} - - - name: Install dependencies - if: inputs.package-manager != 'none' - run: | - case "${{ inputs.package-manager }}" in - npm) npm ci ;; - yarn) yarn --frozen-lockfile ;; - pnpm) corepack enable && pnpm install --frozen-lockfile ;; - esac - name: Build contracts run: forge build --build-info --extra-output storageLayout @@ -84,4 +90,5 @@ jobs: UPGRADES_CONFIG: ${{ inputs.upgrades-config }} BASE_BRANCH: ${{ inputs.base-branch }} PACKAGE_MANAGER: ${{ inputs.package-manager }} + OZ_UPGRADES_CORE_VERSION: ${{ inputs.oz-upgrades-core-version }} run: bash .etherform/scripts/upgrade-safety/validate.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..ae4783b --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,36 @@ +# End-to-end test: runs the real _foundry-cicd.yml reusable workflow against +# the fixture Foundry project in tests/fixtures/project. This exercises change +# detection, the nested _ci.yml call (build, test, fmt, coverage + threshold), +# and the graceful skip of upgrade safety when no upgrades.json exists. +name: E2E + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + fixture-cicd: + name: Fixture CI/CD + uses: ./.github/workflows/_foundry-cicd.yml + with: + working-directory: tests/fixtures/project + skip-if-no-changes: false + check-formatting: true + run-coverage: true + coverage-min-threshold: 90 + coverage-post-comment: false + + # _deploy-testnet.yml relies on `environment: ` meaning + # "no environment". This job fails loudly if GitHub ever changes that. + empty-environment-guard: + name: Empty Environment Guard + runs-on: ubuntu-latest + environment: ${{ github.event_name == 'schedule' && 'never-used' || '' }} + steps: + - name: Confirm empty environment is accepted + run: echo "empty environment string is accepted" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75da3db..91c7b92 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,43 +3,51 @@ name: Test Scripts on: push: branches: [main] - paths: ['scripts/**', 'tests/**', '.github/workflows/test.yml'] + paths: ['scripts/**', 'tests/**', '.github/**'] pull_request: - paths: ['scripts/**', 'tests/**', '.github/workflows/test.yml'] + paths: ['scripts/**', 'tests/**', '.github/**'] + +permissions: + contents: read jobs: + actionlint: + name: Workflow Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Run actionlint + run: | + bash <(curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.7/scripts/download-actionlint.bash) 1.7.7 + ./actionlint -color + shellcheck: name: ShellCheck Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - - name: Run ShellCheck on all scripts + - name: Run ShellCheck on scripts run: find scripts/ -name '*.sh' -exec shellcheck -s bash {} + + # Tests intentionally export vars inside subshells for isolation, which + # trips the info-level SC2030/SC2031 checks + - name: Run ShellCheck on tests + run: find tests/ -name '*.sh' -exec shellcheck -s bash --severity=warning {} + + unit-tests: name: Unit Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Test prepare-env - run: bash tests/test-prepare-env.sh - - - name: Test parse-broadcast - run: bash tests/test-parse-broadcast.sh - - - name: Test resolve-network - run: bash tests/test-resolve-network.sh - - - name: Test deployment-summary - run: bash tests/test-deployment-summary.sh - - - name: Test deployment-comment - run: bash tests/test-deployment-comment.sh - - - name: Test extract-summary - run: bash tests/test-extract-summary.sh - - - name: Test check-threshold - run: bash tests/test-check-threshold.sh + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Run all unit tests + run: | + FAILED=0 + for t in tests/test-*.sh; do + echo "::group::$t" + bash "$t" || FAILED=1 + echo "::endgroup::" + done + exit "$FAILED" diff --git a/.gitignore b/.gitignore index 5d707ee..ce8511f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ # Worktrees .conductor/ + +# Fixture project build artifacts +tests/fixtures/project/out/ +tests/fixtures/project/cache/ diff --git a/README.md b/README.md index e0ef87e..8078709 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,28 @@ permissions: contents: read pull-requests: write +concurrency: + group: cicd-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: cicd: uses: BreadchainCoop/etherform/.github/workflows/_foundry-cicd.yml@main - secrets: inherit ``` That's it. Push the file and PRs will run `forge build`, `forge test`, and `forge fmt --check`. No secrets or extra config files are needed yet. -`pull-requests: write` is granted up front so the coverage PR comment works once you turn coverage on; it's harmless while coverage is off. `secrets: inherit` lets etherform see any secrets you add later (e.g. `RPC_URL`, `PRIVATE_KEY`) without you having to enumerate them. +`pull-requests: write` is granted up front so the coverage PR comment works once you turn coverage on; it's harmless while coverage is off. The `concurrency` block cancels superseded CI runs when you push to a PR in quick succession (deploys are additionally serialized inside etherform and never cancelled mid-flight). + +When you enable features that need secrets later, pass them explicitly: + +```yaml + secrets: + RPC_URL: ${{ secrets.RPC_URL }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} +``` + +(`secrets: inherit` also works, but enumerating keeps the workflow's access explicit.) ### Step 2 — Node-managed Solidity dependencies (e.g. OpenZeppelin) @@ -87,7 +100,7 @@ extra_output = ["storageLayout"] } ``` -Each contract is compared against the version on `main`. `_foundry-cicd.yml` runs the check by default — no additional input needed. +Each contract is compared against the version on `main`. `_foundry-cicd.yml` runs the check automatically whenever `.github/upgrades.json` exists — no additional input needed. Without the file, the upgrade-safety job is skipped. For the rarer "compare a V2 against a V1 kept in the same repo" case, and for guidance on intentionally removing entries, see the [Upgrade Safety](#upgrade-safety) section below. @@ -124,7 +137,7 @@ Deploy every PR to a testnet so end-to-end deployment behavior is validated befo deploy-on-pr: true ``` -The deploy job verifies the contracts on Blockscout and posts a deployment summary in the run. +The deploy job verifies the contracts on Blockscout, writes a `deployments//deployment.json` artifact for frontends, and posts a deployment summary in the run. Deploys only run for pull requests from the same repository (fork PRs have no access to secrets), and are serialized per branch so two pushes can't interleave deployments. ### Step 5 — Coverage threshold and static analysis (optional) @@ -136,10 +149,37 @@ The deploy job verifies the contracts on Blockscout and posts a deployment summa run-halmos: true ``` +### Step 6 — Mainnet deploys on merge (optional) + +The deploy workflow is network-agnostic: the chain is whatever your `RPC_URL` points at, and the chain ID from the broadcast is matched against `deploy-networks.json` for the explorer link. For mainnet, call `_deploy-testnet.yml` directly from a workflow that triggers on merge to `main`, and put it behind a [protected GitHub Environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment) so the deploy waits for a required reviewer: + +```yaml +# .github/workflows/deploy-mainnet.yml +name: Deploy Mainnet +on: + push: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + deploy: + uses: BreadchainCoop/etherform/.github/workflows/_deploy-testnet.yml@main + with: + environment: production # protected GitHub Environment + secrets: + PRIVATE_KEY: ${{ secrets.MAINNET_PRIVATE_KEY }} + RPC_URL: ${{ secrets.MAINNET_RPC_URL }} +``` + +Add the mainnet entry (name, `chain_id`, `blockscout_url`) to the `testnets` array in `deploy-networks.json` so the explorer link resolves. + ### Common pitfalls - **One workflow file, not several.** `_foundry-cicd.yml` already covers CI, upgrade safety, and deploy. Don't also add separate `_ci.yml` or `_upgrade-safety.yml` wrappers — every PR will run everything twice. -- **Testing changes to etherform itself.** When you point `uses:` at an etherform branch (e.g. `@my-branch`), you also need to set `etherform-ref: my-branch`. The workflow does a separate sparse checkout of etherform's bash scripts that uses `etherform-ref` (default `main`); without it, your `uses:` change has no effect on the script logic. +- **Pin your ref.** `@main` tracks etherform's latest commit. For reproducible CI, pin `uses:` to a tag or commit SHA. The bash scripts are automatically checked out at the same commit as the workflow you call (via `job.workflow_sha`), so a single pin covers both; `etherform-ref` only needs to be set to override that (rarely needed). - **`403 Resource not accessible by integration`.** Permissions are granted by the calling workflow, not at the org level. Start with the `permissions:` block in step 1 and grow it if a feature you turn on later requires more. ## Usage @@ -282,6 +322,19 @@ contract MyContract is Initializable { See the [OpenZeppelin docs](https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable) for all supported annotations. +Alternatively, pass extra flags to the OZ validate CLI per contract via `options` in `upgrades.json` (useful when you can't annotate the source, e.g. vendored contracts): + +```json +{ + "contracts": [ + { + "contract": "src/Token.sol:Token", + "options": ["--unsafeAllowRenames"] + } + ] +} +``` + ## Configuration ### Network Configuration @@ -322,9 +375,11 @@ If your Foundry project uses npm/yarn/pnpm for Solidity dependencies (e.g., Open | Input | Type | Default | Description | |-------|------|---------|-------------| | `check-formatting` | boolean | `true` | Run `forge fmt --check` | -| `test-verbosity` | string | `'vvv'` | Test verbosity (`v`, `vv`, `vvv`, `vvvv`) | +| `test-verbosity` | string | `'vvv'` | Test verbosity (`v`, `vv`, `vvv`, `vvvv`, `vvvvv`) | | `package-manager` | string | `'none'` | Package manager (`none`, `npm`, `yarn`, `pnpm`) | -| `node-version` | string | `'20'` | Node.js version for package installation | +| `node-version` | string | `'22'` | Node.js version for package installation | +| `foundry-version` | string | `'stable'` | Foundry version (`stable`, `nightly`, or a specific version) | +| `working-directory` | string | `'.'` | Directory containing the Foundry project (for monorepos) | | `run-slither` | boolean | `false` | Run Slither static analysis | | `slither-fail-on` | string | `'high'` | Minimum severity to fail on (`low`, `medium`, `high`) | | `slither-config` | string | `'slither.config.json'` | Path to slither.config.json | @@ -334,7 +389,8 @@ If your Foundry project uses npm/yarn/pnpm for Solidity dependencies (e.g., Open | `coverage-post-comment` | boolean | `true` | Post coverage summary as a sticky PR comment | | `coverage-min-threshold` | number | `0` | Minimum coverage % to pass (0 = disabled) | | `run-halmos` | boolean | `false` | Run Halmos symbolic execution | -| `etherform-ref` | string | `'main'` | Git ref for etherform scripts checkout | +| `halmos-version` | string | `''` | Halmos version to install (empty = latest) | +| `etherform-ref` | string | `''` | Git ref for etherform scripts checkout (default: the ref this workflow was called at) | | Secret | Required | Description | |--------|----------|-------------| @@ -347,45 +403,55 @@ If your Foundry project uses npm/yarn/pnpm for Solidity dependencies (e.g., Open | Input | Type | Default | Description | |-------|------|---------|-------------| | `package-manager` | string | `'none'` | Package manager (`none`, `npm`, `yarn`, `pnpm`) | -| `node-version` | string | `'20'` | Node.js version for package installation | +| `node-version` | string | `'22'` | Node.js version for package installation | +| `foundry-version` | string | `'stable'` | Foundry version (`stable`, `nightly`, or a specific version) | | `upgrades-config` | string | `'.github/upgrades.json'` | Path to upgrade safety config | | `base-branch` | string | `'main'` | Base branch for upgrade safety comparison | -| `etherform-ref` | string | `'main'` | Git ref for etherform scripts checkout | +| `oz-upgrades-core-version` | string | `'1.44.2'` | Version of `@openzeppelin/upgrades-core` used for validation | +| `etherform-ref` | string | `''` | Git ref for etherform scripts checkout (default: the ref this workflow was called at) | ### `_deploy-testnet.yml` +Despite the historical name, this workflow deploys to whatever network `RPC_URL` points at (see [Step 6](#step-6--mainnet-deploys-on-merge-optional) for mainnet). The chain ID from the broadcast artifact is matched against the network config to resolve the network name and explorer URL. + | 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 | | `verify-contracts` | boolean | `true` | Verify on Blockscout | +| `environment` | string | `''` | GitHub Environment to deploy under (empty = none); use a protected environment for mainnet | | `package-manager` | string | `'none'` | Package manager (`none`, `npm`, `yarn`, `pnpm`) | -| `node-version` | string | `'20'` | Node.js version for package installation | -| `etherform-ref` | string | `'main'` | Git ref for etherform scripts checkout | +| `node-version` | string | `'22'` | Node.js version for package installation | +| `foundry-version` | string | `'stable'` | Foundry version (`stable`, `nightly`, or a specific version) | +| `etherform-ref` | string | `''` | Git ref for etherform scripts checkout (default: the ref this workflow was called at) | ### `_foundry-cicd.yml` -The all-in-one workflow accepts all inputs from the above workflows plus: +The all-in-one workflow accepts all inputs from the above workflows (except `environment`) plus: | Input | Type | Default | Description | |-------|------|---------|-------------| | `skip-if-no-changes` | boolean | `true` | Skip if no contract files changed | | `contract-paths` | string | `src/**`, `script/**`, etc. | Paths to watch for changes | -| `main-branch` | string | `'main'` | Base branch for upgrade safety comparison | | `deploy-on-pr` | boolean | `false` | Deploy to testnet on PR | -All workflows also accept `etherform-ref` (default: `'main'`) to control which etherform branch the scripts are checked out from. Override this when testing against an unreleased etherform branch. +Internally it calls `_ci.yml`, `_upgrade-safety.yml`, and `_deploy-testnet.yml` as nested reusable workflows, so check names appear as e.g. `CI/CD / ci / Build & Test`. The upgrade-safety job runs only when the `upgrades-config` file exists; deploys run only for same-repo pull requests. + +All workflows also accept `etherform-ref` to override which etherform ref the bash scripts are checked out from. By default the scripts come from the same commit as the workflow you call (`job.workflow_sha`), so this is only needed for unusual setups. ## Scripts -Shared logic is extracted into modular bash scripts under `scripts/`. Workflows check out these scripts at runtime via `actions/checkout`. The scripts are independently testable. +Shared logic is extracted into modular bash scripts under `scripts/`. Workflows check out these scripts (and the shared setup action) at runtime via a sparse `actions/checkout` of etherform pinned to the same commit as the workflow. The scripts are independently testable. | Directory | Scripts | Purpose | |-----------|---------|---------| -| `scripts/deploy/` | `prepare-env.sh`, `parse-broadcast.sh`, `resolve-network.sh`, `deployment-summary.sh`, `verify-blockscout.sh` | Deployment helpers | +| `scripts/deploy/` | `prepare-env.sh`, `parse-broadcast.sh`, `resolve-network.sh`, `write-deployment-json.sh`, `deployment-summary.sh`, `deployment-comment.sh`, `verify-blockscout.sh` | Deployment helpers | | `scripts/coverage/` | `extract-summary.sh`, `check-threshold.sh` | Coverage reporting | -| `scripts/upgrade-safety/` | `validate.sh` | Upgrade safety validation | +| `scripts/upgrade-safety/` | `validate-config.sh`, `validate.sh` | Upgrade safety validation | + +Run tests locally: `for t in tests/test-*.sh; do bash "$t"; done` + +## Repo CI -Run tests locally: `bash tests/test-*.sh` +Etherform tests itself: `test.yml` runs actionlint, ShellCheck, and the unit tests for every script, and `e2e.yml` runs the real `_foundry-cicd.yml` against the fixture Foundry project in `tests/fixtures/project` on every PR — exercising change detection, build/test/format, coverage extraction and thresholds, and the graceful upgrade-safety skip. diff --git a/docs/specs/ci-cd-automated-deployments.md b/docs/specs/ci-cd-automated-deployments.md index 2bd3827..d749d0f 100644 --- a/docs/specs/ci-cd-automated-deployments.md +++ b/docs/specs/ci-cd-automated-deployments.md @@ -1,5 +1,13 @@ # Technical Spec — Auto CI/CD: Testnet & Mainnet Deployments With Upgrade Safety Validation (Foundry + Blockscout) +> **Status: partially superseded.** This is the original design document; the +> implementation diverged in several ways. Upgrade safety uses the OpenZeppelin +> upgrades-core CLI comparing against the base branch (not the +> flattening/`ValidateUpgrade.s.sol` snapshot approach described below), and the +> entry points are reusable workflows (`_foundry-cicd.yml` etc.), not a single +> composite action. See the [README](../../README.md) for how things actually +> work today, including mainnet deploys via protected GitHub Environments. + ## 1. Background ### Problem Statement: What hurts today? diff --git a/scripts/coverage/extract-summary.sh b/scripts/coverage/extract-summary.sh index 306a3ae..d854bd7 100755 --- a/scripts/coverage/extract-summary.sh +++ b/scripts/coverage/extract-summary.sh @@ -28,8 +28,14 @@ total_stmts_hit=0; total_stmts_all=0 total_branch_hit=0; total_branch_all=0 total_funcs_hit=0; total_funcs_all=0 +# Pull "hit total" out of a cell like "82.35% (14/17)". POSIX sed (no GNU +# grep -P) so the test suite also runs on macOS. +extract() { + out=$(echo "$1" | sed -n -E 's|.*\(([0-9]+)/([0-9]+)\).*|\1 \2|p') + echo "${out:-0 0}" +} + while IFS='|' read -r _ file lines stmts branches funcs _; do - extract() { echo "$1" | grep -oP '\(\K[0-9]+/[0-9]+' | tr '/' ' '; } read -r lh lt <<< "$(extract "$lines")" read -r sh st <<< "$(extract "$stmts")" read -r bh bt <<< "$(extract "$branches")" diff --git a/scripts/deploy/parse-broadcast.sh b/scripts/deploy/parse-broadcast.sh index c9ec4d4..fc03ee9 100755 --- a/scripts/deploy/parse-broadcast.sh +++ b/scripts/deploy/parse-broadcast.sh @@ -3,20 +3,61 @@ set -euo pipefail # Parse Foundry broadcast artifacts after deployment. # Finds run-latest.json, extracts chain ID, writes deployment-summary.txt. # +# Deployments are collected from top-level CREATE and CREATE2 transactions. +# Contracts deployed by other contracts (additionalContracts) have no name in +# the broadcast artifact; they are listed in the log but excluded from the +# summary since they cannot be labeled or verified. +# +# Env inputs: +# DEPLOY_SCRIPT — optional, deploy script spec (e.g. script/Deploy.s.sol:Deploy); +# scopes the broadcast search to that script's directory +# # Outputs (via $GITHUB_OUTPUT): # broadcast_file — path to the broadcast JSON -# chain_id — chain ID extracted from directory structure +# chain_id — chain ID from the artifact (directory name as fallback) + +SEARCH_DIR="broadcast" +if [[ -n "${DEPLOY_SCRIPT:-}" ]]; then + # script/Deploy.s.sol:Deploy -> broadcast/Deploy.s.sol + SCRIPT_FILE=$(basename "${DEPLOY_SCRIPT%%:*}") + if [[ -d "broadcast/$SCRIPT_FILE" ]]; then + SEARCH_DIR="broadcast/$SCRIPT_FILE" + fi +fi + +BROADCAST_FILES=$(find "$SEARCH_DIR" -name "run-latest.json" -type f 2>/dev/null | sort) +if [[ -z "$BROADCAST_FILES" ]]; then + echo "::error::No broadcast file found under $SEARCH_DIR" + exit 1 +fi -BROADCAST_FILE=$(find broadcast -name "run-latest.json" -type f | head -1) -if [[ -z "$BROADCAST_FILE" ]]; then - echo "No broadcast file found" +FILE_COUNT=$(echo "$BROADCAST_FILES" | wc -l | tr -d ' ') +if [[ "$FILE_COUNT" -gt 1 ]]; then + echo "::error::Found $FILE_COUNT broadcast files under $SEARCH_DIR — cannot determine which deployment to use:" + echo "$BROADCAST_FILES" + echo "Set DEPLOY_SCRIPT to scope the search, or clean up stale broadcast directories." exit 1 fi +BROADCAST_FILE="$BROADCAST_FILES" echo "broadcast_file=$BROADCAST_FILE" >> "$GITHUB_OUTPUT" -# Extract chain ID from path: broadcast/