name: Release Desktop

# STAGE 1 of the decoupled release pipeline: BUILD ONLY.
# Builds the desktop client for macOS (arm64 + x64) and Windows (x64), mirrors
# the installers to R2, and registers them in D1 as UNPUBLISHED (is_latest=0)
# so the website keeps serving the previous release. It does NOT notarize
# (Apple's notary service stalls this large bundle for hours) and does NOT
# create a GitHub Release.
#
# Full flow:
#   1. (this workflow) build + upload to R2 + D1 as unpublished.
#   2. (local) download the mac dmgs, run desktop/build/notarize-dmg.sh to
#      notarize + staple + re-upload the stapled dmgs to R2.
#   3. (Publish Desktop workflow) flip D1 is_latest=1 and attach GitHub
#      Release assets — makes the version live on the site.
#
# Manual only: run stage 1 via workflow_dispatch. Tag pushes do NOT trigger a
# build, so cutting a release tag never rebuilds installers or overwrites R2.
on:
  workflow_dispatch:
    inputs:
      version:
        description: "Version to stamp (e.g. 1.0.0-test). Used for package.json and R2 path."
        type: string
        default: "0.0.0-dev"
      publish_r2:
        description: "Upload installers to R2 + register in D1 (needs Cloudflare secrets)"
        type: boolean
        default: false

permissions:
  contents: write

jobs:
  build:
    name: Build ${{ matrix.name }}
    runs-on: ${{ matrix.os }}
    strategy:
      # Don't cancel the other platforms if one fails — we want to see all
      # failures in a single run.
      fail-fast: false
      matrix:
        include:
          - name: macOS arm64
            os: macos-14
            platform: mac
            arch: arm64
            eb_flags: --mac --arm64
          - name: macOS x64
            os: macos-15-intel
            platform: mac
            arch: x64
            eb_flags: --mac --x64
          - name: Windows x64
            os: windows-latest
            platform: win
            arch: x64
            eb_flags: --win --x64

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Derive version
        # Tag push: strip the leading "v" from GITHUB_REF_NAME (e.g. v1.2.0).
        # Manual dispatch: use the provided version input.
        id: ver
        shell: bash
        run: |
          if [ "${{ github.event_name }}" = "push" ]; then
            ref="${GITHUB_REF_NAME:-}"
            echo "version=${ref#v}" >> "$GITHUB_OUTPUT"
          else
            echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
          fi

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Build Python backend (PyInstaller)
        shell: bash
        run: |
          python -m pip install --upgrade pip
          pip install -r desktop/build/requirements-desktop.txt
          pip install pyinstaller
          # Run from repo root so the spec's relative datas resolve correctly.
          pyinstaller desktop/build/cowagent-backend.spec \
            --noconfirm \
            --distpath desktop/build/dist \
            --workpath desktop/build/build-work

      - name: Install desktop deps
        working-directory: desktop
        run: npm ci

      - name: Write version into package.json
        working-directory: desktop
        shell: bash
        run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version

      # Compile renderer + main in its OWN step, alone, so the npm.cmd batch
      # wrapper (see the note on the build step below) can't take out anything
      # after it.
      - name: Compile (vite + tsc)
        working-directory: desktop
        shell: bash
        run: npm run build

      # Download the Windows signing CLI. The URL comes from a repo variable, so
      # nothing about the signing setup is hardcoded in a public workflow. Only
      # runs on the Windows leg and only when a URL is set; otherwise the build
      # stays unsigned. SIGNTOOL_PATH is exported for the next step's
      # electron-builder.win.js to invoke.
      - name: Download Windows signing CLI
        if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != ''
        shell: bash
        env:
          SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
        run: |
          mkdir -p "$RUNNER_TEMP/signtool"
          curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
          # Unzip and locate the signtool executable regardless of nesting.
          unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
          exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
          if [ -z "$exe" ]; then
            echo "signtool.exe not found in downloaded archive" >&2
            find "$RUNNER_TEMP/signtool" -type f >&2
            exit 1
          fi
          # Normalize to a Windows-style path for execFileSync in Node.
          echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
          echo "resolved signtool: $exe"

      - name: Build & publish (electron-builder)
        working-directory: desktop
        shell: bash
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # Signing secrets are passed through as-is; we only export them to the
          # environment below when non-empty. An empty CSC_LINK would make
          # electron-builder try to load a bogus certificate and fail, so unset
          # is the correct state for unsigned builds.
          MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
          MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
          # Windows code signing via the signing CLI. Credentials are
          # secrets; SIGNTOOL_PATH was exported by the download step above.
          # COW_SIGN_DRY_RUN (repo variable) lets us validate the whole pipeline
          # with a self-signed cert before buying a real one — no quota used.
          SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
          SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
          SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
          COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
        run: |
          # Pick the signing cert for THIS platform only. The mac and win secrets
          # are both present in the job env, but a mac cert must never leak into a
          # Windows build (electron-builder would try to load it and fail), and
          # vice versa. electron-builder reads a single CSC_LINK/CSC_KEY_PASSWORD
          # pair, so we set it per-platform. An empty CSC_LINK is treated by
          # electron-builder as a broken cert path, so we leave it entirely unset
          # for an unsigned build.
          #
          # NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
          # (an `unset` can return non-zero and abort under errexit).
          # macOS keeps the classic CSC_LINK (.p12) flow. Windows no longer uses
          # a local .pfx (EV private keys can't be exported since 2023); it signs
          # via the CLI wired into electron-builder.win.js instead, using the
          # SIGNTOOL_* env already set above — nothing to export here.
          case "${{ matrix.platform }}" in
            mac)
              if [ -n "$MAC_CSC_LINK" ]; then
                export CSC_LINK="$MAC_CSC_LINK"
                export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
              fi
              ;;
          esac

          # Never let electron-builder publish: our publish target is a generic
          # (read-only) feed served from R2/D1, which it can't upload to. We mirror
          # installers to R2 and register them in D1 ourselves (publish-r2 job).
          # `--publish never` still emits the latest*.yml files.
          #
          # CONFIG PER PLATFORM: each platform loads its OWN dynamic config.
          #   mac -> electron-builder.js     (injects mac.binaries for signing)
          #   win -> electron-builder.win.js (wires the sign hook; electron-builder
          #                                   signs the app, backend and installer)
          # HISTORY: passing --config on Windows previously broke the build (no
          # installer, job still green). That happened because the MAC config
          # (electron-builder.js) was a no-op on Windows yet still disturbed the
          # run. The fix is a DEDICATED win config that correctly extends
          # config.win — not sharing the mac one. If a build ever runs WITHOUT
          # signing configured, electron-builder.win.js still returns the base
          # config unchanged (sign hook just skips), so the installer is still
          # produced.
          #
          # Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
          # npx.cmd (a batch wrapper) and running it from this Git Bash step can
          # make bash return before the wrapped process finishes. node skips it.
          case "${{ matrix.platform }}" in
            mac) config_arg="--config electron-builder.js" ;;
            win) config_arg="--config electron-builder.win.js" ;;
            *)   config_arg="" ;;
          esac
          node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never

      # Upload artifacts regardless of outcome, so a failed run still surfaces
      # the built installers (and, on success, the notarized+stapled dmg).
      - name: Upload artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          # One bundle per platform/arch so the publish job can collect them all.
          name: cowagent-${{ matrix.platform }}-${{ matrix.arch }}
          path: |
            desktop/release/*.dmg
            desktop/release/*.zip
            desktop/release/*.exe
            desktop/release/*.yml
            desktop/release/*.blockmap
          if-no-files-found: ignore
          retention-days: 7

  # Mirror the release installers to R2 (CDN-backed) and register them in D1 so
  # cowagent.ai/download/{platform}/latest can resolve and count downloads.
  # Runs only on tag pushes, and is a no-op (skips) until the Cloudflare secrets
  # are configured, so it never blocks unsigned/dry builds.
  publish-r2:
    name: Publish to R2 + D1
    # Require every platform in the build matrix to succeed before publishing,
    # so a release on R2/D1 is always complete (all installers present) rather
    # than partial. needs: build already gates on all matrix jobs succeeding.
    needs: build
    runs-on: ubuntu-latest
    # Run on a tag push, or on a manual dispatch when publish_r2 is checked.
    if: >-
      (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
      (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_r2 == 'true')
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Guard on Cloudflare secrets
        id: guard
        env:
          CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
        run: |
          if [ -n "$CF_TOKEN" ]; then
            echo "enabled=true" >> "$GITHUB_OUTPUT"
          else
            echo "enabled=false" >> "$GITHUB_OUTPUT"
            echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish."
          fi

      - name: Derive version
        if: steps.guard.outputs.enabled == 'true'
        id: ver
        run: |
          if [ "${{ github.event_name }}" = "push" ]; then
            echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
          else
            echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
          fi

      - name: Download all build artifacts
        if: steps.guard.outputs.enabled == 'true'
        uses: actions/download-artifact@v4
        with:
          path: artifacts

      - name: Stage installers
        if: steps.guard.outputs.enabled == 'true'
        id: stage
        run: |
          mkdir -p dist
          # Flatten installers + their .blockmap (used by electron-updater for
          # differential downloads) from every per-platform artifact dir. The
          # .yml feed is generated dynamically by the /update Function from D1,
          # so the yml files themselves don't need to go to R2.
          # .zip is the mac auto-update artifact (electron-updater's MacUpdater
          # can ONLY consume zip, not dmg — the dmg is for manual downloads).
          find artifacts -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) -exec cp {} dist/ \;
          echo "Staged files:"; ls -la dist
          # When the whole matrix failed there's nothing to publish; flag it so
          # the R2/D1 steps skip instead of writing an empty/partial release.
          if [ -n "$(ls -A dist 2>/dev/null)" ]; then
            echo "has_files=true" >> "$GITHUB_OUTPUT"
          else
            echo "has_files=false" >> "$GITHUB_OUTPUT"
            echo "::warning::No installers found in any artifact — skipping R2/D1 publish."
          fi

      - name: Upload installers to R2
        if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true'
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          VER: ${{ steps.ver.outputs.version }}
        run: |
          # Reuse the existing cow-skills bucket under a desktop/ prefix; this
          # is served by the cdn.cowagent.ai custom domain.
          for f in dist/*; do
            base="$(basename "$f")"
            key="desktop/v${VER}/${base}"
            echo "==> Uploading $base -> r2://cow-skills/$key"
            npx --yes wrangler@latest r2 object put "cow-skills/$key" \
              --file "$f" --remote
          done

      - name: Register release rows in D1
        if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true'
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          VER: ${{ steps.ver.outputs.version }}
        run: |
          # This build job ALWAYS registers rows as unpublished (is_latest=0), so
          # /download/<p>/latest keeps serving the previous release and the new
          # version stays invisible on the site. macOS dmgs still need to be
          # notarized+stapled locally (build/notarize-dmg.sh) before they're
          # safe to ship. Promotion to latest happens later, only after
          # notarization, via the separate "Publish Desktop" workflow.
          echo "==> Registering $VER as unpublished (is_latest=0)."

          # Build one upsert per (version, platform) carrying both the dmg
          # (manual download) and the mac zip (auto-update) columns. See
          # .github/scripts/register-releases.mjs for the mapping. No --latest
          # here: rows stay unpublished until the publish workflow promotes them.
          node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
          echo "==> D1 statements:"; cat d1.sql
          npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
