oll.am · CI/CD Constitution · IMPLEMENTED · 2026-07-03

main is the Truth. Deploy is a Decision.

One code line. Environments are targets you send a chosen commit to — never branches that auto-run. This is the model to lock in before the next service ships. Two independent audits converged on it; below is the recommendation, the migration, and the one call that's yours.

The principle

A git branch must never decide what runs in an environment. main is the single source of truth; a deploy is a deliberate act that promotes a specific commit to a specific environment. Merging code and running code are two separate events.

Where we are — the coupling to break

Today three things fuse "what's in version control" to "what's running", and they fight each other:

The CI gates themselves (oll-core.yml, services.yml, site.yml) are already clean and correctly path-scoped — they stay.

The target model — trunk + environments-as-targets

feat/*PRgreen CI (path-scoped)main — source of truth
on merge to main →staging envstage.<svc>.oll.am · Neon branch · test Stripe · auto
git tag v* →production env<svc>.oll.am · prod Neon · live Stripe · gated + ordered + health-checked

Trunk-based. main is the only long-lived branch. Short-lived feat/* → PR → green CI → squash-merge. The stage branch is retired; "staging" becomes an environment. A merge to main updates the truth and auto-deploys staging (disposable, for integration proof) — but never production. Production only moves when you tag a commit, and that tagged commit is one that already ran on staging, so prod always gets exactly what staging proved. No drift, full audit trail ("prod runs v1.4.0"), and rollback is "redeploy the previous tag".

The three environments

EnvironmentWhatDomainNetwork · DB · StripeDeployed by
productionlive apps, frozen-by-default<svc>.oll.amollam · prod Neon · livegit tag v* — explicit, gated, dependency-ordered (oll-core + oll-model → products → site), health-gated
stagingpre-prod integration proofstage.<svc>.oll.amollam-stage · Neon branch · testauto on merge to main, per-service, path-scoped
PR preview (optional, frontends)ephemeral per-PRpr-<n>.<svc>.oll.amCoolify preview · Neon branch · testCoolify preview, per PR

Same service, two environments = two Coolify apps from one repo (same Base Directory, same Dockerfile), differing only in their Coolify Environment and injected config — textbook 12-factor. Cost guard: don't run a permanent staging twin of every frozen service; keep a standing staging app only for the service under active change, or spin it ephemeral, verify, promote, remove.

The deploy mechanism — how the decoupling is enforced

  1. Coolify per-app "Automatic Deployment" → OFF, everywhere (Core, oll-model, oll-write, foto, and site). This single toggle is the real coupling. Until it's off, no workflow change achieves decoupling. Sam · console
  2. The only path to any environment is an explicit, gated GitHub Actions → Coolify call (per-app deploy webhook or the Coolify API by app UUID). Already modelled via the COOLIFY_HOOK_<SVC> secrets + DEPLOY_<SVC>_ENABLED arming flags.
  3. Staging — a deploy-staging.yml on push to main, path-scoped, fires the *-stage webhooks. Trunk tip is always live on stage.<svc>.oll.am.
  4. Production — one consolidated deploy-prod.yml triggered by a tag v* (and workflow_dispatch with a ref input as override). Path-scoped to services changed since the last prod tag, dependency-ordered, health-gated between tiers. This is deploy-all.yml's exact proven structure, retargeted.
  5. Site docs fast-path stays fast, honestly — the :47 loop keeps pushing docs to main, but the site deploy becomes an explicit webhook fire from the cron/workflow, not Coolify auto-deploy. Fast and decoupled.

Environment config — 12-factor

All config lives per environment in Coolify, never in code, never in git. To keep it documented without committing secrets: a versioned, value-less env.manifest per service (every required var, a one-line description, a secret: true|false marker). Coolify holds the real values per environment. An optional CI guard (fits our executable-arch-rules habit) fails the build if a service's boot-gate requires a var the manifest doesn't list — so config drift can't ship. Exposed secrets are burned: rotate the live Stripe key at the prod-tag cutover; staging uses test keys only.

Migration — keep · change · delete

ItemActionWhy
oll-core.yml · services.yml · site.ymlkeep (retarget PR base to main)Clean, path-scoped CI gates. Only the PR base changes stage→main.
deploy-all.ymlchangedeploy-prod.ymlAlready the correct decoupled engine (tiers, health gates, arm flags). Add on: push: tags: ['v*'] + a ref input.
deploy.yml (push-to-main auto-fire)delete / fold inThe push-triggered deploy IS the coupling we're removing. Its path-filter + health logic merges into the consolidated deploy workflow.
automerge-stage.ymlchange → automerge into mainKeep auto-merge-on-green, retargeted to PRs into main (same hold/wip/draft guards). Safe — main no longer auto-deploys prod.
sync-stage.ymldeleteIts only job is main↔stage reconciliation. No stage branch, no need.
The stage branchretire Sam decisionMigrate any unique commits into feat PRs to main, then delete. This is the crux decision (below).
Coolify auto-deploy (all apps)Sam · console — OFFThe real coupling; invisible to Actions; only the owner can flip it.

✅ Decided & done — trunk-based is live (2026-07-03)

The stage branch is retired. main is the single source of truth.

Sam: yes, retire stage. Executed: stage promoted into main then deleted; sync-stage.yml + automerge-stage.yml deleted; deploy is now tag-triggered + CI-green-gated (deploy-all.yml with a preflight gate and a services=all·changed·list picker); Coolify per-app auto-deploy OFF on all backends (ON only for site). Branch count went 45 → 3 (main + two flagged feature branches); workflow count 6 → 4 (oll-core·services·site CI + deploy-all CD). The staging environment is deliberately deferred (one env for now — minimal maintenance); it becomes a set of stage.<svc>.oll.am Coolify apps when pre-prod validation is worth the upkeep, never a branch.

Quality gates — the ladder every service climbs

CI is the gate; it ships nothing. Each service climbs its own ladder before a commit is eligible to deploy. All gates are path-scoped (a service's gate runs only when its own files change).

Service · workflowGate ladder (in order)Gate dependency
Core · oll-core.yml 1. DTO-drift (regen from openapi.yaml, fail on drift) → 2. ruff lint → 3. pytest + coverage (on a real Postgres) → 4. Schemathesis contract-fuzz (every endpoint vs the spec, on a booted Core) → 5. docker boot path (build → migrate → serve → container healthy) 1–4 are sequential steps in the test job; 5 (docker-smoke) runs in parallel as its own job. Both must pass.
Products (foto · oll-write · oll-model) · services.yml 1. ruff lint → 2. pytest (mocks Core + providers, keyless) → 3. docker boot + health (build the real image, boot keyless via mock provider, container must report healthy) Matrix over the 3 services. docker-boot needs: test — the boot gate runs only after lint+test pass.
Site · site.yml design-language conformancedesign-audit.sh: design tokens · internal links · nav presence · naming canon. Fails the build on any drift. Single job.

What the gates catch: the docker-boot gate is the one that matters most — it catches the exact class of failure that takes a service down in prod (a boot-gate miss, a $PORT mismatch, a broken healthcheck) before it can deploy. Contract-fuzz catches spec/route drift; DTO-drift catches generated-code drift; design-audit catches doc/UX drift.

Service dependency — the deploy order (CD)

T0 · foundationCore · oll-model — no internal deps, deploy in parallel
T0 healthy (not failed)
T1 · productsfoto · oll-write — call oll-core + oll-model over HTTP
T1 healthy
T2 · edgesite — static landing, last

deploy-all.yml enforces this with needs: chains: a tier proceeds only when its deps succeeded or were skipped (flag off) — never when a dep failed. Each deploy job fires the service's Coolify webhook, then waits on a post-deploy health gate (Core: /api/health + /health/neon + /health/stripe; oll-model: default-provider ok; foto/write: status:ok) before the next tier starts.

GitHub config — variables, secrets, and the Coolify webhook

Two switches arm each service's deploy: a variable (is this service allowed to deploy?) and a secret (where to send the deploy). Both are read by deploy-all.yml; a service with the flag off is skipped, and a missing hook secret fails loudly.

TypeNamePurposeNow
VariableDEPLOY_CORE_ENABLEDarm Core's deploy jobfalse
VariableDEPLOY_MODEL_ENABLEDarm oll-modelfalse
VariableDEPLOY_WRITE_ENABLEDarm oll-writefalse
VariableDEPLOY_FOTO_ENABLEDarm fotofalse
VariableDEPLOY_SITE_ENABLEDarm sitetrue
VariableWRITE_HEALTH_URL (optional)override write's health-probe URLdefaults to write.oll.am/api/health
SecretCOOLIFY_HOOK_CORE · _MODEL · _WRITE · _FOTO · _SITEthe per-app Coolify deploy-webhook URLnone set

The Coolify webhook (per app): in Coolify → the app → Webhooks tab → copy the Deploy Webhook URL. Coolify's deploy webhook is authenticated by an API token (Coolify → Keys & Tokens → create a token) sent as Authorization: Bearer <token>. Set GitHub secret COOLIFY_HOOK_<SVC> to the full webhook URL (and, if your Coolify build needs the token separately, add it too). Then DEPLOY_<SVC>_ENABLED=true arms it, and a deploy-all.yml dispatch (or the manual Coolify Deploy button, which needs none of this) ships it. Until the hooks are set, the Coolify Deploy button is the deliberate-deploy mechanism — which is fine for the minimal setup.

Honest gaps in the gate coverage

Three best-practice gates we do NOT yet have:
  1. CD doesn't verify CI-green for the deployed commit. ✅ CLOSED. deploy-all.yml is now tag-triggered (push: tags: v*, no push-to-branch trigger — a merge never deploys) with a preflight gate that refuses to deploy any commit carrying a failing CI check-run. The push-triggered deploy.yml was retired. Ship with git tag vX.Y.Z && git push origin vX.Y.Z.
  2. No security-scan gate — no pip-audit / npm audit / CodeQL on the pipeline. Worth adding a dependency-vuln gate.
  3. Product FRONTEND CI lives in the product repos (humaniz / specview / ollwrite have their own ci), not oll-am. oll-am's gates cover Core + the backend services + the site only. That's correct by ownership, but worth stating so nothing is assumed covered here that isn't.

Rollback & the promotion ritual

Promote (staging → prod): merge PR to main → staging auto-deploys → verify on stage.<svc>.oll.am (health + a real flow) → git tag vX.Y.Z && git push --tags → prod deploy fires for the changed service(s), ordered + health-gated → freeze. Rollback: re-run the prod deploy pinned to the previous tag (auditable), or Coolify's one-click rollback to the last-good deployment (fastest). DB migrations must be expand/contract (forward-only, backward-compatible) so rolling code back never breaks against an already-migrated DB.

Do this before the next service ships

Land the workflow + branch changes and flip the Coolify auto-deploy toggles first. Then foto (or the next thing) is the first service to ride the new rails end-to-end: merge → staging → verify → tag → prod.

On your yes to retiring stage, I'll implement the workflow side on a PR (consolidate the deploy workflows, retarget CI + automerge to main, delete sync-stage), and hand you the exact Coolify console checklist (auto-deploy OFF per app, the *-stage apps, the webhook secrets). Nothing deploys until you tag.

oll.am · CI/CD & environments · proposal for sign-off · 2026-07-03 · supersedes the branch-model section of The Factory on acceptance
Two independent audits (best-practice research + current-state map) converged on this model.