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.

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, 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-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. A fail-fast guard refuses to run if the resolved target is the production database.
  • migrate-production runs db:migrate up against production (environment: production) on push to main. 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

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