Skip to content

CI and the gate

CI and the gate

The gate is the machine-checkable definition of done. It runs identically on a developer’s machine and in CI, so an agent can close its own loop without waiting on GitHub.

Nothing merges on a red gate. The gate decides, not the agent’s assessment.

The eight steps

1. Lint, typecheck, test, build

Terminal window
pnpm turbo lint typecheck test build

Turborepo runs it across every workspace package with caching. This is the step that catches most things.

2. Schema integrity

Terminal window
pnpm --filter db check # also runs as part of `pnpm --filter db test`

Per ADR-0008, the Drizzle schema is the single source of truth. This fails on drift between schema and migrations, on conflicting migration ordering (the parallel-branch case), and on migration files missing from — or absent in — the journal.

⚠️ Not drizzle-kit check. Despite the name, it does not detect any of those. Verified against drizzle-kit 0.31.10: it reported “Everything’s fine” for a hand-edited migration, for schema drift with no migration generated, and for two migrations sharing a journal index. Using it here would have been a gate step that cannot fail — the exact vacuous-coverage failure this document warns about. packages/db/scripts/check-schema.mjs does the real work; its header records what was tested.

3. Database tests

Real Postgres, never mocks — pg_trgm and FTS cannot be meaningfully faked.

  • Locally: Docker Compose Postgres from packages/db
  • In CI: an ephemeral Neon branch per PR, torn down even on failure

See ADR-0011.

4. API tests on the real Workers runtime

Via @cloudflare/vitest-pool-workers. Not Node with mocked bindings.

The Workers runtime differs from Node in ways that matter here — bindings, the request lifecycle, available globals. A test suite that passes under Node and fails on deploy is worse than no test suite, because it produces confidence without coverage.

5. Deploy smoke and bundle size

Terminal window
pnpm --filter api exec wrangler deploy --dry-run

Catches configuration errors — a missing binding, a bad compatibility_date — without deploying. The bundle-size check is discipline for the 10ms CPU ceiling: bundle growth is the leading indicator of CPU growth.

6. Docs-diff check

A PR touching apps/, packages/ or tools/ must also touch docs/, or carry the docs:n-a label.

tools/ is included because it holds real code — tools/catalogue-forge at WI-025 — even though it is never deployed.

Deliberately crude. It cannot tell good documentation from bad — it only makes forgetting impossible. Per ADR-0013, a docs follow-up PR is a docs PR that never gets written.

docs:n-a is a legitimate escape (a pure refactor, a dependency bump). Using it on a feature PR is a review finding.

7. Migration safety

Destructive DDL — DROP TABLE, DROP COLUMN, DROP SCHEMA, DROP DATABASE, TRUNCATE — fails the gate unless the PR carries migration:destructive.

Scoped to added lines in .sql files. Scanning every file would match prose — this very document names DROP COLUMN — and a check that cries wolf is a check that gets ignored.

The label is not a rubber stamp. It marks the PR as needing a human who has thought about the data already in the table.

8. Contract check

apps/web and apps/mobile must typecheck against packages/shared.

ADR-0002 gives two clients on one API; this is what stops them drifting. Combined with drizzle-zod (ADR-0008), a database column change that breaks a client fails here rather than in production.

Running the gate

Terminal window
# Full gate, as CI runs it
pnpm gate
# Fast inner loop while iterating
pnpm turbo lint typecheck test --filter=<package>

Run the full gate before opening a PR. A per-package run is for iteration, not for step 5 of the loop.

When the gate fails

  1. Read the actual error. Not the summary line — the error.
  2. Fix the cause, not the symptom. Deleting an assertion is not fixing a test.
  3. Re-run the full gate.
  4. Three failures for the same reason: stop and report. The problem is not where you think it is.

Never disable a check, add a skip, loosen a type, or widen a lint exclusion to get to green. If a check is genuinely wrong, that is a work item with an ADR, not an inline edit.

Branch protection — read this before assuming main is safe

The rule is that main requires a passing gate and a PR, with no direct pushes by anyone (ADR-0015).

⚠️ That rule is not enforced. GitHub’s free tier blocks both branch protection and rulesets on private repositories, so nothing server-side rejects a push to main — see ADR-0018.

What exists instead:

LayerStrength
CI gate on the PREnforced — a red gate blocks the merge
.githooks/pre-push blocking pushes to mainAdvisory — client-side, bypassable with --no-verify, inert unless core.hooksPath is set
The rule in AGENTS.md and the-loop.mdConvention

So the gate is real; the bypass is not closed. Enable the hook on every clone:

Terminal window
git config core.hooksPath .githooks

A push to main is a process failure and gets reviewed as one, whether or not the hook caught it.