pmat 3.11.0

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP, HTTP)
# Per-repo release workflow — tagged releases only
# Generated by machines/clean-room/deploy-workflows.sh — do not edit manually.
# Spec: docs/specifications/release-system.md
#
# Flow: tag push → create draft release → unified gate → verify → publish
#       → cross-compiled binaries (4 Linux targets) → finalize release
#
# Tag formats:
#   v1.0.0              — single-crate repos
#   v-<crate>-1.0.0     — workspace repos (e.g. v-apr-cli-0.4.0)
#
# IMPORTANT: Tags must be pushed with a PAT or deploy key (not GITHUB_TOKEN),
# otherwise this workflow will not trigger (GitHub anti-recursion measure).
#
# IMPORTANT: All actions pinned to commit SHAs (CVE-2025-30066 mitigation).

name: Release

on:
  push:
    tags: ['v*']

permissions:
  contents: write    # create GitHub Release + upload assets
  id-token: write    # OIDC for crates.io Trusted Publishing

# One release at a time per repo
concurrency:
  group: release-${{ github.repository }}
  cancel-in-progress: false

jobs:
  # ── Create draft release (runs first, before matrix) ───
  # Ripgrep pattern: create release once, then matrix jobs upload to it.
  # Draft prevents users from seeing incomplete releases.
  create-release:
    runs-on: [self-hosted, clean-room]
    outputs:
      version: ${{ steps.parse.outputs.version }}
      crate_name: ${{ steps.parse.outputs.crate_name }}
      has_binaries: ${{ steps.bincheck.outputs.has_binaries }}
    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      - name: Parse tag and verify version
        id: parse
        run: |
          TAG="${GITHUB_REF_NAME}"

          # Parse tag format: v1.0.0 or v-cratename-1.0.0
          if [[ "$TAG" =~ ^v-([a-z][a-z0-9_-]*)-([0-9]+\..+)$ ]]; then
            CRATE_NAME="${BASH_REMATCH[1]}"
            TAG_VER="${BASH_REMATCH[2]}"
            echo "Workspace release: crate=$CRATE_NAME version=$TAG_VER"
          elif [[ "$TAG" =~ ^v([0-9]+\..+)$ ]]; then
            CRATE_NAME=""
            TAG_VER="${BASH_REMATCH[1]}"
            echo "Single-crate release: version=$TAG_VER"
          else
            echo "::error::Tag '$TAG' does not match expected format (v1.0.0 or v-crate-1.0.0)"
            exit 1
          fi

          # Use cargo metadata for reliable version extraction
          if [ -n "$CRATE_NAME" ]; then
            CARGO_VER=$(cargo metadata --format-version 1 --no-deps \
              | jq -r ".packages[] | select(.name == \"$CRATE_NAME\") | .version")
            if [ -z "$CARGO_VER" ] || [ "$CARGO_VER" = "null" ]; then
              echo "::error::Crate '$CRATE_NAME' not found in workspace"
              exit 1
            fi
          else
            CARGO_VER=$(cargo metadata --format-version 1 --no-deps \
              | jq -r '.packages[0].version')
          fi

          if [ "$TAG_VER" != "$CARGO_VER" ]; then
            echo "::error::Tag version $TAG_VER != Cargo.toml version $CARGO_VER"
            exit 1
          fi

          echo "crate_name=$CRATE_NAME" >> "$GITHUB_OUTPUT"
          echo "version=$TAG_VER" >> "$GITHUB_OUTPUT"
          echo "Version verified: $TAG_VER"

      - name: Detect binary targets
        id: bincheck
        run: |
          HAS_BINS=$(cargo metadata --format-version 1 --no-deps \
            | jq '[.packages[].targets[] | select(.kind[] == "bin")] | length')
          echo "has_binaries=$( [ "$HAS_BINS" -gt 0 ] && echo true || echo false )" >> "$GITHUB_OUTPUT"
          echo "Binary targets found: $HAS_BINS"

      - name: Create draft GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release create "$GITHUB_REF_NAME" \
            --draft \
            --verify-tag \
            --title "$GITHUB_REF_NAME" \
            --generate-notes

  # ── Gate: unified CI must pass before publish ──────────
  gate:
    needs: create-release
    uses: paiml/.github/.github/workflows/unified-gate.yml@main
    with:
      repo: ${{ github.event.repository.name }}
      pr_sha: ${{ github.sha }}
    secrets: inherit

  # ── Verify: package tarball integrity ──────────────────
  verify:
    needs: [create-release, gate]
    runs-on: [self-hosted, clean-room]
    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      - name: Verify package tarball
        run: |
          CRATE="${{ needs.create-release.outputs.crate_name }}"
          if [ -n "$CRATE" ]; then
            cargo package --verify -p "$CRATE"
          else
            cargo package --verify
          fi

  # ── Publish: OIDC trusted publishing to crates.io ──────
  publish:
    needs: [create-release, verify]
    runs-on: [self-hosted, clean-room]
    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      - name: Authenticate to crates.io (OIDC)
        uses: rust-lang/crates-io-auth-action@b7e9a28eded4986ec6b1fa40eeee8f8f165559ec  # v1.0.3

      - name: Publish
        run: |
          CRATE="${{ needs.create-release.outputs.crate_name }}"
          if [ -n "$CRATE" ]; then
            cargo publish -p "$CRATE"
          else
            cargo publish
          fi

  # ── Build cross-compiled binaries (4 Linux targets) ────
  # Best-effort: binary build failures do NOT fail the release.
  # crates.io publish is the source of truth.
  build-binaries:
    needs: [create-release, publish]
    if: needs.create-release.outputs.has_binaries == 'true'
    runs-on: [self-hosted, clean-room]
    strategy:
      fail-fast: false
      matrix:
        target:
          - x86_64-unknown-linux-gnu
          - x86_64-unknown-linux-musl
          - aarch64-unknown-linux-gnu
          - aarch64-unknown-linux-musl
    env:
      CARGO: cargo
      # Pin cross version — new releases have broken CI in the past (ripgrep lesson)
      CROSS_VERSION: v0.2.5
    steps:
      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

      - name: Install target prerequisites
        run: |
          case "${{ matrix.target }}" in
            x86_64-*-musl)
              sudo apt-get update -qq
              sudo apt-get install -y -qq musl-tools >/dev/null
              rustup target add "${{ matrix.target }}"
              ;;
            aarch64-*)
              # Use pinned cross binary for ARM targets (no cargo install — too slow)
              dir="$RUNNER_TEMP/cross-download"
              mkdir -p "$dir"
              echo "$dir" >> "$GITHUB_PATH"
              curl -sL "https://github.com/cross-rs/cross/releases/download/$CROSS_VERSION/cross-x86_64-unknown-linux-musl.tar.gz" \
                | tar xz -C "$dir"
              echo "CARGO=cross" >> "$GITHUB_ENV"
              ;;
          esac

      - name: Discover binary names and features
        id: bins
        run: |
          BINS=$(cargo metadata --format-version 1 --no-deps \
            | jq -r '[.packages[].targets[] | select(.kind[] == "bin") | .name] | join(" ")')
          echo "names=$BINS" >> "$GITHUB_OUTPUT"
          echo "Binaries to build: $BINS"

          # Detect features needed for binary builds:
          # 1. .release.toml (explicit override) takes precedence
          # 2. Otherwise, auto-detect required-features from bin targets
          if [ -f .release.toml ] && grep -q 'features' .release.toml; then
            FEATURES=$(grep '^features' .release.toml | sed 's/.*\[//;s/\].*//;s/"//g;s/ //g')
          else
            FEATURES=$(cargo metadata --format-version 1 --no-deps \
              | jq -r '[.packages[].targets[] | select(.kind[] == "bin") | (."required-features" // [])[] ] | unique | join(",")')
          fi
          if [ -n "$FEATURES" ]; then
            echo "feature_flags=--features $FEATURES" >> "$GITHUB_OUTPUT"
            echo "Build features: $FEATURES"
          else
            echo "feature_flags=" >> "$GITHUB_OUTPUT"
            echo "Build features: (default)"
          fi

      - name: Build release binaries (release-lto profile)
        run: |
          $CARGO build --profile release-lto --target "${{ matrix.target }}" ${{ steps.bins.outputs.feature_flags }}

      - name: Strip binaries
        run: |
          TARGET="${{ matrix.target }}"
          for BIN in ${{ steps.bins.outputs.names }}; do
            BIN_PATH="target/${TARGET}/release-lto/$BIN"
            case "$TARGET" in
              aarch64-*)
                # Strip via cross Docker image with target-appropriate strip tool
                docker run --rm -v "$PWD/target:/target:Z" \
                  "ghcr.io/cross-rs/${TARGET}:main" \
                  aarch64-linux-gnu-strip "/$BIN_PATH"
                ;;
              *)
                strip "$BIN_PATH"
                ;;
            esac
          done

      - name: Package archives
        run: |
          VERSION="${{ needs.create-release.outputs.version }}"
          TARGET="${{ matrix.target }}"

          for BIN in ${{ steps.bins.outputs.names }}; do
            ARCHIVE="${BIN}-${VERSION}-${TARGET}"
            mkdir -p "$ARCHIVE"

            cp "target/${TARGET}/release-lto/$BIN" "$ARCHIVE/"

            # Include license and readme if present
            for f in README.md LICENSE LICENSE-MIT COPYING UNLICENSE; do
              [ -f "$f" ] && cp "$f" "$ARCHIVE/"
            done

            # tar.gz archive + per-archive sha256 (ripgrep pattern)
            tar czf "${ARCHIVE}.tar.gz" "$ARCHIVE"
            shasum -a 256 "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256"
            echo "Packaged: ${ARCHIVE}.tar.gz ($(du -h "${ARCHIVE}.tar.gz" | cut -f1))"
          done

      - name: Upload to GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release upload "$GITHUB_REF_NAME" \
            *.tar.gz *.sha256 \
            --clobber

  # ── Finalize: mark release as non-draft ────────────────
  # Only runs after publish succeeds. Binary build failures
  # do not block finalization — crates.io is the source of truth.
  publish-release:
    needs: [create-release, publish, build-binaries]
    if: always() && needs.publish.result == 'success'
    runs-on: [self-hosted, clean-room]
    steps:
      - name: Publish GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release edit "$GITHUB_REF_NAME" --draft=false

      - name: Write summary
        if: always()
        run: |
          VERSION="${{ needs.create-release.outputs.version }}"
          echo "### Release $GITHUB_REF_NAME" >> "$GITHUB_STEP_SUMMARY"
          echo "" >> "$GITHUB_STEP_SUMMARY"
          echo "- crates.io: **published** ($VERSION)" >> "$GITHUB_STEP_SUMMARY"
          if [ "${{ needs.build-binaries.result }}" = "success" ]; then
            echo "- Binaries: **4/4 targets uploaded**" >> "$GITHUB_STEP_SUMMARY"
          elif [ "${{ needs.build-binaries.result }}" = "skipped" ]; then
            echo "- Binaries: skipped (library-only crate)" >> "$GITHUB_STEP_SUMMARY"
          else
            echo "- Binaries: **partial** (some targets failed, best-effort)" >> "$GITHUB_STEP_SUMMARY"
          fi
          echo "- GitHub Release: **published**" >> "$GITHUB_STEP_SUMMARY"