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) — one advisory Quality job on ready pull requests: format check, lint, typecheck and unit tests as one turbo pass. Draft pushes run no Quality job, and nothing waits on it — main’s ruleset requires the Vercel – web status
  3. CD (Vercel) — previews are phase- and class-aware (blind-until-ready): the three product apps build previews from the ready flip on — draft pushes build nothing — and the four quiet apps build only on merge to main, which also 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 └────────┬─────────┘ │ ▼ ┌──────────────────────────────────────┐ │ DRAFT PR — every push │ All changes go through a PR, opened draft. │ (blind-until-ready) │ No Quality job, no previews; db-migrate / └────────┬─────────────────────────────┘ db-audit skip draft pushes │ Build flips ready-for-review, then pushes ▼ ┌──────────────────────────────────────┐ │ Quality (advisory) │ One turbo pass: format · lint · typecheck │ (GH Actions, per ready-state push) │ · test. Skipped when only .icm/**, └────────┬─────────────────────────────┘ .github/** or markdown/MDX changed ▼ ┌──────────────────────────────────────┐ │ Vercel preview builds │ Affected PRODUCT apps only (web, │ (per ready-state push) │ tenant-management, agentic-interface); └────────┬─────────────────────────────┘ Vercel – web is the required status │ │ When Ready to merge is ticked & squash merged ▼ ┌──────────────────────────┐ │ Merge to main │ Branch deleted after merge └────────┬─────────────────┘ │ ▼ ┌──────────────────────────┐ │ Vercel production build │ All seven projects evaluate; the four QUIET │ & deploy │ apps build here and not before; Turborepo └──────────────────────────┘ orchestrates 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)

One quality workflow runs on ready pull requests, beside the two database workflows (Stage 4). All changes go through a pull request; main’s ruleset requires the Vercel – web status, which only a pull request’s preview carries.

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.

The Quality workflow — one advisory job, one turbo pass

Workflow: .github/workflows/quality.yaml · check run: Quality (advisory) — advisory: ci-status.sh reports it and never reds on it, and no ruleset requires it (main requires Vercel – web; it required Quality Project until 2026-09-24, D43) Trigger: pull_request — ready_for_review, synchronize, reopened — and manual dispatch; never on main

There is exactly one CI quality job (the old separate format/lint/typecheck workflows are long retired). Because nothing waits on it, it may skip a diff it has nothing to say about:

  • Draft pushes run no Quality job. The job-level if: skips a draft’s synchronize; the session’s format.sh / lint.sh / security-check.sh over the changed files are the draft’s check. The ready_for_review flip itself starts the first run.
  • A PR whose whole diff is .icm/**, **/*.md, **/*.mdx or .github/** runs no Quality job — the trigger’s paths-ignore filters it out before a runner starts.
  • A bookkeeping push on a code PR skips the turbo pass. GitHub evaluates paths-ignore against the whole PR, so once a PR touches code every push triggers the job. Its Scope step then diffs the head against the head of the branch’s last successful Quality run; when every changed path is one the trigger already ignores (the release record, the changelog page, the close-out archive move), the job skips the rest and reports success, listing the paths in its summary. No earlier success, an unfetchable baseline (a force push) or a dispatch runs everything. The skipped head inherits its verdict from that last tested head, which was tested against an older main.
  • Everything else runs one turbo pass — turbo run format:check format:check:root lint typecheck test with --continue=dependencies-successful, so one failure does not hide the others and wall time tracks the typecheck critical path rather than a sum of serial steps. format:check is a cached per-package turbo task, with a root task for the files no package owns.
  • A newer push cancels the run in flight on the same branch.

A red run is still a finding the stage fixes on the branch — advisory means the merge never waits on it, not that it may be ignored.

Details that keep the gate honest: the ESLint warning ceiling lives in each package’s own lint script (--max-warnings=45 — per-package, as the old appended flag always effectively was); prettier.config.js and .prettierignore are turbo globalDependencies, so a config or ignore edit busts every package’s cached format result; typecheck still declares "dependsOn": ["^build"] and shares the remote cache with Vercel.

Storybook is archived — out of the workspace. apps/storybook was archived on 2026-09-23 and is excluded from the pnpm workspace (!apps/storybook in pnpm-workspace.yaml), so turbo never sees @sustentus/storybook: it is not installed, linted, typechecked, tested or built, and root ESLint and Prettier ignore it. The component source of truth (@sustentus/ui) is still checked here as usual. apps/storybook/README.md has the restore steps.

The E2E persona smoke tier is not in this gate. apps/e2e (@sustentus/e2e) drives a real browser against a deployed base URL, which this job has neither a network for nor a target for, so the workspace defines no test script and turbo run test never reaches it. Its lint, typecheck and format checks do run here like every other workspace’s. No workflow runs it: the operator runs it by hand, below — see apps/e2e/README.md.

The persona walk — the operator’s, not CI’s

No workflow opens a page. Preview smoke (.github/workflows/preview-smoke.yaml, with its two .github/scripts) was retired on 2026-09-24 (D43): it ran once per commit status, up to 100 minutes a walk, and no PR waits for it any more. smoke_check is absent from .icm/project.json, so ci-status.sh requires no smoke check.

The six-persona walk (apps/e2e) is now run by hand:

  • The operator, at Ready to merge — E2E_BASE_URL=<web preview URL> pnpm e2e, with the preview Clerk values exported in the shell. The preview URL is the one ci-status.sh printed for the ready head. The walk signs in as each demo seat, walks that persona’s dashboard and sidebar pages, and fails only on a hard failure (a sign-in that does not land, a 5xx, a blank main region, an error boundary, an uncaught console error). apps/e2e/README.md has the preconditions and how to read the walk summary; Preview database says which Clerk instance the values must come from.
  • An agent, only on request — when the operator asks for a walk in that session, through .icm/scripts/e2e.sh walk <base-url> [-g <persona>] and nothing else. It is never a routine check or a gate; the runner forms (pnpm e2e, turbo run e2e, playwright test) stay blocked in agent sessions (Automation offload).

A walk that drifted (a renamed route, a moved sidebar entry) is repaired in apps/e2e with the browser-smoke skill; a page that is really broken is parked as a triage stub.


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 draft PR branchNo preview builds — drafts are blind: each product app’s ignore step skips while the PR is draft, and the quiet apps create no work-branch deployment at all
Commit pushed to a ready PR branchPreview build of the affected product apps (web, tenant-management) and of agentic-interface on every ready push, affected or not
Pull request merged into main (squash merge)Production deployment — all seven projects evaluate; the four quiet apps (demo, docs, help-centre, marketing) build here and not before

From the ready-for-review flip on, every push rebuilds the affected product apps and publishes unique preview URLs — those previews are what the operator smokes before ticking Ready to merge. Draft pushes build nothing, by three cooperating filters: Vercel’s native “Skip deployment for unaffected projects” toggle filters unaffected projects platform-side (no deployment record, no build slot), each product app’s ignore command (scripts/vercel-ignore.sh) skips the build while the branch’s open PR is draft (failing open into turbo-ignore on any API error, so an outage strands no preview) — and for agentic-interface, called with --every-ready, builds on every ready push rather than asking turbo-ignore, because the platform’s preview hands a customer off to the agent app’s preview of the same branch and that branch alias must exist (see Deployment) — and the quiet apps’ vercel.json disables work-branch deployments entirely via git.deploymentEnabled. When the PR is squash merged and the branch deleted, Vercel triggers the 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 ├── 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

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 in the ready phase — draft pushes skip the global migrate lock entirely, and the ready_for_review flip itself migrates, so a migration written while draft still reaches the preview DB before anything on that head can merge — 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
Quality CIGitHub ActionsReady-state PR pushes (advisory)One turbo pass: format · lint · typecheck · test; skipped on drafts and when only .icm/**, .github/** or markdown/MDX changed
Preview buildVercel + TurborepoReady-state pushes onlyAffected product apps (web, console, agent); quiet apps and drafts build nothing
Persona walkOperator, by handReady to merge, on the web previewSix persona sign-ins and dashboard walks; not a CI check (Preview smoke retired, D43)
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