name: CI

on:
  pull_request:
    paths:
      - "**.go"
      - "go.mod"
      - "go.sum"
      - "frontend/**"
      - ".nvmrc"
      - "Makefile"
      - ".github/workflows/ci.yml"
  push:
    branches:
      - main
    paths:
      - "**.go"
      - "go.mod"
      - "go.sum"
      - "frontend/**"
      - ".nvmrc"
      - "Makefile"
      - ".github/workflows/ci.yml"

permissions:
  contents: read

jobs:
  go-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
          cache: true
      - name: Stub internal/web/dist for go:embed
        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
      - name: Test
        run: |
          go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
          go test -shuffle=on -count=1 $(cat /tmp/go-packages.txt)

  postgres-durable-first:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: xui_durable
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres -d xui_durable"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    env:
      XUI_DB_TYPE: postgres
      XUI_DB_DSN: "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=xui_durable sslmode=disable"
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
          cache: true
      - name: Stub internal/web/dist for go:embed
        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
      - name: PostgreSQL durable-first tests
        run: |
          set -o pipefail
          go test ./internal/web/service -run 'PostgresCommitFailure' -count=1 -v | tee /tmp/postgres-durable-first.log
          # Count passes rather than assert no SKIP: a renamed or deleted test
          # prints "no tests to run" and exits 0, leaving the step green for nothing.
          passed=$(grep -c -- '--- PASS' /tmp/postgres-durable-first.log || true)
          if [ "$passed" -lt 1 ]; then
            echo "expected at least 1 passing durable-first test, got $passed" >&2
            exit 1
          fi

      - name: PostgreSQL schema and migration tests
        run: |
          set -o pipefail
          go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
          # Both must pass. Counting, not SKIP-matching: renaming either test would
          # otherwise leave this step green while testing nothing.
          passed=$(grep -c -- '--- PASS' /tmp/postgres-schema.log || true)
          if [ "$passed" -lt 2 ]; then
            echo "expected 2 passing PostgreSQL schema tests, got $passed" >&2
            exit 1
          fi

  codegen:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
          cache: true
      - uses: actions/setup-node@v7
        with:
          node-version-file: .nvmrc
      - name: Regenerate schemas, examples and OpenAPI
        run: npm run gen
        working-directory: frontend
      - name: Fail if generated files are stale (run 'npm run gen' and commit)
        run: git diff --exit-code -- frontend/src/generated frontend/public/openapi.json

  govulncheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
          cache: true
      - name: Stub internal/web/dist for go:embed
        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
      - name: Install govulncheck
        run: go install golang.org/x/vuln/cmd/govulncheck@latest
      - name: Run govulncheck
        run: govulncheck ./...

  # Race + shuffle hygiene gate: data races and order-dependent tests fail the build.
  race:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
          cache: true
      - name: Stub internal/web/dist for go:embed
        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
      - name: Race + shuffle
        run: |
          go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
          go test -race -shuffle=on -count=1 $(cat /tmp/go-packages.txt)

  # Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the
  # generated corpus plus 30s of exploration; a crash here is a real input-handling bug.
  fuzz-smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
          cache: true
      - name: Stub internal/web/dist for go:embed
        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
      - name: Fuzz critical parsers (smoke)
        run: |
          go test -run '^$' -fuzz 'FuzzParseLink$' -fuzztime=30s ./internal/util/link/
          go test -run '^$' -fuzz 'FuzzDecodeCertPin$' -fuzztime=30s ./internal/web/runtime/

  golangci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
          cache: true
      - name: Stub internal/web/dist for go:embed
        run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
      - name: golangci-lint
        uses: golangci/golangci-lint-action@v9
        with:
          version: latest

  frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version-file: .nvmrc
          cache: npm
          cache-dependency-path: frontend/package-lock.json
      - name: Install
        run: npm ci
        working-directory: frontend
      - name: Verify generated MSW worker is current
        run: git diff --exit-code -- public/mockServiceWorker.js package-lock.json
        working-directory: frontend
      - name: Lint
        run: npm run lint
        working-directory: frontend
      - name: Format check
        run: npm run format:check
        working-directory: frontend
      - name: Typecheck
        run: npm run typecheck
        working-directory: frontend
      - name: Install Playwright Chromium (Storybook story tests)
        run: npx playwright install --with-deps chromium
        working-directory: frontend
      - name: Test
        run: npm test
        working-directory: frontend
      - name: Build
        run: npm run build
        working-directory: frontend
      - name: Build Storybook
        run: npm run build-storybook
        working-directory: frontend
      - name: Audit
        run: npm audit --omit=dev --audit-level=high
        working-directory: frontend
