feat: complete F44 draft saves #38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 | |
| # F77: 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 | |
| # F79: same arrangement for 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 | |
| # F80: 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 | |
| # F81: 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 | |
| # F89: 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 | |
| # F88: 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 | |
| - 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 | |
| # `@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 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 | |
| # 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. See | |
| # D66. | |
| - run: pnpm test:e2e | |
| 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@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 @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 (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 |