magellan-cli 0.2.0

Deterministic presentation engine for AI-generated technical walkthroughs
name: Release

on:
  push:
    branches:
      - main
  workflow_dispatch:

concurrency:
  group: release-${{ github.ref_name }}
  cancel-in-progress: false

permissions:
  contents: write

jobs:
  prepare-release:
    name: Prepare release
    runs-on: ubuntu-latest
    outputs:
      crate_name: ${{ steps.release.outputs.crate_name }}
      release_needed: ${{ steps.release.outputs.release_needed }}
      release_tag: ${{ steps.release.outputs.release_tag }}
      release_version: ${{ steps.release.outputs.release_version }}
      tag_exists: ${{ steps.release.outputs.tag_exists }}
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0

      - name: Determine release state
        id: release
        env:
          EVENT_NAME: ${{ github.event_name }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail

          crate_name="$(awk -F' *= *' '/^name = /{gsub(/"/,"",$2); print $2; exit}' Cargo.toml)"
          release_version="$(awk -F' *= *' '/^version = /{gsub(/"/,"",$2); print $2; exit}' Cargo.toml)"
          release_tag="v${release_version}"
          head_commit="$(git rev-parse HEAD)"

          release_exists=false
          release_is_draft=false
          if gh release view "$release_tag" --json isDraft >/tmp/release.json 2>/dev/null; then
            release_exists=true
            release_is_draft="$(jq -r '.isDraft' /tmp/release.json)"
          fi

          tag_exists=false
          tag_commit=""
          if git rev-parse "$release_tag" >/dev/null 2>&1; then
            tag_exists=true
            tag_commit="$(git rev-list -n 1 "$release_tag")"
          fi

          release_needed=true
          if [ "$EVENT_NAME" != "workflow_dispatch" ] && [ "$release_exists" = true ] && [ "$release_is_draft" = false ]; then
            echo "::notice::Release ${release_tag} is already published; skipping automatic rebuild so release assets and Homebrew checksums stay stable."
            release_needed=false
          elif [ "$EVENT_NAME" != "workflow_dispatch" ] && [ "$tag_exists" = true ] && [ "$tag_commit" != "$head_commit" ]; then
            echo "::notice::Version ${release_version} is already tagged on a different commit. Bump Cargo.toml before the next automatic release."
            release_needed=false
          fi

          {
            echo "crate_name=${crate_name}"
            echo "release_needed=${release_needed}"
            echo "release_tag=${release_tag}"
            echo "release_version=${release_version}"
            echo "tag_exists=${tag_exists}"
          } >> "$GITHUB_OUTPUT"

      - name: Create and push release tag
        if: steps.release.outputs.release_needed == 'true' && steps.release.outputs.tag_exists != 'true'
        run: |
          set -euo pipefail
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git tag "${{ steps.release.outputs.release_tag }}"
          git push origin "refs/tags/${{ steps.release.outputs.release_tag }}"

  build-local:
    name: Build release asset (${{ matrix.target }})
    runs-on: ${{ matrix.runner }}
    needs: prepare-release
    if: needs.prepare-release.outputs.release_needed == 'true'
    strategy:
      matrix:
        include:
          - target: x86_64-apple-darwin
            runner: macos-15-intel
          - target: aarch64-apple-darwin
            runner: macos-14
          - target: x86_64-unknown-linux-gnu
            runner: ubuntu-22.04
    steps:
      - uses: actions/checkout@v5

      - uses: dtolnay/rust-toolchain@stable

      - uses: actions/cache@v5
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: ${{ runner.os }}-${{ matrix.target }}-cargo-

      - name: cargo build --release
        run: cargo build --release

      - name: Package release artifact
        run: |
          set -euo pipefail
          mkdir -p target/distrib target/release-package
          rm -rf target/release-package/*
          cp target/release/magellan target/release-package/
          cp README.md LICENSE target/release-package/
          archive="target/distrib/magellan-${{ matrix.target }}.tar.xz"
          tar -C target/release-package -cJf "${archive}" .
          shasum -a 256 "${archive}" > "${archive}.sha256"

      - name: Smoke test packaged artifact
        run: ./scripts/smoke-test-installed-magellan.sh target/distrib/magellan-${{ matrix.target }}.tar.xz

      - name: Upload local artifacts to GitHub release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          tag="${{ needs.prepare-release.outputs.release_tag }}"

          if ! gh release view "$tag" >/dev/null 2>&1; then
            gh release create "$tag" --verify-tag --generate-notes --draft
          fi

          gh release upload "$tag" \
            "target/distrib/magellan-${{ matrix.target }}.tar.xz" \
            "target/distrib/magellan-${{ matrix.target }}.tar.xz.sha256" \
            --clobber

      - name: Upload local artifacts for global packaging
        uses: actions/upload-artifact@v6
        with:
          name: local-artifacts-${{ matrix.target }}
          path: |
            target/distrib/*.tar.xz
            target/distrib/*.tar.xz.sha256
          if-no-files-found: error

  build-global:
    name: Build global artifacts
    runs-on: ubuntu-latest
    needs: [prepare-release, build-local]
    if: needs.prepare-release.outputs.release_needed == 'true'
    steps:
      - uses: actions/checkout@v5

      - name: Download local artifacts
        uses: actions/download-artifact@v7
        with:
          pattern: local-artifacts-*
          path: target/distrib
          merge-multiple: true

      - name: Generate Homebrew formula
        run: ./scripts/generate-homebrew-formula.sh target/distrib "${{ needs.prepare-release.outputs.release_version }}" target/distrib/magellan.rb

      - name: Upload Homebrew formula artifact
        uses: actions/upload-artifact@v6
        with:
          name: homebrew-formula
          path: target/distrib/magellan.rb
          if-no-files-found: error

  publish-homebrew:
    name: Publish Homebrew formula
    runs-on: ubuntu-latest
    needs: [prepare-release, build-global]
    if: needs.prepare-release.outputs.release_needed == 'true'
    steps:
      - name: Ensure Homebrew tap token is configured
        env:
          HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
        run: |
          if [ -z "${HOMEBREW_TAP_TOKEN}" ]; then
            echo "::error::Missing HOMEBREW_TAP_TOKEN secret."
            exit 1
          fi

      - uses: actions/checkout@v5
        with:
          persist-credentials: true
          repository: nclandrei/homebrew-tap
          token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
          path: homebrew-tap

      - name: Download Homebrew formula
        uses: actions/download-artifact@v7
        with:
          name: homebrew-formula
          path: homebrew-tap/Formula

      - name: Commit and push formula
        env:
          GITHUB_EMAIL: actions@users.noreply.github.com
          GITHUB_USER: magellan bot
        working-directory: homebrew-tap
        run: |
          set -euo pipefail
          git config user.name "${GITHUB_USER}"
          git config user.email "${GITHUB_EMAIL}"

          git add Formula/magellan.rb
          if git diff --cached --quiet; then
            echo "No Homebrew formula changes to publish."
            exit 0
          fi

          git commit -m "magellan ${{ needs.prepare-release.outputs.release_version }}"
          git push

  publish-crates:
    name: Publish crate (crates.io)
    runs-on: ubuntu-latest
    needs: [prepare-release, build-global]
    if: needs.prepare-release.outputs.release_needed == 'true'
    steps:
      - uses: actions/checkout@v5

      - uses: dtolnay/rust-toolchain@stable

      - uses: actions/cache@v5
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-cargo-publish-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: ${{ runner.os }}-cargo-publish-

      - name: Ensure crates.io token is configured
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          if [ -z "${CARGO_REGISTRY_TOKEN}" ]; then
            echo "::error::Missing CARGO_REGISTRY_TOKEN secret."
            exit 1
          fi

      - name: Check if crate version is already published
        id: crate_version
        run: |
          set -euo pipefail
          if curl -fsS "https://crates.io/api/v1/crates/${{ needs.prepare-release.outputs.crate_name }}/${{ needs.prepare-release.outputs.release_version }}" >/dev/null; then
            echo "already_published=true" >> "$GITHUB_OUTPUT"
          else
            echo "already_published=false" >> "$GITHUB_OUTPUT"
          fi

      - name: cargo package
        if: steps.crate_version.outputs.already_published != 'true'
        run: cargo package --locked

      - name: Publish crate
        if: steps.crate_version.outputs.already_published != 'true'
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: cargo publish --locked

      - name: Skip publish (already exists)
        if: steps.crate_version.outputs.already_published == 'true'
        run: echo "Crate version already published on crates.io; skipping."

  publish-github-release:
    name: Publish GitHub release
    runs-on: ubuntu-latest
    needs: [prepare-release, publish-homebrew, publish-crates]
    if: needs.prepare-release.outputs.release_needed == 'true'
    steps:
      - uses: actions/checkout@v5
      - name: Publish the GitHub release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: gh release edit "${{ needs.prepare-release.outputs.release_tag }}" --draft=false