earl 0.5.2

AI-safe CLI for AI agents
name: Release

on:
  push:
    tags:
      - v*

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

permissions:
  contents: write
  id-token: write
  attestations: write

env:
  CARGO_TERM_COLOR: always

jobs:
  meta:
    name: Resolve Release Metadata
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.meta.outputs.version }}
      tag: ${{ steps.meta.outputs.tag }}
      is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Parse tag and validate
        id: meta
        shell: bash
        run: |
          set -euo pipefail

          TAG="${GITHUB_REF_NAME}"
          if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
            echo "tag $TAG does not match supported format vX.Y.Z or vX.Y.Z-rc.N" >&2
            exit 1
          fi

          VERSION="${TAG#v}"
          CARGO_VERSION="$(awk -F'"' '/^\[package\]/{p=1} p && /^version = /{print $2; exit}' Cargo.toml)"
          if [[ "$VERSION" != "$CARGO_VERSION" ]]; then
            echo "tag version $VERSION must match Cargo.toml version $CARGO_VERSION" >&2
            exit 1
          fi

          IS_PRERELEASE=false
          if [[ "$VERSION" == *-* ]]; then
            IS_PRERELEASE=true
          fi

          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
          echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"

  build-artifacts:
    name: Build ${{ matrix.target }}
    needs: meta
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: macos-15
            target: aarch64-apple-darwin
            build_tool: cargo
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
            build_tool: cargo
          - os: ubuntu-latest
            target: aarch64-unknown-linux-gnu
            build_tool: cargo
          - os: ubuntu-latest
            target: x86_64-unknown-linux-musl
            build_tool: cargo
          - os: ubuntu-latest
            target: x86_64-pc-windows-msvc
            build_tool: xwin
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "pnpm"
          cache-dependency-path: pnpm-lock.yaml

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - name: Cache cargo artifacts
        uses: Swatinem/rust-cache@v2

      - name: Install cross-compilation toolchain (aarch64)
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        run: |
          sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu
          echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
          echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"

      - name: Install cross-compilation toolchain (musl)
        if: matrix.target == 'x86_64-unknown-linux-musl'
        run: sudo apt-get update && sudo apt-get install -y musl-tools

      - name: Install cargo-xwin and LLVM
        if: matrix.build_tool == 'xwin'
        shell: bash
        run: |
          sudo apt-get update && sudo apt-get install -y llvm
          cargo install cargo-xwin --locked

      - name: Build and package
        shell: bash
        run: |
          scripts/release/build-artifact.sh \
            --target "${{ matrix.target }}" \
            --version "${{ needs.meta.outputs.version }}" \
            --build-tool "${{ matrix.build_tool }}" \
            --output-dir dist

      - name: Upload artifact archive
        uses: actions/upload-artifact@v4
        with:
          name: build-${{ matrix.target }}
          path: dist/*

  release-assets:
    name: Assemble Release Assets
    needs:
      - meta
      - build-artifacts
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Download binary archives
        uses: actions/download-artifact@v4
        with:
          pattern: build-*
          path: dist
          merge-multiple: true

      - name: Generate checksums
        shell: bash
        run: scripts/release/generate-checksums.sh dist

      - name: Upload complete release assets
        uses: actions/upload-artifact@v4
        with:
          name: release-assets
          path: dist/*

  create-release:
    name: Publish GitHub Release
    needs:
      - meta
      - release-assets
    runs-on: ubuntu-latest
    environment: release
    permissions:
      contents: write
      id-token: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Download release assets
        uses: actions/download-artifact@v4
        with:
          name: release-assets
          path: dist

      - name: Create GitHub release
        uses: softprops/action-gh-release@v2
        with:
          tag_name: ${{ needs.meta.outputs.tag }}
          name: ${{ needs.meta.outputs.tag }}
          generate_release_notes: true
          prerelease: ${{ needs.meta.outputs.is_prerelease == 'true' }}
          files: dist/*
          fail_on_unmatched_files: true
          make_latest: ${{ needs.meta.outputs.is_prerelease == 'false' }}

      - name: Trigger release notes workflow
        env:
          GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
        run: |
          gh workflow run release-notes.yml \
            --field tag="${{ needs.meta.outputs.tag }}"

  attest-provenance:
    name: Attest Build Provenance
    needs:
      - create-release
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      attestations: write
      contents: read
    steps:
      - name: Download release assets
        uses: actions/download-artifact@v4
        with:
          name: release-assets
          path: dist

      - name: Attest release assets
        uses: actions/attest-build-provenance@v2
        with:
          subject-path: |
            dist/*

  publish-crate:
    name: Publish to crates.io
    needs:
      - meta
      - create-release
    runs-on: ubuntu-latest
    environment: release
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "pnpm"
          cache-dependency-path: pnpm-lock.yaml

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

      - name: Cache cargo artifacts
        uses: Swatinem/rust-cache@v2

      - name: Authenticate with crates.io
        uses: rust-lang/crates-io-auth-action@v1
        id: crates-io-auth

      - name: Publish workspace crates
        shell: bash
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.crates-io-auth.outputs.token }}
        run: |
          set -euo pipefail

          # Publish in dependency order: earl-core first, then protocol
          # crates, and finally the root earl crate.
          CRATES=(
            earl-core
            earl-protocol-http
            earl-protocol-grpc
            earl-protocol-bash
            earl-protocol-sql
            earl-protocol-browser
            earl
          )

          for crate in "${CRATES[@]}"; do
            echo "Publishing $crate..."
            output=$(cargo publish --locked -p "$crate" 2>&1) && echo "$output" || {
              if echo "$output" | grep -q "already exists on crates.io index"; then
                echo "$crate already published, skipping."
              else
                echo "$output" >&2
                exit 1
              fi
            }

            # Wait for crates.io index to update before publishing
            # dependents (skip delay after the last crate).
            if [[ "$crate" != "earl" ]]; then
              sleep 30
            fi
          done