earl 0.6.3

AI-safe CLI for AI agents
name: Release

on:
  push:
    tags:
      - "v*"

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

env:
  CARGO_TERM_COLOR: always

permissions:
  contents: read

jobs:
  meta:
    name: Resolve Release Metadata
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
    outputs:
      version: ${{ steps.meta.outputs.version }}
      tag: ${{ steps.meta.outputs.tag }}
      is_prerelease: ${{ steps.meta.outputs.is_prerelease }}
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Parse tag and validate
        id: meta
        shell: bash
        run: |
          set -euo pipefail

          TAG="${GITHUB_REF_NAME}"
          VERSION="${TAG#v}"
          CARGO_VERSION="$(awk -F'"' '/^\[package\]/{p=1} p && /^version = /{print $2; exit}' Cargo.toml)"

          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

          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"
            echo "version=$VERSION"
            echo "is_prerelease=$IS_PRERELEASE"
          } >> "$GITHUB_OUTPUT"

  build-and-test:
    name: Build and test
    needs: meta
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Install system dependencies
        run: sudo apt-get update -y && sudo apt-get install -y libdbus-1-dev bubblewrap

      - name: Allow bwrap unprivileged user namespaces
        run: |
          echo 'abi <abi/4.0>,' | sudo tee /etc/apparmor.d/bwrap
          echo 'include <tunables/global>' | sudo tee -a /etc/apparmor.d/bwrap
          echo 'profile bwrap /usr/bin/bwrap flags=(unconfined) {' | sudo tee -a /etc/apparmor.d/bwrap
          echo '  userns,' | sudo tee -a /etc/apparmor.d/bwrap
          echo '}' | sudo tee -a /etc/apparmor.d/bwrap
          sudo apparmor_parser -r /etc/apparmor.d/bwrap

      - name: Setup pnpm
        uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

      - name: Setup Node
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version-file: .node-version
          package-manager-cache: false

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

      - name: Build and test
        run: cargo test --locked --all-targets --all-features

  build-artifacts:
    name: Build ${{ matrix.target }}
    needs: meta
    runs-on: ${{ matrix.os }}
    timeout-minutes: 60
    permissions:
      contents: read
    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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Setup pnpm
        uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

      - name: Setup Node
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version-file: .node-version
          package-manager-cache: false

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

      - 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: build-${{ matrix.target }}
          path: dist/*

  release-assets:
    name: Assemble Release Assets
    needs:
      - meta
      - build-artifacts
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Download binary archives
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: release-assets
          path: dist/*

  create-release:
    name: Publish GitHub Release
    needs:
      - meta
      - build-and-test
      - release-assets
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment: release
    permissions:
      contents: write
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Download release assets
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: release-assets
          path: dist

      - name: Create GitHub release
        uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
        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:
      - meta
      - create-release
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      id-token: write
      attestations: write
      contents: read
    steps:
      - name: Download release assets
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: release-assets
          path: dist
      - name: Attest release assets
        uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
        with:
          subject-path: |
            dist/*

  publish-crate:
    name: Publish to crates.io
    needs:
      - meta
      - create-release
    runs-on: ubuntu-latest
    timeout-minutes: 60
    environment: release
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Setup pnpm
        uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

      - name: Setup Node
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version-file: .node-version
          package-manager-cache: false

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

      - name: Verify publish environment
        run: pnpm --version

      - name: Authenticate with crates.io
        uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
        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..."
            if output=$(cargo publish --locked -p "$crate" 2>&1); then
              echo "$output"
            elif grep -q "already exists on crates.io index" <<< "$output"; 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


  component-releases:
    name: Publish Component Releases
    needs:
      - meta
      - publish-crate
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment: release
    permissions:
      contents: write
    steps:
      - name: Create component releases
        env:
          GH_REPO: ${{ github.repository }}
          GH_TOKEN: ${{ github.token }}
          VERSION: ${{ needs.meta.outputs.version }}
        run: |
          set -euo pipefail

          CRATES=(
            earl-core
            earl-protocol-http
            earl-protocol-grpc
            earl-protocol-bash
            earl-protocol-sql
            earl-protocol-browser
          )

          for crate in "${CRATES[@]}"; do
            tag="$crate-v$VERSION"
            if ! gh release view "$tag" >/dev/null 2>&1; then
              gh release create "$tag" \
                --generate-notes \
                --latest=false \
                --target "$GITHUB_SHA" \
                --title "$tag"
            fi

            actual="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq .object.sha)"
            if [[ "$actual" != "$GITHUB_SHA" ]]; then
              echo "$tag points to $actual instead of $GITHUB_SHA" >&2
              exit 1
            fi
          done