# Deployment (CD)

The pipeline (`ci.yml`) publishes a signed multi-arch image to GHCR, then:

- **staging** — automatic on every push to `main` (deploys the `latest` tag).
- **prod** — on a `vX.Y.Z` tag, gated by the protected **`production`** environment
  (manual approval). Records the previously-deployed tag for rollback.

Both call the reusable `deploy.yml`: SSH to the host → `docker compose -f
docker-compose.yml -f docker-compose.prod.yml pull && up -d` → wait until the app
container is healthy → smoke test (`/actuator/health` + a login → `/users/me`
round trip). `scripts/deploy.sh`, `scripts/smoke-test.sh`, and `scripts/rollback.sh`
are the reusable core and can be run by hand on the host.

## One-time host setup

1. Install Docker + the compose plugin.
2. Create the deploy directory (`vars.DEPLOY_DIR`).
3. Place the three PEM files somewhere readable by uid **10001** and point
   `SSL_CERT_PATH` / `SSL_CA_PATH` / `SSL_KEY_PATH` (repo **vars**) at them (see
   `docs/ssl.md`).
4. Ensure the GHCR image is pullable (the host logs in, or the package is public).

The pipeline writes a mode-600 `.env` on the host from the secrets/vars below — no
secret is ever baked into an image or committed.

## Secrets & variables checklist

**Repository / environment secrets** (sensitive — never logged):

| Secret | Purpose |
|---|---|
| `SSH_HOST`, `SSH_USER`, `SSH_KEY` | SSH access to the deploy host |
| `JWT_SECRET` | HS256 signing secret (≥32 bytes) |
| `POSTGRES_PASSWORD` | database password |
| `SSL_KEY_PASSWORD` | private-key passphrase (only if the key is encrypted) |
| `SMOKE_PASSWORD` | password of the smoke-test user |
| `REGISTRY_TOKEN` | only if pushing to a non-GHCR registry (GHCR uses `GITHUB_TOKEN`) |

**Repository / environment variables** (non-sensitive):

| Var | Example |
|---|---|
| `DEPLOY_DIR` | `/opt/demo` |
| `BASE_URL` | `https://demo.example.com:4455` |
| `POSTGRES_DB`, `POSTGRES_USER` | `demo`, `demo` |
| `JWT_ISSUER`, `JWT_AUDIENCE` | `demo`, `demo-clients` |
| `SSL_CERT_PATH`, `SSL_CA_PATH`, `SSL_KEY_PATH` | host paths to the PEM trio |
| `MAIL_HOST`, `MAIL_PORT`, `MAIL_FROM` | SMTP relay settings |
| `SMOKE_EMAIL` | activated smoke-test account |

The deploy jobs fail fast with a readable message if any required secret/var is
missing, and nothing is echoed.

## Database migrations — forward-only

Flyway runs on **app startup** (`validate` mode; Hibernate never generates DDL).
The policy is **forward-only**:

- Never edit or delete a migration that has been applied anywhere — the checksum
  test (`FlywayMigrationIT`) fails the build if you do, and Flyway refuses to start
  against a changed history.
- To change schema, add a new `V{n+1}__*.sql`. To undo a change, write a new
  compensating migration; do not roll a migration back in place.

### A failed migration

Flyway is not auto-transactional across statements on every DDL, so a partially-
applied migration can leave `flyway_schema_history` with a `failed` row that blocks
startup. Recover by:

1. **Roll the app back** to the previous image tag (below) so traffic is served by
   the prior version, which matches the prior schema.
2. Inspect `flyway_schema_history`; manually complete or reverse the partial DDL.
3. `flyway repair` (or delete the failed history row) to clear the failed marker.
4. Fix the migration in a new commit and redeploy.

Because rollback redeploys an **older image against a newer schema**, keep
migrations **backward-compatible for one release** (add columns nullable/with
defaults, don't drop/rename in the same release that reads them) so the previous
version keeps working during a rollback window.

## Rollback

- Automatic target: `deploy.sh` records each deployed tag in `deployed-tag.txt` and
  the prior one in `previous-tag.txt` on the host.
- Run the **`rollback`** workflow (`workflow_dispatch`), pick the environment, and
  optionally enter a specific tag (blank = the previously-deployed tag). It
  redeploys and re-runs the smoke test.
- By hand on the host: `IMAGE_TAG=<tag> REGISTRY=<reg> ./rollback.sh`.
