Skip to content

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-lockfileprisma migrate deploybun run lintbun testbun 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

  1. scp deploy/docker-compose.prod.yml, deploy/nginx.conf, deploy/backup.sh to /opt/esw — the VM copy is refreshed every deploy so the box can never drift from the repo.
  2. 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:

  1. Backend deploys, migrations run, GET https://api.<domain>/ answers.
  2. ./scripts/dump-openapi.sh, commit docs/openapi.json.
  3. Copy it into docs/api/openapi.json in both frontends.
  4. 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.

yaml
- 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:

bash
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=main

Landing — .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

RepoNameKindNotes
apiVM_HOSTsecretPublic IP or SSH hostname
apiVM_USERsecretSSH user
apiVM_SSH_KEYsecretPrivate half of the dedicated CI deploy keypair — not a personal key
apiGHCR_TOKENsecretPAT with read:packages, used by the VM to pull
both frontendsCF_API_TOKENsecretScope: Cloudflare Pages — Edit
both frontendsCF_ACCOUNT_IDsecretSame value, added per repo
both frontendsVITE_APP_API_URLvariablee.g. https://api.e-safework.com
landingCF_API_TOKEN, CF_ACCOUNT_IDsecretSame 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.