workon 0.27.0

Development workspace launcher with Zellij, Claude CLI, and branchdiff
Documentation
name: Release

on:
  push:
    branches: [main]

concurrency:
  group: release
  cancel-in-progress: false

env:
  CARGO_TERM_COLOR: always

permissions:
  contents: read

jobs:
  check-version:
    name: Check for version bump
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      should_release: ${{ steps.check.outputs.should_release }}
      version: ${{ steps.check.outputs.version }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          fetch-tags: true

      - name: Check if version tag exists
        id: check
        run: |
          VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
          if git tag -l "v$VERSION" | grep -q "v$VERSION"; then
            echo "Tag v$VERSION already exists, skipping release"
            echo "should_release=false" >> "$GITHUB_OUTPUT"
          else
            echo "Tag v$VERSION does not exist, proceeding with release"
            echo "should_release=true" >> "$GITHUB_OUTPUT"
          fi

  ci:
    name: CI checks
    needs: check-version
    if: needs.check-version.outputs.should_release == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v7

      # A bare install reads rust-toolchain.toml: the pinned version and its components.
      - name: Rust toolchain (rust-toolchain.toml)
        run: rustup toolchain install

      - uses: Swatinem/rust-cache@v2

      - name: cargo fmt
        run: |
          cargo fmt --all --check
          cargo fmt --all --check --manifest-path fuzz/Cargo.toml

      - name: cargo check
        run: cargo check --locked

      - name: Install multiplexers
        run: sh scripts/install-multiplexers.sh

      - name: cargo test
        run: cargo test --locked

      - name: cargo clippy
        run: cargo clippy --locked -- -D warnings

      - uses: taiki-e/install-action@cargo-deny

      - name: cargo deny (advisories, bans, licenses, sources)
        run: cargo deny check

      - name: Generate distribution assets
        run: cargo run --locked --example generate_assets

      - name: Upload distribution assets
        uses: actions/upload-artifact@v7
        with:
          name: dist-assets
          path: target/assets/
          retention-days: 1

  build:
    name: Build ${{ matrix.target }}
    needs: ci
    runs-on: ${{ matrix.runner }}
    # Notarization waits on Apple, which is the long pole.
    timeout-minutes: 45
    strategy:
      matrix:
        include:
          - target: x86_64-apple-darwin
            runner: macos-26
            archive: workon-x86_64-apple-darwin.tar.gz
            is_macos: true
          - target: aarch64-apple-darwin
            runner: macos-26
            archive: workon-aarch64-apple-darwin.tar.gz
            is_macos: true
          - target: x86_64-unknown-linux-gnu
            runner: ubuntu-latest
            archive: workon-x86_64-unknown-linux-gnu.tar.gz
          - target: aarch64-unknown-linux-gnu
            runner: ubuntu-latest
            archive: workon-aarch64-unknown-linux-gnu.tar.gz
          - target: x86_64-pc-windows-msvc
            runner: windows-latest
            archive: workon-x86_64-pc-windows-msvc.zip
    steps:
      - uses: actions/checkout@v7

      - name: Rust toolchain (rust-toolchain.toml) and target
        run: |
          rustup toolchain install
          rustup target add ${{ matrix.target }}

      - name: Install cross
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        uses: taiki-e/install-action@cross

      - name: Build binary
        shell: bash
        run: |
          if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
            cross build --release --locked --target ${{ matrix.target }}
          else
            cargo build --release --locked --target ${{ matrix.target }}
          fi

      - name: Import Apple certificate
        if: matrix.is_macos && env.APPLE_CERT != ''
        env:
          APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
          APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
        run: |
          KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
          KEYCHAIN_PASSWORD="$(openssl rand -hex 16)"

          echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
          echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"

          echo "$APPLE_CERT" | base64 --decode > "$RUNNER_TEMP/certificate.p12"

          security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
          security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
          security import "$RUNNER_TEMP/certificate.p12" -P "$APPLE_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
          security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
          security list-keychain -d user -s "$KEYCHAIN_PATH"

          rm "$RUNNER_TEMP/certificate.p12"

      - name: Sign binary
        if: matrix.is_macos && env.APPLE_CERT != ''
        env:
          APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
        run: |
          BINARY="target/${{ matrix.target }}/release/workon"
          IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | head -1 | sed 's/.*"\(.*\)".*/\1/')
          echo "Signing with identity: $IDENTITY"
          codesign --force --options=runtime --sign "$IDENTITY" --timestamp "$BINARY"
          codesign -v --verify --deep --strict "$BINARY"

      - name: Notarize binary
        if: matrix.is_macos && env.APPLE_CERT != ''
        env:
          APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
        run: |
          BINARY="target/${{ matrix.target }}/release/workon"
          /usr/bin/ditto -c -k "$BINARY" "$RUNNER_TEMP/workon.zip"
          xcrun notarytool submit "$RUNNER_TEMP/workon.zip" \
            --apple-id "$APPLE_ID" \
            --password "$APPLE_ID_PASSWORD" \
            --team-id "$APPLE_TEAM_ID" \
            --wait
          rm "$RUNNER_TEMP/workon.zip"

      - name: Download distribution assets
        uses: actions/download-artifact@v8
        with:
          name: dist-assets
          path: dist-assets

      - name: Package binary (unix)
        if: runner.os != 'Windows'
        run: |
          mkdir -p staging/completions
          cp target/${{ matrix.target }}/release/workon staging/
          cp dist-assets/workon.1 staging/
          cp dist-assets/completions/* staging/completions/
          cp README.md LICENSE-MIT LICENSE-APACHE staging/
          cd staging
          tar czf ../${{ matrix.archive }} workon workon.1 completions/ README.md LICENSE-MIT LICENSE-APACHE

      - name: Package binary (windows)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          Compress-Archive -Path "target/${{ matrix.target }}/release/workon.exe" -DestinationPath "${{ matrix.archive }}"

      - name: Upload artifact
        uses: actions/upload-artifact@v7
        with:
          name: ${{ matrix.archive }}
          path: ${{ matrix.archive }}
          retention-days: 1

      - name: Cleanup keychain
        if: always() && matrix.is_macos && env.KEYCHAIN_PATH != ''
        run: security delete-keychain "$KEYCHAIN_PATH"

  release:
    name: Create release and publish
    needs: [check-version, build]
    runs-on: ubuntu-latest
    timeout-minutes: 30
    # The only job that writes: it pushes the tag and creates the GitHub release.
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - name: Rust toolchain (rust-toolchain.toml)
        run: rustup toolchain install

      - name: Publish to crates.io
        run: cargo publish || echo "::warning::Publish failed (version may already exist)"
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

      - name: Download all artifacts
        uses: actions/download-artifact@v8
        with:
          path: artifacts
          merge-multiple: true

      - name: Create git tag
        run: |
          git tag "v${{ needs.check-version.outputs.version }}"
          git push origin "v${{ needs.check-version.outputs.version }}"

      - name: Generate release notes
        run: |
          cargo install git-cliff
          git cliff --latest --strip all > RELEASE_NOTES.md

      - name: Generate checksums
        run: |
          cd artifacts
          sha256sum *.tar.gz *.zip > SHA256SUMS.txt
          cat SHA256SUMS.txt

      - name: Create GitHub release
        uses: softprops/action-gh-release@v3
        with:
          tag_name: v${{ needs.check-version.outputs.version }}
          name: v${{ needs.check-version.outputs.version }}
          body_path: RELEASE_NOTES.md
          files: |
            artifacts/*.tar.gz
            artifacts/*.zip
            artifacts/SHA256SUMS.txt

      - name: Trigger Homebrew tap update
        uses: peter-evans/repository-dispatch@v4
        with:
          token: ${{ secrets.TAP_GITHUB_TOKEN }}
          repository: michaeldhopkins/homebrew-tap
          event-type: update-formula
          client-payload: '{"formula": "workon", "repo": "michaeldhopkins/workon", "version": "${{ needs.check-version.outputs.version }}"}'

      - name: Trigger michaeldhopkins.com release index update
        if: env.SITE_TOKEN != ''
        env:
          SITE_TOKEN: ${{ secrets.SITE_GITHUB_TOKEN }}
        uses: peter-evans/repository-dispatch@v4
        with:
          token: ${{ secrets.SITE_GITHUB_TOKEN }}
          repository: michaeldhopkins/michaeldhopkins.com
          event-type: release-published
          client-payload: '{"repo": "michaeldhopkins/workon", "version": "${{ needs.check-version.outputs.version }}"}'