ferrflow 4.0.2

Universal semantic versioning for monorepos and classic repos
Documentation
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      dry_run:
        description: 'Dry run'
        type: boolean
        default: false

permissions:
  contents: read

jobs:
  test:
    name: Test
    if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(release):')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@nightly
        with:
          components: clippy, rustfmt
      - uses: Swatinem/rust-cache@v2
      - name: Format check
        run: cargo fmt --check
      - name: Clippy
        run: cargo clippy -- -D warnings
        env:
          FERRFLOW_HMAC_SECRET: ${{ secrets.FERRFLOW_HMAC_SECRET }}
      - name: Tests
        run: cargo test
        env:
          FERRFLOW_HMAC_SECRET: ${{ secrets.FERRFLOW_HMAC_SECRET }}

  build:
    name: Build Release Binary
    needs: test
    if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(release):')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@nightly
      - uses: Swatinem/rust-cache@v2
      - name: Build ferrflow
        run: cargo build --release
        env:
          FERRFLOW_HMAC_SECRET: ${{ secrets.FERRFLOW_HMAC_SECRET }}
      - name: Upload binary
        uses: actions/upload-artifact@v7
        with:
          name: ferrflow-binary
          path: target/release/ferrflow
          retention-days: 1

  coverage:
    name: Coverage
    if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(release):')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@nightly
      - uses: Swatinem/rust-cache@v2
      - name: Install cargo-tarpaulin
        run: cargo install cargo-tarpaulin
      - name: Generate coverage
        run: cargo tarpaulin --out xml --skip-clean
        env:
          FERRFLOW_HMAC_SECRET: ${{ secrets.FERRFLOW_HMAC_SECRET }}
      - uses: codecov/codecov-action@v6
        with:
          files: cobertura.xml
          token: ${{ secrets.CODECOV_TOKEN }}
          fail_ci_if_error: false

  fixture-generate:
    name: Generate Fixtures
    needs: test
    if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'chore(release):')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Cache generated fixtures
        id: fixture-cache
        uses: actions/cache@v5
        with:
          path: fixtures-generated
          key: fixtures-${{ hashFiles('tests/fixtures/definitions/**') }}

      - name: Generate fixtures
        if: steps.fixture-cache.outputs.cache-hit != 'true'
        uses: FerrLabs/Fixtures@v1
        with:
          definitions: tests/fixtures/definitions

      - name: Resolve generated path
        id: path
        run: |
          if [ -d "fixtures-generated" ]; then
            echo "dir=fixtures-generated" >> "$GITHUB_OUTPUT"
          else
            for d in /tmp/tmp.*/generated; do
              if [ -d "$d" ]; then
                cp -r "$d" fixtures-generated
                echo "dir=fixtures-generated" >> "$GITHUB_OUTPUT"
                break
              fi
            done
          fi

      - name: Upload generated fixtures
        uses: actions/upload-artifact@v7
        with:
          name: fixtures-generated
          path: fixtures-generated/
          include-hidden-files: true
          retention-days: 1

  fixture-monorepo:
    name: Fixtures — Monorepo
    needs: [build, fixture-generate]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v6
      - uses: actions/download-artifact@v8
        with:
          name: ferrflow-binary
          path: bin/
      - uses: actions/download-artifact@v8
        with:
          name: fixtures-generated
          path: fixtures-generated/
      - name: Run monorepo fixture tests
        env:
          FERRFLOW_BIN: ./bin/ferrflow
          DIFF_DIR: fixture-diffs
        run: |
          chmod +x ./bin/ferrflow
          mkdir -p filtered-fixtures
          for d in fixtures-generated/monorepo-*; do
            [ -d "$d" ] && cp -r "$d" filtered-fixtures/
          done
          bash tests/fixtures/run-tests.sh filtered-fixtures

      - name: Upload snapshot diffs
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: fixture-diffs-monorepo
          path: fixture-diffs/
          retention-days: 7
          if-no-files-found: ignore

  fixture-single:
    name: Fixtures — Single Package
    needs: [build, fixture-generate]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v6
      - uses: actions/download-artifact@v8
        with:
          name: ferrflow-binary
          path: bin/
      - uses: actions/download-artifact@v8
        with:
          name: fixtures-generated
          path: fixtures-generated/
      - name: Run single-package fixture tests
        env:
          FERRFLOW_BIN: ./bin/ferrflow
          DIFF_DIR: fixture-diffs
        run: |
          chmod +x ./bin/ferrflow
          mkdir -p filtered-fixtures
          for d in fixtures-generated/single-* fixtures-generated/multiple-* fixtures-generated/no-tags-initial-* fixtures-generated/no-versioned-* fixtures-generated/version-*; do
            [ -d "$d" ] && cp -r "$d" filtered-fixtures/
          done
          bash tests/fixtures/run-tests.sh filtered-fixtures

      - name: Upload snapshot diffs
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: fixture-diffs-single
          path: fixture-diffs/
          retention-days: 7
          if-no-files-found: ignore

  fixture-config:
    name: Fixtures — Config & Formats
    needs: [build, fixture-generate]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v6
      - uses: actions/download-artifact@v8
        with:
          name: ferrflow-binary
          path: bin/
      - uses: actions/download-artifact@v8
        with:
          name: fixtures-generated
          path: fixtures-generated/
      - name: Run config fixture tests
        env:
          FERRFLOW_BIN: ./bin/ferrflow
          DIFF_DIR: fixture-diffs
        run: |
          chmod +x ./bin/ferrflow
          mkdir -p filtered-fixtures
          for d in fixtures-generated/config-* fixtures-generated/format-* fixtures-generated/multi-versioned-*; do
            [ -d "$d" ] && cp -r "$d" filtered-fixtures/
          done
          bash tests/fixtures/run-tests.sh filtered-fixtures

      - name: Upload snapshot diffs
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: fixture-diffs-config
          path: fixture-diffs/
          retention-days: 7
          if-no-files-found: ignore

  fixture-advanced:
    name: Fixtures — Advanced Features
    needs: [build, fixture-generate]
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v6
      - uses: actions/download-artifact@v8
        with:
          name: ferrflow-binary
          path: bin/
      - uses: actions/download-artifact@v8
        with:
          name: fixtures-generated
          path: fixtures-generated/
      - name: Run advanced fixture tests
        env:
          FERRFLOW_BIN: ./bin/ferrflow
          DIFF_DIR: fixture-diffs
        run: |
          chmod +x ./bin/ferrflow
          mkdir -p filtered-fixtures
          for d in fixtures-generated/prerelease-* fixtures-generated/hooks-* fixtures-generated/floating-* \
                   fixtures-generated/tag-* fixtures-generated/changelog-* fixtures-generated/versioning-* \
                   fixtures-generated/orphaned-* fixtures-generated/recover-* fixtures-generated/release-* \
                   fixtures-generated/head-* fixtures-generated/merge-* fixtures-generated/commit-* \
                   fixtures-generated/skip-* fixtures-generated/no-tags-monorepo-*; do
            [ -d "$d" ] && cp -r "$d" filtered-fixtures/
          done
          bash tests/fixtures/run-tests.sh filtered-fixtures

      - name: Upload snapshot diffs
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: fixture-diffs-advanced
          path: fixture-diffs/
          retention-days: 7
          if-no-files-found: ignore

  micro-bench:
    name: Micro Benchmarks
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    continue-on-error: true
    permissions:
      pull-requests: write
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@nightly
      - uses: Swatinem/rust-cache@v2
      - uses: FerrLabs/Benchmarks@v3
        with:
          type: micro
          definitions: benchmarks/fixtures/definitions
          ferrflow-token: ${{ secrets.GITHUB_TOKEN }}
          skip-competitors: true
        env:
          FERRFLOW_HMAC_SECRET: ${{ secrets.FERRFLOW_HMAC_SECRET }}

  benchmark:
    name: Benchmark
    needs: test
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@nightly
      - uses: Swatinem/rust-cache@v2
      - uses: actions/setup-node@v6
        with:
          node-version: '24'
      - uses: FerrLabs/Benchmarks@v3
        with:
          type: full
          definitions: benchmarks/fixtures/definitions
          ferrflow-token: ${{ secrets.GITHUB_TOKEN }}
          skip-competitors: true
        env:
          FERRFLOW_HMAC_SECRET: ${{ secrets.FERRFLOW_HMAC_SECRET }}

  release:
    name: Release
    needs: [test, fixture-monorepo, fixture-single, fixture-config, fixture-advanced, benchmark]
    runs-on: ubuntu-latest
    concurrency:
      group: release-${{ github.workflow }}-${{ github.ref }}
      cancel-in-progress: false
    if: |
      always() &&
      needs.test.result == 'success' &&
      needs.fixture-monorepo.result == 'success' &&
      needs.fixture-single.result == 'success' &&
      needs.fixture-config.result == 'success' &&
      needs.fixture-advanced.result == 'success' &&
      (needs.benchmark.result == 'success' || needs.benchmark.result == 'skipped') &&
      (
        (github.event_name == 'push' && github.ref == 'refs/heads/main') ||
        github.event_name == 'workflow_dispatch'
      )
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
          token: ${{ secrets.FERRFLOW_TOKEN }}
      - uses: dtolnay/rust-toolchain@nightly
      - uses: Swatinem/rust-cache@v2
      - name: Build ferrflow
        run: cargo build --release
        env:
          FERRFLOW_HMAC_SECRET: ${{ secrets.FERRFLOW_HMAC_SECRET }}
      - name: Run ferrflow release
        run: ./target/release/ferrflow ${{ inputs.dry_run == 'true' && '--dry-run' || '' }} release --draft
        env:
          FERRFLOW_TOKEN: ${{ secrets.FERRFLOW_TOKEN }}
          GITHUB_TOKEN: ${{ secrets.FERRFLOW_TOKEN }}
      - name: Download benchmark summary
        if: needs.benchmark.result == 'success'
        uses: actions/download-artifact@v8
        with:
          name: benchmark-release-summary
          path: benchmark-summary/
        continue-on-error: true
      - name: Append benchmark results to draft release
        if: needs.benchmark.result == 'success'
        run: |
          BENCH_FILE="benchmark-summary/release-summary.md"
          if [[ ! -f "$BENCH_FILE" ]]; then
            echo "No benchmark summary found, skipping"
            exit 0
          fi
          TAG=$(./target/release/ferrflow tag --json 2>/dev/null | jq -r '.tag // empty')
          if [[ -z "$TAG" ]]; then
            echo "No tag found, skipping"
            exit 0
          fi
          CURRENT_BODY=$(gh release view "$TAG" --json body --jq '.body' 2>/dev/null || echo "")
          BENCH=$(cat "$BENCH_FILE")
          # Remove existing Performance section to avoid duplicates
          CLEAN_BODY=$(echo "$CURRENT_BODY" | sed '/^## Performance$/,$d')
          printf -v NEW_BODY '%s\n\n%s' "$CLEAN_BODY" "$BENCH"
          gh release edit "$TAG" --notes "$NEW_BODY"
        env:
          GH_TOKEN: ${{ secrets.FERRFLOW_TOKEN }}