Skip to Content
TechnicalDevelopmentCI/CD pipeline

CI/CD pipeline

This page documents the full continuous integration and continuous deployment (CI/CD) pipeline for the Sustentus monorepo, from a local commit through to a live production deployment.

Overview

The pipeline has three distinct stages that run in sequence:

  1. Pre-commit — quality checks run locally before code leaves the developer’s machine
  2. CI (GitHub Actions) — automated formatting, lint, and typecheck checks run on every pull request
  3. CD (Vercel) — every commit pushed to a PR branch triggers a preview build of all relevant apps; merging the PR to main promotes to production

Two further GitHub Actions manage the database lifecycle alongside this flow — a PR-time audit and gated preview/production migration runs (see Stage 4).

Developer commit ┌──────────────────┐ │ Pre-commit hook │ Husky + lint-staged │ (local) │ Auto-formats staged files └────────┬─────────┘ ┌──────────────────────────────────────┐ │ Pull request opened / commit pushed │ All changes must go through a PR │ (repeat on every push to branch) │ Direct pushes to main are not permitted └────────┬─────────────────────────────┘ ├───────────────┬───────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐ │ Format check │ │ Lint check │ │ Typecheck check │ │ (GH Actions) │ │ (GH Actions)│ │ (GH Actions) │ └────────┬────────┘ └──────┬───────┘ └────────┬─────────┘ └────────────┬────┴──────────────────┘ │ All must pass ┌───────────────────────────┐ │ Vercel preview build │ All relevant apps rebuilt │ (every commit on branch) │ Unique preview URL per app └──────────┬────────────────┘ │ (loop back on each new push) │ When PR is approved & squash merged ┌──────────────────────────┐ │ Merge to main │ Branch deleted after merge └────────┬─────────────────┘ ┌──────────────────────────┐ │ Vercel production build │ Turborepo orchestrates │ & deploy │ packages → apps └──────────────────────────┘

Stage 1 — Pre-commit (local)

Configured via Husky  and lint-staged .

Hook location: .husky/pre-commit

When a developer runs git commit, Husky triggers pnpm lint-staged automatically. lint-staged runs Prettier over every staged *.ts, *.tsx, and *.md file, writing the formatted output back into the commit.

// Root package.json "lint-staged": { "*.{ts,tsx,md}": ["prettier --write"] }

This ensures no unformatted code ever reaches the remote repository. The hook runs in milliseconds on only the files touched in that commit.


Stage 2 — Continuous integration (GitHub Actions)

Three workflows run in parallel on every pull request. All changes must go through a pull request — direct pushes to main are not permitted.

These checks are owned entirely by the factory (Husky + CI + Vercel), not by developers or the /pipeline Build agent. The Build stage deliberately does not run format, lint, or typecheck by hand — they are deterministic, non-AI work, so they run here where they can be enforced once and read back automatically. See the development workflow and automation offload for the guardrails that enforce the split.

Format check

Workflow: .github/workflows/format.yaml Trigger: pull requests, manual dispatch

jobs: format: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 (node 24) - uses: pnpm/action-setup@v4 - run: pnpm install --frozen-lockfile - run: pnpm format:check

pnpm format:check runs prettier --check across all *.ts, *.tsx, and *.md files. It does not write changes — it fails the job if any file does not match the Prettier output. This acts as the safety net for commits that bypassed the pre-commit hook (e.g. direct pushes or --no-verify).

Lint check

Workflow: .github/workflows/lint.yaml Trigger: pull requests, manual dispatch

jobs: lint: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 (node 24) - uses: pnpm/action-setup@v4 - run: pnpm install --frozen-lockfile - run: pnpm lint -- --max-warnings=45

pnpm lint runs turbo run lint, which executes ESLint across every workspace package and app. The --max-warnings=45 flag allows the job to pass with up to 45 warnings but fails on any errors or if the warning count is exceeded.

Typecheck check

Workflow: .github/workflows/typecheck.yaml Trigger: pull requests, manual dispatch

jobs: typecheck: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 (node 24) - uses: pnpm/action-setup@v4 - run: pnpm install --frozen-lockfile - run: pnpm typecheck

pnpm typecheck runs turbo run typecheck, which executes tsc --noEmit in every workspace package and app that defines a typecheck script. The turbo task declares "dependsOn": ["^build"], so the shared packages (@sustentus/ui, @sustentus/services) build their type declarations first — an app can only typecheck against the dist types its dependencies emit. Remote caching keeps repeat runs fast.

Storybook is excluded for now. apps/storybook has no typecheck script, so turbo skips it. Its story files carry pre-existing Storybook argTypes/args type errors that predate this gate; the component source of truth (@sustentus/ui) is typechecked. Re-enable it by adding a typecheck script once those stories are cleaned up.

All three jobs must pass before a pull request can be merged.


Stage 3 — Continuous deployment (Vercel)

Deployment is handled by Vercel . Each application in the monorepo has its own independent Vercel project, allowing them to be deployed separately without affecting one another.

Trigger rules

EventOutcome
Commit pushed to a PR branchPreview build of all relevant apps — every push, for the lifetime of the branch
Pull request merged into main (squash merge)Production deployment — branch is deleted after merge

Every time a commit is pushed to an open pull request, Vercel rebuilds all relevant applications and publishes them to unique preview URLs. This means reviewers always have a live, up-to-date build reflecting the latest state of the branch. Preview environments continue to be updated on each push until the PR is squash merged into main and the branch is deleted, at which point Vercel triggers a production deployment.

Build pipeline (Turborepo)

Vercel runs the build command for each app, but Turborepo ensures all dependencies are built first. The dependency graph is declared in turbo.json via "dependsOn": ["^build"], which means each package or app waits for all of its upstream dependencies to finish building before it starts.

Full build order:

Layer 1 — Shared packages (built in parallel, no upstream deps) ├── @sustentus/ui Shared component library (shadcn/ui, Radix UI) └── @sustentus/services Shared business logic, DB, AI, email Layer 2 — Applications (each waits for the packages it consumes) ├── web depends on @sustentus/ui + @sustentus/services ├── docs depends on @sustentus/ui + @sustentus/services ├── help depends on @sustentus/ui + @sustentus/services ├── storybook depends on @sustentus/ui ├── marketing depends on @sustentus/ui ├── dashboards self-contained (local shadcn copies) └── api depends on @sustentus/services

No app in layer 2 will start building until both shared packages in layer 1 have finished. This means a change to @sustentus/ui or @sustentus/services automatically triggers a rebuild of every downstream app that depends on it.

Remote caching is enabled ("remoteCache": { "enabled": true } in turbo.json). Turborepo stores build outputs in a remote cache keyed by the content hash of inputs. If nothing changed in a package, the cached output is restored in seconds rather than rebuilding.

Per-app Vercel configuration

AppRoot directoryBuild command
Marketingapps/marketingcd ../.. && pnpm marketing:build
Webapps/webcd ../.. && pnpm web:build
Dashboardsapps/dashboardscd ../.. && pnpm dashboards:build
Docsapps/docscd ../.. && pnpm docs:build
Storybookapps/storybookcd ../.. && pnpm storybook:build

Each project has its own environment variables configured in the Vercel dashboard. Shared secrets (e.g. Clerk keys, PostHog key) are added to each project individually.

Environment variable management

# Pull environment variables for local development vercel env pull .env.local # Add a production secret via CLI vercel env add CLERK_SECRET_KEY production

Stage 4 — Database lifecycle (GitHub Actions)

Two workflows keep the MongoDB database in step with the code. Both reuse the same connection env as the app (MONGODB_URI as a secret, MONGODB_DATABASE_NAME as a variable), and both fail red when that configuration is missing rather than skipping — a green check means a migration or an audit actually ran. Full reference: database lifecycle; operator steps for the preview database: preview database runbook.

Database migrations

Workflow: .github/workflows/db-migrate.yaml Trigger: pull_request touching packages/services/** or the workflow itself (preview), push to main (production), manual dispatch with a target input

The workflow splits into two environment-scoped jobs, so a schema change is exercised before it reaches production:

  • migrate-preview runs db:migrate up against the preview database (environment: preview) on every same-repo pull request — the migration is applied and tested before merge — then runs db:seed so the database a preview URL reads is populated as well as schema-correct. A fail-fast guard refuses to run if the resolved target is the production database. Seeding is preview-only: migrate-production migrates and stops.
  • migrate-production runs db:migrate up against production (environment: production) on push to main, and asserts the resolved database name is sustentus-prod before it runs.

Each job preflights its configuration before checkout: an empty MONGODB_URI or MONGODB_DATABASE_NAME fails the job in seconds with an ::error:: naming what is missing and where to set it. There is no skip path — “not configured” can no longer read as “migrated successfully”. migrate-preview then writes the resolved database name, the migration outcome, the seed’s created / already-present counts, and URI-scrubbed copies of both outputs to the job summary, so a green check is auditable without opening the logs. A seed failure fails the job like a migration failure does — and because the migration step exits non-zero on failure, nothing is ever seeded against a half-migrated schema.

A manual dispatch takes a target input (preview | production, default preview) and each job gates on it, so one manual run migrates exactly one database — the catch-up path for a preview database left behind by app-only pull requests.

Both jobs keep concurrency with cancel-in-progress: false so an in-flight migration is never killed, are gated to the canonical repository (github.repository == 'sustentus/sustentus') so forked-PR code never receives a secret, and apply migrations non-interactively. A failed migration surfaces as a red status check.

Database audit

Workflow: .github/workflows/db-audit.yaml Trigger: pull requests (same-repo only), nightly schedule, manual dispatch

Runs pnpm --filter @sustentus/services db:audit, a read-only leanness report (unused/redundant indexes, missing tenant indexes, oversized documents, unbounded arrays, orphaned references) against the production database. The check is honest: it distinguishes “audited and clean” from “could not audit”. If any collection cannot be inspected (e.g. the DB user lacks indexStats), or none are inspected, it fails loudly instead of reporting a false all-clear; it exits 0 (skips) only when no MONGODB_URI is configured. Findings on a fully-inspected database are surfaced in the job summary as advisory items, not failed on. Forked PRs are skipped so secrets are never exposed.

Unlike the migration jobs this one declares no environment:, because it must run unattended on every pull request and on the nightly cron — an environment would put it behind that environment’s required reviewers. It therefore resolves repository-level values, and the repository variable MONGODB_DATABASE_NAME (sustentus-prod) is what points it at production. With a MONGODB_URI but no database name it refuses to run rather than audit whatever the URI path happens to name, and it prints the audited database to the job summary.


Summary

StageToolRuns whenWhat it checks
Pre-commitHusky + lint-stagedEvery local git commitFormats staged files with Prettier
Format CIGitHub ActionsPR opened / updatedAll files match Prettier output
Lint CIGitHub ActionsPR opened / updatedESLint passes with ≤ 45 warnings
Typecheck CIGitHub ActionsPR opened / updatedtsc --noEmit passes in every workspace
Preview buildVercel + TurborepoEvery commit pushed to a PR branchFull preview build of all relevant apps
Production buildVercel + TurborepoPR squash merged to main, branch deletedFull production build of all apps
Database auditGitHub ActionsPR (same-repo) + nightly scheduleDB leanness report — honest: fails if it can’t audit
Database migrateGitHub ActionsPR (preview) + push to main (production)Applies pending migrations, environment-gated
Last updated on