name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Default token only gets `contents: read` on PRs from forks; the
# dorny/paths-filter action needs to list PR files via the GitHub
# API, which requires `pull-requests: read`. Without this the
# "Detect Changes" job dies with "Bad credentials" and every
# downstream language CI is skipped.
permissions:
  contents: read
  pull-requests: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  changes:
    name: Detect Changes
    runs-on: ubuntu-latest
    outputs:
      go-services: ${{ steps.filter.outputs.go-services }}
      migrations: ${{ steps.filter.outputs.migrations }}
      tracking: ${{ steps.filter.outputs.tracking }}
      realtime: ${{ steps.filter.outputs.realtime }}
      web: ${{ steps.filter.outputs.web }}
      admin: ${{ steps.filter.outputs.admin }}
      site: ${{ steps.filter.outputs.site }}
      make: ${{ steps.filter.outputs.make }}
      ios: ${{ steps.filter.outputs.ios }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            go-services:
              - 'go.mod'
              - 'go.sum'
              - 'internal/**'
              - 'cmd/**'
            migrations:
              - 'internal/infrastructure/db/migrations/**'
              - 'scripts/check-migrations.sh'
            tracking:
              - 'tracking/**'
            realtime:
              - 'realtime/**'
              - 'deploy/docker/realtime.Dockerfile'
            web:
              - 'web/**'
            admin:
              - 'admin/**'
            site:
              - 'site/**'
            make:
              - 'integrations/make/**'
            ios:
              - 'ios/**'

  migrations-ci:
    name: Migrations
    needs: changes
    if: needs.changes.outputs.migrations == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Check migration versions
        # Runs on the PR's merge commit, so it sees main's versions too. That
        # only holds while the branch is current, so this needs "Require
        # branches to be up to date before merging" in branch protection.
        run: ./scripts/check-migrations.sh

  go-ci:
    name: Go CI
    needs: changes
    if: needs.changes.outputs.go-services == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Set up protoc
        uses: arduino/setup-protoc@v3
        with:
          version: "33.x"
          repo-token: ${{ github.token }}

      - name: Install required Go tools
        run: make setup-tools

      - name: Run golangci-lint
        run: make lint

      - name: Verify protobuf files are up to date
        run: make check-proto

      - name: Run tests with coverage
        run: |
          go test -race -coverprofile=coverage.out -covermode=atomic ./...

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: coverage.out
          flags: go
          fail_ci_if_error: false

  web-ci:
    name: Web CI
    needs: changes
    if: needs.changes.outputs.web == 'true'
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: web
    steps:
      - uses: actions/checkout@v4

      - uses: ./.github/actions/setup-pnpm
        with:
          working-directory: web

      - name: Lint
        run: pnpm lint

      - name: Typecheck
        run: pnpm typecheck

      - name: Run tests
        run: pnpm test:run

      - name: Build
        run: pnpm build

  admin-ci:
    name: Admin CI
    needs: changes
    if: needs.changes.outputs.admin == 'true'
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: admin
    steps:
      - uses: actions/checkout@v4

      - uses: ./.github/actions/setup-pnpm
        with:
          working-directory: admin

      - name: Lint
        run: pnpm lint

      - name: Typecheck
        run: pnpm typecheck

      - name: Run tests
        run: pnpm test:run

      - name: Build
        run: pnpm build

  site-ci:
    name: Site CI
    needs: changes
    if: needs.changes.outputs.site == 'true'
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: site
    steps:
      - uses: actions/checkout@v4

      - uses: ./.github/actions/setup-pnpm
        with:
          working-directory: site
          node-version: "22"

      - name: Build
        # Astro project; build catches type errors, broken imports,
        # missing assets. No separate lint/typecheck script is wired
        # in package.json today.
        run: pnpm build

  make-ci:
    name: Make App CI
    needs: changes
    if: needs.changes.outputs.make == 'true'
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: integrations/make
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Validate Make app
        # IML is interpreted by Make (no compile step); validate.mjs checks the
        # manifest, JSON validity, code-file references, and component enums.
        run: node scripts/validate.mjs

  rust-ci:
    name: Rust CI
    needs: changes
    if: needs.changes.outputs.tracking == 'true'
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: tracking
    steps:
      - uses: actions/checkout@v4

      # The default tracking build is NATS-only (pure Rust, rustls TLS), so it
      # needs no librdkafka / libcurl / libsasl system headers. Those are only
      # required for the optional `--features kafka` build.

      - name: Set up Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy

      - name: Cache Cargo
        uses: Swatinem/rust-cache@v2
        with:
          workspaces: tracking

      - name: Check formatting
        run: cargo fmt --check

      - name: Run Clippy
        run: cargo clippy -- -D warnings

      - name: Run tests
        run: cargo test

  elixir-ci:
    name: Elixir CI
    needs: changes
    if: needs.changes.outputs.realtime == 'true'
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: realtime
    env:
      MIX_ENV: test
    steps:
      - uses: actions/checkout@v4

      - name: Set up Elixir
        # mix.exs requires `elixir: "~> 1.18"`. Bumped here to match.
        # Phoenix 1.8 + plug 1.19 also expect a recent OTP.
        uses: erlef/setup-beam@v1
        with:
          elixir-version: "1.18"
          otp-version: "27"

      - name: Cache deps
        uses: actions/cache@v4
        with:
          path: |
            realtime/deps
            realtime/_build
          key: ${{ runner.os }}-mix-${{ hashFiles('realtime/mix.lock') }}
          restore-keys: ${{ runner.os }}-mix-

      - name: Install dependencies
        run: mix deps.get

      - name: Check formatting
        run: mix format --check-formatted

      - name: Run Credo
        # credo isn't in mix.exs yet; skip when the binary isn't
        # available so CI doesn't false-fail. Re-enable once it's
        # added as a dev dep.
        run: mix help credo > /dev/null 2>&1 && mix credo --strict || echo "credo not installed; skipping"

      - name: Compile
        # Don't fail the build on transitive warnings — jose/CAStore
        # and a couple of our own files emit deprecation warnings on
        # Elixir 1.18 that aren't fixable without forking deps.
        # Real compile errors still fail the step.
        run: mix compile

      - name: Run tests
        run: mix test

  ios-ci:
    name: iOS CI
    needs: changes
    if: needs.changes.outputs.ios == 'true'
    # macOS minutes are 10x Linux; the paths filter keeps this off
    # non-iOS PRs. The app needs the iOS 26 SDK and the Icon Composer
    # .icon app icon, so it must be a macos-26 image.
    runs-on: macos-26
    steps:
      - uses: actions/checkout@v4

      - name: Select newest Xcode
        run: |
          sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)"
          xcodebuild -version

      - name: Build (iOS Simulator, no signing)
        # arm64 only: the generic destination otherwise also builds the
        # x86_64 slice, doubling build time for a compile check.
        run: |
          set -o pipefail
          xcodebuild build \
            -project ios/Warmbly.xcodeproj \
            -scheme Warmbly \
            -destination 'generic/platform=iOS Simulator' \
            ARCHS=arm64 ONLY_ACTIVE_ARCH=YES \
            CODE_SIGNING_ALLOWED=NO

  # Validate the web/admin production Dockerfiles (build + nginx config check)
  # without pushing, so a broken image can't reach a release tag unnoticed. The
  # release workflow is the only other place these build, and only on tags.
  frontend-images:
    name: Frontend Image (${{ matrix.service }})
    needs: changes
    if: needs.changes.outputs.web == 'true' || needs.changes.outputs.admin == 'true'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        service: [web, admin]
    steps:
      - uses: actions/checkout@v4

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

      - name: Build image (no push)
        uses: docker/build-push-action@v6
        with:
          context: ./${{ matrix.service }}
          file: ./${{ matrix.service }}/Dockerfile
          push: false
          platforms: linux/amd64
          cache-from: type=gha,scope=ci-${{ matrix.service }}
          cache-to: type=gha,mode=max,scope=ci-${{ matrix.service }}

  # The one job to mark required in branch protection. Skipped jobs pass
  # (their tree did not change); anything failed or cancelled fails it.
  # Dependency scanning lives in security.yml, off the PR path on purpose.
  ci-status:
    name: CI Status
    runs-on: ubuntu-latest
    needs: [changes, migrations-ci, go-ci, web-ci, admin-ci, site-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images]
    if: always()
    steps:
      - name: Check CI status
        if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
        run: exit 1
