planwarden 0.4.0

CLI planning enforcer for AI agents
Documentation
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 }}"

  dist-local:
    name: Build local artifacts (${{ 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
        with:
          targets: ${{ matrix.target }}

      - 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: Install cargo-dist
        run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh

      - name: Build local dist artifacts
        run: $HOME/.cargo/bin/dist build --tag "${{ needs.prepare-release.outputs.release_tag }}" --target "${{ matrix.target }}" --artifacts=local --output-format=json --allow-dirty > dist-local.json

      - name: Smoke test local dist artifact
        run: |
          artifact="$(jq -r '.artifacts | to_entries[] | select(.value.kind == "executable-zip") | .value.path' dist-local.json | head -n 1)"
          if [ -z "$artifact" ] || [ "$artifact" = "null" ] || [ ! -f "$artifact" ]; then
            echo "::error::No dist tarball found for smoke test."
            exit 1
          fi
          ./scripts/smoke-test-installed-planwarden.sh "$artifact"

      - 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

          files=()
          while IFS= read -r file; do
            files+=("$file")
          done < <(jq -r '.upload_files[]' dist-local.json)
          if [ "${#files[@]}" -eq 0 ]; then
            echo "::error::No release files produced by dist-local."
            exit 1
          fi

          gh release upload "$tag" "${files[@]}" --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

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

      - name: Install cargo-dist
        run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh

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

      - name: Build global dist artifacts
        run: $HOME/.cargo/bin/dist build --tag "${{ needs.prepare-release.outputs.release_tag }}" --artifacts=global --output-format=json --allow-dirty > dist-global.json

      - name: Patch Homebrew formula checksums
        run: ./scripts/patch-homebrew-formula-checksums.sh target/distrib

      - name: Upload global artifacts to GitHub release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          tag="${{ needs.prepare-release.outputs.release_tag }}"
          files=()
          while IFS= read -r file; do
            files+=("$file")
          done < <(jq -r '.upload_files[]' dist-global.json)
          if [ "${#files[@]}" -eq 0 ]; then
            echo "::error::No release files produced by dist-global."
            exit 1
          fi

          gh release upload "$tag" "${files[@]}" --clobber

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

  publish-homebrew:
    name: Publish Homebrew formula
    runs-on: ubuntu-latest
    needs: [prepare-release, dist-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: planwarden bot
        working-directory: homebrew-tap
        run: |
          set -euo pipefail
          git config user.name "${GITHUB_USER}"
          git config user.email "${GITHUB_EMAIL}"

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

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

  publish-crates:
    name: Publish crate (crates.io)
    runs-on: ubuntu-latest
    needs: [prepare-release, dist-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: Detect if crate version already exists
        id: crate_version
        run: |
          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: Package crate
        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:
      - name: Publish the GitHub release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_REPO: ${{ github.repository }}
        run: |
          set -euo pipefail
          gh release edit "${{ needs.prepare-release.outputs.release_tag }}" --draft=false