# 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`) and
  `predeploy.yml` (backend syntax/imports/env + frontend `tsc`/ESLint).
- **Deployment to production was manual by policy.** `render.yaml` sets
  `autoDeploy: false`, and the prior runbook required a human to click the Render
  deploy hook after running `predeploy_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:

1. **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.
2. **No verified staging CD.** Project notes described "staging auto-deploys," but
   `render.yaml` defined only one service and no staging pipeline was codified.
3. **Health checks gave false confidence.** `/api/health` reported 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 to `staging` (paths `backend/**`, `render.yaml`,
  the workflow itself): run pre-deploy checks → on green, **automatically** fire the
  staging Render deploy hook → poll staging `/api/health` until 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`) on `main`: run
  the same checks → fire the production Render deploy hook → poll `/api/health` until
  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.

```mermaid
flowchart TD
    subgraph staging["staging branch"]
      SP[push backend/**] --> SC[checks: syntax / imports / env]
      SC -->|green| SH[fire staging deploy hook]
      SH --> SV[poll /api/health until commit matches]
    end
    subgraph main["main branch — manual"]
      MP[manual run: workflow_dispatch] --> MC[checks: syntax / imports / env]
      MC -->|green| MH[fire prod deploy hook]
      MH --> MV[poll /api/health until commit matches SHA]
    end
    SV -. promote after smoke test .-> MP
```

**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`, and `RENDER_STAGING_DEPLOY_HOOK_URL` once a
  staging service exists). `PROD_BACKEND_URL` is already set as a repo variable.
- A dedicated `drape-backend-staging` Render service (tracking `staging`, 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](../shipping/backend-deploys.md).

## Alternatives considered

- **Turn on Render native `autoDeploy` for `main`.** 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.
