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.Today three things fuse "what's in version control" to "what's running", and they fight each other:
stage branch is a second long-lived trunk. It exists mainly to host automerge-stage.yml + sync-stage.yml — machinery whose only job is to keep two trunks from diverging. A branch literally named stage re-fuses "code line" with "environment".deploy.yml (fires on push to main) and deploy-all.yml (manual) — with copy-pasted webhook + health logic. The push-triggered one means a merge deploys.The CI gates themselves (oll-core.yml, services.yml, site.yml) are already clean and correctly path-scoped — they stay.
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".
| Environment | What | Domain | Network · DB · Stripe | Deployed by |
|---|---|---|---|---|
| production | live apps, frozen-by-default | <svc>.oll.am | ollam · prod Neon · live | git tag v* — explicit, gated, dependency-ordered (oll-core + oll-model → products → site), health-gated |
| staging | pre-prod integration proof | stage.<svc>.oll.am | ollam-stage · Neon branch · test | auto on merge to main, per-service, path-scoped |
| PR preview (optional, frontends) | ephemeral per-PR | pr-<n>.<svc>.oll.am | Coolify preview · Neon branch · test | Coolify 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.
COOLIFY_HOOK_<SVC> secrets + DEPLOY_<SVC>_ENABLED arming flags.deploy-staging.yml on push to main, path-scoped, fires the *-stage webhooks. Trunk tip is always live on stage.<svc>.oll.am.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.: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.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.
| Item | Action | Why |
|---|---|---|
oll-core.yml · services.yml · site.yml | keep (retarget PR base to main) | Clean, path-scoped CI gates. Only the PR base changes stage→main. |
deploy-all.yml | change → deploy-prod.yml | Already 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 in | The push-triggered deploy IS the coupling we're removing. Its path-filter + health logic merges into the consolidated deploy workflow. |
automerge-stage.yml | change → automerge into main | Keep auto-merge-on-green, retargeted to PRs into main (same hold/wip/draft guards). Safe — main no longer auto-deploys prod. |
sync-stage.yml | delete | Its only job is main↔stage reconciliation. No stage branch, no need. |
The stage branch | retire Sam decision | Migrate any unique commits into feat PRs to main, then delete. This is the crux decision (below). |
| Coolify auto-deploy (all apps) | Sam · console — OFF | The real coupling; invisible to Actions; only the owner can flip it. |
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.
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 · workflow | Gate 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 conformance — design-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.
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.
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.
| Type | Name | Purpose | Now |
|---|---|---|---|
| Variable | DEPLOY_CORE_ENABLED | arm Core's deploy job | false |
| Variable | DEPLOY_MODEL_ENABLED | arm oll-model | false |
| Variable | DEPLOY_WRITE_ENABLED | arm oll-write | false |
| Variable | DEPLOY_FOTO_ENABLED | arm foto | false |
| Variable | DEPLOY_SITE_ENABLED | arm site | true |
| Variable | WRITE_HEALTH_URL (optional) | override write's health-probe URL | defaults to write.oll.am/api/health |
| Secret | COOLIFY_HOOK_CORE · _MODEL · _WRITE · _FOTO · _SITE | the per-app Coolify deploy-webhook URL | none 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.
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.pip-audit / npm audit / CodeQL on the pipeline. Worth adding a dependency-vuln gate.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.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.
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.