forge-guard 0.3.6

Pre-deployment smart contract auditing framework for Foundry
Documentation
name: Publish

# Trigger after CI completes on main, then verify all required workflows passed.
# Uses GitHub's native workflow_run event instead of polling.
on:
  workflow_run:
    workflows: ["CI"]
    types:
      - completed

# Only one Publish run per commit — prevents duplicate runs when both the
# main-branch push and the tag push each complete CI and fire workflow_run.
concurrency:
  group: publish-${{ github.event.workflow_run.head_sha }}
  cancel-in-progress: false

jobs:
  publish:
    name: Publish to crates.io
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: write
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.workflow_run.head_sha }}
          fetch-tags: true

      - name: Check that commit has a version tag
        id: tag
        run: |
          COMMIT_SHA="${{ github.event.workflow_run.head_sha }}"
          # || true: under `set -o pipefail`, grep exits 1 when the commit has
          # no version tag; without it the empty-result case would abort the
          # step before the skip branch below.
          GITHUB_REF_NAME=$(git tag --points-at "$COMMIT_SHA" | grep -E '^v[0-9]' | head -1 || true)

          if [ -z "$GITHUB_REF_NAME" ]; then
            echo "ℹ️ Commit $COMMIT_SHA has no version tag — skipping publish"
            echo "skip=true" >> $GITHUB_OUTPUT
            exit 0
          fi

          echo "Found tag: $GITHUB_REF_NAME"
          echo "tag=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT
          echo "skip=false" >> $GITHUB_OUTPUT

      - name: Verify Security Check passed (non-blocking)
        if: steps.tag.outputs.skip != 'true'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          COMMIT_SHA="${{ github.event.workflow_run.head_sha }}"

          SEC_RUN=$(gh run list --workflow="Forge Guard Security Check" --commit "$COMMIT_SHA" --json conclusion,status --jq '.[0] // empty' 2>&1)

          if [ -n "$SEC_RUN" ] && [ "$SEC_RUN" != "null" ]; then
            SEC_CONCLUSION=$(echo "$SEC_RUN" | jq -r '.conclusion // empty')
            SEC_STATUS=$(echo "$SEC_RUN" | jq -r '.status // empty')

            if [ "$SEC_CONCLUSION" = "success" ] && [ "$SEC_STATUS" = "completed" ]; then
              echo "✅ Forge Guard Security Check passed"
            else
              echo "⚠️ Forge Guard Security Check: $SEC_CONCLUSION (status: $SEC_STATUS)"
              echo "   Non-blocking — CI already passed all core checks."
            fi
          else
            echo "ℹ️ Forge Guard Security Check did not run for this commit"
          fi

      - name: Install Rust toolchain
        if: steps.tag.outputs.skip != 'true'
        uses: dtolnay/rust-toolchain@stable

      - name: Generate lockfile for cache
        if: steps.tag.outputs.skip != 'true'
        run: cargo generate-lockfile

      - name: Verify version consistency (Cargo.toml ↔ tag ↔ CHANGELOG ↔ docs)
        if: steps.tag.outputs.skip != 'true'
        run: |
          TAG_NAME="${{ steps.tag.outputs.tag }}"
          VERSION="${TAG_NAME#v}"
          BASE_VERSION=$(echo "$VERSION" | sed 's/-.*//')

          # 1. Check Cargo.toml matches tag
          CARGO_VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml)
          if [ "$CARGO_VERSION" != "$BASE_VERSION" ]; then
            echo "❌ Cargo.toml version ($CARGO_VERSION) does not match tag version ($BASE_VERSION)"
            exit 1
          fi
          echo "✅ Cargo.toml v$CARGO_VERSION matches tag $TAG_NAME"

          # 2. Check CHANGELOG has a section for this version
          if grep -q "^## \[$BASE_VERSION\]" CHANGELOG.md; then
            echo "✅ CHANGELOG.md has section for v$BASE_VERSION"
          else
            echo "❌ CHANGELOG.md is missing section for v$BASE_VERSION"
            echo "   Add: ## [$BASE_VERSION] - $(date +%Y-%m-%d)"
            exit 1
          fi

          # 3. Check README version badge uses dynamic shield.io (not hardcoded)
          if grep -q 'crates\.io/v1/crates/forge-guard' README.md 2>/dev/null || \
             grep -q 'crates\.io/v/forge-guard' README.md 2>/dev/null || \
             grep -q 'shields\.io/crates/v/forge-guard' README.md 2>/dev/null; then
            echo "✅ README.md uses dynamic crates.io version badge"
          else
            echo "⚠️  README.md may have a hardcoded version badge — verify it's dynamic"
          fi

          # 4. Check Cargo.lock matches Cargo.toml
          LOCK_VERSION=$(grep -A2 'name = "forge-guard"' Cargo.lock | grep '^version = ' | sed 's/version = "\(.*\)"/\1/')
          if [ -n "$LOCK_VERSION" ] && [ "$LOCK_VERSION" != "$CARGO_VERSION" ]; then
            echo "⚠️  Cargo.lock version ($LOCK_VERSION) differs from Cargo.toml ($CARGO_VERSION)"
            echo "    Run: cargo generate-lockfile"
          else
            echo "✅ Cargo.lock version is consistent"
          fi

          echo ""
          echo "📋 Version Consistency Summary:"
          echo "   Tag:         $TAG_NAME"
          echo "   Cargo.toml:  v$CARGO_VERSION"
          echo "   CHANGELOG:   v$BASE_VERSION"
          echo ""
          echo "✅ All version checks passed"

      - name: Check if version already published on crates.io
        if: steps.tag.outputs.skip != 'true'
        id: crates_check
        run: |
          TAG_NAME="${{ steps.tag.outputs.tag }}"
          VERSION="${TAG_NAME#v}"
          BASE_VERSION=$(echo "$VERSION" | sed 's/-.*//')

          echo "Checking if forge-guard $BASE_VERSION exists on crates.io..."
          # crates.io requires a User-Agent; without it the API returns 403
          # even for published versions, which would break this check.
          HTTP_STATUS=$(curl -s -H "User-Agent: forge-guard-publish-check" -o /dev/null -w "%{http_code}" \
            "https://crates.io/api/v1/crates/forge-guard/$BASE_VERSION")

          if [ "$HTTP_STATUS" = "200" ]; then
            echo "✅ forge-guard $BASE_VERSION already published — skipping cargo publish"
            echo "skip_publish=true" >> "$GITHUB_OUTPUT"
          else
            echo "forge-guard $BASE_VERSION not found on crates.io — proceeding to publish"
            echo "skip_publish=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Publish to crates.io
        if: steps.tag.outputs.skip != 'true' && steps.crates_check.outputs.skip_publish != 'true'
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          # GitHub Actions runs bash with `set -e`, which would abort on the
          # failing command substitution before we can inspect the status.
          # Wrap the publish in an `if` condition instead (condition failures
          # do not trigger set -e) so a duplicate-publish error can be treated
          # as success.
          if OUTPUT=$(cargo publish --allow-dirty 2>&1); then
            echo "$OUTPUT"
            echo "✅ Published to crates.io"
          else
            STATUS=$?
            echo "$OUTPUT"
            if echo "$OUTPUT" | grep -qiE 'already uploaded|already exists'; then
              echo "✅ Version already published (duplicate run) — continuing"
            else
              exit $STATUS
            fi
          fi

      - name: Determine if prerelease
        if: steps.tag.outputs.skip != 'true'
        id: prerelease
        run: |
          TAG_NAME="${{ steps.tag.outputs.tag }}"
          TAG_VERSION="${TAG_NAME#v}"
          if echo "$TAG_VERSION" | grep -qE '-(alpha|beta|rc|pre|dev)'; then
            echo "is_prerelease=true" >> $GITHUB_OUTPUT
            echo "Release type: PRERELEASE ($TAG_VERSION)"
          else
            echo "is_prerelease=false" >> $GITHUB_OUTPUT
            echo "Release type: STABLE ($TAG_VERSION)"
          fi

      - name: Extract changelog section for release notes
        id: changelog
        if: steps.tag.outputs.skip != 'true'
        run: |
          TAG_NAME="${{ steps.tag.outputs.tag }}"
          VERSION="${TAG_NAME#v}"
          REPO="${{ github.repository }}"
          NOTES_FILE="${RUNNER_TEMP}/release-notes.md"

          # Extract the section between "## [VERSION]" and the next "---" separator
          SECTION=$(sed -n "/^## \[$VERSION\]/,/^---/p" CHANGELOG.md | tail -n +2 | sed \$d)

          if [ -z "$(echo "$SECTION" | tr -d ' \n\r')" ]; then
            echo "⚠️ Could not extract CHANGELOG section for v$VERSION — using fallback"
            echo "See [CHANGELOG.md](https://github.com/$REPO/blob/main/CHANGELOG.md) for the complete changelog." > "$NOTES_FILE"
          else
            echo "✅ Extracted CHANGELOG section for v$VERSION"
            # Find the previous version tag for the compare link
            PREV_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]' | grep -A1 "$TAG_NAME" | tail -1)
            if [ "$PREV_TAG" = "$TAG_NAME" ] || [ -z "$PREV_TAG" ]; then
              COMPARE_LINK="https://github.com/$REPO/releases/tag/$TAG_NAME"
            else
              COMPARE_LINK="https://github.com/$REPO/compare/$PREV_TAG...$TAG_NAME"
            fi

            cat > "$NOTES_FILE" <<- NOTE
          ## What's Changed in v$VERSION

          $SECTION

          ---
          **Full Changelog**: $COMPARE_LINK
          NOTE
          fi

          echo "notes_file=$NOTES_FILE" >> "$GITHUB_OUTPUT"

      - name: Create or update GitHub Release
        if: steps.tag.outputs.skip != 'true'
        env:
          GH_TOKEN: ${{ github.token }}
        shell: bash
        run: |
          TAG_NAME="${{ steps.tag.outputs.tag }}"
          VERSION="${TAG_NAME#v}"
          IS_PRERELEASE="${{ steps.prerelease.outputs.is_prerelease }}"
          NOTES_FILE="${{ steps.changelog.outputs.notes_file }}"

          # Build the prerelease flag explicitly — the step output is the
          # STRING "true"/"false", so ${VAR:+--flag} would wrongly expand
          # --prerelease for "false" (non-empty). Only set it when "true".
          LATEST_FLAG="--latest"
          PRERELEASE_FLAG=""
          if [ "${IS_PRERELEASE}" = "true" ]; then
            PRERELEASE_FLAG="--prerelease"
            echo "ℹ️ Marking release as prerelease"
          fi

          # Check if release already exists
          if gh release view "$TAG_NAME" --json tagName --jq '.tagName' &>/dev/null; then
            echo "ℹ️ Release $TAG_NAME already exists — updating notes and title"
            gh release edit "$TAG_NAME" \
              --title "Forge Guard v${VERSION}" \
              --notes-file "${NOTES_FILE}" \
              ${LATEST_FLAG} \
              ${PRERELEASE_FLAG}
            echo "✅ Release $TAG_NAME updated"
          else
            echo "Creating new release $TAG_NAME..."
            gh release create "$TAG_NAME" \
              --title "Forge Guard v${VERSION}" \
              --notes-file "${NOTES_FILE}" \
              ${LATEST_FLAG} \
              ${PRERELEASE_FLAG} \
              --verify-tag
            echo "✅ Release $TAG_NAME created"
          fi