refactor: rename board config files community.* to meith.* #830
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
| # 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 |