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) — one advisory
Qualityjob 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 theVercel – webstatus - 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 → appsStage 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’ssynchronize; the session’sformat.sh/lint.sh/security-check.shover the changed files are the draft’s check. Theready_for_reviewflip itself starts the first run. - A PR whose whole diff is
.icm/**,**/*.md,**/*.mdxor.github/**runs no Quality job — the trigger’spaths-ignorefilters it out before a runner starts. - A bookkeeping push on a code PR skips the turbo pass. GitHub evaluates
paths-ignoreagainst the whole PR, so once a PR touches code every push triggers the job. ItsScopestep 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 oldermain. - Everything else runs one turbo pass —
turbo run format:check format:check:root lint typecheck testwith--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:checkis 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/storybookwas archived on 2026-09-23 and is excluded from the pnpm workspace (!apps/storybookinpnpm-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.mdhas 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 notestscript andturbo run testnever 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 — seeapps/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 thepreviewClerk values exported in the shell. The preview URL is the oneci-status.shprinted 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.mdhas 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
| Event | Outcome |
|---|---|
| Commit pushed to a draft PR branch | No 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 branch | Preview 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/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 |
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 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-previewrunsdb:migrate upagainst the preview database (environment: preview) on every same-repo pull request in the ready phase — draft pushes skip the global migrate lock entirely, and theready_for_reviewflip 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 runsdb:seedso 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-productionmigrates and stops.migrate-productionrunsdb:migrate upagainst production (environment: production) on push tomain, and asserts the resolved database name issustentus-prodbefore 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
| Stage | Tool | Runs when | What it checks |
|---|---|---|---|
| Pre-commit | Husky + lint-staged | Every local git commit | Formats staged files with Prettier |
| Quality CI | GitHub Actions | Ready-state PR pushes (advisory) | One turbo pass: format · lint · typecheck · test; skipped on drafts and when only .icm/**, .github/** or markdown/MDX changed |
| Preview build | Vercel + Turborepo | Ready-state pushes only | Affected product apps (web, console, agent); quiet apps and drafts build nothing |
| Persona walk | Operator, by hand | Ready to merge, on the web preview | Six persona sign-ins and dashboard walks; not a CI check (Preview smoke retired, D43) |
| 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 |