name: Build images

on:
  push:
    # `:dev` builds dropped 2026-05-26 — operator tests from `:latest` after
    # merge-to-main, not from the dev branch image. Saves one full docker
    # build per dev push.
    branches: [main]
    # Tag-push triggers an immutable per-version image build (e.g.
    # `:v26.05.26.5`) — gives a real rollback story alongside the floating
    # `:main` / `:latest`. Layer reuse keeps the registry-storage cost
    # negligible per tag. Doesn't overlap with the push-to-main build (that
    # one publishes `:main` + `:latest`; the tag-push build publishes only
    # `:<tag>`).
    tags: ['v*']

# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
#   - write:package, read:package  (for docker push to git.fabledsword.com)
#   - write:release                (for ext-<version> release asset cache)
#   - write:issue                  (for future issue-management automation)
# The injected GITHUB_TOKEN cannot be used — it lacks write:package.

jobs:
  # Sign-or-fetch-from-cache: signs the extension via AMO if no ext-<version>
  # Forgejo release exists yet, otherwise downloads the cached signed XPI.
  # Result is uploaded as an Actions artifact for build-web to consume.
  #
  # Why this lives in build.yml (not a separate workflow): the merge-commit's
  # docker image tagged `:latest` MUST carry the XPI. A separate sign workflow
  # racing build.yml leaves `:latest` without the XPI for ~5min (until the
  # commit-back triggers another build). Inline ordering eliminates the race.
  # Cache strategy: Forgejo Release Assets — picked 2026-05-25 over Generic
  # Packages (cleaner API surface) and commit-back-to-side-branch (no extra
  # branch to manage). AMO blocks re-signing the same version (returns 409),
  # so signing is intentionally one-shot per version bump.
  sign-extension:
    if: github.ref == 'refs/heads/main'
    runs-on: python-ci
    container:
      image: git.fabledsword.com/bvandeusen/ci-python:3.14
    steps:
      - uses: actions/checkout@v4

      - name: Resolve extension version
        id: extver
        run: |
          VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
          echo "Resolved extension version: $VERSION"

      - name: Check Forgejo release-asset cache
        id: cache
        env:
          TOKEN: ${{ secrets.RELEASE_TOKEN }}
        run: |
          set -eu
          VERSION=${{ steps.extver.outputs.version }}
          STATUS=$(curl -s -o release.json -w "%{http_code}" \
            -H "Authorization: token $TOKEN" \
            "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000)
          echo "Tag lookup HTTP status: $STATUS"
          # JSON parsing via python (ci-python:3.14 has stdlib json; jq is
          # not in the image and adding it per ci-requirements.md is not
          # warranted for a single consumer — operator-flagged 2026-05-26
          # after a sign job failed with `jq: not found`).
          if [ "$STATUS" = "200" ]; then
            ASSET_ID=$(python3 -c "import json; r=json.load(open('release.json')); xpis=[a for a in r.get('assets', []) if a.get('name','').endswith('.xpi')]; print(xpis[0]['id'] if xpis else '')")
            if [ -n "$ASSET_ID" ]; then
              echo "cached=true" >> "$GITHUB_OUTPUT"
              echo "asset_id=$ASSET_ID" >> "$GITHUB_OUTPUT"
              echo "Cached XPI exists at ext-$VERSION (asset id $ASSET_ID); skipping AMO sign"
            else
              echo "cached=false" >> "$GITHUB_OUTPUT"
              echo "Release ext-$VERSION exists but has no .xpi asset; will re-sign + re-upload"
            fi
          else
            echo "cached=false" >> "$GITHUB_OUTPUT"
            echo "No release named ext-$VERSION; will sign via AMO and upload"
          fi

      # No "download cached XPI in sign-extension" step: build-web
      # fetches directly from the Forgejo ext-<version> release asset
      # (removed 2026-05-26 alongside the actions/upload-artifact
      # removal — sign-extension's job is just to ensure the cache
      # exists on Forgejo; the build-web side reads it independently).

      - name: Sign via AMO (cache miss)
        if: steps.cache.outputs.cached != 'true'
        run: |
          cd extension && npm install --no-save --no-audit --no-fund && npm run sign
        env:
          WEB_EXT_API_KEY: ${{ secrets.MOZILLA_AMO_JWT_KEY }}
          WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }}

      - name: Upload signed XPI to ext-<version> release (cache miss)
        if: steps.cache.outputs.cached != 'true'
        env:
          TOKEN: ${{ secrets.RELEASE_TOKEN }}
        run: |
          set -eux
          VERSION=${{ steps.extver.outputs.version }}
          # AMO renames signed XPIs with its internal addon-id-safe-string;
          # canonicalize to fabledcurator-<version>.xpi so the FC server's
          # whitelist (backend/app/frontend.py expects 'fabledcurator-*.xpi')
          # keeps working.
          SIGNED=$(ls extension/web-ext-artifacts/*.xpi | head -1)
          XPI="extension/web-ext-artifacts/fabledcurator-$VERSION.xpi"
          cp "$SIGNED" "$XPI"
          # Find-or-create the ext-<version> release. Track whether WE
          # created it so an upload failure below can roll back (don't
          # leave an empty release tombstone that the next run's
          # cache-check mistakes for a partial-failure state).
          STATUS=$(curl -s -o release.json -w "%{http_code}" \
            -H "Authorization: token $TOKEN" \
            "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000)
          if [ "$STATUS" = "200" ]; then
            CREATED_BY_US=false
          else
            curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
              -d "{\"tag_name\":\"ext-$VERSION\",\"name\":\"Extension $VERSION (signed XPI cache)\",\"body\":\"Internal cache for the signed XPI consumed by build.yml's build-web job. Not a user-facing FC release.\",\"target_commitish\":\"main\"}" \
              -o release.json \
              "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases"
            CREATED_BY_US=true
          fi
          RELEASE_ID=$(python3 -c "import json; print(json.load(open('release.json'))['id'])")
          test -n "$RELEASE_ID"
          # Rollback-on-failure: if the asset upload fails AND we just
          # created the release in this run, delete it. Prevents an empty
          # ext-<version> release from poisoning the next workflow run
          # (operator-flagged 2026-05-26 — without rollback the next run
          # saw 'release exists, no asset → cache miss → sign' which AMO
          # then rejected with 409 'Version already exists').
          rollback_if_we_created() {
            if [ "$CREATED_BY_US" = "true" ]; then
              echo "Rolling back: deleting just-created release $RELEASE_ID"
              curl -s -X DELETE -H "Authorization: token $TOKEN" \
                "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/$RELEASE_ID" || true
              curl -s -X DELETE -H "Authorization: token $TOKEN" \
                "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/tags/ext-$VERSION" || true
            fi
          }
          trap 'rollback_if_we_created' EXIT
          HTTP_CODE=$(curl -s -X POST -H "Authorization: token $TOKEN" \
            -F "attachment=@$XPI" \
            -o /dev/null -w "%{http_code}" \
            "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/$RELEASE_ID/assets?name=fabledcurator-$VERSION.xpi")
          if [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "200" ]; then
            echo "Asset upload failed with HTTP $HTTP_CODE"
            exit 1
          fi
          # Upload succeeded — clear the rollback trap.
          trap - EXIT
          echo "Uploaded fabledcurator-$VERSION.xpi to ext-$VERSION release"

      # No actions/upload-artifact step: Forgejo Actions (and our
       # act_runner) doesn't support upload-artifact@v4+ (GHES limitation
       # surfaced 2026-05-26). Instead build-web reads the signed XPI
       # straight from the ext-<version> Forgejo release we just uploaded
       # to. Same source of truth; no double-store.

  build-web:
    needs: [sign-extension]
    # sign-extension is main-only; on dev it's skipped, build-web still runs.
    if: always() && (needs.sign-extension.result == 'success' || needs.sign-extension.result == 'skipped')
    runs-on: python-ci
    container:
      image: git.fabledsword.com/bvandeusen/ci-python:3.14
    steps:
      - uses: actions/checkout@v4

      - name: Download signed XPI from Forgejo release asset (main + tags)
        # Fires on main-push AND on tag-push. Tag-push builds re-package the
        # same source code as the preceding main-push build but with an
        # immutable version tag — they need the XPI too, otherwise the
        # versioned image ships without the signed extension.
        #
        # Tag-push vs main-push race (operator-flagged 2026-05-27 after
        # v26.05.27.0 hit it): a release cut fires BOTH workflows almost
        # simultaneously. Main-push runs sign-extension (1-5min AMO round
        # trip) before publishing the ext-<version> release; tag-push
        # skips sign-extension (gated to main) and races straight to
        # this download step. Tag-push lost every time. Fix: poll the
        # ext-<version> release endpoint with a sleep+retry loop (30s
        # for up to 10min total) before giving up. Main-push's signing
        # eventually wins and tag-push picks the release up on a later
        # iteration.
        if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
        env:
          TOKEN: ${{ secrets.RELEASE_TOKEN }}
        run: |
          set -eux
          VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
          # Poll for the ext-<version> release. main-push's sign-extension
          # step (AMO round-trip, 1-5min) needs to finish + upload before
          # tag-push can fetch. 30s * 20 = up to 10min wait, then hard-fail.
          for attempt in $(seq 1 20); do
            STATUS=$(curl -s -o release.json -w "%{http_code}" \
              -H "Authorization: token $TOKEN" \
              "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000)
            if [ "$STATUS" = "200" ]; then
              echo "Found ext-$VERSION release on attempt $attempt"
              break
            fi
            if [ "$attempt" = "20" ]; then
              echo "ERROR: ext-$VERSION release not available after 10min of polling"
              echo "Last HTTP status: $STATUS"
              exit 1
            fi
            echo "Attempt $attempt: ext-$VERSION not yet published (HTTP $STATUS); sleeping 30s"
            sleep 30
          done
          # Extract the .xpi asset's browser_download_url (Forgejo's
          # /releases/assets/<id> endpoint returns ASSET METADATA, not
          # the binary blob — operator-flagged 2026-05-26: my prior
          # code curl'd the metadata endpoint without -f and wrote the
          # resulting 404-page-not-found text into fabledcurator-*.xpi,
          # which Firefox then rejected as "corrupt").
          # browser_download_url is the canonical binary endpoint and
          # is also publicly accessible (no token needed) but we pass
          # the token anyway for symmetry with private-repo support.
          DOWNLOAD_URL=$(python3 -c "import json; r=json.load(open('release.json')); xpis=[a for a in r.get('assets', []) if a.get('name','').endswith('.xpi')]; print(xpis[0]['browser_download_url'])")
          test -n "$DOWNLOAD_URL"
          echo "Downloading XPI from: $DOWNLOAD_URL"
          mkdir -p frontend/public/extension
          DEST="frontend/public/extension/fabledcurator-$VERSION.xpi"
          # -f = fail on HTTP error (prevents silent corruption like the
          # 2026-05-26 incident); -L = follow redirects.
          curl -sfL -H "Authorization: token $TOKEN" -o "$DEST" "$DOWNLOAD_URL"
          # Sanity check: the binary should start with the ZIP magic (PK\x03\x04).
          # If it's anything else, the next docker build will ship a corrupt XPI.
          MAGIC=$(head -c 2 "$DEST" | od -An -c | tr -d ' \n')
          if [ "$MAGIC" != "PK" ]; then
            echo "ERROR: downloaded XPI does not start with ZIP magic 'PK' (got '$MAGIC')"
            echo "File contents preview:"
            head -c 200 "$DEST"
            exit 1
          fi
          cp "$DEST" "frontend/public/extension/fabledcurator-latest.xpi"
          ls -la frontend/public/extension/

      - name: Determine tag
        id: tag
        run: |
          # Three trigger shapes:
          #   refs/tags/v…   → tag-push: opt-in milestone label (vYY.MM.DD,
          #                    no `.N` per family release-posture rule).
          #                    Publish ONLY the immutable version tag;
          #                    don't touch :latest (the main-push build
          #                    for the merge commit already did that).
          #   refs/heads/main → push to main: publish :main + :latest
          #                    (floating) AND :c-<short_sha> (immutable
          #                    per-commit rollback substrate, per family
          #                    release-posture rule "Tags are milestones,
          #                    not gates — commit-SHA images are the
          #                    rollback unit"). Rollback to any commit
          #                    becomes `docker pull …:c-<sha>` without a
          #                    release ceremony.
          #   anything else  → safety net; shouldn't fire given the `on:`
          #                    config above. Tag :dev to surface the
          #                    unexpected run in the registry.
          # POSIX-safe substring (the runner shell is dash/BusyBox sh, not
          # bash — `${var:0:7}` errors with "Bad substitution"; cut works
          # everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
          # main-push build failed at this step.
          SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
          if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
            TAG_NAME="${GITHUB_REF#refs/tags/}"
            echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
          elif [ "${GITHUB_REF##*/}" = "main" ]; then
            echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest,git.fabledsword.com/bvandeusen/fabledcurator:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
          else
            echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
          fi

      - name: Login to Forgejo registry
        uses: docker/login-action@v3
        with:
          registry: git.fabledsword.com
          username: ${{ github.actor }}
          password: ${{ secrets.RELEASE_TOKEN }}

      - name: Build and push web image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: Dockerfile
          push: true
          tags: ${{ steps.tag.outputs.tags }}

  build-ml:
    runs-on: python-ci
    container:
      image: git.fabledsword.com/bvandeusen/ci-python:3.14
    steps:
      - uses: actions/checkout@v4

      - name: Determine tag
        id: tag
        run: |
          # Mirrors build-web's three-shape logic (tag-push / main-push /
          # safety-net dev) including the per-commit :c-<short_sha> tag
          # on main-push per the family release-posture rule. The -ml
          # image follows the same release cadence as the web image.
          # POSIX-safe substring (the runner shell is dash/BusyBox sh, not
          # bash — `${var:0:7}` errors with "Bad substitution"; cut works
          # everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
          # main-push build failed at this step.
          SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
          if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
            TAG_NAME="${GITHUB_REF#refs/tags/}"
            echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
          elif [ "${GITHUB_REF##*/}" = "main" ]; then
            echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest,git.fabledsword.com/bvandeusen/fabledcurator-ml:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
          else
            echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
          fi

      - name: Login to Forgejo registry
        uses: docker/login-action@v3
        with:
          registry: git.fabledsword.com
          username: ${{ github.actor }}
          password: ${{ secrets.RELEASE_TOKEN }}

      - name: Build and push ml image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: Dockerfile.ml
          push: true
          tags: ${{ steps.tag.outputs.tags }}

  # The desktop GPU agent (#114) — published so the operator pulls + runs it on
  # the GPU machine instead of building locally. Independent of web/ml (its own
  # CUDA + onnxruntime-gpu image, context = agent/). Same tag cadence.
  build-agent:
    runs-on: python-ci
    container:
      image: git.fabledsword.com/bvandeusen/ci-python:3.14
    steps:
      - uses: actions/checkout@v4

      - name: Determine tag
        id: tag
        run: |
          SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
          if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
            TAG_NAME="${GITHUB_REF#refs/tags/}"
            echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:${TAG_NAME}" >> "$GITHUB_OUTPUT"
          elif [ "${GITHUB_REF##*/}" = "main" ]; then
            echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:main,git.fabledsword.com/bvandeusen/fabledcurator-agent:latest,git.fabledsword.com/bvandeusen/fabledcurator-agent:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
          else
            echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:dev" >> "$GITHUB_OUTPUT"
          fi

      - name: Login to Forgejo registry
        uses: docker/login-action@v3
        with:
          registry: git.fabledsword.com
          username: ${{ github.actor }}
          password: ${{ secrets.RELEASE_TOKEN }}

      - name: Build and push agent image
        uses: docker/build-push-action@v5
        with:
          context: agent
          file: agent/Dockerfile
          push: true
          tags: ${{ steps.tag.outputs.tags }}
