Siirry sisältöön
ErmisAI

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 test

Job 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_secret

SENTRY_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

ScriptCommandNotes
devnext devDev server on http://localhost:3000. Turbopack, Next 16 canary.
buildNODE_ENV=production next buildThe NODE_ENV prefix is load-bearing.
vercel:buildtsx scripts/deploy/prod-migrate.ts && NODE_ENV=production next buildThe Vercel buildCommand. Migrate, then build.
startnext startServe a prior build.
typechecktsc --project tsconfig.json --noEmitNative TypeScript 7 compiler.
typecheck:watchsame, plus --watch --incremental
testvitest runOne-shot.
test:watchvitest
lintoxlint -f github src/ > lint.md 2>&1Writes a file, prints nothing.
lint:fixoxlint -f stylish --fix src/Terminal output plus autofix.
lint:fix-unsafeoxlint with all plugins, --fix --fix-suggestions --fix-dangerously src/Includes fixes that can change behaviour.
check:fix-suggestionsoxlint with all plugins, --fix --fix-suggestions . then oxfmt --write .Note the target is ., the whole repo, not src/.
formatoxfmt --write src/
format:checkoxfmt --check src/Not run by CI.

Database

ScriptCommandNotes
db:generatedrizzle-kit generateEmits a new drizzle/<timestamp>_<name>/ directory.
db:migratedrizzle-kit migrateApplies pending migrations.
db:checkdrizzle-kit check --dialect postgresqlOffline consistency check of the migration folder. Runs in CI.
db:studiodrizzle-kit studio
db:seed-bootstraptsx scripts/db/seed-bootstrap.tsUpserts the billing plans and the whole source catalog in one transaction.
db:backfill-source-iconstsx scripts/db/backfill-source-icons.tsOne-off, idempotent.
db:repair-source-categoriestsx scripts/db/repair-source-catalog-categories.tsOne-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

ScriptCommandNotes
catalog:validatetsx scripts/catalog/validate-canonical.tsRead-only. Prints path, source count, version, checksum.
catalog:buildtsx scripts/catalog/build-canonical.tsValidates, re-sorts and recomputes checksums for news-sources/catalog.v1.json in place.
catalog:coveragetsx scripts/catalog/report-coverage.tsEU-country coverage report.
catalog:audittsx scripts/catalog/audit-urls.ts --feed-only --output=news-sources/audit-report.jsonNetwork-bound; pings every feedUrl.

Localization, email, API, release

ScriptCommandNotes
i18n:translatetsx scripts/i18n/translate-messages.tsShells out to the local claude CLI, not the Anthropic API.
i18n:translate:drysame, plus --dryReports the diff, calls nothing, writes nothing.
email:previewemail dev --dir src/emailsreact-email preview server.
email:campaignnode --env-file=.env.production --import tsx scripts/email/send-campaign.tsReads .env.production, not .env.
api:openapitsx scripts/api/generate-public-integration-openapi.tsWrites docs/api/public-integration-api.openapi.json.
sentry:sourcemapssentry-cli sourcemaps inject … && … upload … .nextOrg entro314, project ermisai. Needs a built .next and sentry-cli auth.
syncpacksyncpack update --target latest && syncpack fix … && syncpack format && pnpm sentry:sourcemapsChains 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>&1

Both 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.2 ships only lib/tsc.js, lib/getExePath.js and lib/version.cjs. There is no lib/typescript.js, i.e. no programmatic compiler API.
  • node_modules/next/dist/lib/verify-typescript-setup.js:127-128 throws 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.ts

vitest.config.ts sets environment: 'node', globals: true, include: ['tests/**/*.vitest.ts'], clearMocks: true, and three aliases:

AliasTargetWhy
@src/Same as the tsconfig path alias.
next/servernode_modules/next/server.js
server-onlytests/stubs/server-only.tsThe 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:

  1. Message key coverage. For each of the nine non-en UI locales, messages/<locale>.json must exist and must contain every dotted key path present in messages/en.json. The failure message tells you to run pnpm i18n:translate <locale>. collectKeys treats 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.
  2. Compose persona per content locale. Calls resolveStoryComposePrompt for every content locale and asserts the result for a non-en locale does not begin with You are Lyra by ErmisAI — the tell-tale of falling through to the English persona.
  3. Refinement persona per content locale. The same assertion against resolveStoryRefinementPrompt.
  4. 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-en label 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 with pnpm exec knip.
  • pnpm format:checkoxfmt --check src/. Exists; no CI job invokes it. Formatting drift is caught only by whoever runs pnpm 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 test

Using 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.

Tällä sivulla