Skip to content

chore: give the local dev database a named volume, and a compose file #23

chore: give the local dev database a named volume, and a compose file

chore: give the local dev database a named volume, and a compose file #23

Workflow file for this run

# F12 — the gate every change passes.
#
# Ordering is deliberate: the cheap, fast-failing checks run first so a trivial
# mistake does not wait behind a Postgres container. The Postgres-backed job runs
# in parallel with the static one rather than after it, because the two fail for
# unrelated reasons and serialising them just doubles feedback time.
name: CI
on:
push:
branches: [main]
pull_request:
# A new push to the same branch makes the in-flight run obsolete.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# Keeps `next build` from phoning home during CI.
NEXT_TELEMETRY_DISABLED: '1'
jobs:
static:
name: Static checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# No `version:` — action-setup reads `packageManager` from package.json,
# so CI, the Docker image and a developer machine all run one pnpm. They
# ran three (9, 11.x-latest and 10.6) until the image job made that
# visible by failing.
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
# --frozen-lockfile makes CI fail on an out-of-date lockfile rather than
# silently resolving different versions than a developer has locally.
- run: pnpm install --frozen-lockfile
- name: Textual invariants
run: pnpm guards
# Proves the guards above are not inert. A rule whose pattern stopped
# matching passes `pnpm guards` exactly as loudly as one that works.
- name: Textual invariants — probe
run: pnpm guards:probe
# F25: a server-kind slot implemented by a "use client" module compiles,
# renders identically and ships the whole subtree to the browser. Only a
# static check catches it, and only a probe proves the check still works.
- name: Slot server/client boundary
run: pnpm slots:check
- name: Slot server/client boundary — probe
run: pnpm slots:probe
- name: Lint
run: pnpm lint
- name: Architecture boundaries
run: pnpm depcruise
- name: Types
run: pnpm typecheck
# Separate step because the app tier is excluded from the root tsconfig:
# it needs the Next plugin and JSX config from its own. Without this the
# entire app (pages, actions, components) goes unchecked until `next build`.
- name: Types (app)
run: pnpm typecheck:app
- name: Unit and integration tests
run: pnpm test
build:
name: Production build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# No `version:` — action-setup reads `packageManager` from package.json,
# so CI, the Docker image and a developer machine all run one pnpm. They
# ran three (9, 11.x-latest and 10.6) until the image job made that
# visible by failing.
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# The build must succeed with no database reachable AND no runtime secrets:
# DATA_SOURCE=fixture is the documented no-Postgres path, and a build that
# secretly needs a live connection or a production secret would break
# `docker build` and preview deploys. `next build` sets NODE_ENV=production
# and NEXT_PHASE itself; the production-only env rules stand down for the
# build phase and are enforced at server startup instead (instrumentation.ts).
- name: Build
run: pnpm build
env:
DATA_SOURCE: fixture
# F04's acceptance criterion, and the half that was missing for its whole
# life: CI *builds* the standalone image and then **boots** it, in every role.
#
# Building only proves the Dockerfile parses. A boot proves the standalone
# output actually contains what it needs — which is exactly the class of
# failure that does not show up on a developer machine, because there the
# whole node_modules tree is present and the traced bundle is not what runs.
image:
name: Standalone image boots, in every role
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: forum_test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@v4
- name: Build the image
run: docker build -t forum:ci .
# FORUM_ROLE=migrate, not `node node_modules/.bin/drizzle-kit` — drizzle-kit
# is a development tool and is not in the pruned standalone node_modules,
# so compose's migrate service had never worked either.
- name: Apply migrations (migrate role)
run: |
docker run --rm --network host \
-e FORUM_ROLE=migrate \
-e DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/forum_test \
-e DATA_SOURCE=postgres \
-e AUTH_SECRET=ci-auth-secret-ci-auth-secret-32b \
-e TICK_SECRET=ci-tick-secret-ci-tick-secret-32b \
forum:ci
# The web role. Polls the real health route rather than the port, so a
# server that bound and cannot render is reported as the failure it is.
#
# This step is the regression test for a bug that shipped three times:
# `container.ts`, `theme-runtime.ts` and `settings.ts` each loaded
# `@forum/db` with a synchronous `require()`, which Turbopack resolves to
# the pending namespace of an async module — so `getDb` was `undefined`
# and the first call threw. It never showed up here because CI only ever
# built and ran `DATA_SOURCE=fixture`, which takes none of those paths.
- name: Boot the web role
run: |
docker run -d --name forum-web --network host \
-e DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/forum_test \
-e DATA_SOURCE=postgres \
-e AUTH_SECRET=ci-auth-secret-ci-auth-secret-32b \
-e TICK_SECRET=ci-tick-secret-ci-tick-secret-32b \
-e PUBLIC_URL=http://127.0.0.1:3000 \
forum:ci
for i in $(seq 1 40); do
if curl -fsS http://127.0.0.1:3000/api/health >/dev/null 2>&1; then
echo "web role healthy after ${i}s"; exit 0
fi
sleep 1
done
echo "::error::the web role never became healthy"
docker logs forum-web
exit 1
# Renders, not merely responds: the failure this guards against produced a
# healthy /api/health and a 500 on every page.
- name: The board renders against Postgres
run: |
curl -fsS http://127.0.0.1:3000/ | grep -q '<main' \
|| { echo "::error::the board did not render"; docker logs forum-web; exit 1; }
# The worker role — F04's "the same image runs the worker with a flag".
# A worker that starts, registers its tasks and survives a tick is the
# claim; anything less is a container that exits zero and does nothing.
- name: Boot the worker role
run: |
docker run -d --name forum-worker --network host \
-e FORUM_ROLE=worker \
-e DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/forum_test \
-e DATA_SOURCE=postgres \
-e AUTH_SECRET=ci-auth-secret-ci-auth-secret-32b \
-e TICK_SECRET=ci-tick-secret-ci-tick-secret-32b \
-e QUEUE_DRIVER=postgres -e CACHE_DRIVER=memory \
forum:ci
sleep 20
docker logs forum-worker
docker ps --filter name=forum-worker --filter status=running | grep -q forum-worker \
|| { echo "::error::the worker exited instead of looping"; exit 1; }
docker logs forum-worker 2>&1 | grep -q 'worker started' \
|| { echo "::error::the worker never reported starting"; exit 1; }
# It registered its tasks, which is the difference between a loop that
# runs and a loop that works.
- name: The worker registered its tasks
run: |
docker run --rm --network host -e PGPASSWORD=postgres postgres:16-alpine \
psql -h 127.0.0.1 -U postgres -d forum_test -tAc 'select count(*) from tasks' \
| grep -qE '^[1-9]' \
|| { echo "::error::no tasks were registered"; docker logs forum-worker; exit 1; }
- name: Stop
if: always()
run: docker rm -f forum-web forum-worker 2>/dev/null || true
e2e:
name: No-JS and accessibility browser checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# No `version:` — action-setup reads `packageManager` from package.json,
# so CI, the Docker image and a developer machine all run one pnpm. They
# ran three (9, 11.x-latest and 10.6) until the image job made that
# visible by failing.
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm test:e2e
env:
DATA_SOURCE: fixture
NEXT_TELEMETRY_DISABLED: '1'
migrations:
name: Migrations and schema drift
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: forum_test
ports: ['5432:5432']
# Without this the first migration can race the container's startup.
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
# No `version:` — action-setup reads `packageManager` from package.json,
# so CI, the Docker image and a developer machine all run one pnpm. They
# ran three (9, 11.x-latest and 10.6) until the image job made that
# visible by failing.
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Apply migrations
run: pnpm --filter @forum/db migrate
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/forum_test
# Catches the common drift where someone edits schema.ts but forgets to
# generate the migration. A non-empty diff here means the checked-in SQL no
# longer reproduces the declared schema.
#
# This step inspected `packages/db/drizzle` for its whole life — a
# directory that has never existed, since drizzle.config.ts writes to
# `./migrations`. It therefore passed vacuously on every run, including
# the four migrations that were added by hand after the meta snapshot
# stopped being updated. Pointing it at the real directory needed that
# snapshot repaired first (see migrations/meta/0006_snapshot.json).
- name: Assert no uncommitted schema drift
run: |
pnpm --filter @forum/db generate
if [ -n "$(git status --porcelain packages/db/migrations)" ]; then
echo "::error::schema.ts changed without a generated migration."
git --no-pager status --porcelain packages/db/migrations
git --no-pager diff -- packages/db/migrations
exit 1
fi
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/forum_test
# `TEST_DATABASE_URL` switches on client.pg.test.ts, the suite that needs a
# *real* server rather than PGlite. Everything else here runs on PGlite,
# which is the right trade — but it is a different driver, and the first
# thing this suite found was a write path that PGlite accepted and every
# real Postgres rejected (D54). Without this variable the file skips, so a
# developer's `pnpm test` still needs no service.
- name: Postgres-backed tests
run: pnpm test
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/forum_test
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/forum_test
DATA_SOURCE: postgres