name: Build App

on:
  push:
    branches: [main]
    paths:
      - "app/**"
      - "VERSION"
      - ".gitea/workflows/build-app.yml"
  workflow_dispatch:

# Deliberately **not** on pull_request. Every publishing step here is gated on
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
# the same three and publishes them. Six OS builds per push, one set of which
# was unreachable. Previews now carry the PR check; this workflow is releases.

env:
  GITEA_URL: ${{ gitea.server_url }}
  REPO: ${{ gitea.repository }}

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

      - name: Fetch all tags
        run: git fetch --tags

      - name: Compute version from VERSION file and tags
        id: version
        run: |
          MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
          echo "Major.Minor: ${MAJOR_MINOR}"

          # Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
          # `|| true` so an empty grep result doesn't fail the step under pipefail.
          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
            echo "Latest matching tag: ${LATEST_TAG}"
            PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
          else
            # A minor line nobody has tagged yet is a *new* line, and a new line
            # starts at .0 — that is what "we are moving to 0.4.x" means. The
            # old fallback here counted every commit in the repository, which
            # would have made the first 0.4 build 0.4.234.
            echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
            PATCH=0
          fi

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

  build-linux:
    runs-on: ubuntu-latest
    needs: [compute-version]
    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
          echo "Node.js at: $(which node)"
          node --version
          npm --version

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

      - name: Set app version
        run: |
          VERSION="${{ needs.compute-version.outputs.version }}"
          sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
          sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
          sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
          echo "Patched version to ${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
            echo "Rust already installed: $(rustc --version)"
            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/

      - name: Upload to Gitea release
        if: gitea.event_name == 'push'
        env:
          TOKEN: ${{ secrets.REGISTRY_TOKEN }}
        run: |
          TAG="v${{ needs.compute-version.outputs.version }}"
          # Create release
          curl -s -X POST \
            -H "Authorization: token ${TOKEN}" \
            -H "Content-Type: application/json" \
            -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
            "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
          RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
          echo "Release ID: ${RELEASE_ID}"
          # Upload each artifact
          for file in artifacts/*; do
            [ -f "$file" ] || continue
            filename=$(basename "$file")
            echo "Uploading ${filename}..."
            curl -s -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]
    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/')
            echo "Found Node.js $(node --version) (major: ${NODE_MAJOR})"
            if [ "$NODE_MAJOR" -lt 22 ]; then
              echo "Node.js ${NODE_MAJOR} is too old, upgrading to 22..."
              NEED_INSTALL=true
            fi
          else
            echo "Node.js not found, installing 22..."
            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: |
          VERSION="${{ needs.compute-version.outputs.version }}"
          sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
          sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
          sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
          echo "Patched version to ${VERSION}"

      - name: Install Rust stable
        run: |
          if command -v rustup >/dev/null 2>&1; then
            echo "Rust already installed: $(rustc --version)"
            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/

      - name: Upload to Gitea release
        if: gitea.event_name == 'push'
        env:
          TOKEN: ${{ secrets.REGISTRY_TOKEN }}
        run: |
          set -euo pipefail
          TAG="v${{ needs.compute-version.outputs.version }}-mac"

          # Idempotent get-or-create. macOS upload has historically failed
          # mid-stream (curl exit 92, exit 28), leaving the release record
          # with empty assets. A naive POST /releases on the next run hits
          # 409 from Gitea for the duplicate tag, the JSON parse below
          # then yields an empty RELEASE_ID, and pipefail aborts with an
          # opaque exit 1. Look the release up by tag first; create only
          # if it doesn't exist; reuse the existing id otherwise.
          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 "Release ${TAG} not found, creating"
              curl -fsS -X POST \
                -H "Authorization: token ${TOKEN}" \
                -H "Content-Type: application/json" \
                -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
                "${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}"

          # Upload each artifact. If an asset with the same name already
          # exists on the release (left over from a partial prior run),
          # delete it first so the upload is replace-not-conflict.
          # Network hardening: HTTP/1.1 to dodge HTTP/2 stream flakes
          # the macOS runner has hit, retries with backoff for transient
          # drops, and -f so HTTP errors stop being silently swallowed.
          for file in artifacts/*; do
            [ -f "$file" ] || continue
            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 "Deleting existing asset ${filename} (id ${EXISTING_ID})"
              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]
    defaults:
      run:
        shell: cmd
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set app version
        shell: powershell
        run: |
          $version = "${{ needs.compute-version.outputs.version }}"
          (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 MSVC C++ build tools
        shell: cmd
        run: |
          rem Tauri links with MSVC, so rustc needs link.exe and the Windows SDK.
          rem This job previously assumed a hand-provisioned runner; a runner
          rem without them registers fine, advertises windows-latest, accepts the
          rem job, downloads the whole crate graph and only then fails at link
          rem time with "linker `link.exe` not found".
          rem
          rem rustc finds MSVC via vswhere and the registry rather than PATH, so
          rem installing is enough - no dev-shell activation needed here.
          rem
          rem Delayed expansion is required: %VAR% inside a parenthesised block
          rem is substituted when the block is PARSED, not when it runs, so both
          rem %ERRORLEVEL% and %VSEXIT% would read as their pre-block values.
          setlocal enabledelayedexpansion
          set "VCPATH="
          set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
          if exist "%VSWHERE%" (
            for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCPATH=%%i"
          )
          if defined VCPATH (
            echo MSVC build tools already present at !VCPATH!
          ) else (
            echo MSVC build tools not found - installing Visual Studio Build Tools
            curl -fSL -o "%TEMP%\vs_BuildTools.exe" https://aka.ms/vs/17/release/vs_BuildTools.exe || exit /b 1
            "%TEMP%\vs_BuildTools.exe" --quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended
            set "VSEXIT=!ERRORLEVEL!"
            del "%TEMP%\vs_BuildTools.exe" 2>nul
            rem 3010 means installed, reboot pending - a success for our purposes.
            if not "!VSEXIT!"=="0" if not "!VSEXIT!"=="3010" (
              echo Visual Studio Build Tools installer failed with exit code !VSEXIT!
              exit /b 1
            )
            echo Visual Studio Build Tools installed
          )
          endlocal

      - name: Work around WOW64 redirection for 32-bit bundlers
        shell: cmd
        run: |
          rem Tauri downloads its bundlers - candle.exe, light.exe and
          rem makensis.exe - and every one of them is 32-bit. When the runner
          rem runs as SYSTEM its %LOCALAPPDATA% is under
          rem C:\Windows\System32\config\systemprofile, and WOW64 redirection
          rem serves any 32-bit process reading System32 from SysWOW64 instead -
          rem where those directories do not exist. The bundlers then cannot see
          rem their own folder: candle exits 0x80131700 and makensis reports
          rem "Unable to start child process, error 0x2". Tauri surfaces neither,
          rem only "failed to run candle.exe", which is why this is worth a
          rem comment this long.
          rem
          rem Junctioning the SysWOW64 view onto the System32 originals makes the
          rem redirected path resolve to the same files. A runner running as a
          rem normal user has a profile outside System32 and skips all of this.
          echo.%LOCALAPPDATA%| find /I "\system32\" >nul
          if errorlevel 1 goto skipwow

          if not exist "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri" mkdir "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"
          if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local" mkdir "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local"
          if not exist "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\AppData\Local\tauri" "%WINDIR%\System32\config\systemprofile\AppData\Local\tauri"

          if not exist "%WINDIR%\System32\config\systemprofile\.cache" mkdir "%WINDIR%\System32\config\systemprofile\.cache"
          if not exist "%WINDIR%\SysWOW64\config\systemprofile\.cache" mklink /J "%WINDIR%\SysWOW64\config\systemprofile\.cache" "%WINDIR%\System32\config\systemprofile\.cache"

          echo WOW64 junctions in place for the SYSTEM profile
          goto :eof

          :skipwow
          echo Runner profile is outside System32 - WOW64 junctions not needed

      - 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%"
          rem Every Tauri bundler it downloads - candle.exe, light.exe and
          rem makensis.exe - is 32-bit. A runner running as SYSTEM has
          rem %LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, and
          rem WOW64 redirection sends 32-bit processes reading System32 to
          rem SysWOW64, so they cannot see their own directory: candle exits
          rem 0x80131700 and makensis reports "Unable to start child process,
          rem error 0x2".
          rem
          rem The build VM carries junctions from the SysWOW64 view of
          rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the
          rem System32 originals, which makes the redirected view resolve. A
          rem runner running as a normal user needs no such patch.
          cargo tauri build --bundles msi,nsis

      - 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\ || exit /b 1
          copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ || exit /b 1
          dir artifacts\

      - name: Upload to Gitea release
        if: gitea.event_name == 'push'
        shell: powershell
        env:
          TOKEN: ${{ secrets.REGISTRY_TOKEN }}
          COMMIT_SHA: ${{ gitea.sha }}
          VERSION: ${{ needs.compute-version.outputs.version }}
        run: |
          $ErrorActionPreference = "Stop"
          $tag = "v$env:VERSION-win"
          $headers = @{ Authorization = "token $env:TOKEN" }
          $api = "$env:GITEA_URL/api/v1/repos/$env:REPO"

          # Idempotent get-or-create. The old cmd-batch version swallowed
          # curl errors and parsed the release id with findstr, so a 409 on
          # a pre-existing tag yielded an empty RELEASE_ID and uploads went to
          # a malformed .../releases//assets URL while the step still reported
          # success. Look the release up by tag first; create only on 404.
          try {
            $release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/tags/$tag"
            Write-Host "Release $tag already exists, reusing"
          } catch {
            if ($_.Exception.Response.StatusCode.value__ -eq 404) {
              Write-Host "Release $tag not found, creating"
              $body = @{
                tag_name = $tag
                name     = "Triple-C v$env:VERSION (Windows)"
                body     = "Automated build from commit $env:COMMIT_SHA"
              } | ConvertTo-Json
              $release = Invoke-RestMethod -Method Post -Headers $headers `
                -ContentType "application/json" -Body $body -Uri "$api/releases"
            } else {
              throw
            }
          }

          $releaseId = $release.id
          if (-not $releaseId) { throw "Failed to resolve release id for $tag" }
          Write-Host "Release ID: $releaseId"

          # Upload each artifact. Delete any same-named asset left over from a
          # partial prior run first, so the upload replaces rather than 409s.
          $existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$releaseId/assets"
          foreach ($file in Get-ChildItem -File -Path artifacts\*) {
            $name = $file.Name
            $dupe = $existing | Where-Object { $_.name -eq $name }
            if ($dupe) {
              Write-Host "Deleting existing asset $name (id $($dupe.id))"
              Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$releaseId/assets/$($dupe.id)" | Out-Null
            }
            Write-Host "Uploading $name..."
            $uploadUri = "$api/releases/$releaseId/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)" }
          }

  create-tag:
    runs-on: ubuntu-latest
    needs: [compute-version, build-linux, build-macos, build-windows]
    if: gitea.event_name == 'push'
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Create version tag
        env:
          TOKEN: ${{ secrets.REGISTRY_TOKEN }}
        run: |
          VERSION="${{ needs.compute-version.outputs.version }}"
          TAG="v${VERSION}"
          echo "Creating tag ${TAG}..."

          # Create annotated tag via Gitea API
          curl -s -X POST \
            -H "Authorization: token ${TOKEN}" \
            -H "Content-Type: application/json" \
            -d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
            "${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"

          echo "Tag ${TAG} created successfully"

  sync-to-github:
    runs-on: ubuntu-latest
    needs: [compute-version, build-linux, build-macos, build-windows]
    if: gitea.event_name == 'push'
    env:
      GH_PAT: ${{ secrets.GH_PAT }}
      GITHUB_REPO: shadowdao/triple-c
    steps:
      - name: Download artifacts from Gitea releases
        env:
          TOKEN: ${{ secrets.REGISTRY_TOKEN }}
          VERSION: ${{ needs.compute-version.outputs.version }}
        run: |
          set -e
          mkdir -p artifacts

          # Download assets from all 3 platform releases
          for TAG_SUFFIX in "" "-mac" "-win"; do
            TAG="v${VERSION}${TAG_SUFFIX}"
            echo "==> Fetching assets for release ${TAG}..."

            RELEASE_JSON=$(curl -sf \
              -H "Authorization: token ${TOKEN}" \
              "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")

            echo "$RELEASE_JSON" | jq -r '.assets[]? | "\(.name) \(.browser_download_url)"' | while read -r NAME URL; do
              [ -z "$NAME" ] && continue
              echo "    Downloading ${NAME}..."
              curl -sfL \
                -H "Authorization: token ${TOKEN}" \
                -o "artifacts/${NAME}" \
                "$URL"
            done
          done

          echo "==> All downloaded artifacts:"
          ls -la artifacts/

      - name: Create GitHub release and upload artifacts
        env:
          VERSION: ${{ needs.compute-version.outputs.version }}
          COMMIT_SHA: ${{ gitea.sha }}
        run: |
          set -e
          TAG="v${VERSION}"

          echo "==> Creating unified release ${TAG} on GitHub..."

          # Delete existing release if present (idempotent re-runs)
          EXISTING=$(curl -sf \
            -H "Authorization: Bearer ${GH_PAT}" \
            -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG}" 2>/dev/null || echo "{}")
          EXISTING_ID=$(echo "$EXISTING" | jq -r '.id // empty')
          if [ -n "$EXISTING_ID" ]; then
            echo "    Deleting existing GitHub release ${TAG} (id: ${EXISTING_ID})..."
            curl -sf -X DELETE \
              -H "Authorization: Bearer ${GH_PAT}" \
              -H "Accept: application/vnd.github+json" \
              "https://api.github.com/repos/${GITHUB_REPO}/releases/${EXISTING_ID}"
          fi

          RESPONSE=$(curl -sf -X POST \
            -H "Authorization: Bearer ${GH_PAT}" \
            -H "Accept: application/vnd.github+json" \
            -H "Content-Type: application/json" \
            "https://api.github.com/repos/${GITHUB_REPO}/releases" \
            -d "{
              \"tag_name\": \"${TAG}\",
              \"name\": \"Triple-C ${TAG}\",
              \"body\": \"Automated build from commit ${COMMIT_SHA}\n\nIncludes Linux, macOS, and Windows artifacts.\",
              \"draft\": false,
              \"prerelease\": false
            }")

          UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
          echo "==> Upload URL: ${UPLOAD_URL}"

          for file in artifacts/*; do
            [ -f "$file" ] || continue
            FILENAME=$(basename "$file")
            MIME="application/octet-stream"
            echo "==> Uploading ${FILENAME}..."
            curl -sf -X POST \
              -H "Authorization: Bearer ${GH_PAT}" \
              -H "Accept: application/vnd.github+json" \
              -H "Content-Type: ${MIME}" \
              --data-binary "@${file}" \
              "${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${FILENAME}")"
          done

          echo "==> GitHub release sync complete."
