Skip to content

fix(moderation): log the moderation the audit trail claimed it already had #353

fix(moderation): log the moderation the audit trail claimed it already had

fix(moderation): log the moderation the audit trail claimed it already had #353

Workflow file for this run

# 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@v7
# 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@v6
- uses: actions/setup-node@v7
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
# The workspace releases in lockstep — see docs/release.md. This fails on
# a manifest, constant or compose pin that names any other version.
- name: One version everywhere
run: pnpm release:check
# The root is an interface: every entry in it is registered with the
# reason it must live there, and a new file fails until it is either
# foldered or registered. scripts/root-check.mjs is the registry.
- name: The repository root is accounted for
run: pnpm root:check
- 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
# 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
# docs/theme-slots.md is generated from the theme contract. Failing
# here means the contract changed and its published reference did not —
# which is how a theme author ends up writing against a field that was
# removed two releases ago.
- name: Theme API reference is current
run: pnpm theme:docs:check
# The same arrangement applies to the hook registry. A hook renamed without its
# reference updated is a plugin author writing against a hook that no
# longer fires — and nothing at runtime says so, because an unknown hook
# name is just a handler nobody calls.
- name: Plugin hook reference is current
run: pnpm plugin:docs:check
# This fails on a hook literal that is not in the registry — the typo that
# would otherwise be a call nothing listens to, with no error anywhere.
- name: Hook call sites resolve
run: pnpm hooks:wired
# The REST reference is read by people who cannot see the source, on
# a board they do not run. A stale endpoint list is a client that 404s.
- name: REST API reference is current
run: pnpm api:docs:check
# The p95 reference is generated from the budget registry and the
# recorded run. A published number no run produced is worse than none.
- name: Performance reference is current
run: pnpm perf:docs:check
# Not a generated file — the index is prose. What is checked is that
# every document in docs/ is reachable from it and every link resolves.
# The new document is the one likeliest to matter and likeliest to be
# missing from the list.
- name: Documentation index is complete
run: pnpm docs:index:check
# The site publishes docs/*.md and holds no copy of any of them, so the one
# way that arrangement rots is silent: a document is added and nobody names
# it in the manifest, so the site simply never mentions it. This fails on
# that, on a manifest entry pointing at a file that is gone, and on the
# README table having drifted from the manifest it is generated from.
- name: Published documentation set is accounted for
run: pnpm site:docs:check
- 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
# And again for the marketing site, which has its own tsconfig for the same
# reason the board does — the Next plugin and JSX settings — and is excluded
# from the root project alongside it.
- name: Types (site)
run: pnpm typecheck:site
- name: Unit and integration tests
run: pnpm test
build:
name: Production build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# 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@v6
- uses: actions/setup-node@v7
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
# meith.dev. It reads `docs/*.md` at build time and prerenders every page,
# so this step is also the check that every published document still
# *renders* — a Markdown table left half-written, or a manifest entry whose
# file was deleted, fails here rather than on the deploy.
- name: Build the site
run: pnpm site:build
# The deploy 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@v7
- name: Build the image
run: docker build -f docker/Dockerfile -t forum:ci .
# COMMUNITY_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 COMMUNITY_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
# `@meith/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 APP_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 — proving "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.
#
# Ninety seconds, and "worker started" **exactly once**, because twenty
# seconds was not long enough to see the bug this now guards. Between
# ticks the sleep's two timers were the only handles holding the event
# loop; both were unref'd, Postgres closed its idle connection at about
# twenty seconds, the loop emptied and Node exited 0 in the middle of a
# `while (!stopping)` loop that had not stopped. Under a restart policy
# that reads as a working board whose log says "worker started" forever,
# so counting the line matters more than finding it.
- name: Boot the worker role
run: |
docker run -d --name forum-worker --network host --restart=no \
-e COMMUNITY_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 90
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; }
starts=$(docker logs forum-worker 2>&1 | grep -c 'worker started' || true)
test "$starts" = "1" \
|| { echo "::error::expected one 'worker started', saw $starts — it is restarting"; exit 1; }
# And the image agrees it is healthy. The single web-shaped healthcheck
# this replaced reported every worker unhealthy for its whole life.
state=$(docker inspect -f '{{.State.Health.Status}}' forum-worker)
test "$state" = "healthy" \
|| { echo "::error::worker health is $state"; 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
# meith.dev's own image. A different app, Dockerfile and Next config branch
# from the board above, so a green `image` job says nothing about it.
site-image:
name: The site image serves meith.dev
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build the site image
run: docker build -f docker/Dockerfile.site -t meith-site:ci .
- name: Boot it
run: |
docker run -d --name meith-site --network host meith-site:ci
for i in $(seq 1 40); do
if curl -fsS http://127.0.0.1:3100/ >/dev/null 2>&1; then
echo "site up after ${i}s"; exit 0
fi
sleep 1
done
echo "::error::the site never answered"
docker logs meith-site
exit 1
# The doc routes read docs/ at request time and 500 when the image ships
# the standalone output alone, which the landing page cannot show. So
# each one is asked for by name.
- name: It renders, docs included
run: |
curl -fsS http://127.0.0.1:3100/ | grep -q '<main' \
|| { echo "::error::the landing page did not render"; docker logs meith-site; exit 1; }
for path in /docs /docs/quickstart /llms.txt /docs/search-index.json /sitemap.xml /robots.txt; do
curl -fsS "http://127.0.0.1:3100$path" >/dev/null \
|| { echo "::error::$path failed"; docker logs meith-site; exit 1; }
done
- name: Stop
if: always()
run: docker rm -f meith-site 2>/dev/null || true
compose:
name: Both self-hosting shapes come up
runs-on: ubuntu-latest
# The guide says `cd meith/docker` and run compose from there — the compose
# files live in docker/ and read the `.env` beside them. This job runs in
# the same directory so it stays the guide, verbatim.
defaults:
run:
working-directory: docker
steps:
- uses: actions/checkout@v7
# Both compose files parse before either is run, so a syntax error is one
# clear failure rather than a confusing one 500 seconds into a build. The
# magic variables are stubbed because `config` only substitutes them.
- name: Every compose file parses
env:
SERVICE_PASSWORD_POSTGRES: stub
SERVICE_BASE64_64_AUTH: stub
SERVICE_BASE64_64_TICK: stub
SERVICE_URL_WEB: http://127.0.0.1:3000
SERVICE_FQDN_WEB_3000: 127.0.0.1
SERVICE_FQDN_SITE_3100: 127.0.0.1
POSTGRES_PASSWORD: stub
AUTH_SECRET: stub-auth-secret-stub-auth-secret
TICK_SECRET: stub-tick-secret-stub-tick-secret
run: |
docker compose -f compose.yml config >/dev/null
docker compose -f compose.coolify.yml config >/dev/null
docker compose -f compose.dev.yml config >/dev/null
docker compose -f compose.site.coolify.yml config >/dev/null
# docs/self-hosting.md tells an operator to write a `.env` and run one
# command. This job is that, verbatim, because three separate bugs got
# into this repository behind a compose file nothing ever started:
# `migrate` was never passed the two secrets so the stack could not come
# up at all; the worker's sleep unref'd its timers so it exited every
# twenty seconds; and the image's healthcheck was web-shaped, so the
# worker was permanently unhealthy. Each was found by running this and
# none by reading it.
- name: Write the .env the guide writes
run: |
cat > .env <<EOF
# hex, not base64: the password is substituted into a postgres:// URL
# and base64 can contain `/`, which is a TypeError from the migration.
POSTGRES_PASSWORD=$(openssl rand -hex 32)
AUTH_SECRET=$(openssl rand -base64 32)
TICK_SECRET=$(openssl rand -base64 32)
APP_URL=http://127.0.0.1:3000
PORT=127.0.0.1:3000
EOF
- name: Bring the stack up
run: docker compose up -d --build --wait --wait-timeout 600
# `migrate` runs to completion and the other two wait on it, so the code
# never serves against a schema behind it. A non-zero exit here is the
# stack refusing to start rather than starting wrong, which is the whole
# point of the service.
- name: The migration succeeded and exited
run: |
code=$(docker compose ps -a --format json migrate | head -1 \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["ExitCode"])')
test "$code" = "0" \
|| { echo "::error::migrate exited $code"; docker compose logs migrate; exit 1; }
- name: The board answers, and renders
run: |
curl -fsS http://127.0.0.1:3000/api/health >/dev/null \
|| { echo "::error::no health"; docker compose logs web; exit 1; }
curl -fsS http://127.0.0.1:3000/ | grep -q '<main' \
|| { echo "::error::the board did not render"; docker compose logs web; exit 1; }
# The guide says bind to localhost so the reverse proxy is the only way
# in. Docker writes its own iptables rules, so a board published on all
# interfaces is plaintext on 3000 whatever ufw says — worth asserting the
# file honours PORT rather than trusting the sentence.
#
# The worker half asks Docker what it bound, rather than reading the CLI's
# output for a service that bound nothing. It used to be `test -z "$(docker
# compose port worker 3000)"` — an assertion resting on an *empty string*,
# which is a convention rather than a contract, and Compose stopped
# honouring it: the command now answers with a placeholder where it used to
# say nothing, so the check read "no mapping" as "a mapping" and every
# branch went red at once with the compose files untouched.
#
# `.HostConfig.PortBindings` is the thing the assertion is actually about
# and cannot be reformatted out from under it. It is also what the Coolify
# step below has always used, so the two halves of one invariant are now
# measured the same way — the divergence is what let this rot on the side
# nobody looked at.
- name: Nothing is published beyond the proxy's port
run: |
docker compose port web 3000 | grep -q '^127\.0\.0\.1:' \
|| { echo "::error::web is not bound to localhost"; docker compose port web 3000; exit 1; }
worker=$(docker compose ps -q worker)
bindings=$(docker inspect --format '{{len .HostConfig.PortBindings}}' "$worker")
test "$bindings" = "0" || {
echo "::error::the worker publishes a port; it serves no HTTP and should bind nothing"
docker inspect --format '{{json .HostConfig.PortBindings}}' "$worker"
exit 1
}
# Sixty seconds is one tick interval, so a worker that dies between ticks
# dies inside this window.
- name: The worker is still there a tick later
run: |
sleep 60
for service in web worker; do
count=$(docker compose ps --format json "$service" | head -1 \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["State"])')
test "$count" = "running" \
|| { echo "::error::$service is $count"; docker compose logs "$service"; exit 1; }
done
starts=$(docker compose logs worker 2>&1 | grep -c 'worker started' || true)
test "$starts" = "1" \
|| { echo "::error::expected one 'worker started', saw $starts"; exit 1; }
# Both processes write to and read from the same uploads volume. Separate
# volumes is the silent failure: an avatar written by a queued job in the
# worker is a 404 in the web server, and nothing errors.
- name: Web and worker share the uploads volume
run: |
docker compose exec -T worker sh -c 'echo probe > /app/.uploads/ci-probe'
docker compose exec -T web sh -c 'cat /app/.uploads/ci-probe' | grep -q probe \
|| { echo "::error::web and worker do not share uploads"; exit 1; }
# The operator CLI ships inside the image, so a server needs no checkout
# and no toolchain. The guide's alias is this command.
- name: The operator CLI runs from the image
run: |
docker compose run --rm --no-deps web node apps/cli/cli.cjs --help | grep -qi 'usage' \
|| { echo "::error::the operator CLI is not in the image"; exit 1; }
- name: Logs on failure
if: failure()
run: docker compose logs --no-color
- name: Down
if: always()
run: docker compose down -v
# ── the other shape ──────────────────────────────────────────────────
#
# docs/quickstart.md tells an operator to point Coolify at this file and
# expect a working board that asks them for nothing. The magic variables
# are supplied here the way Coolify supplies them, which is the only part
# of that promise CI can stand in for — but it is the part that breaks:
# four containers co-operating is not exercised by booting roles one at a
# time, which is all the `image` job does.
#
# The file pulls a pinned released image in production; here it runs
# against the image built from this very commit (MEITH_IMAGE), so the
# compose shape is proven before any release exists to pull.
- name: The Coolify stack comes up as documented
env:
SERVICE_PASSWORD_POSTGRES: ci-postgres-password
SERVICE_BASE64_64_AUTH: ci-auth-secret-ci-auth-secret-32b
SERVICE_BASE64_64_TICK: ci-tick-secret-ci-tick-secret-32b
SERVICE_URL_WEB: http://127.0.0.1:3000
SERVICE_FQDN_WEB_3000: 127.0.0.1
MEITH_IMAGE: meith:ci
run: |
set -euo pipefail
docker build -f Dockerfile -t meith:ci ..
docker compose -f compose.coolify.yml up -d \
--wait --wait-timeout 600 web worker
# Read exit codes through `docker inspect` rather than
# `compose ps --format`, whose JSON shape differs between versions.
migrate_exit=$(docker inspect --format '{{.State.ExitCode}}' \
"$(docker compose -f compose.coolify.yml ps -aq migrate)")
test "$migrate_exit" = "0" \
|| { echo "::error::migrate exited $migrate_exit"
docker compose -f compose.coolify.yml logs migrate; exit 1; }
starts=$(docker compose -f compose.coolify.yml logs worker 2>&1 \
| grep -c 'worker started' || true)
test "$starts" = "1" \
|| { echo "::error::expected one 'worker started', saw $starts"
docker compose -f compose.coolify.yml logs worker; exit 1; }
# `restart: unless-stopped` hides a process that exits by bringing it
# straight back, so a restart count is the only thing that tells a
# working stack from one quietly cycling.
for service in web worker; do
restarts=$(docker inspect --format '{{.RestartCount}}' \
"$(docker compose -f compose.coolify.yml ps -q $service)")
test "$restarts" = "0" \
|| { echo "::error::$service restarted $restarts times"
docker compose -f compose.coolify.yml logs "$service"; exit 1; }
done
# Coolify's proxy terminates TLS and routes to the container. A published
# port would put the board on the host as well, around the proxy and
# without the certificate — and it is a two-line edit to add one.
- name: The Coolify stack publishes no ports
run: |
for container in $(docker compose -f compose.coolify.yml ps -aq); do
bindings=$(docker inspect --format '{{len .HostConfig.PortBindings}}' "$container")
test "$bindings" = "0" || {
name=$(docker inspect --format '{{.Name}}' "$container")
echo "::error::$name published a port; the proxy should be the only way in"
exit 1
}
done
# Both processes write uploads — the web server when somebody sets an
# avatar, the worker when a queued job re-encodes one — so two volumes is
# an avatar that exists for half the board and errors for nobody.
- name: The Coolify stack shares one uploads volume
run: |
set -euo pipefail
docker compose -f compose.coolify.yml exec -T worker \
sh -c 'echo shared > /app/.uploads/ci-probe'
docker compose -f compose.coolify.yml exec -T web \
grep -q shared /app/.uploads/ci-probe \
|| { echo "::error::web cannot read what the worker wrote"; exit 1; }
- name: Coolify logs on failure
if: failure()
run: docker compose -f compose.coolify.yml logs --no-color
- name: Coolify stack down
if: always()
run: docker compose -f compose.coolify.yml down -v
# Sharded across four runners. Serially this suite took eighteen minutes and
# the dev server — which compiles each route on first request and holds the
# compiler for the whole run — hit its memory ceiling around the fourteenth
# and restarted, failing whichever test was mid-flight. A quarter of the
# suite finishes long before that, and the four run at once.
#
# Each shard is a whole runner with its own PGlite database and servers, so
# nothing is shared between them. Playwright splits by file, and every spec
# seeds what it needs, so the split is safe in any order.
e2e:
name: No-JS and accessibility browser checks
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v7
# 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@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
# No service container and no `DATA_SOURCE` here on purpose. The suite
# brings its own Postgres — `e2e/support/database.ts`, started as the
# first `webServer` — so CI runs exactly what a developer runs, which is
# the whole reason the write path is testable in a browser at all.
- run: pnpm test:e2e --shard=${{ matrix.shard }}/4
env:
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@v7
# 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@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Apply migrations
run: pnpm --filter @meith/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 @meith/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. 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