forge-guard 0.3.6

Pre-deployment smart contract auditing framework for Foundry
Documentation
# ═══════════════════════════════════════════════════════════════════════
#  Restore Release — restore a single or all missing GitHub Releases
# ═══════════════════════════════════════════════════════════════════════
#
# Trigger manually via GitHub UI: Actions → Restore Release → Run workflow
#
# Two modes:
#   - "single" (default): restore one version from the CHANGELOG
#   - "all-missing":      restore every CHANGELOG version missing a tag/release
#
name: Restore Release

on:
  workflow_dispatch:
    inputs:
      mode:
        description: 'Release mode'
        required: true
        default: 'single'
        type: choice
        options:
          - single
          - all-missing
      version:
        description: 'Version to release (required for "single" mode, e.g. 0.1.7)'
        required: false
        default: ''
        type: string
      commit_sha:
        description: 'Optional commit SHA (auto-detected if blank)'
        required: false
        default: ''
        type: string

env:
  CARGO_TERM_COLOR: always

jobs:
  restore:
    name: ${{ inputs.mode == 'all-missing' && 'Restore all missing releases' || format('Restore v{0}', inputs.version) }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    outputs:
      versions: ${{ steps.plan.outputs.versions }}
      count: ${{ steps.plan.outputs.count }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          fetch-tags: true

      # ── Parse all versions from CHANGELOG ───────────────────────
      - name: Parse CHANGELOG versions
        id: parse
        run: |
          VERSIONS=$(grep -oP '^## \[\K[0-9]+\.[0-9]+\.[0-9a-zA-Z.-]+(?=\])' CHANGELOG.md | sort -V | uniq)
          echo "versions<<EOF" >> "$GITHUB_OUTPUT"
          echo "$VERSIONS" >> "$GITHUB_OUTPUT"
          echo "EOF" >> "$GITHUB_OUTPUT"
          COUNT=$(echo "$VERSIONS" | wc -l)
          echo "Found $COUNT versions in CHANGELOG:"
          echo "$VERSIONS" | tr '\n' ' '
          echo ""

      # ── Determine versions to restore ───────────────────────────
      - name: Determine versions to restore
        id: plan
        run: |
          if [ "${{ inputs.mode }}" = "single" ]; then
            VERSION="${{ inputs.version }}"
            if [ -z "$VERSION" ]; then
              echo "❌ version input is required in 'single' mode"
              exit 1
            fi
            echo "versions=[\"$VERSION\"]" >> "$GITHUB_OUTPUT"
            echo "count=1" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          MISSING=""
          COUNT=0
          while IFS= read -r ver; do
            [ -z "$ver" ] && continue
            if git rev-parse "v$ver" &>/dev/null 2>&1; then
              echo "✅ v$ver — already tagged, skipping"
            else
              MISSING="$MISSING$ver"$'\n'
              COUNT=$((COUNT + 1))
              echo "❌ v$ver — missing, will restore"
            fi
          done <<< "${{ steps.parse.outputs.versions }}"

          MISSING=$(echo "$MISSING" | sed '/^$/d')
          JSON_ARRAY=$(echo "$MISSING" | jq -R -s -c 'split("\n") | map(select(length > 0))')
          echo "versions=$JSON_ARRAY" >> "$GITHUB_OUTPUT"
          echo "count=$COUNT" >> "$GITHUB_OUTPUT"

      # ── Summary ─────────────────────────────────────────────────
      - name: Summary
        run: |
          COUNT="${{ steps.plan.outputs.count }}"
          if [ "$COUNT" = "0" ]; then
            echo "🎉 All CHANGELOG versions already have tags! Nothing to restore."
          else
            echo "📋 Will restore $COUNT release(s)"
          fi

  # ── Do the actual restore (one job per version) ──────────────────
  create:
    name: Create v${{ matrix.version }}
    needs: [restore]
    if: needs.restore.outputs.count != '0'
    strategy:
      fail-fast: false
      matrix:
        version: ${{ fromJSON(needs.restore.outputs.versions) }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          fetch-tags: true

      - name: Find target commit
        id: find_commit
        run: |
          VERSION="${{ matrix.version }}"
          if [ "${{ inputs.mode }}" = "single" ] && [ -n "${{ inputs.commit_sha }}" ]; then
            echo "Using provided commit: ${{ inputs.commit_sha }}"
            echo "sha=${{ inputs.commit_sha }}" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          FOUND=$(git log --oneline --all --grep="chore(release): v$VERSION" --format="%H" | head -1)
          if [ -n "$FOUND" ]; then
            echo "Found release commit: $FOUND"
            echo "sha=$FOUND" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          echo "Searching Cargo.toml version history for v$VERSION..."
          FOUND=$(git log --all -G"^version = \"$VERSION\"" --format="%H" -- Cargo.toml | tail -1)
          if [ -n "$FOUND" ]; then
            echo "Found commit via Cargo.toml: $FOUND"
            echo "sha=$FOUND" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          echo "⚠️ Could not find a commit for v$VERSION — skipping"
          echo "sha=" >> "$GITHUB_OUTPUT"

      - name: Extract CHANGELOG section
        id: changelog
        run: |
          VERSION="${{ matrix.version }}"
          NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
          SECTION=$(sed -n "/^## \\[$VERSION\\]/,/^---/p" CHANGELOG.md | tail -n +2 | sed \$d)
          if [ -z "$(echo "$SECTION" | tr -d ' \\n\\r')" ]; then
            SECTION=$(sed -n "/^## \\[$VERSION\\]/,/^## \\[/p" CHANGELOG.md | head -n -1 | tail -n +2)
          fi
          if [ -z "$(echo "$SECTION" | tr -d ' \\n\\r')" ]; then
            echo "⚪ No CHANGELOG content found — using default notes"
            echo "Release v${VERSION} of Forge Guard." > "$NOTES_FILE"
          else
            REPO="${{ github.repository }}"
            echo "$SECTION" > "$NOTES_FILE"
            echo "" >> "$NOTES_FILE"
            echo "---" >> "$NOTES_FILE"
            PREV_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]' | awk -v ver="v$VERSION" '$0 < ver {print; exit}')
            if [ -z "$PREV_TAG" ]; then
              echo "**Full Changelog**: https://github.com/$REPO/releases/tag/v${VERSION}" >> "$NOTES_FILE"
            else
              echo "**Full Changelog**: https://github.com/$REPO/compare/$PREV_TAG...v${VERSION}" >> "$NOTES_FILE"
            fi
          fi
          echo "notes_file=$NOTES_FILE" >> "$GITHUB_OUTPUT"

      - name: Create tag and GitHub Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          VERSION="${{ matrix.version }}"
          TAG_NAME="v$VERSION"
          COMMIT="${{ steps.find_commit.outputs.sha }}"
          NOTES_FILE="${{ steps.changelog.outputs.notes_file }}"
          if [ -z "$COMMIT" ]; then
            echo "⚠️ No commit found for v$VERSION — skipping release"
            exit 0
          fi
          if gh release view "$TAG_NAME" --json tagName --jq '.tagName' &>/dev/null; then
            echo "ℹ️  Release $TAG_NAME already exists — updating notes"
            gh release edit "$TAG_NAME" \
              --notes-file "$NOTES_FILE" \
              --title "Forge Guard v${VERSION}"
            echo "✅ Release $TAG_NAME updated"
          else
            echo "Creating Release $TAG_NAME at $COMMIT..."
            gh release create "$TAG_NAME" \
              --title "Forge Guard v${VERSION}" \
              --notes-file "$NOTES_FILE" \
              --target "$COMMIT"
            echo "✅ Release $TAG_NAME created"
          fi

  # ── Finalize: mark the highest restored version as latest ─────
  finalize:
    name: Set latest release
    needs: [restore, create]
    if: always() && needs.restore.result == 'success' && needs.restore.outputs.count != '0'
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          fetch-tags: true

      - name: Determine highest restored version
        id: highest
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          VERSIONS='${{ needs.restore.outputs.versions }}'
          echo "Restored versions: $VERSIONS"
          HIGHEST=$(echo "$VERSIONS" | jq -r '.[]' | sort -V | tail -1)
          echo "Highest version: $HIGHEST"
          echo "tag=v$HIGHEST" >> "$GITHUB_OUTPUT"

      - name: Mark as latest release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          TAG="${{ steps.highest.outputs.tag }}"
          IS_PRERELEASE=$(gh release view "$TAG" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo "true")
          IS_DRAFT=$(gh release view "$TAG" --json isDraft --jq '.isDraft' 2>/dev/null || echo "true")
          echo "$TAG: prerelease=$IS_PRERELEASE, draft=$IS_DRAFT"

          # Convert to full release if needed, then mark as latest
          if [ "$IS_PRERELEASE" = "true" ] || [ "$IS_DRAFT" = "true" ]; then
            echo "Converting $TAG to a full release..."
            gh release edit "$TAG" --draft=false --prerelease=false
          fi

          echo "Marking $TAG as the latest release..."
          gh release edit "$TAG" --latest
          echo "✅ $TAG is now the latest release"