# CI runs first; build only proceeds if all checks pass.
#
# Push to dev:       typecheck + lint + test + build :dev + :<sha>
# Tag v* (release):  typecheck + lint + test + build :latest + :<sha> + :<version>
#
# main pushes are NOT gated here: a merge to main only happens after
# dev has already passed CI, and the release tag is the sole trigger
# for a production image. Re-running CI on the merge commit just burns
# runner time without changing the outcome.
#
# To cut a release:
#   Create a release via the Forgejo UI on main with a v* tag name.
#   The tag push triggers this workflow; build job pushes :latest + :<version>.
#
# PRs aren't triggered on purpose — this is a solo dev→main flow, so
# gating on branch push is already enough.
#
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
# honor `on.push.branches` as a filter — merge commits landing on main
# still trigger the workflow, producing redundant runs on the same SHA
# that was already gated on dev. Every job therefore repeats the ref
# check so main pushes trigger the workflow but every job skips
# immediately (no runner time, no duplicate work).
#
# Required secrets (repo → Settings → Secrets → Actions):
#   REGISTRY_USER   — your Forgejo username
#   REGISTRY_TOKEN  — Forgejo PAT with write:packages scope
name: CI & Build

on:
  push:
    branches: [dev]
    tags: ["v*"]
    paths:
      - "src/**"
      - "frontend/**"
      - "tests/**"
      - "pyproject.toml"
      - "alembic/**"
      - "alembic.ini"
      - "Dockerfile"
      - "assets/**"
      - "fable-mcp/**"
      - ".forgejo/workflows/ci.yml"
  # Manual trigger from the Forgejo Actions UI. Useful when an image has
  # been built but the deployment didn't pick it up, or when re-running
  # against the same source produces different upstream behaviour
  # (e.g. a transient HF download flake during the voice-bundle step).
  workflow_dispatch: {}

# Cancel older runs on the same branch when a newer push lands. Tag runs
# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and
# are never cancelled, so a release build can't kill itself mid-flight.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}

# Least-privilege default. Jobs that need more (build pushes to the
# registry) upgrade explicitly.
permissions:
  contents: read

env:
  REGISTRY: git.fabledsword.com
  IMAGE: git.fabledsword.com/bvandeusen/fabledscribe

jobs:
  typecheck:
    name: TypeScript typecheck
    # Skip on main merge-commit pushes — see workflow header comment.
    if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
    runs-on: python-ci
    container:
      image: git.fabledsword.com/bvandeusen/ci-python:3.14
    steps:
      - uses: actions/checkout@v6

      - name: Cache npm download cache
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
          restore-keys: npm-cache-

      - name: Install dependencies
        run: npm ci
        working-directory: frontend

      - name: Type check
        run: npx vue-tsc --noEmit
        working-directory: frontend

  lint:
    name: Python lint
    if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
    runs-on: python-ci
    container:
      image: git.fabledsword.com/bvandeusen/ci-python:3.14
    steps:
      - uses: actions/checkout@v6

      # ruff is pre-installed in the ci-python image — no install
      # step needed, lint runs in ~2s.
      - name: Lint
        run: ruff check src/

  test:
    name: Python tests
    if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
    runs-on: python-ci
    container:
      image: git.fabledsword.com/bvandeusen/ci-python:3.14
    steps:
      - uses: actions/checkout@v6

      - name: Cache uv packages
        uses: actions/cache@v4
        with:
          path: ~/.cache/uv
          key: uv-${{ hashFiles('pyproject.toml') }}
          restore-keys: uv-

      - name: Create virtual environment
        run: uv venv /opt/venv

      - name: Install package with dev deps
        run: |
          # http-ece doesn't declare setuptools as a build dep, and uv
          # creates bare venvs without it. Install setuptools first so
          # --no-build-isolation can find it.
          uv pip install --python /opt/venv/bin/python setuptools wheel
          uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece
          uv pip install --python /opt/venv/bin/python -e ".[dev]"

      - name: Run tests
        run: /opt/venv/bin/python -m pytest tests/ -q

  build:
    name: Build & push image
    needs: [typecheck, lint, test]
    # Build on dev branch pushes and version tag pushes only.
    # Mirrors the ref guard on the gate jobs above — main merge-commit
    # pushes skip here too, so no production image is ever built from a
    # raw main push (only from the v* tag the release creates).
    if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
    runs-on: python-ci
    container:
      image: git.fabledsword.com/bvandeusen/ci-python:3.14
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v6

      - name: Generate image tags and version
        id: tags
        # POSIX `case` instead of bash `[[ ]]` because act_runner invokes
        # `sh -e` (dash on the ci-python:3.14 image, which has no bash on
        # the default PATH for /bin/sh). Previous `[[ ]]` form failed
        # silently — only the SHA tag got appended, so :dev / :latest
        # never updated in the registry and the deployed stack kept
        # pulling stale images. Verified via `[[: not found` lines in
        # the runner log on commit 2a374d9.
        run: |
          TAGS="${{ env.IMAGE }}:${{ github.sha }}"
          BUILD_VERSION="dev"
          case "${{ github.ref }}" in
            refs/heads/dev)
              TAGS="$TAGS,${{ env.IMAGE }}:dev"
              ;;
            refs/tags/*)
              TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
              BUILD_VERSION="${{ github.ref_name }}"
              ;;
          esac
          echo "value=$TAGS" >> $GITHUB_OUTPUT
          echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT

      - name: Free disk space
        # Self-hosted runner housekeeping. Two-step cleanup:
        # 1. Prune dangling containers/images globally (stops the runner
        #    from accumulating cruft from past failed builds).
        # 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip
        #    mount cache survives but old intermediate layers don't
        #    accumulate indefinitely.
        run: |
          docker system prune -af || true
          docker builder prune --keep-storage 5g -f || true

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Log in to Forgejo registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          provenance: false
          tags: ${{ steps.tags.outputs.value }}
          build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
          # Registry-backed layer cache. Pull from :cache to prime
          # BuildKit, push updated layers back to :cache so the next
          # build starts warm even if the runner's local cache was
          # pruned. `mode=max` exports all intermediate layers, not
          # just the final image, which is what gives the ~80% speedup.
          cache-from: type=registry,ref=${{ env.IMAGE }}:cache
          cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true
