name: CI

# Single gated pipeline for the test contour. Gitea cannot express
# cross-workflow `needs`, so the full test suite and the auto test-deploy live in
# one workflow.
#
# Branch model (CLAUDE.md): feature branches are cut from `development`; a commit
# to a feature branch triggers nothing. The pipeline runs on a PR into
# `development` or `master` (the full test suite — the merge gate) and on a push
# to `development` (after a merge). The deploy job runs only for `development`
# (PR or merge), so a PR into `master` is test-only; the prod deploy is a manual
# workflow.
#
# Path-conditional jobs: `unit`/`integration`/`ui` run only when their
# code changed (the `changes` job decides). Because a skipped required check would
# block a merge under branch protection, the always-running `gate` job aggregates
# their results and is the ONLY required status check; it passes when every
# upstream job either succeeded or was skipped.
#
# Console output is kept plain (NO_COLOR + `docker compose --ansi never` +
# `--progress plain`) so the Gitea logs stay readable.

on:
  pull_request:
    branches: [development, master]
  push:
    branches: [development]

# The dictionary release the test suite validates against — the current
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
# unit/integration jobs inherit it. The deploy job overrides it per contour with
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
env:
  DICT_VERSION: v1.2.1

jobs:
  # changes detects which areas a PR/push touched, so the test jobs can skip when
  # irrelevant. It defaults to running everything when the diff cannot be computed.
  changes:
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
    outputs:
      go: ${{ steps.filter.outputs.go }}
      ui: ${{ steps.filter.outputs.ui }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detect changed paths
        id: filter
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            git fetch -q origin "${{ github.base_ref }}" || true
            range="origin/${{ github.base_ref }}...HEAD"
          else
            before="${{ github.event.before }}"
            if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "${before}^{commit}" 2>/dev/null; then
              range="HEAD~1...HEAD"
            else
              range="${before}...HEAD"
            fi
          fi
          echo "comparison range: $range"
          # Default to running everything; narrow only when the diff is computable.
          go=true; ui=true
          files="$(git diff --name-only "$range" 2>/dev/null || echo __DIFF_FAILED__)"
          if [ "$files" != "__DIFF_FAILED__" ]; then
            echo "changed files:"; echo "$files"
            go=false; ui=false
            if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
            if echo "$files" | grep -qE '^ui/'; then ui=true; fi
            # A workflow or deploy change re-runs everything as a safety net.
            if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
          else
            echo "diff failed; running all jobs"
          fi
          echo "selected: go=$go ui=$ui"
          echo "go=$go" >> "$GITHUB_OUTPUT"
          echo "ui=$ui" >> "$GITHUB_OUTPUT"

  unit:
    needs: changes
    if: ${{ needs.changes.outputs.go == 'true' }}
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
    env:
      # The engine consumes the published scrabble-solver module from this Gitea;
      # GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
      GOPRIVATE: gitea.iliadenisov.ru/*
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Fetch dictionary DAWGs
        run: |
          mkdir -p "${GITHUB_WORKSPACE}/dawg"
          curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
          tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
          ls -la "${GITHUB_WORKSPACE}/dawg"

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version-file: go.work
          cache: true

      - name: gofmt
        run: |
          unformatted="$(gofmt -l .)"
          if [ -n "$unformatted" ]; then
            echo "gofmt needed on:"; echo "$unformatted"; exit 1
          fi

      - name: vet
        run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...

      - name: build
        run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...

      - name: test
        env:
          BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
        run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... ./loadtest/...

  integration:
    needs: changes
    if: ${{ needs.changes.outputs.go == 'true' }}
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
    env:
      # Ryuk (testcontainers' reaper) does not start cleanly on every runner; the
      # suite's TestMain terminates its own container, so disable it.
      TESTCONTAINERS_RYUK_DISABLED: "true"
      GOPRIVATE: gitea.iliadenisov.ru/*
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Fetch dictionary DAWGs
        run: |
          mkdir -p "${GITHUB_WORKSPACE}/dawg"
          curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
          tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
          ls -la "${GITHUB_WORKSPACE}/dawg"

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version-file: go.work
          cache: true

      - name: Integration tests
        # -count=1 disables the cache; -p=1 -parallel=1 keeps the container-backed
        # tests serial; the 15-minute timeout bounds a stuck container pull.
        env:
          BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
        run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...

  ui:
    needs: changes
    if: ${{ needs.changes.outputs.ui == 'true' }}
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
        working-directory: ui
    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Install pnpm
        run: npm install -g pnpm@11.0.9

      - name: Install deps
        run: pnpm install --frozen-lockfile

      - name: Type-check
        run: pnpm run check

      - name: Unit tests
        run: pnpm run test:unit

      - name: Build
        run: pnpm run build

      - name: Bundle-size budget
        run: node scripts/bundle-size.mjs

      - name: Install Playwright browsers
        run: pnpm exec playwright install chromium webkit
        timeout-minutes: 5

      - name: E2E smoke (mock)
        run: pnpm run test:e2e
        timeout-minutes: 5

  # gate is the single branch-protection required check. It always runs and passes
  # only when each upstream job succeeded or was skipped (a path-filtered no-op),
  # failing the merge if any actually failed or was cancelled.
  gate:
    needs: [unit, integration, ui]
    if: always()
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
    steps:
      - name: Aggregate required checks
        run: |
          fail=
          for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do
            name="${r%%:*}"; res="${r#*:}"
            echo "$name = $res"
            case "$res" in
              success|skipped) ;;
              *) echo "::error::$name=$res"; fail=1 ;;
            esac
          done
          [ -z "$fail" ] || { echo "one or more required jobs failed"; exit 1; }
          echo "all required jobs passed or were skipped"

  deploy:
    # Auto test-deploy on a PR into development and on the push that merges it.
    # A PR into master is test-only (this job is skipped); prod deploy is manual.
    # Gates on `gate` (so a real test failure blocks the deploy) but runs even when
    # some test jobs were path-skipped.
    needs: [gate]
    if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
    env:
      NO_COLOR: "1"
      DOCKER_CLI_HINTS: "false"
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Build and (re)deploy the test contour
        working-directory: deploy
        env:
          # Sensitive values -> secrets; non-sensitive -> variables. The compose
          # interpolates these unprefixed names (see deploy/.env.example).
          POSTGRES_PASSWORD: ${{ secrets.TEST_POSTGRES_PASSWORD }}
          AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
          GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
          GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
          TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
          TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
          GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
          GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
          CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
          TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
          TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
          TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
          TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
          # The promo button reuses the UI's Mini App link variable.
          TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
          # The test contour always uses Telegram's test environment — pinned here,
          # not an operator variable. The prod workflow leaves it false.
          TELEGRAM_TEST_ENV: "true"
          VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
          VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
          VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
          VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
          # Unset vars render empty -> the compose ":-" defaults apply.
          POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
          POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
          DICT_VERSION: ${{ vars.TEST_DICT_VERSION }}
          LOG_LEVEL: ${{ vars.TEST_LOG_LEVEL }}
        run: |
          # Seed the config files to a stable host path. The runner checks out into
          # an ephemeral act workspace that is removed after the job, which would
          # dangle the compose config bind mounts in the long-lived containers
          # (e.g. Grafana then logs "no such file or directory"). Bind from a stable
          # dir instead (mirrors ../galaxy-game's $HOME/.galaxy-dev/monitoring).
          conf="$HOME/.scrabble-deploy"
          rm -rf "$conf"
          mkdir -p "$conf"
          cp -r caddy otelcol prometheus tempo grafana "$conf"/
          export SCRABBLE_CONFIG_DIR="$conf"
          # Bot-link mTLS material for the test contour: a private CA + gateway/bot
          # leaves (CN=gateway, the service name the bot dials). Prod supplies these
          # from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
          # together so they always share the fresh CA (see deploy/gen-certs.sh).
          bash "$GITHUB_WORKSPACE/deploy/gen-certs.sh" "$conf/certs"
          # App version for the About screen: the git tag if present, else the short SHA
          # (the test checkout is shallow/untagged, so this is the SHA here — fine).
          export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
          # The telegram-local profile brings the bot + its VPN sidecar; prod runs the
          # bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
          # main host omits both. Without the profile they would not start here.
          docker compose --ansi never --profile telegram-local build --progress plain
          docker compose --ansi never --profile telegram-local up -d --remove-orphans
          # The config-only services bind-mount the reseeded config dir. A plain `up -d`
          # leaves them on the previous bind mount (the dir was rm'd + recreated), so a
          # changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
          # pick up the fresh config.
          docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana

      - name: Probe the landing, gateway and backend
        run: |
          set -u
          # Three probes. "/" is the static landing container and "/app/" the
          # gateway-served SPA shell (both through the contour caddy on the edge net).
          # The backend /readyz is probed on the internal net as well: the caddy probes
          # are blind to a crash-looping backend (the landing is static and the SPA
          # shell is served without it), which let a bad deploy go green while the
          # backend was down — so check it directly here.
          for i in $(seq 1 20); do
            if docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/ &&
               docker run --rm --network edge alpine:3.20 wget -q -T 5 -O /dev/null http://scrabble/app/ &&
               docker run --rm --network scrabble-internal alpine:3.20 wget -q -T 5 -O /dev/null http://backend:8080/readyz; then
              echo "healthy: GET / (landing) + /app/ (gateway) + backend /readyz"
              exit 0
            fi
            sleep 3
          done
          echo "probe failed; recent landing + gateway + backend logs:"
          docker logs --tail 50 scrabble-landing || true
          docker logs --tail 50 scrabble-gateway || true
          docker logs --tail 50 scrabble-backend || true
          exit 1

      - name: Probe the Telegram validator and bot liveness
        run: |
          set -u
          # The gateway/backend probes cannot see a crash-looping validator or bot
          # (the validator answers only internal gRPC; the bot long-polls + egresses
          # through the VPN sidecar with no public ingress). Inspect the containers
          # directly: each must be running, not restarting, with a stable restart
          # count. A grace period lets the VPN handshake and the bot-link dial settle.
          sleep 20
          for name in scrabble-telegram-validator scrabble-telegram-bot; do
            ok=
            for i in $(seq 1 20); do
              status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo missing)"
              restarting="$(docker inspect -f '{{.State.Restarting}}' "$name" 2>/dev/null || echo true)"
              if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
                c1="$(docker inspect -f '{{.RestartCount}}' "$name")"
                sleep 5
                c2="$(docker inspect -f '{{.RestartCount}}' "$name")"
                if [ "$c1" = "$c2" ]; then
                  echo "$name healthy: status=$status restarts=$c2"
                  ok=1
                  break
                fi
                echo "$name still restarting ($c1 -> $c2); waiting"
              fi
              sleep 3
            done
            if [ -z "$ok" ]; then
              echo "$name not healthy; recent logs:"
              docker logs --tail 80 "$name" || true
              exit 1
            fi
          done

      - name: Prune dangling images
        if: always()
        run: docker image prune -f
