Scripts, quality gates and CI
Every pnpm script, what it does, the traps in each, and the checks that must pass before merge.
Every command in this repository is a pnpm script. There is no Makefile, no task runner, and no
npm/yarn fallback — package.json:5 pins "packageManager": "pnpm@11.17.0" and package.json:7
sets "engines": { "node": ">=24.18.0" }.
mise.toml pins only pnpm (pnpm = "latest"). There is no .nvmrc and no .tool-versions, so the
Node floor is enforced by engines alone. CI uses node-version: 24
(.github/workflows/ci.yml:31,70).
What must be green before merge
.github/workflows/ci.yml defines two jobs, both triggered on push to main and on pull requests
targeting main, with cancel-in-progress: true concurrency.
Job quality — "Typecheck · Lint · Test · Schema", in this order:
pnpm install --frozen-lockfile
pnpm typecheck
pnpm exec oxlint -f github src/
pnpm db:check
pnpm testJob build — "Production build": the same install, then pnpm build with placeholder public
env values (.github/workflows/ci.yml:53-60):
NEXT_PUBLIC_APP_URL: https://ermisai.com
NEXT_PUBLIC_SITE_URL: https://ermisai.com
APP_URL: https://ermisai.com
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_Y2xlcmsuZXhhbXBsZS5jb20k
CLERK_SECRET_KEY: sk_test_ci_placeholder_value_not_a_real_secretSENTRY_AUTH_TOKEN is deliberately absent so @sentry/nextjs skips the sourcemap upload and just
builds. No database URL is supplied to either job — see the db:check note below for why that works.
Locally the equivalent quartet is pnpm typecheck, pnpm test, pnpm build, pnpm lint
(README.md:111-116). Note that CI runs pnpm exec oxlint -f github src/, not pnpm lint — the
two do different things (see pnpm lint prints nothing).
Script reference
oxfmt is configured with sortPackageJson.sortScripts: true (.oxfmtrc.jsonc), but package.json
is only reformatted when someone runs pnpm check:fix-suggestions (the only script targeting .),
so the scripts block is currently near-alphabetical with vercel:build kept next to build
(package.json:11-12) rather than fully sorted. Grouped here by purpose instead.
Build, run, check
| Script | Command | Notes |
|---|---|---|
dev | next dev | Dev server on http://localhost:3000. Turbopack, Next 16 canary. |
build | NODE_ENV=production next build | The NODE_ENV prefix is load-bearing. |
vercel:build | tsx scripts/deploy/prod-migrate.ts && NODE_ENV=production next build | The Vercel buildCommand. Migrate, then build. |
start | next start | Serve a prior build. |
typecheck | tsc --project tsconfig.json --noEmit | Native TypeScript 7 compiler. |
typecheck:watch | same, plus --watch --incremental | |
test | vitest run | One-shot. |
test:watch | vitest | |
lint | oxlint -f github src/ > lint.md 2>&1 | Writes a file, prints nothing. |
lint:fix | oxlint -f stylish --fix src/ | Terminal output plus autofix. |
lint:fix-unsafe | oxlint with all plugins, --fix --fix-suggestions --fix-dangerously src/ | Includes fixes that can change behaviour. |
check:fix-suggestions | oxlint with all plugins, --fix --fix-suggestions . then oxfmt --write . | Note the target is ., the whole repo, not src/. |
format | oxfmt --write src/ | |
format:check | oxfmt --check src/ | Not run by CI. |
Database
| Script | Command | Notes |
|---|---|---|
db:generate | drizzle-kit generate | Emits a new drizzle/<timestamp>_<name>/ directory. |
db:migrate | drizzle-kit migrate | Applies pending migrations. |
db:check | drizzle-kit check --dialect postgresql | Offline consistency check of the migration folder. Runs in CI. |
db:studio | drizzle-kit studio | |
db:seed-bootstrap | tsx scripts/db/seed-bootstrap.ts | Upserts the billing plans and the whole source catalog in one transaction. |
db:backfill-source-icons | tsx scripts/db/backfill-source-icons.ts | One-off, idempotent. |
db:repair-source-categories | tsx scripts/db/repair-source-catalog-categories.ts | One-off, idempotent. |
db:check needs no database. drizzle.config.ts throws unless one of MIGRATIONS_DATABASE_URL,
SUPABASE_DATABASE_URL or DATABASE_URL parses as a postgres:/postgresql: URL — but
drizzle-kit check --dialect postgresql takes the dialect from the command line and never trips that
requirement. Running it with all three variables set to not-a-url still prints
Everything's fine. That is why the CI job supplies no database credentials.
Source catalog
| Script | Command | Notes |
|---|---|---|
catalog:validate | tsx scripts/catalog/validate-canonical.ts | Read-only. Prints path, source count, version, checksum. |
catalog:build | tsx scripts/catalog/build-canonical.ts | Validates, re-sorts and recomputes checksums for news-sources/catalog.v1.json in place. |
catalog:coverage | tsx scripts/catalog/report-coverage.ts | EU-country coverage report. |
catalog:audit | tsx scripts/catalog/audit-urls.ts --feed-only --output=news-sources/audit-report.json | Network-bound; pings every feedUrl. |
Localization, email, API, release
| Script | Command | Notes |
|---|---|---|
i18n:translate | tsx scripts/i18n/translate-messages.ts | Shells out to the local claude CLI, not the Anthropic API. |
i18n:translate:dry | same, plus --dry | Reports the diff, calls nothing, writes nothing. |
email:preview | email dev --dir src/emails | react-email preview server. |
email:campaign | node --env-file=.env.production --import tsx scripts/email/send-campaign.ts | Reads .env.production, not .env. |
api:openapi | tsx scripts/api/generate-public-integration-openapi.ts | Writes docs/api/public-integration-api.openapi.json. |
sentry:sourcemaps | sentry-cli sourcemaps inject … && … upload … .next | Org entro314, project ermisai. Needs a built .next and sentry-cli auth. |
syncpack | syncpack update --target latest && syncpack fix … && syncpack format && pnpm sentry:sourcemaps | Chains a real Sentry upload at the end. |
Traps worth knowing
pnpm lint prints nothing
package.json:32 is:
oxlint -f github src/ > lint.md 2>&1Both stdout and stderr go to lint.md at the repo root. The terminal stays silent whether the run
passed or failed. The shell exit code still reflects pass/fail, but if you are reading output rather
than checking $?, open lint.md:
Found 0 warnings and 0 errors.
Finished in 15.1s on 519 files with 378 rules using 12 threads.lint.md is tracked in git, so a lint run dirties your working tree. When you want output on screen,
use pnpm lint:fix (stylish formatter plus autofix) or run oxlint directly.
pnpm lint and pnpm format only cover src/. scripts/, tests/ and drizzle/ are outside the
default target. Only check:fix-suggestions widens the scope to ..
Linting is oxlint, formatting is oxfmt — there is no ESLint config and no Prettier config in the
repo. .oxlintrc.json sets "options": { "typeAware": true }, so even a plain pnpm lint runs
type-aware rules through oxlint-tsgolint. Plugins enabled: oxc, typescript, unicorn,
react, jsx-a11y, vitest, import, nextjs, promise. oxfmt is configured with no semicolons,
single quotes, printWidth 100, trailing commas everywhere, and Tailwind class sorting against
./src/app/globals.css.
pnpm build does no type checking
next.config.ts:113-120 sets typescript.ignoreBuildErrors: true, with the rationale recorded in
the file: Next's build-time type check goes through typescript.createProgram, which the native TS
7.0 compiler does not expose until 7.1. The build prints Skipping validation of types and moves on.
Type safety comes from pnpm typecheck in the CI quality job and nowhere else. A green build tells
you nothing about types.
NODE_ENV=production is pinned in both build scripts
package.json:11-12 hardcode the prefix on build and vercel:build. CLAUDE.md records the
reason: a shell-exported NODE_ENV=development leaking into next build otherwise breaks
prerendering. Do not strip the prefix, and do not pin NODE_ENV in an env file — the tooling sets it
(development for next dev, production for the build scripts).
Never remove @typescript/native-preview
package.json:101 carries @typescript/native-preview alongside typescript@^7.0.2. It looks
unused and knip flags it, which is why knip.json lists it under ignoreDependencies next to
tw-animate-css, shadcn, tailwindcss and redis.
The mechanism, verified in node_modules:
typescript@7.0.2ships onlylib/tsc.js,lib/getExePath.jsandlib/version.cjs. There is nolib/typescript.js, i.e. no programmatic compiler API.node_modules/next/dist/lib/verify-typescript-setup.js:127-128throws when!useTypeScriptCli && !hasNativePreview && installedTypeScript && !installedTypeScript.apiPath.- With native-preview present, the same file logs
Detected @typescript/native-preview as TypeScript compiler…, writes the app type declarations, and returns early instead of trying to install TypeScript.
This repo satisfies both escape hatches: experimental.useTypeScriptCli: true (next.config.ts:304)
and the native-preview devDependency.
CLAUDE.md:11 records the operational rule and its symptom: never remove
@typescript/native-preview — without it next build fails silently after "Skipping validation of
types". Treat any dependency-pruning suggestion against this package as a false positive.
pnpm syncpack uploads Sentry sourcemaps
package.json chains && pnpm sentry:sourcemaps onto the end of the dependency-bump script. A
routine version bump therefore requires a built .next and valid sentry-cli auth, and pushes
artifacts to the real entro314/ermisai Sentry project. Run the syncpack steps individually if
that is not what you want.
pnpm email:campaign reads .env.production
The script is node --env-file=.env.production --import tsx …. It does not read .env. Missing
RESEND_API_KEY or EMAIL_POSTAL_ADDRESS in that specific file makes marketing sends hard-fail.
Use pnpm exec, never npx, for drizzle-kit
scripts/deploy/prod-migrate.ts:14-15 states it directly: the migration gate invokes
pnpm exec drizzle-kit migrate and "NOT npx - npx re-resolves and trips the version gate". The same
applies to anything you run by hand.
AGENTS.md is regenerated by next dev
node_modules/next/dist/server/lib/generate-agent-files.js rewrites AGENTS.md on every dev-server
start. Removing it from a diff only recreates the uncommitted change; commit it with your work to
keep the tree clean.
What pnpm typecheck actually covers
tsconfig.json is strict-plus: strict, noImplicitAny, noUncheckedIndexedAccess,
noUncheckedSideEffectImports, noImplicitOverride, noFallthroughCasesInSwitch
(tsconfig.json:18-24). exactOptionalPropertyTypes is off (tsconfig.json:17).
include covers src/**, scripts/**, next.config.ts, .next/types/** and .next/dev/types/**
— plus one explicit re-add:
"src/app/.well-known/**/*.ts",TS globs skip dot-directories, so without that line the .well-known routes drop out of the program
silently and type-aware lint cannot resolve their aliases (tsconfig.json:57-59).
tests/** is not in tsconfig.json's include, so pnpm typecheck does not typecheck the test
suite. tests/tsconfig.json exists — it extends the root config and adds vitest/globals — but no
package.json script and no CI job references it. Type errors in a test file are found only when
Vitest fails at runtime.
extendedDiagnostics: true (tsconfig.json:12) means every run ends with a diagnostics block (file
count, line count, check time). That is normal output, not a warning.
sourceRoot: "/" and inlineSources: true are set for Sentry stack-trace grouping
(tsconfig.json:44-50).
How the test suite runs
pnpm test is vitest run. The suite is 88 files and 585 tests and finishes in roughly 13 seconds.
Tests live in tests/*.vitest.ts (plus tests/marketing/) and are not colocated with source.
Run one suite with:
pnpm vitest run tests/locale-parity.vitest.tsvitest.config.ts sets environment: 'node', globals: true, include: ['tests/**/*.vitest.ts'],
clearMocks: true, and three aliases:
| Alias | Target | Why |
|---|---|---|
@ | src/ | Same as the tsconfig path alias. |
next/server | node_modules/next/server.js | |
server-only | tests/stubs/server-only.ts | The real package throws outside the react-server condition. |
testTimeout and hookTimeout are both raised to 30_000 ms because heavy integration-style suites
(rss-aggregation, local-platform-data) were killed at the 5-second default under CI CPU
contention (vitest.config.ts:24-27).
tests/stubs/server-only.ts is an inert export {}. A server/client boundary violation that the
real server-only marker would catch in the app build is invisible to the test suite.
The run is noisy on success
Suites deliberately exercise failure paths and print to stderr. Lines like these appear on a fully green run and are not failures:
[news-pipeline] synthesis attempt failed (…)
[rss-aggregation] skipping cluster … after synthesis failure
[news-pipeline] cluster refinement failed (…)
[request-origin] context=api:… source=- trace=-Read the Test Files … passed summary, not the scrollback.
Environment neutralization is partial
tests/setup.env.ts is the first setupFiles entry. Vitest auto-loads the repository .env into
process.env, which would otherwise make the suite pass or fail depending on whose machine ran it.
The setup file deletes these keys before any test:
APP_URL, NEXT_PUBLIC_APP_URL, DATABASE_URL, SUPABASE_DATABASE_URL,
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY, ERMIS_ENABLE_PROVIDER_OPTIONS_MATRIX, VERCEL_ENV,
VERCEL, UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, REDIS_URL, KV_URL.
It then pins process.env.NODE_ENV = 'test'.
The Upstash entries have a documented reason recorded in the file: live credentials made unit tests hit real Redis, so the edge rate limiter returned genuine 429s under full-suite load and cache keys written by previous runs leaked back in.
The list is not exhaustive. Any key not on it — including AI_GATEWAY_API_KEY, CLERK_SECRET_KEY
and ERMIS_CONTENT_LOCALE — still reaches the test process from your local .env. A test that
starts depending on one of those passes locally and behaves differently in CI, where the value is
absent. Tests that need a value should set it explicitly with vi.stubEnv(...).
The structural gates
Four suites are not unit tests of a function — they encode architectural constraints and fail CI when a new file breaks the convention. Read these as rules, not as trivia.
tests/api-route-auth-guard.vitest.ts — every API route gates itself
There is no global middleware auth gate for API routes. Middleware matchers in src/proxy.ts are
documented as defense-in-depth only, so every handler authenticates itself, and this test is the
backstop.
It recursively collects every route.ts under src/app/api, asserts there are more than 50 of them
(so a path regression cannot make the test vacuously pass), and requires each file to match at least
one recognized gate pattern:
auth(), getResolvedClerkSessionMetadata, isAuthorizedCronRequest, authorize\w+Access,
authorizeWordPressRequest, resolveApiKeyAccess, revokeApiKeyByRawKey, x-ermis-api-key,
consumeWordPressConnectSession, verifyHumanRequest, requireAdminSession|requireSuperAdmin,
validateEvent|verifyWebhook|constructEvent|verifyResendWebhook|Webhook\(,
verifySvixSignature|RESEND_WEBHOOK_SECRET, createRssConsumer|rssQueueTopics,
handlePublicCmsStoryExportRequest.
The public allowlist has exactly two entries, each with a justification comment in the file:
src/app/api/health/route.ts— liveness/readiness probe, no secrets.src/app/api/openapi/public-integrations/route.ts— static public OpenAPI document.
This is a textual check. It proves an auth token appears in the file, not that the gate is
applied correctly or covers every verb. Passing it is necessary, not sufficient — review the actual
gate too. Behavioural role enforcement is covered separately by
tests/route-audit-regressions.vitest.ts.
If you add a genuinely public route, adding it to PUBLIC_ROUTE_ALLOWLIST is a deliberate,
reviewable act — the constant is not meant to grow.
tests/locale-parity.vitest.ts — ten locales stay in step
39 cases in four groups:
- Message key coverage. For each of the nine non-
enUI locales,messages/<locale>.jsonmust exist and must contain every dotted key path present inmessages/en.json. The failure message tells you to runpnpm i18n:translate <locale>.collectKeystreats an array as a single leaf, so an array with fewer items than English still passes, and extra keys in a locale file are not flagged. - Compose persona per content locale. Calls
resolveStoryComposePromptfor every content locale and asserts the result for a non-enlocale does not begin withYou are Lyra by ErmisAI— the tell-tale of falling through to the English persona. - Refinement persona per content locale. The same assertion against
resolveStoryRefinementPrompt. - Category label coverage. Every id from
listCategoryFilterIds()must resolve to a non-empty label that is not the raw id, in every UI locale. A non-enlabel byte-identical to English is accepted, because some category names are intentional anglicisms.
Tests 2 and 3 exist because composeTemplateVersionsByLocale and
refinementTemplateVersionsByLocale are plain as const with no satisfies constraint, so a
missing persona falls back to English silently instead of failing to compile.
Practical consequence: any change to messages/en.json must reach all ten files before CI passes.
See Localization system for the translation workflow and the
full add-a-locale checklist.
tests/client-message-scoping.vitest.ts — namespaces reach the client
Each layout hands its NextIntlClientProvider only an allowlisted subset of message namespaces
(src/lib/i18n/client-messages.ts). This test walks the real module-import graph of each route
segment — src/app/[locale], src/app/(tenant)/app, src/app/(internal)/admin — descends into
every client module it reaches, collects the top-level namespaces those modules read via
useTranslations('<ns>'), and fails if the layout's allowlist does not cover the set.
Without it, adding a client-side useTranslations('X') and forgetting to widen the allowlist ships
raw message keys to production. The failure message names the exact missing namespace.
tests/route-audit-regressions.vitest.ts — role gates behave
Behavioural counterpart to the auth-guard textual check. It asserts, among others, that ops gets
403 on /api/admin/review, editor gets 403 on /api/admin/deliveries, and member gets 403 on
/api/team, /api/team/invite, /api/billing/subscription, /api/usage and
/api/integrations/api-keys. It also covers the one-time raw API key value, the WordPress export
plan gate, and the source-icon SSRF rejections.
Configured but never run
Three things exist in the repo and are wired to nothing. Knowing this saves you assuming a check has your back:
- knip (
knip.json,knip@^6) — dead-code and dependency analysis. No package.json script and no CI job. Run it manually withpnpm exec knip. pnpm format:check—oxfmt --check src/. Exists; no CI job invokes it. Formatting drift is caught only by whoever runspnpm format.tests/tsconfig.json— the config that would typecheck the test suite. No runner references it.
Before you push
Run the four commands CI runs, in the same order:
pnpm typecheck
pnpm exec oxlint -f github src/
pnpm db:check
pnpm testUsing pnpm exec oxlint rather than pnpm lint keeps the output on screen and leaves lint.md
untouched.
If you touched messages/en.json, propagate to the other nine locales before re-running the suite —
the parity gate fails otherwise. See Localization system.
If you added a file under src/app/api, confirm it carries a real auth gate. The guard test only
checks that a recognized token is present.
If you changed src/lib/db/schema.ts, generate and review the migration before pushing —
pnpm db:check validates the migration folder, not your intent. See
Migrations and schema changes.
Run pnpm build when you changed anything the bundler sees. Remember it type-checks nothing, so it
is a bundling and prerendering check only.
Local development setup
Version pins, env files, and running without Postgres.
Migrations and schema changes
The drizzle-kit v1 layout and the production migrate-then-build gate.
Route surfaces, proxy and auth gates
Why every API route carries its own gate.
Localization system
Ten locales, the deep-merge fallback, and the translation script.
Deploying to production
The Vercel build shape and the post-deploy smoke tests.
