name: Release

# SPEC-001 §2/§3/§4 — auto-versioning, signed Windows build, changelog generation, release.
#
# On every push to main this workflow:
#   1. version  — determine the next semver from conventional commits, bump component manifests,
#                 commit `chore: release vX.Y.Z [skip ci]`, and create + push tag vX.Y.Z.
#   2. changelog — generate CHANGELOG.md + per-component changelogs with git-cliff (run inside
#                 the version job so it is part of the release commit).
#   3. build    — cross-compile the Windows agent (x86_64-pc-windows-gnu) to guruconnect.exe.
#   4. sign     — sign guruconnect.exe with Azure Trusted Signing via jsign (fails the job if
#                 signing fails — never publish unsigned).
#   5. publish  — upload signed exe + .sha256 + changelog artifacts; create a Gitea release.
#
# Loop guard: the workflow skips entirely when the head commit is a release commit
# (`chore: release` / `[skip ci]`), and the release commit itself carries `[skip ci]`.
#
# All jobs run on ubuntu-latest. GuruConnect ships a single .exe (no WiX/MSI); jsign is a Java
# tool that signs PE binaries on Linux, so no Windows runner is required.

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  # ---------------------------------------------------------------------------
  # §3 VERSION + §4 CHANGELOG
  # ---------------------------------------------------------------------------
  version:
    name: Version + Changelog
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.bump.outputs.version }}
      released: ${{ steps.bump.outputs.released }}
    steps:
      - name: Checkout (full history + tags)
        uses: actions/checkout@v4
        with:
          fetch-depth: 0
          # CI_PUSH_TOKEN is a push-capable token used to commit the bump and push the tag.
          token: ${{ secrets.CI_PUSH_TOKEN }}

      - name: Loop guard - skip release commits
        id: guard
        run: |
          HEAD_MSG="$(git log -1 --pretty=%s)"
          echo "[INFO] HEAD commit subject: ${HEAD_MSG}"
          if echo "${HEAD_MSG}" | grep -qiE '\[skip ci\]|^chore: release'; then
            echo "[INFO] Head commit is a release/skip-ci commit; skipping release."
            echo "skip=true" >> "$GITHUB_OUTPUT"
          else
            echo "skip=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Install git-cliff
        if: steps.guard.outputs.skip != 'true'
        run: |
          set -euo pipefail
          CLIFF_VERSION="2.6.1"
          URL="https://github.com/orhun/git-cliff/releases/download/v${CLIFF_VERSION}/git-cliff-${CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
          echo "[INFO] Downloading git-cliff ${CLIFF_VERSION}"
          curl -fsSL "$URL" -o /tmp/git-cliff.tar.gz
          tar -xzf /tmp/git-cliff.tar.gz -C /tmp
          sudo install -m 0755 "/tmp/git-cliff-${CLIFF_VERSION}/git-cliff" /usr/local/bin/git-cliff
          git-cliff --version

      - name: Determine next version and bump components
        id: bump
        if: steps.guard.outputs.skip != 'true'
        run: |
          set -euo pipefail

          # ----- locate the last release tag (vX.Y.Z) -----
          LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
          if [ -z "${LAST_TAG}" ]; then
            echo "[INFO] No prior release tag found; baseline is current manifest version."
            BASE_VERSION="$(grep -m1 '^version' agent/Cargo.toml | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')"
            RANGE=""
          else
            echo "[INFO] Last release tag: ${LAST_TAG}"
            BASE_VERSION="${LAST_TAG#v}"
            RANGE="${LAST_TAG}..HEAD"
          fi
          echo "[INFO] Base version: ${BASE_VERSION}"

          # ----- collect commit subjects (and full bodies) since the last tag -----
          if [ -n "${RANGE}" ]; then
            COMMITS="$(git log "${RANGE}" --pretty=%s || true)"
            # Full messages, NUL-delimited, for BREAKING CHANGE footer detection.
            BODIES="$(git log "${RANGE}" --pretty=%B || true)"
            CHANGED_FILES="$(git diff --name-only "${LAST_TAG}" HEAD || true)"
          else
            COMMITS="$(git log --pretty=%s || true)"
            BODIES="$(git log --pretty=%B || true)"
            CHANGED_FILES="$(git ls-files)"
          fi

          # ----- determine bump level from conventional commits -----
          # Breaking changes (pre-1.0): a `!` before the colon on any type/scope (feat!:, fix!:,
          # type(scope)!:) or a `BREAKING CHANGE` footer forces at least a MINOR bump. We do NOT
          # force a major bump while the project is pre-1.0; existing feat->minor, fix/perf->patch
          # logic is preserved otherwise.
          BUMP="none"
          while IFS= read -r line; do
            if [ -z "$line" ]; then continue; fi
            case "$line" in
              # `<type>!:` or `<type>(scope)!:` — breaking change marker in the subject.
              *'!:'*)
                BUMP="minor"
                ;;
              feat:*|feat\(*)
                BUMP="minor"
                ;;
              fix:*|fix\(*|perf:*|perf\(*)
                if [ "$BUMP" != "minor" ]; then BUMP="patch"; fi
                ;;
            esac
          done <<< "$COMMITS"

          # `BREAKING CHANGE:` / `BREAKING-CHANGE:` footer anywhere in a commit body -> at least minor.
          if printf '%s' "$BODIES" | grep -qE 'BREAKING[ -]CHANGE'; then
            echo "[INFO] BREAKING CHANGE footer detected; bumping at least minor (pre-1.0)."
            BUMP="minor"
          fi

          # If only chores/docs but code actually changed, default to patch.
          if [ "$BUMP" = "none" ]; then
            if echo "$CHANGED_FILES" | grep -qE '^(agent/|server/|dashboard/|proto/)'; then
              echo "[INFO] Only chores in commits but code changed; defaulting to patch."
              BUMP="patch"
            fi
          fi

          if [ "$BUMP" = "none" ]; then
            echo "[INFO] No release-worthy changes detected; skipping release."
            echo "released=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          echo "[INFO] Bump level: ${BUMP}"

          # ----- compute next version -----
          IFS='.' read -r MAJ MIN PAT <<< "${BASE_VERSION}"
          case "$BUMP" in
            minor) MIN=$((MIN + 1)); PAT=0 ;;
            patch) PAT=$((PAT + 1)) ;;
          esac
          NEXT="${MAJ}.${MIN}.${PAT}"
          echo "[INFO] Next version: ${NEXT}"
          echo "version=${NEXT}" >> "$GITHUB_OUTPUT"
          echo "released=true" >> "$GITHUB_OUTPUT"

          # ----- determine which components changed (bump only those) -----
          agent_changed=false; server_changed=false; dashboard_changed=false
          echo "$CHANGED_FILES" | grep -qE '^(agent/|proto/)' && agent_changed=true || true
          echo "$CHANGED_FILES" | grep -qE '^(server/|proto/)' && server_changed=true || true
          echo "$CHANGED_FILES" | grep -qE '^dashboard/' && dashboard_changed=true || true
          # On the very first release (no prior tag) bump all components together.
          if [ -z "${RANGE}" ]; then
            agent_changed=true; server_changed=true; dashboard_changed=true
          fi
          echo "[INFO] Changed: agent=${agent_changed} server=${server_changed} dashboard=${dashboard_changed}"

          # ----- bump manifests for changed components -----
          bump_cargo() {
            local file="$1"
            # Replace only the first top-level `version = "x.y.z"` (the [package] version).
            sed -i -E "0,/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/s//version = \"${NEXT}\"/" "$file"
            echo "[OK] Bumped ${file} -> ${NEXT}"
          }
          if [ "$agent_changed" = "true" ]; then bump_cargo agent/Cargo.toml; fi
          if [ "$server_changed" = "true" ]; then bump_cargo server/Cargo.toml; fi
          if [ "$dashboard_changed" = "true" ]; then
            # Bump the "version" field in dashboard/package.json without extra tooling.
            sed -i -E "0,/\"version\": \"[0-9]+\.[0-9]+\.[0-9]+\"/s//\"version\": \"${NEXT}\"/" dashboard/package.json
            echo "[OK] Bumped dashboard/package.json -> ${NEXT}"
          fi

          # Keep the workspace version in sync (Cargo.toml [workspace.package]).
          if [ -f Cargo.toml ]; then
            sed -i -E "0,/^version = \"[0-9]+\.[0-9]+\.[0-9]+\"/s//version = \"${NEXT}\"/" Cargo.toml || true
          fi

      - name: Generate changelog (git-cliff)
        if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
        env:
          VERSION: ${{ steps.bump.outputs.version }}
        run: |
          set -euo pipefail
          mkdir -p changelogs

          # Per-version notes: only the NEW version's section (unreleased commits, tagged). Strip
          # the header AND footer so this fragment is reusable for per-component files (it must not
          # carry the # Changelog preamble or the [0.1.0] footer).
          git-cliff --config cliff.toml --tag "v${VERSION}" --unreleased --strip all \
            --output /tmp/version-notes.md
          echo "[OK] Generated per-version notes"

          # Fallback when the version block has no user-facing entries (chore/docs-only release):
          # ensure the per-component files and release notes are never effectively empty.
          if ! grep -qE '^- ' /tmp/version-notes.md; then
            echo "[INFO] No user-facing changelog entries; using maintenance fallback line."
            {
              printf '## [%s] - %s\n\n' "${VERSION}" "$(date -u +%Y-%m-%d)"
              printf -- '- Maintenance release — no user-facing changes.\n'
            } > /tmp/version-notes.md
          fi

          # Regenerate the WHOLE CHANGELOG.md over full history with --output (NOT --prepend).
          # git-cliff emits: header (# Changelog preamble) -> version blocks newest-first ->
          # footer ([0.1.0] carried verbatim from cliff.toml). This is idempotent and always
          # well-formed; --prepend would insert the new block above the # Changelog title.
          echo "[INFO] Regenerating CHANGELOG.md over full history (tag v${VERSION})"
          git-cliff --config cliff.toml --tag "v${VERSION}" --output CHANGELOG.md
          echo "[OK] Updated CHANGELOG.md"

          # Write per-component + LATEST files for each component that changed.
          write_component() {
            local comp="$1"
            local upper
            upper="$(echo "$comp" | tr '[:lower:]' '[:upper:]')"
            mkdir -p "changelogs/${comp}"
            cp /tmp/version-notes.md "changelogs/${comp}/v${VERSION}.md"
            cp /tmp/version-notes.md "changelogs/LATEST_${upper}.md"
            echo "[OK] Wrote changelogs/${comp}/v${VERSION}.md and changelogs/LATEST_${upper}.md"
          }

          # Re-derive the set of changed components (same logic as the bump step). On the first
          # release (no prior tag) all components are considered changed.
          LAST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
          if [ -z "${LAST_TAG}" ]; then
            CHANGED_FILES="$(git ls-files)"
            FIRST_RELEASE=true
          else
            CHANGED_FILES="$(git diff --name-only "${LAST_TAG}" HEAD || true)"
            FIRST_RELEASE=false
          fi

          if [ "$FIRST_RELEASE" = "true" ]; then
            write_component agent
            write_component server
            write_component dashboard
          else
            echo "$CHANGED_FILES" | grep -qE '^(agent/|proto/)' && write_component agent || true
            echo "$CHANGED_FILES" | grep -qE '^(server/|proto/)' && write_component server || true
            echo "$CHANGED_FILES" | grep -qE '^dashboard/' && write_component dashboard || true
          fi

      - name: Commit release + create tag
        if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
        env:
          VERSION: ${{ steps.bump.outputs.version }}
        run: |
          set -euo pipefail
          git config user.name "guruconnect-ci"
          git config user.email "ci@azcomputerguru.com"

          git add -A
          if git diff --cached --quiet; then
            echo "[WARNING] No changes staged for release commit; skipping commit/tag."
            exit 0
          fi
          git commit -m "chore: release v${VERSION} [skip ci]"
          git tag "v${VERSION}"

          # Push commit and tag using CI_PUSH_TOKEN embedded in the remote URL.
          # GITHUB_REPOSITORY is provided by the Actions runner (Gitea-compatible).
          REMOTE="https://${{ secrets.CI_PUSH_TOKEN }}@git.azcomputerguru.com/${GITHUB_REPOSITORY}.git"
          git push "${REMOTE}" "HEAD:${GITHUB_REF_NAME}"
          git push "${REMOTE}" "v${VERSION}"
          echo "[OK] Pushed release commit and tag v${VERSION}"

      - name: Upload changelog artifact
        if: steps.guard.outputs.skip != 'true' && steps.bump.outputs.released == 'true'
        uses: actions/upload-artifact@v3
        with:
          name: changelog
          path: |
            CHANGELOG.md
            changelogs/
          retention-days: 90

  # ---------------------------------------------------------------------------
  # §2 BUILD + SIGN + PUBLISH
  # ---------------------------------------------------------------------------
  build-sign-publish:
    name: Build, Sign, Publish Agent
    runs-on: ubuntu-latest
    needs: version
    if: needs.version.outputs.released == 'true'
    steps:
      - name: Checkout the release tag
        uses: actions/checkout@v4
        with:
          # Build the exact commit that was tagged (the release commit), not the pre-bump head.
          ref: v${{ needs.version.outputs.version }}
          fetch-depth: 0

      - name: Install Rust toolchain
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          # Single source of truth for the Windows target used by the build below.
          target: x86_64-pc-windows-gnu
          override: true

      - name: Install cross-compilation tools
        run: |
          sudo apt-get update
          sudo apt-get install -y mingw-w64

      - name: Cache Cargo dependencies
        uses: actions/cache@v3
        with:
          path: |
            ~/.cargo/bin/
            ~/.cargo/registry/index/
            ~/.cargo/registry/cache/
            ~/.cargo/git/db/
            target/
          key: ${{ runner.os }}-cargo-agent-release-${{ hashFiles('agent/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-agent-release-
            ${{ runner.os }}-cargo-agent-

      - name: Build agent (cross-compile for Windows)
        run: |
          set -euo pipefail
          # Target is installed by the toolchain step above (single source of truth).
          cd agent
          cargo build --release --target x86_64-pc-windows-gnu
          echo "[OK] Built agent for x86_64-pc-windows-gnu"

      - name: Stage unsigned binary
        run: |
          set -euo pipefail
          cp agent/target/x86_64-pc-windows-gnu/release/guruconnect.exe ./guruconnect.exe
          ls -l ./guruconnect.exe

      # --- §2 Azure Trusted Signing (port of sign-windows.sh) ---
      - name: Acquire Azure Trusted Signing token
        id: token
        run: |
          set -euo pipefail
          echo "[INFO] Requesting Azure code-signing access token"
          RESP="$(curl -fsS -X POST \
            "https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" \
            -H "Content-Type: application/x-www-form-urlencoded" \
            --data-urlencode "grant_type=client_credentials" \
            --data-urlencode "client_id=${{ secrets.AZURE_CLIENT_ID }}" \
            --data-urlencode "client_secret=${{ secrets.AZURE_CLIENT_SECRET }}" \
            --data-urlencode "scope=https://codesigning.azure.net/.default")"
          TOKEN="$(printf '%s' "$RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')"
          if [ -z "$TOKEN" ] || [ "$TOKEN" = "None" ]; then
            echo "[ERROR] Failed to acquire Azure Trusted Signing token"
            exit 1
          fi
          echo "[OK] Acquired signing token"
          # Mask and export the token for the sign step without printing it.
          echo "::add-mask::${TOKEN}"
          echo "TS_TOKEN=${TOKEN}" >> "$GITHUB_ENV"

      - name: Install JRE + jsign
        run: |
          set -euo pipefail
          sudo apt-get update
          sudo apt-get install -y default-jre-headless
          JSIGN_VERSION="6.0"
          curl -fsSL "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar" \
            -o /tmp/jsign.jar
          echo "[OK] Installed JRE and jsign ${JSIGN_VERSION}"

      - name: Sign guruconnect.exe (Azure Trusted Signing)
        run: |
          set -euo pipefail
          echo "[INFO] Signing guruconnect.exe with Azure Trusted Signing"
          java -jar /tmp/jsign.jar \
            --storetype TRUSTEDSIGNING \
            --keystore "${{ secrets.TS_ENDPOINT }}" \
            --storepass "${TS_TOKEN}" \
            --alias "${{ secrets.TS_ACCOUNT }}/${{ secrets.TS_CERT_PROFILE }}" \
            --tsaurl "${{ secrets.TS_TIMESTAMP_URL }}" \
            --tsmode RFC3161 \
            --alg SHA-256 \
            --name "GuruConnect Agent" \
            --url "https://www.azcomputerguru.com" \
            --replace \
            guruconnect.exe
          echo "[OK] Signing command completed"

      - name: Verify signature present (fail release if unsigned)
        run: |
          set -euo pipefail
          echo "[INFO] Verifying Authenticode signature is present"
          # jsign's --info on a signed PE lists the signature(s); fail if none reported.
          OUT="$(java -jar /tmp/jsign.jar --info guruconnect.exe 2>&1 || true)"
          echo "$OUT"
          if echo "$OUT" | grep -qiE 'signature|signer|signed'; then
            echo "[OK] Signature present"
          else
            echo "[ERROR] No signature detected on guruconnect.exe - refusing to publish unsigned binary"
            exit 1
          fi

      - name: Compute SHA-256 of signed binary
        id: sha
        run: |
          set -euo pipefail
          sha256sum guruconnect.exe | awk '{print $1}' > guruconnect.exe.sha256
          SUM="$(cat guruconnect.exe.sha256)"
          echo "[OK] SHA-256: ${SUM}"
          echo "sha256=${SUM}" >> "$GITHUB_OUTPUT"

      - name: Download changelog artifact
        uses: actions/download-artifact@v3
        with:
          name: changelog
          path: changelog-artifact

      - name: Upload signed agent artifacts
        uses: actions/upload-artifact@v3
        with:
          name: guruconnect-agent-signed
          path: |
            guruconnect.exe
            guruconnect.exe.sha256
          retention-days: 90

      # --- §2/§4 PUBLISH: create a Gitea release and attach assets ---
      #
      # Gitea release mechanism (decision): the GitHub-only actions/create-release@v1 +
      # GITHUB_TOKEN flow used by the old deploy.yml does NOT work on Gitea. We use the Gitea
      # REST API directly via curl, which is guaranteed available on the ubuntu-latest runner and
      # does not depend on a third-party action being registered in this Gitea instance.
      #   POST /api/v1/repos/{owner}/{repo}/releases    (create release for the tag)
      #   POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets  (attach each asset)
      # Auth: CI_PUSH_TOKEN (token=...). GITHUB_REPOSITORY / GITHUB_SERVER_URL come from the runner.
      - name: Create Gitea release and upload assets
        env:
          VERSION: ${{ needs.version.outputs.version }}
          SHA256: ${{ steps.sha.outputs.sha256 }}
          GITEA_TOKEN: ${{ secrets.CI_PUSH_TOKEN }}
        run: |
          set -euo pipefail
          API_BASE="https://git.azcomputerguru.com/api/v1/repos/${GITHUB_REPOSITORY}"
          TAG="v${VERSION}"
          echo "[INFO] Creating Gitea release ${TAG} on ${GITHUB_REPOSITORY}"

          BODY="$(printf 'GuruConnect %s\n\nSHA-256 (guruconnect.exe): %s\n\nSee CHANGELOG.md and /api/changelog for details.' "${TAG}" "${SHA256}")"

          # Build the JSON payload with python (handles escaping of the multi-line body safely).
          CREATE_PAYLOAD="$(TAG="$TAG" BODY="$BODY" python3 -c 'import json,os; print(json.dumps({"tag_name": os.environ["TAG"], "name": "Release " + os.environ["TAG"], "body": os.environ["BODY"], "draft": False, "prerelease": False}))')"

          RELEASE_JSON="$(curl -fsS -X POST \
            "${API_BASE}/releases" \
            -H "Authorization: token ${GITEA_TOKEN}" \
            -H "Content-Type: application/json" \
            -d "${CREATE_PAYLOAD}")"

          RELEASE_ID="$(printf '%s' "$RELEASE_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')"
          if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "None" ]; then
            echo "[ERROR] Failed to create Gitea release"
            echo "$RELEASE_JSON"
            exit 1
          fi
          echo "[OK] Created release id=${RELEASE_ID}"

          upload_asset() {
            local file="$1"
            echo "[INFO] Uploading asset: ${file}"
            curl -fsS -X POST \
              "${API_BASE}/releases/${RELEASE_ID}/assets?name=$(basename "$file")" \
              -H "Authorization: token ${GITEA_TOKEN}" \
              -H "Content-Type: application/octet-stream" \
              --data-binary @"${file}" > /dev/null
            echo "[OK] Uploaded ${file}"
          }
          upload_asset guruconnect.exe
          upload_asset guruconnect.exe.sha256
          if [ -f changelog-artifact/CHANGELOG.md ]; then
            upload_asset changelog-artifact/CHANGELOG.md
          fi
          echo "[OK] Release ${TAG} published with signed binary, checksum, and changelog"
