Skip to content

fix(community): extend pending/disabled states to every action button… #827

fix(community): extend pending/disabled states to every action button…

fix(community): extend pending/disabled states to every action button… #827

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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
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: Workspace integrity
run: pnpm workspace:check
- name: Every verify gate runs in CI
run: pnpm ci:parity:check
# The workspace releases in lockstep — see docs/contributing/release.md. This fails on
# a manifest, constant or compose pin that names any other version.
- name: One version everywhere
run: pnpm release:check
# create-meith is the one published package with a dist step — see
# docs/contributing/release.md, "They ship TypeScript source, deliberately". The
# dry run below packs it with `pnpm pack`, which only sees what is
# already on disk, so the build has to run first — release.yml's `npm`
# job orders these the same way, immediately before npm-publish.mjs.
- name: Build create-meith
run: pnpm --filter create-meith build
# docs/contributing/release.md documents this as the gate that catches, before a
# release ever runs, a `files` allowlist or `bin` path that has rotted:
# every publishable package is packed with `pnpm pack` and its tarball
# checked against its own manifest, with nothing published and the
# registry never contacted. board-workspace below packs only the
# @meith/web / @meith/cli / @meith/theme-default closure, so this is
# the only coverage for every other publishable package.
- name: Release dry run — every publishable tarball matches its manifest
run: node scripts/npm-publish.mjs --dry-run
# 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
- name: Message catalog is complete
run: pnpm i18n:check
# 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/reference/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
- name: Board plugin manifests are current
run: pnpm board:gen: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
- name: Documentation links resolve
run: pnpm docs:links: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: Marketplace registry is current
run: pnpm marketplace:gen:check
- name: Board installer is current
run: pnpm board-installer:gen:check
- name: Deploy templates match the scaffold
run: pnpm templates:gen:check
- name: Extension scaffold templates match the examples
run: pnpm extension:gen:check
- name: Lint and formatting
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: Redis for the cache driver contract
run: |
command -v redis-server >/dev/null 2>&1 \
|| (sudo apt-get update && sudo apt-get install -y --no-install-recommends redis-server)
- name: Unit and integration tests, with the coverage gate
run: pnpm test:coverage
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-report
path: coverage
if-no-files-found: error
build:
name: Production build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
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:18-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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Build the image
run: docker build -f docker/Dockerfile -t forum:ci .
# MEI-90: docs/customization/marketplace.md documents `board:eject` as a bind-mounted,
# non-root `docker run`/`docker compose run` invocation — this is what
# proves that invocation against the real image this job just built,
# not board-eject-smoke.mts's stand-in (that script runs `community
# board:eject` from the checkout, never through the image at all, which
# is exactly how MEI-90's bug went uncaught). Needs no database and no
# network, so it runs here rather than in its own job.
- name: board:eject through the image, the documented way (MEI-90)
run: |
mkdir eject-out
docker run --rm --user "$(id -u):$(id -g)" \
-v "$PWD/eject-out:/data/my-board" \
forum:ci community board:eject /data/my-board
test -f eject-out/package.json \
|| { echo "::error::eject wrote nothing to the bind-mounted host directory"; exit 1; }
grep -q '"name": "my-board"' eject-out/package.json \
|| { echo "::error::package.json is not the ejected workspace"; exit 1; }
if grep -q '"@meith/web": "latest"' eject-out/package.json; then
echo "::error::ejected package.json pins @meith/web to latest, never latest"
exit 1
fi
owner=$(stat -c '%u' eject-out/package.json)
test "$owner" = "$(id -u)" \
|| { echo "::error::ejected files are owned by uid $owner, not the invoking user $(id -u)"; exit 1; }
if docker run --rm --user "$(id -u):$(id -g)" \
-v "$PWD/eject-out:/data/my-board" \
forum:ci community board:eject /data/my-board; then
echo "::error::eject re-ran against a non-empty bind-mounted directory instead of refusing"
exit 1
fi
# 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
# MEI-104: docs/guides/operations/operating.md and upgrading.md document the
# backup as a bind-mounted, non-root `docker compose run`, and the upgrade
# runbook destroys the pgdata volume two lines after taking it. The image
# runs as uid 1001, which owns nothing on the host, so the documented shape
# is the only one that produces a bundle — this proves it against the real
# image rather than trusting the prose. Runs after the migrations so there
# is a schema to dump.
- name: backup through the image, the documented way (MEI-104)
run: |
mkdir -p backups
docker run --rm --network host --user "$(id -u):$(id -g)" \
-v "$PWD/backups:/backup" \
-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 community backup --out /backup/ci.tar.gz
test -f backups/ci.tar.gz \
|| { echo "::error::backup wrote nothing to the bind-mounted host directory"; exit 1; }
owner=$(stat -c '%u' backups/ci.tar.gz)
test "$owner" = "$(id -u)" \
|| { echo "::error::the bundle is owned by uid $owner, not the invoking user $(id -u)"; exit 1; }
tar tzf backups/ci.tar.gz | grep -q '^db.dump$' \
|| { echo "::error::the bundle carries no db.dump"; tar tzf backups/ci.tar.gz; exit 1; }
# 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:18-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
# MEI-75: `create-meith` scaffolds a board that depends on `@meith/web` and
# `@meith/cli` and calls a `forum-web` bin — this is the job that proves
# that promise is real rather than aspirational. It packs every workspace
# package `@meith/web`'s own dependency closure newly requires (none of
# them are on the real npm registry yet), scaffolds a board exactly as a
# user would, installs it from the packed tarballs, and builds and boots
# the result — twice, once at each materialization depth, because Vercel
# deploys through `--at-root` and every board bug found so far shipped
# through that one. See docs/contributing/development.md, "Consuming the board from a
# workspace", and scripts/board-workspace-smoke.mts for the mechanism and
# why the boot check runs against Postgres rather than fixture mode.
board-workspace:
name: A scaffolded board builds and boots from packed tarballs
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: board_workspace_smoke
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Pack, scaffold, install, build and boot at both depths
run: pnpm board:workspace:smoke
env:
DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/board_workspace_smoke
extension-workspace:
name: Scaffolded extensions build against packed kits and load into a board
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Pack the kits, scaffold a plugin and a theme, prove them, install them into a board
run: pnpm extension:workspace:smoke
# MEI-77: the scaffold's own deploy kit — Dockerfile, compose.yml,
# .github/workflows/build.yml — proves it actually builds and boots, not
# just that its strings look right (scaffold.test.ts covers that half).
# Cannot pull the real ghcr.io/meith-dev/meith-base — it does not exist
# until the next release publishes it — so this builds a local stand-in
# from packed tarballs and tags it identically, then builds the
# unmodified scaffolded Dockerfile against it. See
# scripts/board-deploy-kit-smoke.mts for the full reasoning and what this
# does and does not prove.
board-deploy-kit:
name: The scaffold's deploy kit builds and boots (local stand-in base image)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: board_deploy_kit_smoke
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Pack, scaffold, build a stand-in base image, build the deploy kit and boot it
run: pnpm board:deploy-kit:smoke
env:
DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/board_deploy_kit_smoke
# MEI-81: the epic's own closing acceptance rule — eject → build via the
# scaffold Dockerfile → boot against the *same* database → the board comes
# up unchanged, before any plugin is added. Seeds a distinctive thread first
# (the same "Start here" content the backup/restore job already checks
# for), runs `community board:eject` for real against this checkout
# standing in for a running stock image, then reuses the same local
# stand-in base image trick as board-deploy-kit above to build the ejected
# Dockerfile unmodified. See scripts/board-eject-smoke.mts.
board-eject:
name: A graduated board builds and boots unchanged against the same database
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: board_eject_smoke
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Seed, eject, pack, build a stand-in base image, build and boot the graduated image
run: pnpm board:eject:smoke
env:
DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/board_eject_smoke
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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/getting-started/deployment/docker-compose.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. docs/guides/operations/operating.md documents every command as
# `community <cmd>` — checked here too (MEI-81), not just the
# underlying `node apps/cli/cli.cjs` this comment used to call "the
# guide's alias" back when nothing in the image actually put a
# `community` binary on PATH to make that alias real.
- 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; }
docker compose run --rm --no-deps web community --help | grep -qi 'usage' \
|| { echo "::error::the documented \`community\` command is not in the image"; exit 1; }
- name: The image carries the postgres client tools backup needs
run: |
docker compose run --rm --no-deps web pg_dump --version | grep -q 'pg_dump (PostgreSQL) 18' \
|| { echo "::error::pg_dump 18 is not in the image"; exit 1; }
docker compose run --rm --no-deps web pg_restore --version >/dev/null \
|| { echo "::error::pg_restore 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/getting-started/deployment/coolify.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
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
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:18-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@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
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: Redis for the cache driver contract
run: |
command -v redis-server >/dev/null 2>&1 \
|| (sudo apt-get update && sudo apt-get install -y --no-install-recommends redis-server)
- 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
backup:
name: Backup and restore round-trip
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: forum_live
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATA_SOURCE: postgres
DATABASE_URL: postgres://postgres:postgres@localhost:5432/forum_live
UPLOADS_DIR: /tmp/uploads-live
PGPASSWORD: postgres
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Postgres 18 client tools
run: |
sudo install -d /usr/share/postgresql-common/pgdg
sudo curl -fsS -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \
https://www.postgresql.org/media/keys/ACCC4CF8.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
| sudo tee /etc/apt/sources.list.d/pgdg.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends postgresql-client-18
echo "/usr/lib/postgresql/18/bin" >> "$GITHUB_PATH"
/usr/lib/postgresql/18/bin/pg_dump --version | grep -q ' 18\.' \
|| { echo "::error::postgresql-client-18 did not install version 18"; exit 1; }
- name: Seed a board with content and an upload
run: |
pnpm --filter @meith/db migrate
pnpm community demo:seed
mkdir -p /tmp/uploads-live/avatars/ab
cp apps/community/public/placeholder-user.jpg /tmp/uploads-live/avatars/ab/attachment.jpg
env:
DEMO_MODE: '1'
- name: Take a backup
run: |
pnpm community backup --out /tmp/board-ci.tar.gz
tar tzf /tmp/board-ci.tar.gz | sort | tee /tmp/bundle-members.txt
for member in manifest.json db.dump uploads.tar.gz; do
grep -qx "$member" /tmp/bundle-members.txt \
|| { echo "::error::the bundle is missing $member"; exit 1; }
done
- name: Restore into a fresh database and a fresh uploads directory
run: |
createdb -h localhost -U postgres forum_restored
pnpm community restore /tmp/board-ci.tar.gz \
--uploads-dir /tmp/uploads-restored | tee /tmp/restore.log
grep -q 'nothing to do' /tmp/restore.log \
|| { echo "::error::the restore had migrations to apply on a dump this build just took"; exit 1; }
env:
RESTORE_DATABASE_URL: postgres://postgres:postgres@localhost:5432/forum_restored
- name: The restore refuses a database that is not empty
run: |
if pnpm community restore /tmp/board-ci.tar.gz \
--uploads-dir /tmp/uploads-refused 2>/tmp/refused.log; then
echo "::error::a restore over a live database was accepted"; exit 1
fi
grep -qi 'empty database' /tmp/refused.log \
|| { echo "::error::the refusal did not say why"; cat /tmp/refused.log; exit 1; }
env:
RESTORE_DATABASE_URL: postgres://postgres:postgres@localhost:5432/forum_restored
- name: The seeded content survived the round trip
run: |
live=$(psql -h localhost -U postgres -d forum_live -tAc 'select count(*) from posts')
restored=$(psql -h localhost -U postgres -d forum_restored -tAc 'select count(*) from posts')
test "$restored" -gt 0 || { echo "::error::the restored board holds no posts"; exit 1; }
test "$live" = "$restored" \
|| { echo "::error::post counts differ: $live live, $restored restored"; exit 1; }
cmp /tmp/uploads-live/avatars/ab/attachment.jpg /tmp/uploads-restored/avatars/ab/attachment.jpg \
|| { echo "::error::the upload did not survive the round trip"; exit 1; }
- name: The restored board boots and serves a seeded thread
run: |
DATABASE_URL=postgres://postgres:postgres@localhost:5432/forum_restored \
UPLOADS_DIR=/tmp/uploads-restored pnpm dev >/tmp/dev.log 2>&1 &
for i in $(seq 1 120); do
if curl -fsS http://127.0.0.1:3000/api/health >/dev/null 2>&1; then break; fi
sleep 1
done
curl -fsS http://127.0.0.1:3000/api/health >/dev/null \
|| { echo "::error::the restored board never became healthy"; tail -50 /tmp/dev.log; exit 1; }
curl -fsS http://127.0.0.1:3000/ | grep -q '<main' \
|| { echo "::error::the restored board did not render"; tail -50 /tmp/dev.log; exit 1; }
thread=$(psql -h localhost -U postgres -d forum_restored -tAc \
"select id || '-' || slug from threads where title like 'Start here%' limit 1")
test -n "$thread" || { echo "::error::the seeded start-here thread is gone"; exit 1; }
curl -fsS "http://127.0.0.1:3000/thread/$thread" | grep -qF 'Start here' \
|| { echo "::error::the seeded thread did not render after the restore"; tail -50 /tmp/dev.log; exit 1; }
templates-in-sync:
name: Deploy template repositories are in sync
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm templates:sync:check