CI/CD pipelines
Five workflows across four repos. All deploy workflows trigger on push to main only.
API — .github/workflows/deploy.yml
Three jobs, strictly sequential (needs), with concurrency: api-deploy, cancel-in-progress: false so two deploys never interleave on the box.
check
Runs Bun 1.3.13 against real service containers — Postgres 16 and Redis 7 — because 23 of the 85 tests are integration tests hitting Postgres through Prisma (permit lifecycle, audit-chain verification, notification pagination). Redis is there because redis.plugin.ts builds its client at module scope: a missing Redis is a boot crash, not a degraded route.
MINIO_* are set to dummy values. Nothing in the suite performs a real putObject, but createStorageFromEnv() builds a client at request time, so unset values make the upload routes throw before the guard under test runs.
Steps: bun install --frozen-lockfile → prisma migrate deploy → bun run lint → bun test → bun run typecheck → ./scripts/check-cookie-domain.sh.
bun test, not bun run test
The npm test script is still the template's exit 1 placeholder. The runner itself finds all 43 .spec.ts files.
check-cookie-domain.sh boots better-auth twice and asserts the real Set-Cookie attributes — the one thing that silently breaks login and cannot be caught by a type check.
build
docker/build-push-action@v6, platforms: linux/amd64 (native — the droplet and the runner are both x86_64, so no QEMU), GHA layer cache, tagged both 😒{{ github.sha }} and :latest.
deploy
scpdeploy/docker-compose.prod.yml,deploy/nginx.conf,deploy/backup.shto/opt/esw— the VM copy is refreshed every deploy so the box can never drift from the repo.ssh:docker login ghcr.io,export IMAGE_TAG=${{ github.sha }},pull api,up -d --wait api nginx,docker image prune -f.
/opt/esw/.env is never touched by CI. It is hand-created and holds every secret; CI only sets IMAGE_TAG.
--wait blocks on the healthcheck, so a container that dies on a bad migration fails the job instead of reporting a green deploy.
API — .github/workflows/contract.yml
Regenerates and checks the OpenAPI contract. scripts/check-contract-sync.mjs runs in both frontend repos locally and fails on drift — the point is that a shape change breaks a pipeline, not a screen.
When the contract changes, the order is fixed:
- Backend deploys, migrations run,
GET https://api.<domain>/answers. ./scripts/dump-openapi.sh, commitdocs/openapi.json.- Copy it into
docs/api/openapi.jsonin both frontends. - Deploy the frontends, either order — they are independent of each other.
Frontends — one deploy.yml each
Identical apart from four lines (name, concurrency group, project name). Triggers on push: [main] and on every pull_request, so PRs get a preview deployment.
- run: bun run build
env:
VITE_APP_API_URL: ${{ vars.VITE_APP_API_URL }}VITE_* is inlined at build time
Changing the API URL needs a rebuild, not a restart. It is a repo variable (Settings → Secrets and variables → Actions → Variables), not a secret.
There is no check-contract-sync step in frontend CI: that script lives in the umbrella repo, which gitignores both frontends. It stays a local pre-push check.
wrangler is invoked directly rather than through cloudflare/wrangler-action — the action swallows wrangler's stderr and reports only The process failed with exit code 1, which hid two separate failures in a row.
The Pages project must already exist as a direct-upload project; pages deploy will not create one non-interactively:
wrangler pages project create esw-safety --production-branch=main
wrangler pages project create esw-contractor --production-branch=main
wrangler pages project create esw-landing --production-branch=mainLanding — .github/workflows/deploy.yml
One job, two steps that matter: bun run check then wrangler pages deploy dist.
bun run check is build && build:ssr && check-render.mjs && check-landing.mjs — it runs the build itself, so there is no separate build step. check-render.mjs SSRs the real component tree and asserts on the output; check-landing.mjs fails on any off-origin URL in the bundle and on any sub-AA text/surface pair. The first gate is load-bearing: the landing is served from the apex, where COOKIE_DOMAIN=.e-safework.com applies, so an off-origin request would hand a third party a referrer from a page carrying a session cookie.
No lint (no lint config), no test:unit, no check-contract-sync, and no VITE_* variable — the landing consumes no API. Full detail in ../deployment/14-landing-deployment.
Secrets and variables
| Repo | Name | Kind | Notes |
|---|---|---|---|
| api | VM_HOST | secret | Public IP or SSH hostname |
| api | VM_USER | secret | SSH user |
| api | VM_SSH_KEY | secret | Private half of the dedicated CI deploy keypair — not a personal key |
| api | GHCR_TOKEN | secret | PAT with read:packages, used by the VM to pull |
| both frontends | CF_API_TOKEN | secret | Scope: Cloudflare Pages — Edit |
| both frontends | CF_ACCOUNT_ID | secret | Same value, added per repo |
| both frontends | VITE_APP_API_URL | variable | e.g. https://api.e-safework.com |
| landing | CF_API_TOKEN, CF_ACCOUNT_ID | secret | Same values again. No VITE_* |
GITHUB_TOKEN is used for the GHCR push (workflow-scoped, packages: write); GHCR_TOKEN is the separate PAT the VM uses to pull.
Branching
dev is where work lands; it reaches main through a PR whose checks must pass. Merging into main ships to production.
Recommended once main is the default branch: a ruleset requiring a PR plus the check status check. Note Cloudflare Pages keeps its own production-branch setting per project, independent of the workflow — both must say main.