gvsn 1.0.2

A fast, cross-platform Go version manager written in Rust
Documentation
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  # ── Detect whether this push/PR touches anything that affects the built binary ──
  #
  # Deliberately narrow: only source/dependency files count as "code". Pipeline
  # files (this workflow included), scripts, and docs must never trigger a full
  # lint+test+release cycle, even when they change build/release *logic* - a
  # workflow YAML bug (like the one fixed for release.yml's idempotency) isn't
  # something `cargo test`/`clippy` would catch anyway, so gating those files
  # behind the Rust test suite never bought real protection.
  #
  # Jobs below are still triggered normally and report a `skipped` conclusion
  # when `code` is false - unlike relying on paths-ignore alone, a `skipped` job
  # still posts a check run, so required status checks in branch protection are
  # satisfied instead of hanging as "expected but never reported".
  changes:
    name: Detect code changes
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      code: ${{ steps.filter.outputs.code }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
      - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v3
        id: filter
        with:
          filters: |
            code:
              - "src/**"
              - "Cargo.toml"
              - "Cargo.lock"
              - "build.rs"
              - "rust-toolchain.toml"

  # ── Code format & lint ────────────────────────────────────────────────────────
  lint:
    name: Lint & Format
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4

      - name: Install Rust stable (rustfmt + clippy)
        uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy

      - name: Cache Cargo registry and build artifacts
        uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2

      - name: Check formatting
        run: cargo fmt --all -- --check

      - name: Clippy (deny warnings)
        run: cargo clippy --all-targets --all-features -- -D warnings

  # ── Unit tests - Windows · Linux · macOS ─────────────────────────────────────
  #
  # Intentionally no job-level `if` here (unlike `lint`): a job-level `if` on a
  # matrix job skips the whole job *before* the matrix expands, which posts a
  # single check run literally named "Test (${{ matrix.os }})" instead of the
  # three required contexts ("Test (ubuntu-latest)" etc.) - those then stay
  # "Expected" forever and block merging. Guarding every step instead lets the
  # matrix expand normally; each OS still gets its own named check, it just
  # completes almost instantly with nothing to do when code changes are absent.
  test:
    name: Test (${{ matrix.os }})
    needs: changes
    runs-on: ${{ matrix.os }}
    permissions:
      contents: read
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
        if: needs.changes.outputs.code == 'true'

      - name: Install Rust stable
        if: needs.changes.outputs.code == 'true'
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Cargo registry and build artifacts
        if: needs.changes.outputs.code == 'true'
        uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2
        with:
          key: ${{ matrix.os }}

      - name: Run unit tests
        if: needs.changes.outputs.code == 'true'
        run: cargo test --all-targets -- --nocapture

      - name: Verify release build compiles
        if: needs.changes.outputs.code == 'true'
        run: cargo build --release

  # ── Auto-tag + release (push to main only) ────────────────────────────────────
  #
  # Reads conventional commits since the last tag and computes the next semver.
  # Creates an annotated tag on the current HEAD and pushes it - no commit to
  # main is needed, so branch protection is never triggered.
  # Pushing the tag fires release.yml which cross-compiles and publishes binaries.
  #
  # Bump rules (both the commit prefix AND an actual code/dependency file
  # change are required - see the file-path check in "Determine version bump"):
  #   feat!: / BREAKING CHANGE  -> major
  #   feat:                      -> minor
  #   fix: / perf: / refactor: / security:  -> patch
  #   docs: / chore: / ci:, or no src/Cargo.*/build.rs/rust-toolchain.toml change -> no release
  auto-release:
    name: Auto Tag & Release
    needs: [lint, test]
    # lint/test are skipped (not run) for pushes that only touch non-code paths
    # (see the `changes` job) - `always()` plus explicit result checks lets this
    # job proceed on a skip, since the default implicit `success()` condition
    # would otherwise treat "skipped" as blocking, same as a failure.
    if: |
      always() &&
      (needs.lint.result == 'success' || needs.lint.result == 'skipped') &&
      (needs.test.result == 'success' || needs.test.result == 'skipped') &&
      github.ref == 'refs/heads/main' &&
      github.event_name == 'push' &&
      github.actor != 'github-actions[bot]'
    runs-on: ubuntu-latest
    permissions:
      contents: write
      actions: write

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
        with:
          fetch-depth: 0

      # ── 1. Determine bump type from Conventional Commits ─────────────────────
      - name: Determine version bump
        id: bump
        run: |
          set -euo pipefail

          LATEST_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -1)
          [ -z "$LATEST_TAG" ] && LATEST_TAG="v0.0.0"
          echo "Latest tag: $LATEST_TAG"

          if git rev-parse "${LATEST_TAG}" >/dev/null 2>&1; then
            COMMITS=$(git log "${LATEST_TAG}..HEAD" --format="%s %b")
          else
            COMMITS=$(git log --format="%s %b")
          fi

          echo "Commits since ${LATEST_TAG}:"
          echo "$COMMITS"

          BUMP="none"
          if echo "$COMMITS" | grep -qE '^[a-z]+(\([^)]*\))?!:|BREAKING.CHANGE'; then
            BUMP="major"
          elif echo "$COMMITS" | grep -qE '^feat(\([^)]*\))?:'; then
            BUMP="minor"
          elif echo "$COMMITS" | grep -qE '^(fix|perf|refactor|security)(\([^)]*\))?:'; then
            BUMP="patch"
          fi

          # A Conventional Commit prefix alone is not enough: require that at
          # least one file actually relevant to the built binary changed too.
          # Otherwise e.g. "fix(ci): ..." on a workflow-only change would tag
          # and publish a release with no code difference from the last one.
          if [ "$BUMP" != "none" ]; then
            if git rev-parse "${LATEST_TAG}" >/dev/null 2>&1; then
              CHANGED_FILES=$(git diff --name-only "${LATEST_TAG}..HEAD")
            else
              CHANGED_FILES=$(git log --name-only --format="")
            fi
            echo "Changed files since ${LATEST_TAG}:"
            echo "$CHANGED_FILES"

            if ! echo "$CHANGED_FILES" | grep -qE '^(src/|Cargo\.toml$|Cargo\.lock$|build\.rs$|rust-toolchain\.toml$)'; then
              echo "Commit prefix implies a release, but no code/dependency files changed - skipping."
              BUMP="none"
            fi
          fi

          echo "Bump type: $BUMP"
          echo "bump=${BUMP}"             >> "$GITHUB_OUTPUT"
          echo "latest_tag=${LATEST_TAG}" >> "$GITHUB_OUTPUT"

      # ── 2. Compute the new version number ────────────────────────────────────
      - name: Compute new version
        id: version
        if: steps.bump.outputs.bump != 'none'
        run: |
          LATEST="${{ steps.bump.outputs.latest_tag }}"
          VER="${LATEST#v}"
          MAJOR=$(echo "$VER" | cut -d. -f1)
          MINOR=$(echo "$VER" | cut -d. -f2)
          PATCH=$(echo "$VER" | cut -d. -f3)

          case "${{ steps.bump.outputs.bump }}" in
            major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
            minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
            patch) PATCH=$((PATCH + 1)) ;;
          esac

          NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
          echo "New tag: $NEW_TAG"
          echo "new_tag=${NEW_TAG}" >> "$GITHUB_OUTPUT"

      # ── 3. Create annotated tag and push it ──────────────────────────────────
      #
      # Pushing a tag does not touch any branch, so branch protection rules
      # are not involved.
      #
      # NOTE: GITHUB_TOKEN tag pushes do not trigger downstream workflows
      # (GitHub anti-loop policy). release.yml is dispatched explicitly in
      # the next step instead of relying on the push event.
      - name: Create and push version tag
        if: steps.bump.outputs.bump != 'none'
        run: |
          NEW_TAG="${{ steps.version.outputs.new_tag }}"
          git config user.name  "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git tag -a "$NEW_TAG" -m "Release ${NEW_TAG}"
          git push origin "$NEW_TAG"
          echo "Tag ${NEW_TAG} pushed"

      # ── 4. Dispatch release.yml for the new tag ──────────────────────────────
      #
      # GITHUB_TOKEN tag pushes do not fire push.tags triggers in other
      # workflows. Dispatch explicitly so the cross-platform build runs.
      - name: Dispatch release workflow
        if: steps.bump.outputs.bump != 'none'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          NEW_TAG="${{ steps.version.outputs.new_tag }}"
          gh workflow run release.yml \
            --repo "${{ github.repository }}" \
            --ref "$NEW_TAG"
          echo "Dispatched release.yml for ${NEW_TAG}"

      - name: Skip message
        if: steps.bump.outputs.bump == 'none'
        run: |
          echo "No releasable commits since ${{ steps.bump.outputs.latest_tag }}."
          echo "Only docs/chore/ci commits found - skipping version bump."