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:
- Pre-commit — quality checks run locally before code leaves the developer’s machine
- CI (GitHub Actions) — automated formatting, lint, and typecheck checks run on every pull request
- CD (Vercel) — every commit pushed to a PR branch triggers a preview build of all relevant apps; merging the PR to
mainpromotes 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.
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:checkpnpm 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=45pnpm 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 typecheckpnpm 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/storybookhas notypecheckscript, so turbo skips it. Its story files carry pre-existing StorybookargTypes/argstype errors that predate this gate; the component source of truth (@sustentus/ui) is typechecked. Re-enable it by adding atypecheckscript 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
| Event | Outcome |
|---|---|
| Commit pushed to a PR branch | Preview 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/servicesNo 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
| App | Root directory | Build command |
|---|---|---|
| Marketing | apps/marketing | cd ../.. && pnpm marketing:build |
| Web | apps/web | cd ../.. && pnpm web:build |
| Dashboards | apps/dashboards | cd ../.. && pnpm dashboards:build |
| Docs | apps/docs | cd ../.. && pnpm docs:build |
| Storybook | apps/storybook | cd ../.. && 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 productionStage 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, MONGODB_DATABASE_NAME), read from a GitHub Environment’s secrets, and skip gracefully until a secret is configured. Full reference: database lifecycle.
Database migrations
Workflow: .github/workflows/db-migrate.yaml
Trigger: pull_request (preview) and push to main (production), manual dispatch
The workflow splits into two environment-scoped jobs, so a schema change is exercised before it reaches production:
migrate-previewrunsdb:migrate upagainst the preview database (environment: preview) on every same-repo pull request — the migration is applied and tested before merge. A fail-fast guard refuses to run if the resolved target is the production database.migrate-productionrunsdb:migrate upagainst production (environment: production) on push tomain. The production environment carries required-reviewer protection — a human approval gate that must clear before the migration runs.
Both jobs read MONGODB_URI + MONGODB_DATABASE_NAME from their environment’s secrets (no DB name is hardcoded), 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). 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.
Summary
| Stage | Tool | Runs when | What it checks |
|---|---|---|---|
| Pre-commit | Husky + lint-staged | Every local git commit | Formats staged files with Prettier |
| Format CI | GitHub Actions | PR opened / updated | All files match Prettier output |
| Lint CI | GitHub Actions | PR opened / updated | ESLint passes with ≤ 45 warnings |
| Typecheck CI | GitHub Actions | PR opened / updated | tsc --noEmit passes in every workspace |
| Preview build | Vercel + Turborepo | Every commit pushed to a PR branch | Full preview build of all relevant apps |
| Production build | Vercel + Turborepo | PR squash merged to main, branch deleted | Full production build of all apps |
| Database audit | GitHub Actions | PR (same-repo) + nightly schedule | DB leanness report — honest: fails if it can’t audit |
| Database migrate | GitHub Actions | PR (preview) + push to main (production) | Applies pending migrations, environment-gated |