name: PR Gate

# Pre-merge gate — the authoritative green/red signal for every PR before it
# lands on main. Free tier has no branch protection, so merge enforcement lives
# in scripts/safe-merge.mjs (refuses to merge a PR whose "PR Gate" is not green);
# this workflow produces the signal that guard reads.

on:
  pull_request:
    branches: [main]

concurrency:
  group: pr-gate-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  gate:
    name: Full gate
    # Self-hosted runner carries repo secrets and local state; fork PRs must never run here.
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: [self-hosted, linux, x64, platform-gate]
    timeout-minutes: 90
    steps:
      - name: Checkout
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          fetch-depth: 0
          fetch-tags: true
      - name: Setup Node
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: 26
      - name: Setup pnpm
        uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
        with:
          # The default ~/setup-pnpm is shared by every runner on the box, so a
          # concurrent job replaces the pnpm binary turbo is mid-spawn on.
          dest: ${{ runner.temp }}/setup-pnpm
      - name: Fetch base branch
        env:
          BASE_REF: ${{ github.event.pull_request.base.ref }}
        run: git fetch --tags origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}"
      - name: Install
        # pnpm derives its store from PNPM_HOME, which now lives in the
        # per-job _temp dir — without this the store is wiped every run.
        run: pnpm install --frozen-lockfile --store-dir "$RUNNER_TOOL_CACHE/pnpm-store"
      - name: Install Playwright Chromium
        run: pnpm --filter @app/consumer exec playwright install chromium
      - name: Detect package source changes
        id: package_src_changes
        env:
          BASE_REF: ${{ github.event.pull_request.base.ref }}
        run: |
          changed="$(git diff --name-only "origin/${BASE_REF}...HEAD" -- 'packages/*/src/**')"
          if [ -n "$changed" ]; then
            echo "has_changes=true" >> "$GITHUB_OUTPUT"
            printf '%s\n' "$changed"
          else
            echo "has_changes=false" >> "$GITHUB_OUTPUT"
          fi
      - name: Enforce changesets for package source changes
        if: steps.package_src_changes.outputs.has_changes == 'true'
        run: |
          pnpm changeset status --output="${RUNNER_TEMP}/changeset-status.json" --since "origin/${BASE_REF}"
          node <<'NODE'
          const fs = require('fs')
          const path = require('path')
          const { execFileSync } = require('child_process')
          const baseRef = process.env.BASE_REF
          const status = JSON.parse(
            fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'changeset-status.json'), 'utf8'),
          )
          const changedFiles = execFileSync(
            'git',
            ['diff', '--name-only', `origin/${baseRef}...HEAD`, '--', 'packages/*/src/**', '.changeset/*.md'],
            { encoding: 'utf8' },
          )
            .trim()
            .split('\n')
            .filter(Boolean)

          const changedPackages = new Set()
          for (const file of changedFiles) {
            const match = /^packages\/([^/]+)\/src\//.exec(file)
            if (match) {
              const manifest = JSON.parse(
                fs.readFileSync(`packages/${match[1]}/package.json`, 'utf8'),
              )
              changedPackages.add(manifest.name)
            }
          }

          const releaseNames = new Set((status.releases || []).map((release) => release.name))
          const changedChangesets = changedFiles.filter((file) => file.startsWith('.changeset/') && file !== '.changeset/README.md')
          const hasEmptyChangeset = changedChangesets.some((file) => {
            const source = fs.readFileSync(file, 'utf8')
            const match = /^---\r?\n([\s\S]*?)\r?\n?---/.exec(source)
            if (!match) {
              return false
            }
            return match[1].trim().length === 0
          })

          const uncovered = [...changedPackages].filter((pkgName) => !releaseNames.has(pkgName))
          if (uncovered.length > 0 && !hasEmptyChangeset) {
            console.error(
              `Changeset coverage missing for: ${uncovered.join(', ')}. Add a package changeset or an explicit empty changeset.`,
            )
            process.exit(1)
          }

          console.log(
            uncovered.length === 0
              ? `Changeset coverage PASS: ${[...changedPackages].join(', ')}`
              : `Changeset coverage PASS via empty changeset for: ${uncovered.join(', ')}`,
          )
          NODE
        env:
          BASE_REF: ${{ github.event.pull_request.base.ref }}
      - name: Gitleaks
        run: |
          version='8.24.2'
          sha256='fa0500f6b7e41d28791ebc680f5dd9899cd42b58629218a5f041efa899151a8e'
          archive="gitleaks_${version#v}_linux_x64.tar.gz"
          curl -fsSL -o /tmp/gitleaks.tgz "https://github.com/gitleaks/gitleaks/releases/download/v${version}/${archive}"
          echo "${sha256}  /tmp/gitleaks.tgz" | sha256sum -c -
          tar -xzf /tmp/gitleaks.tgz -C /tmp
          /tmp/gitleaks detect --source . --no-git --redact --verbose
      - name: Slopgate changed files
        run: |
          files=$(git diff --name-only "${{ github.event.pull_request.base.sha }}"...HEAD -- '*.ts' '*.tsx' '*.astro')
          [ -z "$files" ] && exit 0
          for file in $files; do
            pnpm exec slopgate --file "$file" --config .slopgate/config.toml || exit 1
          done
      - name: Gate
        run: pnpm gate
        env:
          # Runner shares the dev box. Widen the core budget (default reserve 8)
          # but rely on nice/build.slice in cpu-limit.sh to keep the machine
          # responsive.
          BUILD_CPU_RESERVE: "2"
          # CI runs tests with wall-clock timeouts; under SCHED_IDLE a busy box
          # starves them into spurious failures (PGLite boots > 30s). Idle
          # scheduling stays on for interactive local runs only.
          BUILD_SCHED_IDLE: "0"
          # Same box as dev — use the machine-wide turbo cache (ACL-shared with
          # the dev user). Work already computed by the local pre-push replays
          # here instead of recomputing; no tar/untar of actions/cache.
          TURBO_CACHE_DIR: /var/cache/platform-turbo
