name: Release

on:
  push:
    tags: ['v*.*.*']
  # Allow auto-tag.yml to invoke the release flow against a freshly-
  # pushed tag — GITHUB_TOKEN-pushed tags don't trigger `on: push:tags`.
  workflow_dispatch:

permissions:
  contents: write

jobs:
  build:
    name: ${{ matrix.target }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest,  target: x86_64-unknown-linux-gnu,  suffix: linux-x86_64,  ext: ''     }
          - { os: ubuntu-latest,  target: aarch64-unknown-linux-gnu, suffix: linux-aarch64, ext: '',    cross: true }
          - { os: macos-latest,   target: x86_64-apple-darwin,       suffix: macos-x86_64,  ext: ''     }
          - { os: macos-latest,   target: aarch64-apple-darwin,      suffix: macos-aarch64, ext: ''     }
          - { os: windows-latest, target: x86_64-pc-windows-msvc,    suffix: windows-x86_64, ext: '.exe' }
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - uses: Swatinem/rust-cache@v2

      - name: Install cross-compile toolchain (linux/aarch64)
        if: matrix.cross
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-aarch64-linux-gnu
          mkdir -p .cargo
          cat >> .cargo/config.toml <<EOF
          [target.aarch64-unknown-linux-gnu]
          linker = "aarch64-linux-gnu-gcc"
          EOF

      - name: Build release
        # Statically link the MSVC C runtime on Windows so agtop.exe runs
        # on a clean Windows install with no VC++ Redistributable present.
        # Without +crt-static the binary needs vcruntime140.dll /
        # msvcp140.dll on the target machine — winget users on freshly-
        # imaged Windows hosts hit a silent loader failure (no console
        # output, exit code 0xC0000135) when the runner image's CRT
        # version was newer than what they had installed.  Costs ~1 MB
        # binary size; in return the winget install just works.
        env:
          RUSTFLAGS: ${{ matrix.target == 'x86_64-pc-windows-msvc' && '-C target-feature=+crt-static' || '' }}
        run: cargo build --release --locked --target ${{ matrix.target }}

      - name: Package (unix)
        if: runner.os != 'Windows'
        shell: bash
        run: |
          name="agtop-${{ matrix.suffix }}"
          mkdir -p "dist/$name"
          cp "target/${{ matrix.target }}/release/agtop${{ matrix.ext }}" "dist/$name/"
          cp README.md LICENSE "dist/$name/"
          cd dist
          tar -czf "${name}.tar.gz" "$name"

      - name: Package (windows)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          $name = "agtop-${{ matrix.suffix }}"
          New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null
          Copy-Item "target/${{ matrix.target }}/release/agtop${{ matrix.ext }}" "dist/$name/"
          Copy-Item "README.md","LICENSE" "dist/$name/"
          Compress-Archive -Path "dist/$name" -DestinationPath "dist/$name.zip" -Force

      - uses: softprops/action-gh-release@v3
        with:
          files: |
            dist/agtop-${{ matrix.suffix }}.tar.gz
            dist/agtop-${{ matrix.suffix }}.zip
          fail_on_unmatched_files: false
          generate_release_notes: true

  sha256sums:
    name: Aggregate SHA256SUMS
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Download release assets, hash, and upload SHA256SUMS
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          version=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' Cargo.toml)
          tag="v$version"
          mkdir -p sums
          cd sums
          # gh release download retries internally; the build matrix has
          # already completed by the time we get here so all 5 assets exist.
          gh release download "$tag" --repo "${{ github.repository }}" \
            --pattern 'agtop-*.tar.gz' --pattern 'agtop-*.zip'
          # Standard `sha256sum`-format file: `<sha>  <filename>` per line.
          # Sort for determinism so re-runs produce identical bytes.
          sha256sum agtop-*.tar.gz agtop-*.zip 2>/dev/null | sort > SHA256SUMS
          echo "── SHA256SUMS ──"
          cat SHA256SUMS
          gh release upload "$tag" SHA256SUMS --clobber \
            --repo "${{ github.repository }}"

  publish-crates:
    name: Publish to crates.io
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
      - name: cargo publish
        env:
          CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
        run: |
          if [ -z "$CRATES_IO_TOKEN" ]; then
            echo "::error::CRATES_IO_TOKEN secret not set"
            exit 1
          fi
          version=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' Cargo.toml)
          # Direct API check (cargo search has index-propagation lag of
          # several minutes after a fresh upload).
          if curl -sfo /dev/null "https://crates.io/api/v1/crates/agtop/$version"; then
            echo "agtop $version already on crates.io — skipping"
            exit 0
          fi
          # Even with the API check above, a re-dispatched run can race
          # against a still-propagating publish.  Trap the canonical
          # "already exists" error and treat as success; hard-fail on
          # anything else so silent breakage can't ship.
          if out=$(cargo publish --token "$CRATES_IO_TOKEN" 2>&1); then
            echo "$out"
            exit 0
          fi
          echo "$out"
          if echo "$out" | grep -q "already exists on crates.io index"; then
            echo "agtop $version already on crates.io (caught via publish error) — treating as success"
            exit 0
          fi
          exit 1

  publish-npm:
    name: Publish to npm (@mbrassey/agtop)
    # SHA256SUMS must exist before npm publishes — the install.js
    # postinstall fetches it to verify the downloaded prebuilt.
    needs: [build, sha256sums]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          registry-url: "https://registry.npmjs.org"
      - name: Build npm tarball
        run: bash packages/npm/build.sh
      - name: Publish (skip if version already on npm)
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          if [ -z "$NODE_AUTH_TOKEN" ]; then
            echo "::error::NPM_TOKEN secret not set"
            exit 1
          fi
          cd packages/npm/build
          version=$(node -p "require('./package.json').version")
          name=$(node -p "require('./package.json').name")
          published=$(npm view "$name@$version" version 2>/dev/null || true)
          if [ -n "$published" ]; then
            echo "$name@$version already on npm — skipping"
            exit 0
          fi
          npm publish --access public

  publish-aur:
    name: Publish to AUR
    needs: build
    runs-on: ubuntu-latest
    # Run inside an Arch container so makepkg / .SRCINFO regeneration
    # is native rather than chained through a third-party action.
    container:
      image: archlinux:latest
    steps:
      - name: Install host deps
        run: |
          pacman -Sy --noconfirm --needed git openssh sudo base-devel curl awk
          useradd -m builder
          echo 'builder ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers

      - uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Setup SSH for AUR
        env:
          AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
        run: |
          if [ -z "$AUR_SSH_PRIVATE_KEY" ]; then
            echo "::error::AUR_SSH_PRIVATE_KEY secret not set"
            exit 1
          fi
          mkdir -p /home/builder/.ssh
          printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > /home/builder/.ssh/id_aur
          chmod 600 /home/builder/.ssh/id_aur
          ssh-keyscan -t ed25519,rsa,ecdsa aur.archlinux.org > /home/builder/.ssh/known_hosts 2>/dev/null
          cat > /home/builder/.ssh/config <<'EOF'
          Host aur.archlinux.org
            IdentityFile ~/.ssh/id_aur
            User aur
            StrictHostKeyChecking yes
            IdentitiesOnly yes
          EOF
          chmod 600 /home/builder/.ssh/config /home/builder/.ssh/known_hosts
          chown -R builder:builder /home/builder/.ssh

      - name: Resolve version
        id: ver
        run: |
          v=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' Cargo.toml)
          echo "version=$v" >> "$GITHUB_OUTPUT"

      - name: Compute release tarball sha256
        id: sha
        run: |
          url="https://github.com/mbrassey/agtop/archive/v${{ steps.ver.outputs.version }}.tar.gz"
          for i in $(seq 1 10); do
            if curl -sfL -o /tmp/agtop.tar.gz "$url"; then break; fi
            echo "tarball not ready (attempt $i) — sleeping 10s"
            sleep 10
          done
          sha=$(sha256sum /tmp/agtop.tar.gz | awk '{print $1}')
          echo "sha=$sha" >> "$GITHUB_OUTPUT"

      - name: Update AUR repo
        env:
          VERSION: ${{ steps.ver.outputs.version }}
          SHA256:  ${{ steps.sha.outputs.sha }}
        run: |
          # The Actions checkout lives at $GITHUB_WORKSPACE inside the
          # container.  Stage the PKGBUILD into builder's homedir so
          # makepkg can run as the unprivileged user (it refuses root).
          cp packages/pacman/PKGBUILD /home/builder/PKGBUILD.in
          chown builder:builder /home/builder/PKGBUILD.in
          sudo -u builder -H bash -se <<INNER
          set -euo pipefail
          cd "\$HOME"
          git clone ssh://aur@aur.archlinux.org/agtop.git aur-repo
          cd aur-repo
          cp ../PKGBUILD.in PKGBUILD
          # Rewrite pkgver, pkgrel, source, and sha256sums.  Pre-2.4.3
          # this step skipped pkgver entirely, which left the AUR repo
          # forever pinned to whatever literal pkgver= was committed
          # in packages/pacman/PKGBUILD on disk (2.3.9), while the
          # sha256 was bumped to match the new tarball — every release
          # shipped a broken AUR PKGBUILD that failed integrity check.
          # \\\${pkgver} stays literal in the source URL so PKGBUILD's
          # own variable substitution drives the URL; \${VERSION} and
          # \${SHA256} substitute here from the outer GH Actions step.
          sed -i "s|^pkgver=.*|pkgver=${VERSION}|" PKGBUILD
          sed -i "s|^pkgrel=.*|pkgrel=1|" PKGBUILD
          sed -i 's|^source=.*|source=("agtop-\${pkgver}.tar.gz::https://github.com/mbrassey/agtop/archive/v\${pkgver}.tar.gz")|' PKGBUILD
          sed -i "s|^sha256sums=.*|sha256sums=('${SHA256}')|" PKGBUILD
          # Sanity check: fail loud if any of the three rewrites
          # didn't take.  Defends against future PKGBUILD reformat.
          grep -q "^pkgver=${VERSION}\$"          PKGBUILD || { echo "pkgver rewrite failed"; exit 1; }
          grep -q "^sha256sums=('${SHA256}')\$"   PKGBUILD || { echo "sha256 rewrite failed"; exit 1; }
          makepkg --printsrcinfo > .SRCINFO
          git config user.name  'mbrassey'
          git config user.email 'matt@brassey.io'
          git add PKGBUILD .SRCINFO
          if git diff --cached --quiet; then
            echo "AUR repo already at this version — skipping push"
            exit 0
          fi
          git commit -m "agtop ${VERSION}"
          git push
          INNER

  publish-debian:
    name: Publish .deb to apt repo (mbrassey.github.io/apt)
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Resolve version
        id: ver
        run: |
          v=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' Cargo.toml)
          echo "version=$v" >> "$GITHUB_OUTPUT"

      - name: Install apt-repo tooling
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y -qq dpkg-dev gpg

      # Pull the prebuilt linux-x86_64 binary this run's matrix
      # already produced — saves ~2 min vs recompiling.
      - name: Download linux-x86_64 prebuilt
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          mkdir -p deb-bin
          gh release download "v${{ steps.ver.outputs.version }}" \
            --repo "${{ github.repository }}" \
            --pattern 'agtop-linux-x86_64.tar.gz' --dir deb-bin
          tar -xzf deb-bin/agtop-linux-x86_64.tar.gz -C deb-bin
          mkdir -p target/release
          install -m 0755 deb-bin/agtop-linux-x86_64/agtop target/release/agtop

      - name: Build .deb
        run: bash packages/deb/build.sh

      - name: Import GPG signing key
        env:
          KEY: ${{ secrets.APT_REPO_GPG_PRIVATE_KEY }}
        run: |
          if [ -z "$KEY" ]; then
            echo "::error::APT_REPO_GPG_PRIVATE_KEY secret not set"; exit 1
          fi
          mkdir -p ~/.gnupg && chmod 700 ~/.gnupg
          printf '%s' "$KEY" | gpg --batch --import 2>&1 | tail -3
          echo "GPG_KEY_ID=$(gpg --list-secret-keys --with-colons \
            | awk -F: '/^sec/{print $5; exit}')" >> "$GITHUB_ENV"

      - name: Stage apt repo (clone + add deb + regen metadata)
        env:
          TOK: ${{ secrets.APT_REPO_TOKEN }}
          VERSION: ${{ steps.ver.outputs.version }}
        run: |
          if [ -z "$TOK" ]; then
            echo "::error::APT_REPO_TOKEN secret not set"; exit 1
          fi
          tap=$(mktemp -d)
          git clone --depth=1 \
            "https://x-access-token:${TOK}@github.com/MBrassey/apt.git" "$tap"
          DEB="packages/deb/agtop_${VERSION}_amd64.deb"
          [ -f "$DEB" ] || { echo "::error::missing $DEB"; ls packages/deb/; exit 1; }
          # Flat repo layout — no dists/ tree.  Users add a single
          # `deb [signed-by=...] https://mbrassey.github.io/apt ./` line.
          mkdir -p "$tap/pool"
          cp "$DEB" "$tap/pool/"
          ( cd "$tap" && dpkg-scanpackages --multiversion pool /dev/null \
              > Packages
            gzip -9k -f Packages
            apt-ftparchive release \
              -o APT::FTPArchive::Release::Origin="MBrassey" \
              -o APT::FTPArchive::Release::Label="agtop" \
              -o APT::FTPArchive::Release::Suite="stable" \
              -o APT::FTPArchive::Release::Codename="agtop" \
              -o APT::FTPArchive::Release::Architectures="amd64" \
              -o APT::FTPArchive::Release::Components="main" \
              . > Release
            gpg --batch --yes --default-key "${GPG_KEY_ID}" -abs -o Release.gpg Release
            gpg --batch --yes --default-key "${GPG_KEY_ID}" --clearsign -o InRelease Release
          )

          cd "$tap"
          git config user.name  "mbrassey"
          git config user.email "matt@brassey.io"
          git add -A
          if git diff --cached --quiet; then
            echo "no changes — apt repo already at v${VERSION}"
            exit 0
          fi
          git commit -m "agtop ${VERSION}"
          git push

      # Hardening: tear down the gpg-agent and shred the imported
      # private key as soon as the publish step completes, so any
      # later step in the same job (none today, but defensive
      # against future additions) cannot inherit signing capability.
      # Runs even on prior-step failure via `if: always()`.
      - name: Wipe GPG signing material
        if: always()
        run: |
          gpgconf --kill all 2>/dev/null || true
          if [ -d "$HOME/.gnupg" ]; then
            find "$HOME/.gnupg" -type f -exec shred -u {} + 2>/dev/null || true
            rm -rf "$HOME/.gnupg"
          fi

  # Snap Store publish was retired in 2.4.2: GitHub Actions runners
  # repeatedly hit `error: unable to contact snap store` during the
  # `snap install lxd` bootstrap step inside snapcore/action-build,
  # which was outside our control and burned ~4m per release.  apt
  # repo at mbrassey.github.io/apt covers Debian / Ubuntu / Mint /
  # Pop!_OS users with a signed .deb on every tag, so the channel
  # we lost was redundant.  snap/snapcraft.yaml is kept around for
  # users who still want to build locally with `snapcraft pack`.

  publish-winget:
    name: Publish to winget (microsoft/winget-pkgs)
    # SHA256SUMS must be on the release before wingetcreate computes
    # the installer hash from the URL (it re-downloads, but matching
    # the published sums catches any mismatch early).
    needs: [build, sha256sums]
    runs-on: windows-latest
    steps:
      - name: Resolve version
        id: ver
        shell: bash
        # Tag is `v<X>.<Y>.<Z>`; the manifest version field expects
        # the bare semver without the leading `v`.
        run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

      - name: Install wingetcreate
        shell: pwsh
        run: |
          Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
          .\wingetcreate.exe --version

      - name: Submit manifest update
        shell: pwsh
        env:
          WINGET_PAT: ${{ secrets.WINGET_PAT }}
        run: |
          if ([string]::IsNullOrEmpty($env:WINGET_PAT)) {
            Write-Host "::warning::WINGET_PAT secret not set — skipping winget submission"
            exit 0
          }
          $ver = "${{ steps.ver.outputs.version }}"
          $url = "https://github.com/MBrassey/agtop/releases/download/v$ver/agtop-windows-x86_64.zip"
          # `update -s -t` opens the PR against microsoft/winget-pkgs
          # via the user's fork (auto-detected from the token). The
          # tool fetches the URL, computes sha256, patches the three
          # YAML manifests under manifests/m/MBrassey/agtop/$ver/,
          # commits, pushes branch `agtop-$ver` to the fork, and
          # opens the PR — same flow as scripts/finalize_winget_port.sh
          # but driven entirely by CI.
          .\wingetcreate.exe update MBrassey.agtop `
            --version $ver `
            --urls "$url|x64" `
            --submit `
            --token $env:WINGET_PAT

  publish-homebrew:
    name: Publish to Homebrew tap
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Resolve version + sha256
        id: meta
        run: |
          set -euo pipefail
          v=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' Cargo.toml)
          url="https://github.com/mbrassey/agtop/archive/refs/tags/v${v}.tar.gz"
          for i in $(seq 1 10); do
            if curl -sfL -o /tmp/src.tar.gz "$url"; then break; fi
            sleep 10
          done
          sha=$(sha256sum /tmp/src.tar.gz | awk '{print $1}')
          echo "version=$v"  >> "$GITHUB_OUTPUT"
          echo "sha=$sha"    >> "$GITHUB_OUTPUT"
          echo "url=$url"    >> "$GITHUB_OUTPUT"

      - name: Template formula
        run: |
          mkdir -p homebrew-out
          sed \
            -e "s|^  url \".*\"\$|  url \"${{ steps.meta.outputs.url }}\"|" \
            -e "s|^  sha256 \".*\"\$|  sha256 \"${{ steps.meta.outputs.sha }}\"|" \
            homebrew/agtop.rb > homebrew-out/agtop.rb
          echo "── final formula ──"
          cat homebrew-out/agtop.rb

      - name: Push to mbrassey/homebrew-tap
        env:
          HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
        run: |
          if [ -z "$HOMEBREW_TAP_TOKEN" ]; then
            echo "::warning::HOMEBREW_TAP_TOKEN secret not set — skipping tap push (formula was templated above for inspection)"
            exit 0
          fi
          git config --global user.name  mbrassey
          git config --global user.email matt@brassey.io
          tap_dir=$(mktemp -d)
          git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/MBrassey/homebrew-tap.git" "$tap_dir"
          mkdir -p "$tap_dir/Formula"
          cp homebrew-out/agtop.rb "$tap_dir/Formula/agtop.rb"
          cd "$tap_dir"
          git add Formula/agtop.rb
          if git diff --cached --quiet; then
            echo "tap formula unchanged — skipping push"
            exit 0
          fi
          git commit -m "agtop ${{ steps.meta.outputs.version }}"
          git push
