diff --git a/.github/workflows/_foundry-cicd.yml b/.github/workflows/_foundry-cicd.yml index e183ba8..a2f8e29 100644 --- a/.github/workflows/_foundry-cicd.yml +++ b/.github/workflows/_foundry-cicd.yml @@ -2,6 +2,12 @@ # Usage: jobs..uses: BreadchainCoop/etherform/.github/workflows/_foundry-cicd.yml@main name: Foundry CI/CD +# Group by event_name so a `pull_request: closed` release run cannot cancel a +# concurrent `push` CI run on the same branch (and vice versa). +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + on: workflow_call: inputs: @@ -108,6 +114,21 @@ on: description: 'Verify deployed contracts on Blockscout' type: boolean default: true + + # Release Options + release-on-merge: + description: 'On PR merge, redeploy and publish a GitHub Release with deployed addresses' + type: boolean + default: false + tag-prefix: + description: 'Prefix for the release tag; PR number is appended' + type: string + default: 'testnet-pr-' + release-on-collision: + description: 'Behavior if the release tag already exists (replace, skip, fail)' + type: string + default: 'replace' + # Halmos Options run-halmos: description: 'Run Halmos symbolic execution' @@ -130,6 +151,9 @@ on: DEPLOY_ENV_VARS: description: 'Newline-separated KEY=VALUE pairs exported before running the deploy script' required: false + GH_TOKEN: + description: 'Optional override for the GitHub token used to create releases; defaults to GITHUB_TOKEN' + required: false jobs: detect-changes: @@ -156,6 +180,7 @@ jobs: - name: Check for contract changes id: filter + if: github.event.action != 'closed' uses: dorny/paths-filter@v3 with: filters: ${{ steps.paths.outputs.filter }} @@ -163,7 +188,10 @@ jobs: - name: Decide whether to run id: decide run: | - if [[ "${{ inputs.skip-if-no-changes }}" == "false" ]]; then + if [[ "${{ github.event.action }}" == "closed" ]]; then + echo "PR close event — release-only mode; CI/upgrade-safety/deploy will skip" + echo "should-run=false" >> $GITHUB_OUTPUT + elif [[ "${{ 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 @@ -523,3 +551,126 @@ jobs: ETH_RPC_URL: ${{ secrets.RPC_URL }} BROADCAST_FILE: ${{ steps.parse.outputs.broadcast_file }} run: bash .etherform/scripts/deploy/verify-blockscout.sh + + release-testnet: + name: Release Testnet + runs-on: ubuntu-latest + permissions: + contents: write + if: | + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + inputs.release-on-merge + steps: + - name: Resolve merge commit SHA + id: sha + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + FALLBACK_SHA: ${{ github.sha }} + run: | + if [[ -n "$MERGE_SHA" && "$MERGE_SHA" != "null" ]]; then + echo "sha=$MERGE_SHA" >> "$GITHUB_OUTPUT" + else + echo "::warning::merge_commit_sha not available, falling back to github.sha" + echo "sha=$FALLBACK_SHA" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout repository at merge commit + uses: actions/checkout@v4 + with: + submodules: recursive + ref: ${{ steps.sha.outputs.sha }} + + - 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" \ + --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: 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: Verify contracts on Blockscout + if: inputs.verify-contracts + env: + BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + ETH_RPC_URL: ${{ secrets.RPC_URL }} + BROADCAST_FILE: ${{ steps.parse.outputs.broadcast_file }} + run: bash .etherform/scripts/deploy/verify-blockscout.sh + + - name: Build release body + env: + BROADCAST_FILE: ${{ steps.parse.outputs.broadcast_file }} + BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + NETWORK_NAME: ${{ steps.network.outputs.network_name }} + CHAIN_ID: ${{ steps.parse.outputs.chain_id }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + MERGE_SHA: ${{ steps.sha.outputs.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + OUTPUT_FILE: release-body.md + run: bash .etherform/scripts/release/build-release-body.sh + + - name: Create GitHub release + env: + REPO: ${{ github.repository }} + TAG: ${{ inputs.tag-prefix }}${{ github.event.pull_request.number }} + TITLE: "Testnet — ${{ steps.network.outputs.network_name }} — PR #${{ github.event.pull_request.number }}" + BODY_FILE: release-body.md + TARGET_SHA: ${{ steps.sha.outputs.sha }} + COLLISION_POLICY: ${{ inputs.release-on-collision }} + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + run: bash .etherform/scripts/release/create-release.sh diff --git a/.github/workflows/_release-testnet.yml b/.github/workflows/_release-testnet.yml new file mode 100644 index 0000000..6b1a0bc --- /dev/null +++ b/.github/workflows/_release-testnet.yml @@ -0,0 +1,190 @@ +# Reusable testnet deployment + GitHub Release workflow. +# Triggered by the consumer when a PR is merged: redeploys to testnet from the merge +# commit, then creates a GitHub Release tagged `` containing the +# deployed contract names and addresses. +# +# Consumer-side wrapper (in their repo): +# +# on: +# pull_request: +# types: [closed] +# jobs: +# release: +# if: github.event.pull_request.merged == true +# permissions: +# contents: write +# uses: BreadchainCoop/etherform/.github/workflows/_release-testnet.yml@main +# secrets: +# PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} +# RPC_URL: ${{ secrets.RPC_URL }} +name: Release Testnet (Reusable) + +on: + workflow_call: + inputs: + 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 + verify-contracts: + description: 'Verify deployed contracts on Blockscout' + type: boolean + default: true + package-manager: + description: 'Package manager for Node.js dependencies (none, npm, yarn, pnpm)' + type: string + default: 'none' + node-version: + description: 'Node.js version for package installation' + type: string + default: '20' + tag-prefix: + description: 'Prefix for the release tag; PR number is appended' + type: string + default: 'testnet-pr-' + release-on-collision: + description: 'Behavior if the release tag already exists (replace, skip, fail)' + type: string + default: 'replace' + etherform-ref: + description: 'Git ref for etherform scripts checkout (default: main)' + type: string + default: 'main' + secrets: + PRIVATE_KEY: + required: true + RPC_URL: + required: true + DEPLOY_ENV_VARS: + description: 'Newline-separated KEY=VALUE pairs exported before running the deploy script' + required: false + GH_TOKEN: + description: 'Optional override for the GitHub token used to create the release; defaults to GITHUB_TOKEN' + required: false + +jobs: + release-testnet: + name: Deploy & Release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Resolve merge commit SHA + id: sha + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + FALLBACK_SHA: ${{ github.sha }} + run: | + if [[ -n "$MERGE_SHA" && "$MERGE_SHA" != "null" ]]; then + echo "sha=$MERGE_SHA" >> "$GITHUB_OUTPUT" + else + echo "::warning::merge_commit_sha not available, falling back to github.sha" + echo "sha=$FALLBACK_SHA" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout repository at merge commit + uses: actions/checkout@v4 + with: + submodules: recursive + ref: ${{ steps.sha.outputs.sha }} + + - 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" \ + --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: 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: Verify contracts on Blockscout + if: inputs.verify-contracts + env: + BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + ETH_RPC_URL: ${{ secrets.RPC_URL }} + BROADCAST_FILE: ${{ steps.parse.outputs.broadcast_file }} + run: bash .etherform/scripts/deploy/verify-blockscout.sh + + - name: Build release body + env: + BROADCAST_FILE: ${{ steps.parse.outputs.broadcast_file }} + BLOCKSCOUT_URL: ${{ steps.network.outputs.blockscout_url }} + NETWORK_NAME: ${{ steps.network.outputs.network_name }} + CHAIN_ID: ${{ steps.parse.outputs.chain_id }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + MERGE_SHA: ${{ steps.sha.outputs.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + OUTPUT_FILE: release-body.md + run: bash .etherform/scripts/release/build-release-body.sh + + - name: Create GitHub release + env: + REPO: ${{ github.repository }} + TAG: ${{ inputs.tag-prefix }}${{ github.event.pull_request.number }} + TITLE: "Testnet — ${{ steps.network.outputs.network_name }} — PR #${{ github.event.pull_request.number }}" + BODY_FILE: release-body.md + TARGET_SHA: ${{ steps.sha.outputs.sha }} + COLLISION_POLICY: ${{ inputs.release-on-collision }} + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + run: bash .etherform/scripts/release/create-release.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a22c56e..79cd8fe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,3 +40,9 @@ jobs: - name: Test check-threshold run: bash tests/test-check-threshold.sh + + - name: Test build-release-body + run: bash tests/test-build-release-body.sh + + - name: Test create-release + run: bash tests/test-create-release.sh diff --git a/README.md b/README.md index ec5a00d..3831eda 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Reusable GitHub Actions workflows for Foundry smart contract CI/CD with upgrade | `_ci.yml` | Build, test, format check, coverage, and Halmos | | `_upgrade-safety.yml` | OpenZeppelin upgrade safety validation | | `_deploy-testnet.yml` | Testnet deployment with Blockscout verification | +| `_release-testnet.yml` | Redeploy on PR merge and publish a GitHub Release | | `_foundry-cicd.yml` | All-in-one orchestrator combining all of the above | ## Usage @@ -219,6 +220,62 @@ If your Foundry project uses npm/yarn/pnpm for Solidity dependencies (e.g., Open | `node-version` | string | `'20'` | Node.js version for package installation | | `etherform-ref` | string | `'main'` | Git ref for etherform scripts checkout | +### `_release-testnet.yml` + +Triggered by a consumer-side wrapper on PR merge. Redeploys the merge commit to testnet and publishes a GitHub Release containing the deployed contract names and addresses with Blockscout links. + +> **Tip:** if you are already using `_foundry-cicd.yml`, prefer setting `release-on-merge: true` there instead of adding a second wrapper file. See [`_foundry-cicd.yml`](#_foundry-cicdyml) below. + +| 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 | +| `indexing-wait` | number | `60` | Seconds to wait before verification | +| `verify-contracts` | boolean | `true` | Verify on Blockscout | +| `package-manager` | string | `'none'` | Package manager (`none`, `npm`, `yarn`, `pnpm`) | +| `node-version` | string | `'20'` | Node.js version for package installation | +| `tag-prefix` | string | `'testnet-pr-'` | Prefix for the release tag; PR number is appended | +| `release-on-collision` | string | `'replace'` | Behavior if the tag exists (`replace`, `skip`, `fail`) | +| `etherform-ref` | string | `'main'` | Git ref for etherform scripts checkout | + +| Secret | Required | Description | +|--------|----------|-------------| +| `PRIVATE_KEY` | Yes | Deployer wallet private key | +| `RPC_URL` | Yes | Testnet RPC endpoint | +| `DEPLOY_ENV_VARS` | No | Newline-separated `KEY=VALUE` pairs exported before the deploy script | +| `GH_TOKEN` | No | Override for the token used to create the release; defaults to `GITHUB_TOKEN` | + +#### Consumer wrapper + +```yaml +# .github/workflows/release-testnet.yml in the consumer repo +name: Release Testnet +on: + pull_request: + types: [closed] + +jobs: + release: + if: github.event.pull_request.merged == true + permissions: + contents: write + uses: BreadchainCoop/etherform/.github/workflows/_release-testnet.yml@main + with: + deploy-script: script/Deploy.s.sol:Deploy + # release-on-collision: replace # default + secrets: + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + RPC_URL: ${{ secrets.RPC_URL }} +``` + +#### Behavior + +- The merge commit (`pull_request.merge_commit_sha`) is deployed to testnet again — this is a fresh deploy, separate from any PR-time validation deploy. Testnet gas is the tradeoff for not having to find and reuse the PR's earlier artifact. +- The release is tagged `testnet-pr-` (override via `tag-prefix`) and targets the merge commit. +- If the tag already exists (e.g. PR was reverted then re-merged), default behavior is to delete and recreate. Switch to `skip` to keep the original release, or `fail` to error out. +- Releases are **not** marked as pre-releases — testnet and mainnet are parallel deployment targets, not pre/release stages. The release title and a banner in the body identify the deployment as testnet. +- If the deploy fails (e.g. RPC outage, nonce conflict), no release is created. Re-run the workflow to retry. + ### `_foundry-cicd.yml` The all-in-one workflow accepts all inputs from the above workflows plus: @@ -229,9 +286,42 @@ The all-in-one workflow accepts all inputs from the above workflows plus: | `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 | +| `release-on-merge` | boolean | `false` | On PR merge, redeploy and publish a GitHub Release | +| `tag-prefix` | string | `'testnet-pr-'` | Prefix for the release tag; PR number is appended | +| `release-on-collision` | string | `'replace'` | Behavior if the release tag exists (`replace`, `skip`, `fail`) | + +It also accepts an optional `GH_TOKEN` secret to override the token used for release creation (defaults to `GITHUB_TOKEN`). 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. +#### Consumer wrapper (CI + release in one file) + +To run CI on push/PR and publish a testnet release on merge from a single workflow file, list `closed` in the `pull_request` types and set `release-on-merge: true`: + +```yaml +# .github/workflows/cicd.yml +name: CI/CD +on: + push: + pull_request: + types: [opened, synchronize, reopened, closed] + +jobs: + cicd: + uses: BreadchainCoop/etherform/.github/workflows/_foundry-cicd.yml@main + with: + release-on-merge: true + secrets: inherit +``` + +When a `pull_request: closed` event fires: + +- If the PR was merged and `release-on-merge: true`, only the `release-testnet` job runs. +- The CI / upgrade-safety / deploy jobs are skipped (the workflow short-circuits in `detect-changes`). +- If the PR was closed without merging, no jobs run. + +Use the standalone [`_release-testnet.yml`](#_release-testnetyml) instead if you need release in a separate workflow file (e.g. for distinct status checks or tighter permission scoping). + ## 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. @@ -241,5 +331,6 @@ Shared logic is extracted into modular bash scripts under `scripts/`. Workflows | `scripts/deploy/` | `prepare-env.sh`, `parse-broadcast.sh`, `resolve-network.sh`, `deployment-summary.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/release/` | `build-release-body.sh`, `create-release.sh` | Release notes + GitHub Release creation | Run tests locally: `bash tests/test-*.sh` diff --git a/scripts/release/build-release-body.sh b/scripts/release/build-release-body.sh new file mode 100755 index 0000000..23de61c --- /dev/null +++ b/scripts/release/build-release-body.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail +# Build a markdown release body for a testnet deployment. +# +# Env inputs: +# BROADCAST_FILE — required, path to run-latest.json +# BLOCKSCOUT_URL — required +# NETWORK_NAME — required +# CHAIN_ID — required +# PR_NUMBER — required +# PR_TITLE — required +# PR_URL — required +# MERGE_SHA — required, full merge commit SHA +# RUN_URL — required, URL of the workflow run that produced this release +# OUTPUT_FILE — required, path to write the markdown body + +: "${BROADCAST_FILE:?BROADCAST_FILE is required}" +: "${BLOCKSCOUT_URL:?BLOCKSCOUT_URL is required}" +: "${NETWORK_NAME:?NETWORK_NAME is required}" +: "${CHAIN_ID:?CHAIN_ID is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${PR_TITLE:?PR_TITLE is required}" +: "${PR_URL:?PR_URL is required}" +: "${MERGE_SHA:?MERGE_SHA is required}" +: "${RUN_URL:?RUN_URL is required}" +: "${OUTPUT_FILE:?OUTPUT_FILE is required}" + +if [[ ! -f "$BROADCAST_FILE" ]]; then + echo "::error::Broadcast file not found: $BROADCAST_FILE" + exit 1 +fi + +# Strip trailing slash from Blockscout URL so we can join with /address/... +BLOCKSCOUT_URL="${BLOCKSCOUT_URL%/}" + +SHORT_SHA="${MERGE_SHA:0:7}" +DEPLOYER=$(jq -r '.transactions[0].from // empty' "$BROADCAST_FILE") +CREATE_COUNT=$(jq '[.transactions[] | select(.transactionType == "CREATE")] | length' "$BROADCAST_FILE") + +{ + echo "> **Testnet deployment** — \`${NETWORK_NAME}\` (chain \`${CHAIN_ID}\`)" + echo "" + echo "Merged PR: [#${PR_NUMBER} ${PR_TITLE}](${PR_URL})" + echo "" + echo "Commit: \`${SHORT_SHA}\`" + if [[ -n "$DEPLOYER" && "$DEPLOYER" != "null" ]]; then + echo "" + echo "Deployer: [\`${DEPLOYER}\`](${BLOCKSCOUT_URL}/address/${DEPLOYER})" + fi + echo "" + + if [[ "$CREATE_COUNT" -eq 0 ]]; then + echo "_No contracts deployed in this run._" + else + echo "## Contracts" + echo "" + echo "| Contract | Address | Explorer |" + echo "|----------|---------|----------|" + # Escape pipes in contract names so they don't break the table. + jq -r --arg blockscout "$BLOCKSCOUT_URL" ' + .transactions[] + | select(.transactionType == "CREATE") + | "| \(.contractName | gsub("\\|"; "\\|")) | `\(.contractAddress)` | [View](\($blockscout)/address/\(.contractAddress)) |" + ' "$BROADCAST_FILE" + fi + + echo "" + echo "_Generated by [workflow run](${RUN_URL})._" +} > "$OUTPUT_FILE" + +echo "Wrote release body to $OUTPUT_FILE (${CREATE_COUNT} contract(s))" diff --git a/scripts/release/create-release.sh b/scripts/release/create-release.sh new file mode 100755 index 0000000..42ae6ce --- /dev/null +++ b/scripts/release/create-release.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail +# Create a GitHub release, handling tag collisions per policy. +# +# Env inputs: +# REPO — required, owner/name (e.g. GITHUB_REPOSITORY) +# TAG — required, e.g. testnet-pr-42 +# TITLE — required +# BODY_FILE — required, path to release notes markdown +# TARGET_SHA — required, commit the release tag should point at +# COLLISION_POLICY — required, one of: replace | skip | fail +# GH_TOKEN — required (consumed by gh CLI) + +: "${REPO:?REPO is required}" +: "${TAG:?TAG is required}" +: "${TITLE:?TITLE is required}" +: "${BODY_FILE:?BODY_FILE is required}" +: "${TARGET_SHA:?TARGET_SHA is required}" +: "${COLLISION_POLICY:?COLLISION_POLICY is required}" + +case "$COLLISION_POLICY" in + replace|skip|fail) ;; + *) + echo "::error::Invalid COLLISION_POLICY: $COLLISION_POLICY (expected replace|skip|fail)" + exit 1 + ;; +esac + +if [[ ! -f "$BODY_FILE" ]]; then + echo "::error::Body file not found: $BODY_FILE" + exit 1 +fi + +# Probe whether the release already exists. Distinguish "not found" from auth/network errors +# so we don't silently treat a 401 as "tag is free." +EXISTS=0 +if VIEW_OUTPUT=$(gh release view "$TAG" --repo "$REPO" 2>&1); then + EXISTS=1 +else + if echo "$VIEW_OUTPUT" | grep -qi "release not found\|not found"; then + EXISTS=0 + else + echo "::error::Failed to query release '$TAG' on $REPO:" + echo "$VIEW_OUTPUT" + exit 1 + fi +fi + +if [[ "$EXISTS" -eq 1 ]]; then + case "$COLLISION_POLICY" in + replace) + echo "Release '$TAG' already exists — deleting per replace policy" + gh release delete "$TAG" --cleanup-tag --yes --repo "$REPO" + ;; + skip) + echo "Release '$TAG' already exists — skipping per skip policy" + { + echo "## Release" + echo "" + echo "> Release \`$TAG\` already exists; skipped per \`release-on-collision: skip\`." + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + exit 0 + ;; + fail) + echo "::error::Release '$TAG' already exists and collision policy is 'fail'" + exit 1 + ;; + esac +fi + +gh release create "$TAG" \ + --repo "$REPO" \ + --title "$TITLE" \ + --notes-file "$BODY_FILE" \ + --target "$TARGET_SHA" + +RELEASE_URL="https://github.com/${REPO}/releases/tag/${TAG}" +{ + echo "## Release" + echo "" + echo "Created [\`${TAG}\`](${RELEASE_URL})." +} >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + +echo "Created release $TAG at $RELEASE_URL" diff --git a/tests/test-build-release-body.sh b/tests/test-build-release-body.sh new file mode 100755 index 0000000..736ce9f --- /dev/null +++ b/tests/test-build-release-body.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$SCRIPT_DIR/scripts/release/build-release-body.sh" +FIXTURES="$SCRIPT_DIR/tests/fixtures" +FAILURES=0 + +echo "=== Testing scripts/release/build-release-body.sh ===" + +common_env() { + export BLOCKSCOUT_URL="https://eth-sepolia.blockscout.com" + export NETWORK_NAME="sepolia" + export CHAIN_ID="11155111" + export PR_NUMBER="42" + export PR_TITLE="Add greeter contract" + export PR_URL="https://github.com/owner/repo/pull/42" + export MERGE_SHA="abcdef1234567890abcdef1234567890abcdef12" + export RUN_URL="https://github.com/owner/repo/actions/runs/999" +} + +# Test 1: Renders banner, PR link, commit, and contract table from fixture +( + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + common_env + export BROADCAST_FILE="$FIXTURES/broadcast/Deploy.s.sol/31337/run-latest.json" + export OUTPUT_FILE="$TMPDIR/body.md" + + bash "$SCRIPT" > /dev/null + + grep -q "Testnet deployment" "$OUTPUT_FILE" || { echo "FAIL: missing testnet banner"; exit 1; } + grep -q "\`sepolia\`" "$OUTPUT_FILE" || { echo "FAIL: network name not in banner"; exit 1; } + grep -q "chain \`11155111\`" "$OUTPUT_FILE" || { echo "FAIL: chain id not in banner"; exit 1; } + grep -q "#42 Add greeter contract" "$OUTPUT_FILE" || { echo "FAIL: PR title/number missing"; exit 1; } + grep -q "https://github.com/owner/repo/pull/42" "$OUTPUT_FILE" || { echo "FAIL: PR URL missing"; exit 1; } + grep -q "Commit: \`abcdef1\`" "$OUTPUT_FILE" || { echo "FAIL: short SHA missing"; exit 1; } + grep -q "MyToken" "$OUTPUT_FILE" || { echo "FAIL: MyToken row missing"; exit 1; } + grep -q "MyProxy" "$OUTPUT_FILE" || { echo "FAIL: MyProxy row missing"; exit 1; } + grep -q '`0x1234567890abcdef1234567890abcdef12345678`' "$OUTPUT_FILE" || { echo "FAIL: backticked address missing"; exit 1; } + grep -q "https://eth-sepolia.blockscout.com/address/0x1234567890abcdef1234567890abcdef12345678" "$OUTPUT_FILE" || { echo "FAIL: explorer link missing"; exit 1; } + grep -q "workflow run" "$OUTPUT_FILE" || { echo "FAIL: workflow run footer missing"; exit 1; } + + # Should NOT include CALL transactions — only 2 data rows (MyToken, MyProxy) + # 1 header + 2 data rows = 3 lines starting with "| " (separator starts with "|-") + CREATE_ROWS=$(grep -c '^| ' "$OUTPUT_FILE" || true) + [[ "$CREATE_ROWS" -eq 3 ]] || { echo "FAIL: expected 3 table lines, got $CREATE_ROWS"; cat "$OUTPUT_FILE"; exit 1; } + + echo "PASS: renders banner, PR link, commit, and contract table" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 2: Omits deployer line when broadcast.transactions[0].from is null +( + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + common_env + export BROADCAST_FILE="$FIXTURES/broadcast/Deploy.s.sol/31337/run-latest.json" + export OUTPUT_FILE="$TMPDIR/body.md" + + bash "$SCRIPT" > /dev/null + + if grep -q "^Deployer:" "$OUTPUT_FILE"; then + echo "FAIL: deployer line present despite null from" + exit 1 + fi + echo "PASS: omits deployer line when from is null" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 3: Includes deployer line and link when from is set +( + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + common_env + cat > "$TMPDIR/run-latest.json" <<'EOF' +{ + "transactions": [ + { + "transactionType": "CREATE", + "contractName": "Greeter", + "contractAddress": "0x1111111111111111111111111111111111111111", + "from": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + ] +} +EOF + export BROADCAST_FILE="$TMPDIR/run-latest.json" + export OUTPUT_FILE="$TMPDIR/body.md" + + bash "$SCRIPT" > /dev/null + + grep -q "Deployer: \[\`0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef\`\]" "$OUTPUT_FILE" \ + || { echo "FAIL: deployer line missing or malformed"; cat "$OUTPUT_FILE"; exit 1; } + grep -q "https://eth-sepolia.blockscout.com/address/0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" "$OUTPUT_FILE" \ + || { echo "FAIL: deployer Blockscout link missing"; exit 1; } + echo "PASS: includes deployer line and link" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 4: Renders empty placeholder when no CREATE transactions +( + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + common_env + cat > "$TMPDIR/run-latest.json" <<'EOF' +{ + "transactions": [ + { + "transactionType": "CALL", + "contractName": "Greeter", + "contractAddress": "0x1111111111111111111111111111111111111111", + "from": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + ] +} +EOF + export BROADCAST_FILE="$TMPDIR/run-latest.json" + export OUTPUT_FILE="$TMPDIR/body.md" + + bash "$SCRIPT" > /dev/null + + grep -q "_No contracts deployed in this run\._" "$OUTPUT_FILE" \ + || { echo "FAIL: empty placeholder missing"; cat "$OUTPUT_FILE"; exit 1; } + if grep -q "^| Contract " "$OUTPUT_FILE"; then + echo "FAIL: table header should not be present when empty" + exit 1 + fi + echo "PASS: empty case renders placeholder" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 5: Strips trailing slash from BLOCKSCOUT_URL so links don't double-slash +( + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + common_env + export BLOCKSCOUT_URL="https://eth-sepolia.blockscout.com/" + export BROADCAST_FILE="$FIXTURES/broadcast/Deploy.s.sol/31337/run-latest.json" + export OUTPUT_FILE="$TMPDIR/body.md" + + bash "$SCRIPT" > /dev/null + + if grep -q "blockscout.com//address/" "$OUTPUT_FILE"; then + echo "FAIL: double slash in explorer URL" + exit 1 + fi + grep -q "blockscout.com/address/" "$OUTPUT_FILE" \ + || { echo "FAIL: explorer URL malformed"; cat "$OUTPUT_FILE"; exit 1; } + echo "PASS: trailing slash stripped from BLOCKSCOUT_URL" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 6: Fails when BROADCAST_FILE doesn't exist +( + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + common_env + export BROADCAST_FILE="$TMPDIR/missing.json" + export OUTPUT_FILE="$TMPDIR/body.md" + + if bash "$SCRIPT" > /dev/null 2>&1; then + echo "FAIL: should have failed on missing broadcast file" + exit 1 + fi + echo "PASS: fails on missing broadcast file" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 7: Fails when a required env var is missing +( + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + common_env + unset PR_NUMBER + export BROADCAST_FILE="$FIXTURES/broadcast/Deploy.s.sol/31337/run-latest.json" + export OUTPUT_FILE="$TMPDIR/body.md" + + if bash "$SCRIPT" > /dev/null 2>&1; then + echo "FAIL: should have failed on missing PR_NUMBER" + exit 1 + fi + echo "PASS: fails on missing required env var" +) || { FAILURES=$((FAILURES + 1)); } + +TOTAL=7 +echo "--- $((TOTAL - FAILURES))/$TOTAL tests passed ---" +exit $FAILURES diff --git a/tests/test-create-release.sh b/tests/test-create-release.sh new file mode 100755 index 0000000..c74f143 --- /dev/null +++ b/tests/test-create-release.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$SCRIPT_DIR/scripts/release/create-release.sh" +FAILURES=0 + +echo "=== Testing scripts/release/create-release.sh ===" + +# Helper: install a mock 'gh' on PATH that records its args and returns scripted exit codes. +# The mock is configured per-test via env vars: +# MOCK_GH_VIEW_EXIT — exit code for `gh release view` (default 1) +# MOCK_GH_VIEW_OUTPUT — stdout/stderr text for `gh release view` (default "release not found") +# MOCK_GH_DELETE_EXIT — exit code for `gh release delete` (default 0) +# MOCK_GH_CREATE_EXIT — exit code for `gh release create` (default 0) +# The mock writes one line per invocation to $MOCK_GH_LOG, e.g. "view testnet-pr-42". +install_mock_gh() { + local bindir="$1" + cat > "$bindir/gh" <<'MOCK_EOF' +#!/usr/bin/env bash +# Mock gh CLI for create-release.sh tests. +LOG="${MOCK_GH_LOG:-/dev/null}" +echo "$*" >> "$LOG" + +if [[ "$1" == "release" && "$2" == "view" ]]; then + echo "${MOCK_GH_VIEW_OUTPUT:-release not found}" + exit "${MOCK_GH_VIEW_EXIT:-1}" +elif [[ "$1" == "release" && "$2" == "delete" ]]; then + exit "${MOCK_GH_DELETE_EXIT:-0}" +elif [[ "$1" == "release" && "$2" == "create" ]]; then + exit "${MOCK_GH_CREATE_EXIT:-0}" +fi + +echo "mock gh: unhandled args: $*" >&2 +exit 99 +MOCK_EOF + chmod +x "$bindir/gh" +} + +setup_test() { + TMPDIR=$(mktemp -d) + trap 'rm -rf "$TMPDIR"' EXIT + BIN="$TMPDIR/bin" + mkdir -p "$BIN" + install_mock_gh "$BIN" + export PATH="$BIN:$PATH" + export MOCK_GH_LOG="$TMPDIR/gh.log" + : > "$MOCK_GH_LOG" + + echo "release body" > "$TMPDIR/body.md" + export REPO="owner/repo" + export TAG="testnet-pr-42" + export TITLE="Testnet — PR #42" + export BODY_FILE="$TMPDIR/body.md" + export TARGET_SHA="abcdef1234567890abcdef1234567890abcdef12" + export GITHUB_STEP_SUMMARY="$TMPDIR/summary.md" + : > "$GITHUB_STEP_SUMMARY" +} + +# Test 1: Tag does not exist — creates release directly +( + setup_test + export COLLISION_POLICY="replace" + export MOCK_GH_VIEW_EXIT=1 + export MOCK_GH_VIEW_OUTPUT="release not found" + + bash "$SCRIPT" > /dev/null + + grep -q "^release view " "$MOCK_GH_LOG" || { echo "FAIL: did not call gh release view"; exit 1; } + if grep -q "^release delete " "$MOCK_GH_LOG"; then + echo "FAIL: should not have deleted when release does not exist" + exit 1 + fi + grep -q "^release create " "$MOCK_GH_LOG" || { echo "FAIL: did not call gh release create"; exit 1; } + grep -q "Created \[\`testnet-pr-42\`\]" "$GITHUB_STEP_SUMMARY" || { echo "FAIL: step summary missing"; exit 1; } + echo "PASS: creates release when tag is free" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 2: Tag exists + replace policy — deletes then creates +( + setup_test + export COLLISION_POLICY="replace" + export MOCK_GH_VIEW_EXIT=0 + + bash "$SCRIPT" > /dev/null + + grep -q "^release delete testnet-pr-42 --cleanup-tag --yes" "$MOCK_GH_LOG" \ + || { echo "FAIL: did not call gh release delete with cleanup-tag"; cat "$MOCK_GH_LOG"; exit 1; } + grep -q "^release create " "$MOCK_GH_LOG" || { echo "FAIL: did not call gh release create"; exit 1; } + echo "PASS: replace policy deletes then creates" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 3: Tag exists + skip policy — exits 0 without delete or create +( + setup_test + export COLLISION_POLICY="skip" + export MOCK_GH_VIEW_EXIT=0 + + bash "$SCRIPT" > /dev/null + + if grep -q "^release delete " "$MOCK_GH_LOG"; then + echo "FAIL: should not have deleted on skip" + exit 1 + fi + if grep -q "^release create " "$MOCK_GH_LOG"; then + echo "FAIL: should not have created on skip" + exit 1 + fi + grep -q "skipped per" "$GITHUB_STEP_SUMMARY" || { echo "FAIL: step summary missing skip note"; exit 1; } + echo "PASS: skip policy exits cleanly" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 4: Tag exists + fail policy — exits non-zero +( + setup_test + export COLLISION_POLICY="fail" + export MOCK_GH_VIEW_EXIT=0 + + if bash "$SCRIPT" > /dev/null 2>&1; then + echo "FAIL: should have exited non-zero on fail policy" + exit 1 + fi + if grep -q "^release create " "$MOCK_GH_LOG"; then + echo "FAIL: should not have created on fail" + exit 1 + fi + echo "PASS: fail policy exits non-zero" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 5: gh release view fails for a reason other than "not found" — surface error, do not create +( + setup_test + export COLLISION_POLICY="replace" + export MOCK_GH_VIEW_EXIT=1 + export MOCK_GH_VIEW_OUTPUT="HTTP 401: Bad credentials" + + if bash "$SCRIPT" > /dev/null 2>&1; then + echo "FAIL: should have exited non-zero on auth error" + exit 1 + fi + if grep -q "^release create " "$MOCK_GH_LOG"; then + echo "FAIL: must not create release after view error" + exit 1 + fi + echo "PASS: surfaces non-not-found view error" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 6: Invalid collision policy fails fast +( + setup_test + export COLLISION_POLICY="bogus" + + if bash "$SCRIPT" > /dev/null 2>&1; then + echo "FAIL: should have rejected invalid policy" + exit 1 + fi + if [[ -s "$MOCK_GH_LOG" ]]; then + echo "FAIL: should not have called gh on invalid policy" + cat "$MOCK_GH_LOG" + exit 1 + fi + echo "PASS: rejects invalid collision policy" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 7: Missing BODY_FILE fails fast +( + setup_test + export COLLISION_POLICY="replace" + export BODY_FILE="$TMPDIR/does-not-exist.md" + + if bash "$SCRIPT" > /dev/null 2>&1; then + echo "FAIL: should have failed on missing body file" + exit 1 + fi + echo "PASS: fails on missing body file" +) || { FAILURES=$((FAILURES + 1)); } + +# Test 8: Missing required env var fails fast +( + setup_test + export COLLISION_POLICY="replace" + unset TAG + + if bash "$SCRIPT" > /dev/null 2>&1; then + echo "FAIL: should have failed on missing TAG" + exit 1 + fi + echo "PASS: fails on missing TAG" +) || { FAILURES=$((FAILURES + 1)); } + +TOTAL=8 +echo "--- $((TOTAL - FAILURES))/$TOTAL tests passed ---" +exit $FAILURES