# CI/CD for the dezky monorepo (Gitea Actions). Installs deps and typechecks
# each app/service independently — the repo is NOT a single pnpm workspace yet,
# so every app has its own lockfile and is built from its own directory.
#
# Change-gated: the `changes` job diffs the push range and every later job
# only runs for the apps that actually changed. A docs-only push runs nothing;
# an operator-only push typechecks, builds and deploys just operator. Editing
# this workflow file counts as "everything changed".
#
# Deploys are intentionally asymmetric:
#  - app change   → `kubectl set image deploy/<app> …:<sha>` (only that app
#                    rolls; nothing else is touched)
#  - manifest change (fleet/apps/**) → `kubectl apply -k` with every app's
#                    image pinned to what is LIVE right now (or the fresh sha
#                    for apps built in the same push) — a plain apply would
#                    reset unchanged apps to :latest.
name: ci

on:
  push:
    branches: [main]
  pull_request:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      portal: ${{ steps.diff.outputs.portal }}
      booking: ${{ steps.diff.outputs.booking }}
      operator: ${{ steps.diff.outputs.operator }}
      website: ${{ steps.diff.outputs.website }}
      platform_api: ${{ steps.diff.outputs.platform_api }}
      infra: ${{ steps.diff.outputs.infra }}
      deploy_any: ${{ steps.diff.outputs.deploy_any }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Detect changed paths
        id: diff
        run: |
          # Push range: event.before..sha. On the first push to a branch (or a
          # force-push where `before` is gone) fall back to "everything".
          BEFORE="${{ github.event.before }}"
          if [ -z "$BEFORE" ] || ! git cat-file -e "$BEFORE" 2>/dev/null || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
            FILES="ALL"
          else
            FILES=$(git diff --name-only "$BEFORE" "${{ github.sha }}")
          fi
          # PRs: diff against the target branch instead.
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            git fetch origin "${{ github.base_ref }}" --depth=1
            FILES=$(git diff --name-only "origin/${{ github.base_ref }}" "${{ github.sha }}")
          fi
          echo "changed files:"; echo "$FILES"
          hit() { [ "$FILES" = "ALL" ] || echo "$FILES" | grep -qE "$1"; }
          # The workflow itself changing re-runs everything.
          if hit '^\.gitea/workflows/ci\.yml$'; then FILES="ALL"; fi
          out() { echo "$1=$2" >> "$GITHUB_OUTPUT"; echo "  $1=$2"; }
          hit '^apps/portal/'            && out portal true        || out portal false
          hit '^apps/booking/'           && out booking true       || out booking false
          hit '^apps/operator/'          && out operator true      || out operator false
          hit '^apps/website/'           && out website true       || out website false
          hit '^services/platform-api/'  && out platform_api true  || out platform_api false
          hit '^infrastructure/production/fleet/apps/' && out infra true || out infra false
          if hit '^apps/(portal|booking|operator)/|^services/platform-api/|^infrastructure/production/fleet/apps/'; then
            out deploy_any true
          else
            out deploy_any false
          fi

  # ── Typecheck (one job per app so each is individually skippable) ─────────
  # Node comes from the runner image (catthehacker ships node 24) — NOT
  # actions/setup-node, whose shared tool-cache races across concurrent jobs
  # ("node: Text file busy"). corepack (bundled with node) reads each app's
  # own packageManager — what this per-app monorepo (no root package.json) needs.
  tc_portal:
    runs-on: ubuntu-latest
    needs: [changes]
    if: needs.changes.outputs.portal == 'true'
    defaults: { run: { working-directory: apps/portal } }
    steps:
      - uses: actions/checkout@v4
      - name: Install + typecheck
        run: |
          corepack enable
          pnpm install --frozen-lockfile
          pnpm typecheck

  tc_booking:
    runs-on: ubuntu-latest
    needs: [changes]
    if: needs.changes.outputs.booking == 'true'
    defaults: { run: { working-directory: apps/booking } }
    steps:
      - uses: actions/checkout@v4
      - name: Install + typecheck
        run: |
          corepack enable
          pnpm install --frozen-lockfile
          pnpm typecheck

  tc_operator:
    runs-on: ubuntu-latest
    needs: [changes]
    if: needs.changes.outputs.operator == 'true'
    defaults: { run: { working-directory: apps/operator } }
    steps:
      - uses: actions/checkout@v4
      - name: Install + typecheck
        run: |
          corepack enable
          pnpm install --frozen-lockfile
          pnpm typecheck

  tc_website:
    runs-on: ubuntu-latest
    needs: [changes]
    if: needs.changes.outputs.website == 'true'
    defaults: { run: { working-directory: apps/website } }
    steps:
      - uses: actions/checkout@v4
      - name: Install + typecheck
        run: |
          corepack enable
          pnpm install --frozen-lockfile
          pnpm typecheck

  tc_platform_api:
    runs-on: ubuntu-latest
    needs: [changes]
    if: needs.changes.outputs.platform_api == 'true'
    defaults: { run: { working-directory: services/platform-api } }
    steps:
      - uses: actions/checkout@v4
      - name: Install + typecheck
        run: |
          corepack enable
          pnpm install --frozen-lockfile
          pnpm typecheck

  test_platform_api:
    runs-on: ubuntu-latest
    needs: [changes]
    if: needs.changes.outputs.platform_api == 'true'
    defaults: { run: { working-directory: services/platform-api } }
    steps:
      - uses: actions/checkout@v4
      - name: Install + test
        run: |
          corepack enable
          pnpm install --frozen-lockfile
          pnpm test

  # ── Build + push images (only on main, only for changed apps) ─────────────
  # Uses REGISTRY_TOKEN (PAT with package read+write) — the per-job
  # GITHUB_TOKEN is not accepted by the registry — and the dind sidecar for
  # docker build. Tags: :latest for humans, :<sha> for the deploy pin.
  build_portal:
    runs-on: ubuntu-latest
    needs: [changes, tc_portal]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.portal == 'true'
    steps:
      - uses: actions/checkout@v4
      - name: Build + push
        run: |
          echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin
          IMG=git.lastcloud.io/ronnibaslund/dezky/portal
          docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" apps/portal
          docker push "$IMG:latest"
          docker push "$IMG:${{ github.sha }}"

  build_booking:
    runs-on: ubuntu-latest
    needs: [changes, tc_booking]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.booking == 'true'
    steps:
      - uses: actions/checkout@v4
      - name: Build + push
        run: |
          echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin
          IMG=git.lastcloud.io/ronnibaslund/dezky/booking
          docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" apps/booking
          docker push "$IMG:latest"
          docker push "$IMG:${{ github.sha }}"

  build_operator:
    runs-on: ubuntu-latest
    needs: [changes, tc_operator]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.operator == 'true'
    steps:
      - uses: actions/checkout@v4
      - name: Build + push
        run: |
          echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin
          IMG=git.lastcloud.io/ronnibaslund/dezky/operator
          docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" apps/operator
          docker push "$IMG:latest"
          docker push "$IMG:${{ github.sha }}"

  build_platform_api:
    runs-on: ubuntu-latest
    needs: [changes, tc_platform_api, test_platform_api]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.platform_api == 'true'
    steps:
      - uses: actions/checkout@v4
      - name: Build + push
        run: |
          echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin
          IMG=git.lastcloud.io/ronnibaslund/dezky/platform-api
          docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" services/platform-api
          docker push "$IMG:latest"
          docker push "$IMG:${{ github.sha }}"

  # ── Deploy (only what changed) ─────────────────────────────────────────────
  # always() so skipped builds don't block it; the explicit result checks make
  # any FAILED build (or its typecheck/test, transitively) abort the deploy.
  deploy:
    runs-on: ubuntu-latest
    needs: [changes, build_portal, build_booking, build_operator, build_platform_api]
    if: >-
      always() &&
      github.event_name == 'push' && github.ref == 'refs/heads/main' &&
      needs.changes.outputs.deploy_any == 'true' &&
      needs.build_portal.result != 'failure' && needs.build_portal.result != 'cancelled' &&
      needs.build_booking.result != 'failure' && needs.build_booking.result != 'cancelled' &&
      needs.build_operator.result != 'failure' && needs.build_operator.result != 'cancelled' &&
      needs.build_platform_api.result != 'failure' && needs.build_platform_api.result != 'cancelled'
    steps:
      - uses: actions/checkout@v4

      - name: Install kubectl + kustomize
        run: |
          curl -fsSLo /usr/local/bin/kubectl https://dl.k8s.io/release/v1.33.4/bin/linux/amd64/kubectl
          chmod +x /usr/local/bin/kubectl
          curl -fsSL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.6.0/kustomize_v5.6.0_linux_amd64.tar.gz \
            | tar -xz -C /usr/local/bin kustomize

      - name: Deploy to k3s
        env:
          KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
          # Build results, not just change flags: an app whose typecheck/test
          # failed has result=skipped on its build — its image for this SHA
          # does NOT exist, and pinning it would ImagePullBackOff the app.
          BUILT_PORTAL: ${{ needs.build_portal.result }}
          BUILT_BOOKING: ${{ needs.build_booking.result }}
          BUILT_OPERATOR: ${{ needs.build_operator.result }}
          BUILT_PLATFORM_API: ${{ needs.build_platform_api.result }}
        run: |
          export KUBECONFIG=/tmp/kubeconfig
          echo "$KUBECONFIG_B64" | base64 -d > "$KUBECONFIG"
          REG=git.lastcloud.io/ronnibaslund/dezky
          SHA=${{ github.sha }}
          declare -A CHANGED=(
            [portal]=${{ needs.changes.outputs.portal }}
            [booking]=${{ needs.changes.outputs.booking }}
            [operator]=${{ needs.changes.outputs.operator }}
            [platform-api]=${{ needs.changes.outputs.platform_api }}
          )
          declare -A BUILT=(
            [portal]=$BUILT_PORTAL
            [booking]=$BUILT_BOOKING
            [operator]=$BUILT_OPERATOR
            [platform-api]=$BUILT_PLATFORM_API
          )
          # Deployable = changed AND its image was actually built this run.
          deployable() { [ "${CHANGED[$1]}" = "true" ] && [ "${BUILT[$1]}" = "success" ]; }
          ROLL=""
          if [ "${{ needs.changes.outputs.infra }}" = "true" ]; then
            # Manifest change: apply the whole kustomization, but pin every
            # app to its CURRENT live image (or this push's sha if it was
            # rebuilt) so the apply never resets anything to :latest.
            cd infrastructure/production/fleet/apps
            for app in portal booking platform-api operator; do
              if deployable "$app"; then
                IMG="$REG/$app:$SHA"
              else
                IMG=$(kubectl -n dezky-apps get deploy "$app" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "$REG/$app:latest")
              fi
              kustomize edit set image "$REG/$app=$IMG"
            done
            kubectl apply -k .
            ROLL="portal booking platform-api operator"
          else
            # App-only change: roll just the changed apps; nothing else moves.
            for app in portal booking platform-api operator; do
              if deployable "$app"; then
                kubectl -n dezky-apps set image "deploy/$app" "$app=$REG/$app:$SHA"
                ROLL="$ROLL $app"
              fi
            done
          fi
          for app in $ROLL; do
            kubectl -n dezky-apps rollout status "deploy/$app" --timeout=180s
          done
