# 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(/