# Continuous integration.
#
# Runs on every push and pull request. The job is deliberately one sequence
# rather than a fan-out: this is a small project, the whole thing takes a
# couple of minutes, and a single log is easier to read than five.
#
# What it actually proves, in order of how likely each is to catch something:
#
#   1. Every package typechecks.
#   2. The migration chain applies to a REAL, empty Postgres. This has already
#      caught one migration that Drizzle generated but Postgres refused
#      (a jsonb -> integer cast with no USING clause).
#   3. The seed is idempotent — running it twice leaves the same row counts.
#      This caught a seed that silently duplicated 27 contacts.
#   4. The unit tests pass.
#   5. The server boots against that database and answers.
#   6. The front end builds, and the CSP hash for the inline theme script still
#      matches what the proxy is configured to allow. Editing that script
#      changes its hash, and the failure mode is a silent white flash for
#      dark-mode users rather than an error.
#
# THE CSP HASH IS DUPLICATED IN THREE PLACES: the `expected` constant below,
# `deploy/Caddyfile.example`, and the LIVE Caddyfile on cloud-2. Only the first
# two are checked by anything. The live one is the copy that actually decides
# whether a browser runs the script, and nothing in this repository can see it,
# so changing the script means editing all three by hand — see deploy/README.md.
#
# Shipping is a two-step, and the second step is a human:
#
#   push to main   -> `verify` only. Nothing is published, nothing deploys.
#   tag release-*  -> `verify`, then `publish` pushes the image to the Gitea
#                     registry. The production host notices it and deploys.
#
# So the tag IS the ship decision. No credential on this runner can reach
# cloud-2; the host pulls, the runner never pushes to it.

name: CI

on:
  push:
    branches: [main]
    # A tag push runs the same verification and then, and only then, publishes.
    tags: ['release-*']
  pull_request:

jobs:
  verify:
    runs-on: ubuntu-latest

    # Postgres is started as a step rather than through `services:`, because
    # this runner is configured with `container.network: host`.
    #
    # That single setting explains three failed attempts, and is worth writing
    # down so nobody repeats them:
    #
    #   `services:`                     Service containers are not resolvable by
    #                                   name from a host-networked job, giving
    #                                   "getaddrinfo EAI_AGAIN postgres".
    #   `--network container:$HOSTNAME` /etc/hostname reports the HOST's name,
    #                                   not a container id, so the namespace
    #                                   join finds no such container.
    #   default-gateway addressing      The wrong idea entirely: with host
    #                                   networking the default route is the
    #                                   real router, not a docker bridge.
    #
    # Because the job shares the host's network namespace, a published port is
    # simply on 127.0.0.1. The port is derived from the run id so concurrent
    # runs cannot collide.
    env:
      PG_CONTAINER: pig-ci-pg-${{ github.run_id }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      # Corepack ships with Node and installs the exact pnpm pinned by
      # `packageManager` in package.json, so CI, the image and a laptop all run
      # the same version. `--activate` puts it on PATH; the download prompt is
      # disabled because a non-interactive runner cannot answer it and would
      # otherwise hang until the job times out.
      - name: Enable pnpm
        env:
          COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'
        run: |
          corepack enable
          corepack prepare --activate
          pnpm --version

      - name: Start Postgres
        run: |
          PG_PORT=$(( 45000 + (${{ github.run_id }} % 15000) ))
          echo "Publishing Postgres on 127.0.0.1:${PG_PORT}"

          docker rm -f "$PG_CONTAINER" 2>/dev/null || true
          docker run -d --name "$PG_CONTAINER" \
            -p "127.0.0.1:${PG_PORT}:5432" \
            -e POSTGRES_USER=pig -e POSTGRES_PASSWORD=pig -e POSTGRES_DB=pig \
            postgres:16-alpine

          # pg_isready inside the container only proves the server started.
          # What matters is that THIS job can reach it through the published
          # port, so the readiness check is made from here, over TCP.
          for i in $(seq 1 60); do
            if node -e "
              const net=require('net');
              const s=net.connect(${PG_PORT},'127.0.0.1');
              s.on('connect',()=>{s.end();process.exit(0)});
              s.on('error',()=>process.exit(1));
            " 2>/dev/null; then
              echo "Reachable after ${i}s"
              echo "DATABASE_URL=postgres://pig:pig@127.0.0.1:${PG_PORT}/pig" >> "$GITHUB_ENV"
              exit 0
            fi
            sleep 1
          done
          echo "Postgres never became reachable on 127.0.0.1:${PG_PORT}"
          docker logs "$PG_CONTAINER" 2>&1 | tail -30
          exit 1

      - name: Install
        # --frozen-lockfile fails rather than quietly resolving a different
        # tree when the lockfile and manifests disagree. That is the whole
        # point of committing a lockfile, and it is the default in CI anyway —
        # stated here so it survives someone running this locally.
        run: pnpm install --frozen-lockfile

      - name: Typecheck every package
        run: pnpm run typecheck

      - name: Unit tests
        run: pnpm run test

      - name: Migrations apply to a real Postgres
        run: pnpm exec tsx packages/db/src/migrate.ts

      - name: Migrations are re-runnable
        run: pnpm exec tsx packages/db/src/migrate.ts

      - name: Seed is idempotent
        # A seed that duplicates on a second run corrupts any database it is
        # pointed at twice, and nobody notices until the counts look odd.
        run: |
          pnpm exec tsx packages/db/src/seed/index.ts > /dev/null
          count() { docker exec "$PG_CONTAINER" psql -U pig -d pig -tAc "select count(*) from contacts"; }
          BEFORE=$(count)
          pnpm exec tsx packages/db/src/seed/index.ts > /dev/null
          AFTER=$(count)
          echo "contacts: $BEFORE -> $AFTER"
          test "$BEFORE" = "$AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }

      - name: Critical path E2E against Postgres and Hono
        run: pnpm run test:e2e

      - name: Server boots and answers
        run: |
          NODE_ENV=development PIG_PORT=8930 pnpm exec tsx apps/api/src/server.ts &
          for i in $(seq 1 30); do
            curl -sf http://127.0.0.1:8930/api/health && break
            sleep 1
          done
          curl -sf http://127.0.0.1:8930/api/health | grep -q '"ok":true'

      - name: Front end builds
        run: pnpm -F @pig/web run build

      - name: Inline theme script still matches the deployed CSP hash
        # The proxy allows exactly one inline script by hash. If the script
        # changes and the CSP is not updated, dark-mode users get a white flash
        # on every load and nothing anywhere reports an error.
        run: |
          node -e "
            const fs=require('fs'), crypto=require('crypto');
            const html=fs.readFileSync('apps/web/dist/index.html','utf8');
            const m=html.match(/<script>([\s\S]*?)<\/script>/);
            if(!m){ console.error('No inline script found in index.html'); process.exit(1); }
            const hash='sha256-'+crypto.createHash('sha256').update(m[1]).digest('base64');
            const expected='sha256-1tTDwCq+TCEyPDSZeYqW5HbmP+unUg8hrgRiZBiH/IU=';
            if(hash!==expected){
              console.error('Inline script hash changed.');
              console.error('  now:      '+hash);
              console.error('  expected: '+expected);
              console.error('Update the CSP in deploy/Caddyfile.example AND on the server,');
              console.error('then update the expected hash in this workflow.');
              process.exit(1);
            }
            console.log('CSP hash unchanged: '+hash);
          "

      - name: Docker image builds
        run: docker build -t pig:ci .

      - name: Stop Postgres
        if: always()
        run: docker rm -f "$PG_CONTAINER" 2>/dev/null || true

  # Publish the image that production will run.
  #
  # Only on a `release-*` tag. A push to main proves the commit is sound and
  # stops there; tagging is the deliberate, human act that says "ship this".
  # The production host polls the registry for the newest release tag and
  # deploys it (scripts/autodeploy.sh) — which is how this gets automated
  # WITHOUT the thing scripts/deploy.sh refuses to do. Nothing here holds a
  # credential for cloud-2, and nothing here can execute anything on cloud-2.
  #
  # `gitea.ref` and `github.ref` are the same object in Gitea Actions; the
  # gitea-prefixed spelling is used for the ref test because that is the one
  # documented for tag conditions, and github.* elsewhere to match the job
  # above.
  publish:
    needs: verify
    if: startsWith(gitea.ref, 'refs/tags/release-')
    runs-on: ubuntu-latest

    env:
      REGISTRY: git.karti.ai
      # Gitea namespaces packages under the lowercased owner, so PIG/pig is
      # published as pig/pig.
      IMAGE: git.karti.ai/pig/pig
      # THE POINT OF THIS VARIABLE: the shared act_runner on cloud-1 runs with
      # `container.network: host`, and its docker config is visible to jobs
      # from every other repository on that host. A plain `docker login` would
      # leave a credential in ~/.docker/config.json that any of them could
      # read. Pointing DOCKER_CONFIG at a per-run directory keeps the token out
      # of the shared file entirely; the logout step below is the second belt.
      DOCKER_CONFIG: /tmp/pig-docker-${{ github.run_id }}

    steps:
      - uses: actions/checkout@v4

      - name: Log in to the Gitea registry
        # The per-run Actions token, not a long-lived secret: it is minted for
        # this run and dies with it. --password-stdin because an argument is
        # visible in the runner's process list to anything else on that host.
        run: |
          mkdir -p "$DOCKER_CONFIG"
          printf '%s' '${{ secrets.GITHUB_TOKEN }}' \
            | docker login "$REGISTRY" -u '${{ github.actor }}' --password-stdin

      - name: Build and push
        # Both cloud-1 and cloud-2 are aarch64, so this is a native build and
        # needs no --platform. The layer cache from the `verify` job's
        # `docker build` is warm on this same daemon, so the rebuild is cheap.
        #
        # Two tags, always pushed together: the tag is what a human asked for,
        # the short sha is what is unambiguous a year later when tags have been
        # moved or deleted.
        run: |
          TAG="${GITHUB_REF#refs/tags/}"
          SHORT_SHA=$(printf '%s' "${{ github.sha }}" | cut -c1-7)
          echo "Publishing $IMAGE:$TAG and $IMAGE:$SHORT_SHA"

          docker build -t "$IMAGE:$TAG" -t "$IMAGE:$SHORT_SHA" .
          docker push "$IMAGE:$TAG"
          docker push "$IMAGE:$SHORT_SHA"

          # Print the digest: it is what the host poller compares against, and
          # the only identifier that cannot be reassigned.
          docker image inspect "$IMAGE:$TAG" \
            --format '{{range .RepoDigests}}{{println .}}{{end}}'

      - name: Log out
        if: always()
        # Runs even when the build failed, because a failed job that left a
        # credential behind is exactly the leak this is guarding against.
        run: |
          docker logout "$REGISTRY" || true
          rm -rf "$DOCKER_CONFIG"
