pokeys-lib 1.0.13

Pure Rust core library for PoKeys device control - USB/Network connectivity, I/O, PWM, encoders, SPI/I2C protocols
Documentation
name: Release

on:
  push:
    branches: [main]
    paths-ignore:
      - 'docs/**'
      - '*.md'
      - '.github/workflows/docs.yml'

permissions:
  contents: write
  pull-requests: write

# Serialize Release runs on the same ref so two release workflows can't race
# against each other. Non-release pushes (docs/**, *.md) bypass this workflow
# entirely via paths-ignore but can still land on main during a release run —
# the rebase-and-retry loop in `Commit version bump` handles that case.
concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

jobs:
  release:
    runs-on: ubuntu-latest
    if: "!contains(github.event.head_commit.message, 'chore(release)')"
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@stable

      - name: Cache dependencies
        uses: Swatinem/rust-cache@v2

      - name: Determine version bump
        id: version
        run: |
          # Get commits since last tag
          LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
          if [ -z "$LAST_TAG" ]; then
            COMMITS=$(git log --oneline)
            LAST_TAG="v0.0.0"
          else
            COMMITS=$(git log ${LAST_TAG}..HEAD --oneline)
          fi

          echo "Last tag: $LAST_TAG"
          echo "Commits since last tag:"
          echo "$COMMITS"

          # Determine bump type based on conventional commits
          BUMP="patch"
          if echo "$COMMITS" | grep -q "^[a-f0-9]* feat"; then
            BUMP="minor"
          fi
          if echo "$COMMITS" | grep -q "BREAKING CHANGE\|^[a-f0-9]* feat!"; then
            BUMP="major"
          fi

          # Derive current version from the last tag, not Cargo.toml.
          # If a previous run failed after pushing the tag but before bumping
          # Cargo.toml on main, reading Cargo.toml would recompute the same
          # NEW_VERSION on every subsequent merge and collide with the
          # already-pushed tag. The tag is the source of truth.
          CURRENT_VERSION="${LAST_TAG#v}"

          # Calculate new version
          IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
          case $BUMP in
            major) NEW_VERSION="$((MAJOR + 1)).0.0" ;;
            minor) NEW_VERSION="$MAJOR.$((MINOR + 1)).0" ;;
            patch) NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" ;;
          esac

          echo "Current version: $CURRENT_VERSION"
          echo "Bump type: $BUMP"
          echo "New version: $NEW_VERSION"

          echo "bump=$BUMP" >> $GITHUB_OUTPUT
          echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
          echo "current=$CURRENT_VERSION" >> $GITHUB_OUTPUT
          echo "last_tag=$LAST_TAG" >> $GITHUB_OUTPUT

      - name: Check if release needed
        run: |
          if [ "${{ steps.version.outputs.version }}" = "${{ steps.version.outputs.current }}" ]; then
            echo "No version bump needed. Current version: ${{ steps.version.outputs.current }}"
            echo "No commits found that require a release."
            exit 0
          else
            echo "Version bump needed: ${{ steps.version.outputs.current }} -> ${{ steps.version.outputs.version }}"
          fi

      - name: Generate changelog
        if: steps.version.outputs.version != steps.version.outputs.current
        id: changelog
        run: |
          LAST_TAG="${{ steps.version.outputs.last_tag }}"
          NEW_VERSION="${{ steps.version.outputs.version }}"

          # Generate changelog from commits
          CHANGELOG=$(git log ${LAST_TAG}..HEAD --pretty=format:"- %s" --grep="^feat\|^fix\|^perf\|^refactor" | sed 's/^- \([a-f0-9]*\) /- /')

          # Create release notes
          cat > release_notes.md << EOF
          ## What's Changed

          $CHANGELOG

          **Full Changelog**: https://github.com/${{ github.repository }}/compare/${LAST_TAG}...v${NEW_VERSION}
          EOF

          # Update CHANGELOG.md
          if [ ! -f CHANGELOG.md ]; then
            echo "# Changelog" > CHANGELOG.md
            echo "" >> CHANGELOG.md
            echo "All notable changes to this project will be documented in this file." >> CHANGELOG.md
            echo "" >> CHANGELOG.md
          fi

          # Prepend new version to changelog
          {
            head -n 4 CHANGELOG.md
            echo "## [${NEW_VERSION}] - $(date +%Y-%m-%d)"
            echo ""
            echo "$CHANGELOG"
            echo ""
            tail -n +5 CHANGELOG.md
          } > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md

      - name: Update version
        if: steps.version.outputs.version != steps.version.outputs.current
        run: |
          sed -i 's/^version = ".*"/version = "${{ steps.version.outputs.version }}"/' Cargo.toml
          cargo check # Update Cargo.lock

      - name: Run tests
        if: steps.version.outputs.version != steps.version.outputs.current
        run: |
          cargo test --verbose --lib --bins --tests
          cargo test --doc

      - name: Run clippy
        if: steps.version.outputs.version != steps.version.outputs.current
        run: cargo clippy --lib --bins --tests -- -D warnings

      - name: Check formatting
        if: steps.version.outputs.version != steps.version.outputs.current
        run: cargo fmt --check

      - name: Commit version bump
        if: steps.version.outputs.version != steps.version.outputs.current
        run: |
          NEW_TAG="v${{ steps.version.outputs.version }}"

          # Belt-and-braces: if the tag already exists (locally or on the
          # remote) abort before mutating anything. Without this, the next
          # `git tag -a` aborts mid-step and leaves a dangling local
          # chore(release) commit that never gets pushed.
          if git rev-parse -q --verify "refs/tags/${NEW_TAG}" >/dev/null \
             || git ls-remote --exit-code --tags origin "${NEW_TAG}" >/dev/null 2>&1; then
            echo "::error::Tag ${NEW_TAG} already exists. Aborting to avoid divergent release state."
            echo "::error::This usually means a previous run created the tag but failed before pushing the version bump."
            echo "::error::Manually reconcile: bump Cargo.toml past ${NEW_TAG}, or delete the orphan tag."
            exit 1
          fi

          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git add Cargo.toml Cargo.lock CHANGELOG.md
          git commit -m "chore(release): bump version to ${{ steps.version.outputs.version }}"

          # Push the version-bump commit BEFORE creating the tag. If `main`
          # advanced while we were running (e.g. a docs PR landed under
          # paths-ignore, or the concurrency group was disabled), rebase onto
          # the new tip and retry. Tagging-before-pushing previously left
          # orphan `v$VERSION` tags on transient push failures.
          PUSH_ATTEMPTS=5
          for attempt in $(seq 1 $PUSH_ATTEMPTS); do
            if git push origin main; then
              break
            fi
            if [ "$attempt" -eq "$PUSH_ATTEMPTS" ]; then
              echo "::error::Failed to push version bump after $PUSH_ATTEMPTS attempts."
              exit 1
            fi
            echo "Push rejected (attempt $attempt/$PUSH_ATTEMPTS); rebasing onto origin/main and retrying."
            git fetch origin main
            git rebase origin/main
          done

          git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
          git push origin "${NEW_TAG}"

      - name: Publish to crates.io
        if: steps.version.outputs.version != steps.version.outputs.current
        run: cargo publish --allow-dirty --token ${{ secrets.CRATES_IO_TOKEN }}

      - name: Create GitHub Release
        if: steps.version.outputs.version != steps.version.outputs.current
        uses: softprops/action-gh-release@v3
        with:
          tag_name: v${{ steps.version.outputs.version }}
          body_path: release_notes.md
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}