ADR-0001 — Gated CI/CD deploy pipeline for the backend
- Status: Accepted
- Date: 2026-08-23
- Deciders: Wes (engineering), Kenji (owner, KCB1099)
Context
The Drape backend (backend/server.py, FastAPI) runs on Render as the service
drape-backend. Before this ADR:
- CI already existed and was solid. Two GitHub Actions workflows run on every
push/PR to
main:backend-checks.yml(syntax, imports, secret scan,.env-not-tracked, flake8, a startup smoke test hitting/api/health) andpredeploy.yml(backend syntax/imports/env + frontendtsc/ESLint). - Deployment to production was manual by policy.
render.yamlsetsautoDeploy: false, and the prior runbook required a human to click the Render deploy hook after runningpredeploy_check.sh,validate_env.py, and applying Supabase migrations first (schema leads code).
That manual posture is correct for this app — it is a payment path (Stripe Connect) handling BIPA-regulated biometric data with real users — but it had three gaps:
- CI success neither triggered nor blocked a deploy. The deploy was a separate manual action, so a red check could not stop a human, and a green one shipped nothing. The two were disconnected.
- No verified staging CD. Project notes described "staging auto-deploys," but
render.yamldefined only one service and no staging pipeline was codified. - Health checks gave false confidence.
/api/healthreported liveness only, so polling it after a deploy could pass against the old instance still serving traffic during the rollover.
This ADR revisits and supersedes the earlier "production deploy is manual, full stop" policy. It keeps the safety intent (no blind production deploys) while closing the gaps above.
Decision
GitHub Actions is the single control point for backend deploys, using Render
deploy hooks. render.yaml remains autoDeploy: false so Render never deploys on
its own — a red check or a missing approval means nothing ships.
Two workflows implement this:
-
deploy-staging.yml— on push tostaging(pathsbackend/**,render.yaml, the workflow itself): run pre-deploy checks → on green, automatically fire the staging Render deploy hook → poll staging/api/healthuntil the new commit is live. This is "auto-deploy, but only if the checks pass" — strictly safer than Render's native branch auto-deploy, which ships regardless of CI. -
deploy-production.yml— a manual run (workflow_dispatch) onmain: run the same checks → fire the production Render deploy hook → poll/api/healthuntil the live commit matches the deployed SHA. The manual trigger is the gate (see the plan constraint below): only a user with write access can start it, and the checks always run before anything deploys.
To make the post-deploy verification trustworthy, /api/health now returns a
commit field sourced from Render's injected RENDER_GIT_COMMIT. The workflows wait
until status ∈ {healthy, degraded} and commit == <pushed short SHA>, so a
"pass" means the new code is actually live — not the old instance.
Migrations remain human-ordered. Supabase migrations still run before the production deploy; the person triggering the manual run confirms they were applied. Automating migrations inside the pipeline is deliberately left as a future ADR.
Plan constraint (why the gate is a manual trigger, not reviewer approval). The
original intent was a push-triggered prod deploy that pauses on the production
GitHub Environment for required-reviewer approval. That protection rule is not
available on this account's plan for a private repo — it requires GitHub
Team/Enterprise (attempting to set it returns HTTP 422: "billing plan"). Rather than
block on a billing change, production is gated by a manual workflow_dispatch
trigger: a deploy only happens when a write-access user deliberately runs the
workflow, and the checks run inside that run. Staging still auto-deploys on push
(no protection rule needed).
Upgrade path. On GitHub Team/Enterprise, add required reviewers to the
production environment and re-add a push trigger to deploy-production.yml; the
deploy job will then pause for reviewer approval automatically. No other change is
needed — the deploy/verify steps are identical.
Consequences
Easier / better
- Deploys are gated on green CI automatically; the two are no longer disconnected.
- Every production release has an audit trail (who approved, which SHA, health result).
- Rollback is "re-run the deploy hook on the previous good commit" — the pipeline verifies the live commit either way.
- Staging gets true CD, so changes are exercised in a real environment before prod.
- No blind production deploys survive: payment/BIPA risk stays behind a human gate.
New obligations (one-time setup, then ongoing)
- Kenji must generate the Render deploy hook URL(s) and store them as repo
secrets (
RENDER_DEPLOY_HOOK_URL, andRENDER_STAGING_DEPLOY_HOOK_URLonce a staging service exists).PROD_BACKEND_URLis already set as a repo variable. - A dedicated
drape-backend-stagingRender service (trackingstaging, own DB) must exist for staging CD to be meaningful. - The person running the production deploy must confirm migrations were applied first.
- Production has no reviewer-approval gate on the current plan — it relies on the manual trigger. Revisit if/when the org moves to GitHub Team (see upgrade path).
Full step-by-step setup and the rollback procedure live in the operational runbook: Backend deploys.
Alternatives considered
- Turn on Render native
autoDeployformain. Rejected: ships regardless of CI status, cannot order Supabase migrations before code, and removes the human gate on a payment/biometric service. - Keep deploys purely manual (prior policy). Rejected: CI and deploy stayed disconnected, the manual steps were error-prone and under-documented, and staging had no CD.
- Auto-deploy production on green with no approval. Rejected: for this app the blast radius (money movement, biometric data) warrants a human checkpoint.
- Full GitOps (ArgoCD/Flux) or a Render Blueprint-only flow. Rejected as over-engineered for a single Render service and a solo-founder team; deploy hooks + Actions give the needed control with far less machinery.