name: Release

on:
  release:
    types: [published]
  workflow_dispatch:
    inputs:
      update_homebrew:
        description: 'Update Homebrew formula after build'
        required: false
        default: 'false'
        type: choice
        options: ['true','false']
      release_tag:
        description: 'Tag to build from AND upload artifacts to (e.g. v1.2.3). Empty = build the dispatched ref.'
        required: false
      targets:
        description: 'Platforms to build (comma-separated: windows, linux, macos, or all). Empty = all.'
        required: false
        default: ''
        type: string

permissions:
  contents: write

jobs:
  # Resolve which platforms to build into a matrix. A real release always
  # builds every target; the `targets` input only filters manual dispatch
  # runs (empty => all). The build job's matrix is generated from this so
  # there is a single source of truth for the platform list.
  setup:
    name: Resolve build matrix
    runs-on: ubuntu-latest
    outputs:
      includes: ${{ steps.resolve.outputs.includes }}
    steps:
      - name: Resolve target platforms
        id: resolve
        env:
          # release event => build everything; the input only applies to dispatch.
          TARGETS: ${{ github.event_name == 'release' && 'all' || github.event.inputs.targets }}
        run: |
          set -euo pipefail

          # Full build matrix; each entry is tagged with os_family for filtering.
          # JSON uses only double quotes, so a single-quoted bash literal is safe
          # and avoids heredoc indentation pitfalls inside this YAML block.
          ALL='[
            {"os_family":"linux",   "target":"x86_64-unknown-linux-gnu",    "os":"ubuntu-22.04",             "artifact_name":"all-smi",     "asset_name":"all-smi-linux-x86_64",       "archive_ext":".tar.gz", "protoc_platform":"linux-x86_64"},
            {"os_family":"linux",   "target":"x86_64-unknown-linux-musl",   "os":"ubuntu-latest",           "artifact_name":"all-smi",     "asset_name":"all-smi-linux-x86_64-musl",  "archive_ext":".tar.gz", "protoc_platform":"linux-x86_64"},
            {"os_family":"linux",   "target":"aarch64-unknown-linux-gnu",   "os":"ubuntu-22.04-arm",        "artifact_name":"all-smi",     "asset_name":"all-smi-linux-aarch64",      "archive_ext":".tar.gz", "protoc_platform":"linux-aarch_64"},
            {"os_family":"linux",   "target":"aarch64-unknown-linux-musl",  "os":"ubuntu-24.04-arm",        "artifact_name":"all-smi",     "asset_name":"all-smi-linux-aarch64-musl", "archive_ext":".tar.gz", "protoc_platform":"linux-aarch_64"},
            {"os_family":"macos",   "target":"aarch64-apple-darwin",        "os":"macos-14",                "artifact_name":"all-smi",     "asset_name":"all-smi-macos-aarch64",      "archive_ext":".zip",    "protoc_platform":"osx-aarch_64"},
            {"os_family":"windows", "target":"x86_64-pc-windows-msvc",      "os":"windows-on-macmini02-x64","artifact_name":"all-smi.exe", "asset_name":"all-smi-windows-x86_64",     "archive_ext":".zip",    "protoc_platform":""}
          ]'

          # Normalize selection: lowercase, strip spaces; empty => all.
          sel="$(printf '%s' "${TARGETS:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
          if [ -z "$sel" ]; then sel="all"; fi

          # Validate tokens and expand `all` into concrete families.
          fams=""
          IFS=','
          for tok in $sel; do
            case "$tok" in
              all) fams="windows linux macos" ;;
              windows|linux|macos) fams="$fams $tok" ;;
              "") ;;
              *) echo "::error::Unknown build target '$tok'. Allowed: windows, linux, macos, all."; exit 1 ;;
            esac
          done
          unset IFS

          fam_json="$(printf '%s\n' $fams | awk 'NF' | sort -u | jq -R . | jq -cs .)"
          includes="$(printf '%s' "$ALL" | jq -c --argjson fams "$fam_json" \
            '[ .[] | select(.os_family as $f | $fams | index($f)) ]')"

          if [ "$(printf '%s' "$includes" | jq 'length')" -eq 0 ]; then
            echo "::error::No build targets selected."; exit 1
          fi

          echo "Building families: $fam_json"
          printf '%s' "$includes" | jq -r '.[] | "  - " + .target'
          echo "includes=$includes" >> "$GITHUB_OUTPUT"

  build:
    name: Build ${{ matrix.target }}
    needs: setup
    runs-on: ${{ matrix.os }}
    environment: packaging

    strategy:
      fail-fast: false
      matrix:
        include: ${{ fromJSON(needs.setup.outputs.includes) }}

    env:
      BIN_NAME: all-smi
      BUNDLE_ID: ${{ vars.BUNDLE_ID }}
      # Must match the release asset filename version in protoc releases (e.g. "30.2" or "32.0-rc-2")
      PROTOC_VERSION: "31.1"

    steps:
      # 1) Checkout repository.
      # Self-healing release: build the *source of the target tag* while running
      # the *current* workflow definition. GitHub always executes the release.yml
      # baked into the triggering ref, so a bug in an old tag's workflow (e.g. a
      # broken notarization step) cannot be fixed by re-running that tag. To
      # recover, dispatch this workflow from a branch carrying the fixed
      # release.yml and pass the old tag as release_tag: the fixed workflow runs,
      # but the old tag's source is what gets built and uploaded.
      #   - release event      -> the published release's tag
      #   - workflow_dispatch  -> the release_tag input, when provided
      #   - otherwise          -> the exact commit that triggered the run
      - name: Checkout code
        uses: actions/checkout@v6
        with:
          ref: ${{ github.event.release.tag_name || github.event.inputs.release_tag || github.sha }}

      # 1.5) Pin Cargo/rustup to persistent paths on the Windows self-hosted
      # runner so registry/git/target survive workspace cleanup between jobs.
      # Must run before any cargo or rustup invocation so they use these paths.
      - name: Setup persistent cache paths (Windows self-hosted)
        if: matrix.target == 'x86_64-pc-windows-msvc'
        shell: pwsh
        run: |
          $cargoHome = "C:\.cargo"
          if (!(Test-Path $cargoHome)) { New-Item -ItemType Directory -Path $cargoHome -Force | Out-Null }
          echo "CARGO_HOME=$cargoHome" >> $env:GITHUB_ENV
          echo "RUSTUP_HOME=C:\.rustup" >> $env:GITHUB_ENV

      # 2) Cache Cargo build artifacts
      # Skipped on the Windows self-hosted runner: its persistent CARGO_HOME
      # (configured above) already caches the registry across runs, so the
      # GitHub Actions cache would only mirror an empty ~/.cargo location.
      - name: Cache cargo
        if: matrix.target != 'x86_64-pc-windows-msvc'
        uses: actions/cache@v5
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-${{ matrix.target }}-

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

      # 4) Add target architecture
      - name: Add target architecture
        run: rustup target add ${{ matrix.target }}

      # 5) Install musl tools only for musl builds
      - name: Install musl tools (Linux musl only)
        if: contains(matrix.target, 'musl')
        run: |
          sudo apt update
          sudo apt install -y musl-tools

      # 5.5) Install AMD GPU dependencies for Linux builds
      - name: Install AMD GPU dependencies (Linux only)
        if: runner.os == 'Linux'
        run: |
          sudo apt update
          sudo apt install -y libdrm-dev libdrm-amdgpu1

      # 6) Setup protoc (cached) - skip on Windows as TPU support is Linux-only
      - name: Setup protoc
        if: runner.os != 'Windows'
        uses: ./.github/actions/setup-protoc
        with:
          version: ${{ env.PROTOC_VERSION }}
          platform: ${{ matrix.protoc_platform }}

      # 7) Build the release binary
      - name: Build release binary
        run: cargo build --release --target ${{ matrix.target }} --locked

      # 8) macOS code signing
      - name: Import Developer ID certificate
        if: runner.os == 'macOS'
        uses: apple-actions/import-codesign-certs@v6
        with:
          p12-file-base64: ${{ secrets.DEV_ID_CERT_P12 }}
          p12-password: ${{ secrets.DEV_ID_CERT_PASSWORD }}

      - name: Code sign macOS binary
        if: runner.os == 'macOS'
        run: |
          BIN=target/${{ matrix.target }}/release/${{ matrix.artifact_name }}
          codesign --force --timestamp --options runtime \
                   --sign "Developer ID Application" "$BIN"

      # 8.5) macOS notarization via App Store Connect API key
      # We submit a temporary zip wrapping the already-codesigned binary
      # (notarytool only accepts .zip/.dmg/.pkg). Stapling is intentionally
      # skipped: `xcrun stapler` only operates on .app/.dmg/.pkg containers,
      # not on bare Mach-O executables. Gatekeeper verifies the notarization
      # ticket online at first launch instead.
      - name: Notarize macOS binary
        if: runner.os == 'macOS'
        env:
          AC_API_KEY_ID: ${{ secrets.AC_API_KEY_ID }}
          AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }}
          AC_API_PRIVATE_KEY_P8: ${{ secrets.AC_API_PRIVATE_KEY_P8 }}
        run: |
          set -euo pipefail

          BIN="target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
          NOTARIZE_ZIP="${RUNNER_TEMP}/notarize.zip"
          KEY_FILE="${RUNNER_TEMP}/AuthKey.p8"

          # Always wipe the API key file, even on early failure.
          cleanup() {
            rm -f "$KEY_FILE" "$NOTARIZE_ZIP"
          }
          trap cleanup EXIT

          # The AC_API_PRIVATE_KEY_P8 secret stores the App Store Connect .p8
          # key base64-encoded (same convention as DEV_ID_CERT_P12, which the
          # import-codesign-certs action consumes as base64). Decode it back to
          # PEM here. Also tolerate a raw .p8 pasted verbatim, and strip CR so a
          # CRLF-mangled paste still parses. Writing the secret verbatim without
          # decoding makes notarytool fail with the opaque "Error: invalidAsn1".
          if [ -z "${AC_API_PRIVATE_KEY_P8:-}" ]; then
            echo "::error::AC_API_PRIVATE_KEY_P8 secret is not set or empty"
            exit 1
          fi
          if printf '%s' "$AC_API_PRIVATE_KEY_P8" | grep -q 'BEGIN PRIVATE KEY'; then
            printf '%s\n' "$AC_API_PRIVATE_KEY_P8" | tr -d '\r' > "$KEY_FILE"
          else
            printf '%s' "$AC_API_PRIVATE_KEY_P8" | tr -d '[:space:]' | base64 --decode > "$KEY_FILE"
          fi
          chmod 600 "$KEY_FILE"

          # Surface a clear message if the materialized key is not a valid
          # PKCS#8 key, instead of the opaque notarytool "invalidAsn1" later.
          if command -v openssl >/dev/null 2>&1 && ! openssl pkey -in "$KEY_FILE" -noout 2>/dev/null; then
            echo "::warning::AC_API_PRIVATE_KEY_P8 did not parse as a PKCS#8 private key after decoding; notarization will likely fail. Re-store it as base64 of the .p8 file (base64 -i AuthKey_XXXX.p8 | pbcopy)."
          fi

          /usr/bin/ditto -c -k --keepParent "$BIN" "$NOTARIZE_ZIP"

          echo "Submitting notarization request..."
          SUBMIT_OUTPUT=$(xcrun notarytool submit "$NOTARIZE_ZIP" \
            --key "$KEY_FILE" \
            --key-id "$AC_API_KEY_ID" \
            --issuer "$AC_API_ISSUER_ID" \
            --wait --timeout 30m 2>&1) || true
          echo "$SUBMIT_OUTPUT"

          if echo "$SUBMIT_OUTPUT" | grep -qE 'status: (Invalid|Rejected)'; then
            SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | awk '/^[[:space:]]*id:/ {print $2; exit}')
            if [ -n "${SUBMISSION_ID:-}" ]; then
              echo "Notarization failed (submission $SUBMISSION_ID). Fetching log..."
              xcrun notarytool log "$SUBMISSION_ID" \
                --key "$KEY_FILE" \
                --key-id "$AC_API_KEY_ID" \
                --issuer "$AC_API_ISSUER_ID" || true
            fi
            exit 1
          fi

          if ! echo "$SUBMIT_OUTPUT" | grep -q 'status: Accepted'; then
            echo "::error::notarytool did not report an Accepted status"
            exit 1
          fi

          # Gatekeeper verification. The ticket may take a moment to propagate;
          # treat a transient failure as a warning rather than a hard error.
          if /usr/sbin/spctl --assess --type execute --verbose "$BIN"; then
            echo "spctl assessment passed"
          else
            echo "::warning::spctl assessment did not pass yet; ticket may still be propagating"
          fi

      # 9) Package binaries
      - name: Package Linux binary (tar.gz)
        if: runner.os == 'Linux'
        run: |
          BIN_DIR=target/${{ matrix.target }}/release
          mkdir -p package
          cp "$BIN_DIR/${{ matrix.artifact_name }}" package/
          cp docs/man/all-smi.1 package/
          tar -C package -czf ${{ matrix.asset_name }}.tar.gz .

      - name: Package macOS binary (zip)
        if: runner.os == 'macOS'
        run: |
          BIN="target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
          ASSET="${{ matrix.asset_name }}.zip"
          mkdir -p package
          cp "$BIN" package/
          cp docs/man/all-smi.1 package/
          ditto -c -k --sequesterRsrc package "$ASSET"

      # Sign the Windows binary before packaging so the .zip artifact contains
      # the signed .exe. Uses signtool with the certificate already installed
      # in the self-hosted runner's Windows Certificate Store (same setup as
      # backend.ai-go's packaging workflow). Timestamp via SSL.com so the
      # signature remains valid after the signing certificate expires.
      - name: Sign Windows binary with signtool
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          $ErrorActionPreference = "Stop"
          $SignTool = "C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe"
          $TimestampUrl = "http://ts.ssl.com/"
          $Bin = "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"

          if (-not (Test-Path $SignTool)) {
            Write-Error "signtool.exe not found at expected path: $SignTool"
            exit 1
          }
          if (-not (Test-Path $Bin)) {
            Write-Error "Binary not found: $Bin"
            exit 1
          }

          Write-Host "Signing: $Bin"
          & $SignTool sign /tr $TimestampUrl /td sha256 /fd sha256 /a $Bin
          if ($LASTEXITCODE -ne 0) {
            Write-Error "signtool sign failed (exit $LASTEXITCODE)"
            exit 1
          }

          & $SignTool verify /pa $Bin
          if ($LASTEXITCODE -ne 0) {
            Write-Host "::warning::signtool verify /pa returned exit $LASTEXITCODE"
          }

      - name: Package Windows binary (zip)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          $BIN = "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
          $ASSET = "${{ matrix.asset_name }}.zip"
          New-Item -ItemType Directory -Force -Path package
          Copy-Item $BIN -Destination package/
          Compress-Archive -Path package/* -DestinationPath $ASSET

      # 10) Generate checksum
      - name: Generate checksum (Linux/macOS)
        if: runner.os != 'Windows'
        run: |
          FILE="${{ matrix.asset_name }}${{ matrix.archive_ext }}"
          if [[ "$RUNNER_OS" == "Linux" ]]; then
            sha256sum "$FILE" > "$FILE.sha256"
          else
            shasum -a 256 "$FILE" > "$FILE.sha256"
          fi

      - name: Generate checksum (Windows)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          $FILE = "${{ matrix.asset_name }}${{ matrix.archive_ext }}"
          $hash = (Get-FileHash -Path $FILE -Algorithm SHA256).Hash.ToLower()
          "$hash  $FILE" | Out-File -FilePath "$FILE.sha256" -Encoding ASCII -NoNewline

      # 11) Upload release artifacts and checksum
      - name: Upload release artifacts
        if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
        uses: softprops/action-gh-release@v2
        with:
          tag_name: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
          files: |
            ${{ matrix.asset_name }}${{ matrix.archive_ext }}
            ${{ matrix.asset_name }}${{ matrix.archive_ext }}.sha256

  # ============================================================================
  # Microsoft Teams release notification (Power Automate Workflows webhook)
  # ============================================================================
  notify-teams:
    name: Notify Teams on release
    needs: build
    if: github.event_name == 'release'
    runs-on: ubuntu-latest
    permissions: {}

    steps:
      - name: Build Adaptive Card payload
        env:
          TAG:  ${{ github.event.release.tag_name }}
          NAME: ${{ github.event.release.name }}
          URL:  ${{ github.event.release.html_url }}
          BODY: ${{ github.event.release.body }}
          REPO: ${{ github.repository }}
        run: |
          TRIMMED=$(printf '%s' "$BODY" | head -c 2000)
          jq -n \
            --arg tag "$TAG" --arg name "$NAME" \
            --arg url "$URL" --arg body "$TRIMMED" --arg repo "$REPO" '
          {
            type: "message",
            attachments: [{
              contentType: "application/vnd.microsoft.card.adaptive",
              content: {
                "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
                type: "AdaptiveCard",
                version: "1.5",
                body: [
                  { type: "TextBlock", size: "Large", weight: "Bolder",
                    text: ("🚀 " + $repo + " " + $tag + " released") },
                  { type: "TextBlock", text: $name, wrap: true, isSubtle: true },
                  { type: "TextBlock", text: $body, wrap: true }
                ],
                actions: [
                  { type: "Action.OpenUrl", title: "View release", url: $url }
                ]
              }
            }]
          }' > card.json

      - name: POST to Teams workflow
        if: env.WEBHOOK_URL != ''
        env:
          WEBHOOK_URL: ${{ secrets.TEAMS_RELEASE_NOTIFICATION_WORKFLOW_URL }}
        run: |
          curl -sSf -X POST \
            -H "Content-Type: application/json" \
            --data-binary @card.json \
            "$WEBHOOK_URL"

  promote-release:
    name: Promote pre-release to release
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'release' && github.event.release.prerelease
    steps:
      - name: Promote to full release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh api repos/${{ github.repository }}/releases/${{ github.event.release.id }} \
            --method PATCH \
            -f prerelease=false \
            -f make_latest=true