name: Build App (Preview)

# Builds the Tauri app for branches other than main and publishes the bundles as
# a **prerelease**, so they are downloadable from the Releases page. No GitHub
# sync.
#
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
# a push that breaks any of them fails here. build-app.yml used to do that job
# in parallel and publish nothing, which meant six OS builds per push and one
# unreachable set of bundles; it is now releases-only.
#
# The cost of the swap, stated plainly: one prerelease per PR commit that
# touches `app/**` — so the workflow prunes its own, keeping the newest
# KEEP_PREVIEWS (see Lifecycle).
#
# ## Why not workflow artifacts
#
# Two attempts failed before this one, and both failure modes are worth knowing:
#
#  * `actions/upload-artifact@v4` cannot run here at all. It bundles
#    `@actions/artifact` v2, whose `isGhes()` treats any GITHUB_SERVER_URL that
#    is not github.com / *.ghe.com / *.localhost as GitHub Enterprise Server and
#    throws before making a single request. act_runner sets that variable to this
#    Gitea instance, so every platform died with "GHESNotSupportedError" — after
#    the whole Tauri build had been paid for (run #265).
#  * `@v3` uploads *succeed*, and the files are downloadable by direct URL — but
#    Gitea does not **list** them: `/api/v1/…/runs/<id>/artifacts` reports
#    `total_count: 0` and the run page shows nothing (verified on run #267).
#    A build nobody can find is not a build.
#
# So previews publish the same way every other workflow here does: curl to the
# Gitea releases API. One release per preview, tagged `preview-<sha>`.
#
# ## Lifecycle
#
# The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most
# recent `v<major>.<minor>.<patch>` releases and separately deletes every release
# whose tag does *not* start with `v[0-9]` — so previews never crowd the real
# release list, and a manual cleanup sweeps any this workflow missed.
#
# But that cleanup is a manual, dry-run-by-default action, and one prerelease per
# pushed commit accumulates faster than anyone runs it. So the last job here
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
# release; the point of a preview is the build you are testing now.
#
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub.

env:
  GITEA_URL: ${{ gitea.server_url }}
  REPO: ${{ gitea.repository }}
  # How many preview releases survive a run, newest first — including the one
  # just published.
  KEEP_PREVIEWS: "2"

on:
  # Every push to an open PR: this *is* the branch's build check — it compiles
  # Linux, macOS and Windows — and publishing the result costs nothing extra
  # once they are built. build-app.yml deliberately no longer runs on PRs.
  pull_request:
    branches: [main]
    paths:
      - "app/**"
      - "VERSION"
      - ".gitea/workflows/build-app-preview.yml"
  workflow_dispatch:

jobs:
  compute-version:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.version.outputs.VERSION }}
      sha: ${{ steps.version.outputs.SHA }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Compute preview version
        id: version
        run: |
          MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
          SHORT_SHA=$(git rev-parse --short HEAD)
          # From the checkout, not from `gitea.sha`: on a pull_request event
          # that variable can be the merge ref, which is not the commit anyone
          # is testing and not something to hang a tag on.
          echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT

          # The patch number is computed exactly as build-app.yml does it, so a
          # preview is labelled with the version the release it previews would
          # carry. This used to be hard-coded `.0`, which made every preview
          # installer claim to be x.y.0 no matter what it contained.
          LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
          if [ -n "$LATEST_TAG" ]; then
            PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
            echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)"
          else
            echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
            PATCH=0
          fi

          VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}"
          echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
          echo "Computed preview version: ${VERSION}"

  # One release, created once. The three build jobs run concurrently, so
  # get-or-create in each of them would race on the same tag: whoever loses gets
  # a 409 and (the way the old build-app.yml parsed it) an empty release id that
  # still reported success. Creating it in a job they all depend on removes the
  # race rather than handling it.
  create-release:
    runs-on: ubuntu-latest
    needs: [compute-version]
    outputs:
      release_id: ${{ steps.release.outputs.RELEASE_ID }}
      tag: ${{ steps.release.outputs.TAG }}
    steps:
      - name: Create the preview release
        id: release
        env:
          TOKEN: ${{ secrets.REGISTRY_TOKEN }}
          VERSION: ${{ needs.compute-version.outputs.version }}
          SHA: ${{ needs.compute-version.outputs.sha }}
          BRANCH: ${{ gitea.head_ref || gitea.ref_name }}
        run: |
          set -euo pipefail
          TAG="preview-${VERSION##*.}"
          echo "TAG=${TAG}" >> $GITHUB_OUTPUT

          # Idempotent: re-dispatching the same commit must update the existing
          # release rather than fail on the duplicate tag.
          HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
            -H "Authorization: token ${TOKEN}" \
            "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
          case "${HTTP_CODE}" in
            200) echo "Release ${TAG} already exists, reusing" ;;
            404)
              echo "Creating release ${TAG}"
              # prerelease: true keeps it off "latest" — this is a branch build,
              # not something anyone should install by accident.
              curl -fsS -X POST \
                -H "Authorization: token ${TOKEN}" \
                -H "Content-Type: application/json" \
                -d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${SHA}. Not a release — pruned by Cleanup Old Releases.\"}" \
                "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
              ;;
            *)
              echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
              cat release.json >&2 || true
              exit 1
              ;;
          esac

          RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
          if [ -z "${RELEASE_ID}" ]; then
            echo "Failed to parse release id; response was:" >&2
            cat release.json >&2
            exit 1
          fi
          echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
          echo "Release ${TAG} is id ${RELEASE_ID}"

  build-linux:
    runs-on: ubuntu-latest
    needs: [compute-version, create-release]
    steps:
      - name: Install Node.js 22
        run: |
          NEED_INSTALL=false
          if command -v node >/dev/null 2>&1; then
            NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
            OLD_NODE_DIR=$(dirname "$(which node)")
            echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
            if [ "$NODE_MAJOR" -lt 22 ]; then
              echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
              sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
              hash -r
              NEED_INSTALL=true
            fi
          else
            echo "Node.js not found, installing 22..."
            NEED_INSTALL=true
          fi
          if [ "$NEED_INSTALL" = true ]; then
            curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
            sudo apt-get install -y nodejs
            hash -r
          fi
          node --version
          npm --version

      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set app version
        run: |
          # Tauri / Cargo require a strict semver; strip the preview suffix for
          # the bundle version but keep it in the artifact filename.
          BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
          sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
          sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
          sed -i "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
          echo "Patched version to ${BASE_VERSION}"

      - name: Install system dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y \
            libgtk-3-dev \
            libwebkit2gtk-4.1-dev \
            libayatana-appindicator3-dev \
            librsvg2-dev \
            libsoup-3.0-dev \
            libssl-dev \
            libxdo-dev \
            patchelf \
            pkg-config \
            build-essential \
            curl \
            wget \
            file \
            xdg-utils

      - name: Install Rust stable
        run: |
          if command -v rustup >/dev/null 2>&1; then
            rustup update stable
            rustup default stable
          else
            curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
          fi
          export PATH="$HOME/.cargo/bin:$PATH"
          rustc --version
          cargo --version

      - name: Install frontend dependencies
        working-directory: ./app
        run: |
          rm -rf node_modules package-lock.json
          npm install

      - name: Install Tauri CLI
        working-directory: ./app
        run: |
          export PATH="$HOME/.cargo/bin:$PATH"
          npx tauri --version || npm install @tauri-apps/cli

      - name: Build Tauri app
        working-directory: ./app
        run: |
          export PATH="$HOME/.cargo/bin:$PATH"
          npx tauri build

      - name: Collect artifacts
        run: |
          mkdir -p artifacts
          cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
          cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
          cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
          ls -la artifacts/

      # Assets, not workflow artifacts — see the note at the top of this file.
      # Delete-then-upload so a re-dispatch replaces rather than 409s, and the
      # retry/http1.1 hardening that build-app.yml learned from real macOS
      # upload failures (curl exit 92 and exit 28 mid-stream).
      - name: Upload Linux bundles to the preview release
        shell: bash
        env:
          TOKEN: ${{ secrets.REGISTRY_TOKEN }}
          RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
        run: |
          set -euo pipefail
          shopt -s nullglob
          files=(artifacts/*)
          if [ ${#files[@]} -eq 0 ]; then
            echo "No Linux bundles were produced" >&2
            exit 1
          fi
          for file in "${files[@]}"; do
            filename=$(basename "$file")
            EXISTING_ID=$(curl -sS \
              -H "Authorization: token ${TOKEN}" \
              "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
              | python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
            if [ -n "${EXISTING_ID}" ]; then
              echo "Replacing existing asset ${filename}"
              curl -fsS -X DELETE \
                -H "Authorization: token ${TOKEN}" \
                "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
            fi
            echo "Uploading ${filename}..."
            curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
              -X POST \
              -H "Authorization: token ${TOKEN}" \
              -H "Content-Type: application/octet-stream" \
              --data-binary "@${file}" \
              "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
          done

  build-macos:
    runs-on: macos-latest
    needs: [compute-version, create-release]
    steps:
      - name: Install Node.js 22
        run: |
          NEED_INSTALL=false
          if command -v node >/dev/null 2>&1; then
            NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
            if [ "$NODE_MAJOR" -lt 22 ]; then
              NEED_INSTALL=true
            fi
          else
            NEED_INSTALL=true
          fi
          if [ "$NEED_INSTALL" = true ]; then
            brew install node@22
            brew link --overwrite node@22
          fi
          node --version
          npm --version

      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set app version
        run: |
          BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
          sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
          sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
          sed -i '' "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
          echo "Patched version to ${BASE_VERSION}"

      - name: Install Rust stable
        run: |
          if command -v rustup >/dev/null 2>&1; then
            rustup update stable
            rustup default stable
          else
            curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
          fi
          export PATH="$HOME/.cargo/bin:$PATH"
          rustup target add aarch64-apple-darwin x86_64-apple-darwin
          rustc --version
          cargo --version

      - name: Install frontend dependencies
        working-directory: ./app
        run: |
          rm -rf node_modules
          npm install

      - name: Install Tauri CLI
        working-directory: ./app
        run: |
          export PATH="$HOME/.cargo/bin:$PATH"
          npx tauri --version || npm install @tauri-apps/cli

      - name: Build Tauri app (universal)
        working-directory: ./app
        run: |
          export PATH="$HOME/.cargo/bin:$PATH"
          npx tauri build --target universal-apple-darwin

      - name: Collect artifacts
        run: |
          mkdir -p artifacts
          cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
          cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
          ls -la artifacts/

      # Assets, not workflow artifacts — see the note at the top of this file.
      # Delete-then-upload so a re-dispatch replaces rather than 409s, and the
      # retry/http1.1 hardening that build-app.yml learned from real macOS
      # upload failures (curl exit 92 and exit 28 mid-stream).
      - name: Upload macOS bundles to the preview release
        shell: bash
        env:
          TOKEN: ${{ secrets.REGISTRY_TOKEN }}
          RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
        run: |
          set -euo pipefail
          shopt -s nullglob
          files=(artifacts/*)
          if [ ${#files[@]} -eq 0 ]; then
            echo "No macOS bundles were produced" >&2
            exit 1
          fi
          for file in "${files[@]}"; do
            filename=$(basename "$file")
            EXISTING_ID=$(curl -sS \
              -H "Authorization: token ${TOKEN}" \
              "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
              | python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
            if [ -n "${EXISTING_ID}" ]; then
              echo "Replacing existing asset ${filename}"
              curl -fsS -X DELETE \
                -H "Authorization: token ${TOKEN}" \
                "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
            fi
            echo "Uploading ${filename}..."
            curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
              -X POST \
              -H "Authorization: token ${TOKEN}" \
              -H "Content-Type: application/octet-stream" \
              --data-binary "@${file}" \
              "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
          done

  build-windows:
    runs-on: windows-latest
    needs: [compute-version, create-release]
    defaults:
      run:
        shell: cmd
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set app version
        shell: powershell
        run: |
          $raw = "${{ needs.compute-version.outputs.version }}"
          $version = $raw.Split('-')[0]
          (Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
          (Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
          (Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
          Write-Host "Patched version to $version"

      - name: Install Rust stable
        run: |
          where rustup >nul 2>&1 && (
            rustup update stable
            rustup default stable
          ) || (
            curl -fSL -o rustup-init.exe https://win.rustup.rs/x86_64
            rustup-init.exe -y --default-toolchain stable
            del rustup-init.exe
          )

      - name: Install Node.js
        run: |
          where node >nul 2>&1 && (
            node --version
          ) || (
            curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
            msiexec /i node-install.msi /quiet /norestart
            del node-install.msi
          )

      - name: Verify tools
        run: |
          set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
          rustc --version
          cargo --version
          node --version
          npm --version

      - name: Install Tauri CLI via cargo
        run: |
          set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
          cargo install tauri-cli --version "^2"

      - name: Fix npm platform detection
        run: |
          set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
          npm config set os win32
          npm config list

      - name: Install frontend dependencies
        working-directory: ./app
        run: |
          set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
          if exist node_modules rmdir /s /q node_modules
          npm ci

      - name: Build frontend
        working-directory: ./app
        run: |
          set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
          npm run build

      - name: Build Tauri app
        working-directory: ./app
        env:
          TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
        run: |
          set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
          cargo tauri build

      - name: Collect artifacts
        run: |
          set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
          mkdir artifacts
          copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
          copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
          dir artifacts\

      # PowerShell, because this job's default shell is cmd. Same
      # delete-then-upload shape as the other two.
      - name: Upload Windows bundles to the preview release
        shell: powershell
        env:
          TOKEN: ${{ secrets.REGISTRY_TOKEN }}
          RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
        run: |
          $ErrorActionPreference = "Stop"
          $headers = @{ Authorization = "token $env:TOKEN" }
          $api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
          $files = @(Get-ChildItem -File -Path artifacts\*)
          if ($files.Count -eq 0) { throw "No Windows bundles were produced" }

          $existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets"
          foreach ($file in $files) {
            $name = $file.Name
            $dupe = $existing | Where-Object { $_.name -eq $name }
            if ($dupe) {
              Write-Host "Replacing existing asset $name"
              Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets/$($dupe.id)" | Out-Null
            }
            Write-Host "Uploading $name..."
            $uploadUri = "$api/releases/$env:RELEASE_ID/assets?name=$([uri]::EscapeDataString($name))"
            curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
              -X POST -H "Authorization: token $env:TOKEN" `
              -H "Content-Type: application/octet-stream" `
              --data-binary "@$($file.FullName)" $uploadUri
            if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
          }

  # Keep the preview list short. Runs after the builds and only if all three
  # succeeded: a half-published run must not be what evicts a good older build.
  prune-previews:
    runs-on: ubuntu-latest
    needs: [create-release, build-linux, build-macos, build-windows]
    steps:
      - name: Delete all but the newest preview releases
        env:
          TOKEN: ${{ secrets.REGISTRY_TOKEN }}
          KEEP_TAG: ${{ needs.create-release.outputs.tag }}
        run: |
          set -euo pipefail
          curl -fsS -H "Authorization: token ${TOKEN}" \
            "${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json

          # Newest first by creation time, `preview-` only, and never the one
          # this run just published — a clock skew must not delete it.
          DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY'
          import json, sys
          keep, keep_tag = int(sys.argv[1]), sys.argv[2]
          previews = [r for r in json.load(open("releases.json"))
                      if r["tag_name"].startswith("preview-")]
          previews.sort(key=lambda r: r["created_at"], reverse=True)
          for r in previews[keep:]:
              if r["tag_name"] != keep_tag:
                  print(r["id"], r["tag_name"])
          PY
          )

          if [ -z "${DOOMED}" ]; then
            echo "Nothing to prune (keeping ${KEEP_PREVIEWS})"
            exit 0
          fi

          echo "${DOOMED}" | while read -r ID TAG; do
            [ -z "${ID}" ] && continue
            echo "Deleting ${TAG} (id ${ID})"
            # Best effort: a preview someone deleted by hand mid-run is not a
            # reason to fail a build that otherwise succeeded.
            curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
              "${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true
            curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
              "${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true
          done
