name: Sencho CI

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

# Cancel previous runs on the same branch/PR
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

# Least-privilege default for every job.
permissions:
  contents: read

# ---------------------------------------------------------------------------
# Build & Validation (PRs only, skipped for release-please PRs)
# ---------------------------------------------------------------------------
jobs:
  backend:
    name: Backend (Build, Test, Lint)
    runs-on: ubuntu-latest
    timeout-minutes: 10
    # Skip on the release-please bot PR. It only touches version metadata,
    # so the full build+test+lint+audit stack is pure action-minute waste.
    if: >-
      github.event_name == 'pull_request'
      && github.head_ref != 'release-please--branches--main--components--sencho'
    steps:
      - name: Checkout Code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

      - name: Setup Node.js
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: backend/package-lock.json

      - name: Install Dependencies
        working-directory: ./backend
        run: npm ci

      - name: Build (TypeScript)
        working-directory: ./backend
        run: npm run build

      - name: Unit Tests (Vitest)
        working-directory: ./backend
        run: npm test

      - name: Lint (ESLint)
        working-directory: ./backend
        run: npm run lint

      - name: Audit Dependencies
        working-directory: ./backend
        run: npm audit --audit-level=high

      # Hand the freshly-verified dist/ to the E2E job so it does not have to
      # re-run `tsc` on the same source. retention-days is the minimum; this
      # artifact is only needed for the downstream e2e job in the same run.
      - name: Upload backend dist
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
        with:
          name: backend-dist
          path: backend/dist
          retention-days: 1
          if-no-files-found: error

  frontend:
    name: Frontend (Build, Lint)
    runs-on: ubuntu-latest
    timeout-minutes: 10
    # See backend job rationale: skip bot PRs that cannot affect the frontend.
    if: >-
      github.event_name == 'pull_request'
      && github.head_ref != 'release-please--branches--main--components--sencho'
    steps:
      - name: Checkout Code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

      - name: Setup Node.js
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json

      - name: Install Dependencies
        working-directory: ./frontend
        run: npm ci

      - name: Build (Vite/React)
        working-directory: ./frontend
        run: npm run build

      - name: Lint (ESLint)
        working-directory: ./frontend
        run: npm run lint

      - name: Audit Dependencies
        working-directory: ./frontend
        run: npm audit --audit-level=high

  docker-validate:
    name: Docker Build & Scan
    runs-on: ubuntu-latest
    timeout-minutes: 15
    # See backend job rationale.
    if: >-
      github.event_name == 'pull_request'
      && github.head_ref != 'release-please--branches--main--components--sencho'
    steps:
      - name: Checkout Code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

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

      # Computed once per job run. Feeds Dockerfile's APK_CACHE_BUST arg so
      # the `apk upgrade` layer rebuilds at least once per calendar day, even
      # when every other input to the layer is cached. See Dockerfile:115-122
      # for the rationale.
      - name: Compute daily apk cache bust value
        id: apk-bust
        run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"

      - name: Build Docker image (validation only)
        uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
        with:
          context: .
          push: false
          load: true
          tags: sencho:pr-test
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}

      - name: Scan image for vulnerabilities (Trivy)
        # Hard-fails the PR on any HIGH or CRITICAL finding not suppressed by
        # the OpenVEX document at security/vex/sencho.openvex.json (loaded via
        # trivy.yaml). The VEX document is the canonical triage record and is
        # also attached as a cosign attestation on the published image.
        # If a new CVE must be triaged, add a VEX statement with a
        # justification — do NOT recreate .trivyignore.
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: sencho:pr-test
          exit-code: '1'
          severity: 'CRITICAL,HIGH'
          format: 'table'
          trivy-version: 'latest'
          trivy-config: trivy.yaml

      # Mirror of the release-time pre-publish smoke gate in docker-publish.yml.
      # Catches the class of failure where the source-built Docker CLI or
      # Compose plugin compiles cleanly but does not exec at runtime (e.g. -o
      # pointed at a non-main Go package, ABI breakage in a base image bump).
      # The release workflow additionally polls /api/health; PR CI keeps just
      # the binary check to bound job duration without losing the gate.
      - name: Smoke test built image (binaries)
        run: |
          set -euo pipefail
          docker run --rm --entrypoint sh sencho:pr-test -c \
            'docker --version && docker compose version'

  # ---------------------------------------------------------------------------
  # E2E Tests (PRs only, skipped for release-please PRs)
  # ---------------------------------------------------------------------------
  e2e:
    name: E2E Tests (Playwright)
    runs-on: ubuntu-latest
    timeout-minutes: 15
    needs: [backend, frontend]
    # See backend job rationale.
    if: >-
      github.event_name == 'pull_request'
      && github.head_ref != 'release-please--branches--main--components--sencho'
    steps:
      - name: Checkout Code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

      # Reuse the dist/ that the `backend` job produced and verified, instead
      # of running `tsc` a second time against the same source tree. The
      # composite action's `skip-backend-build` input short-circuits its build
      # step when this artifact is already in place.
      - name: Download backend dist
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
        with:
          name: backend-dist
          path: backend/dist

      - name: Start app & install Playwright
        uses: ./.github/actions/start-app
        with:
          skip-backend-build: 'true'

      - name: Run E2E tests
        # `--project=chromium` is explicit because playwright.config.ts also
        # defines a `screenshots` project that captures docs/images/ for
        # manual review. That project must not run in CI: its output would
        # be discarded after the run, wasting minutes per PR. Keep this
        # flag; do not "simplify" back to `npx playwright test`.
        run: npx playwright test --project=chromium

      - name: Upload E2E report and service logs
        if: failure()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
        with:
          name: playwright-report
          path: |
            e2e/report/
            test-results/
            ci-logs/
          retention-days: 7

  sync-docs:
    name: Sync Docs to sencho-docs
    runs-on: ubuntu-latest
    timeout-minutes: 5
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      # This job mirrors docs/ into the sibling sencho-docs repo. The token
      # must therefore be scoped to `sencho-docs`, not the current repo, so
      # we mint an installation token explicitly targeting that repo.
      # Minted unconditionally so the step graph stays simple; the token
      # auto-revokes on post-step even when the gate below is false.
      - name: Generate GitHub App installation token
        id: app-token
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        with:
          app-id: ${{ secrets.APP_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          owner: ${{ github.repository_owner }}
          repositories: sencho-docs
          permission-contents: write

      - name: Checkout Sencho repo
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
        with:
          path: sencho
          # Need the previous commit to diff against for the docs-change gate.
          fetch-depth: 2

      # Skip the clone/rsync/commit work entirely when the push doesn't touch
      # docs/. rsync --delete is a no-op in that case anyway, but spinning up
      # the runner + cloning sencho-docs + computing the diff still burns
      # ~30s of action minutes per non-docs push to main. With multiple
      # non-docs merges per day this adds up fast.
      - name: Detect docs changes
        id: detect
        working-directory: sencho
        run: |
          if git diff --name-only HEAD~1 HEAD -- docs/ | grep -q .; then
            echo "changed=true" >> "$GITHUB_OUTPUT"
          else
            echo "changed=false" >> "$GITHUB_OUTPUT"
            echo "No changes under docs/ in this commit; skipping sync."
          fi

      - name: Clone or init sencho-docs
        if: steps.detect.outputs.changed == 'true'
        env:
          DOCS_TOKEN: ${{ steps.app-token.outputs.token }}
        run: |
          rm -rf sencho-docs

          REMOTE="https://x-access-token:${DOCS_TOKEN}@github.com/studio-saelix/sencho-docs.git"

          if git clone "${REMOTE}" sencho-docs 2>/dev/null; then
            echo "Cloned existing sencho-docs."
          else
            echo "Clone failed (repo is empty or has no default branch) - initializing."
            mkdir -p sencho-docs
            cd sencho-docs
            git init
            git checkout -b main
            git remote add origin "${REMOTE}"
          fi

      - name: Copy /docs into sencho-docs root
        if: steps.detect.outputs.changed == 'true'
        run: rsync -av --delete --exclude='.git' sencho/docs/ sencho-docs/

      - name: Commit and push to sencho-docs
        if: steps.detect.outputs.changed == 'true'
        working-directory: sencho-docs
        run: |
          git config user.email "docs-bot@sencho.io"
          git config user.name "Sencho Docs Bot"
          git add -A
          git commit -m "docs: sync from main@${{ github.sha }}" || echo "No changes to commit"
          git push origin HEAD:main
