Continuous Integration
Every pull request, and every push to master, runs .github/workflows/ci.yml on GitHub Actions. It checks what the change affects and nothing more. Results show on the PR and in the Actions tab.
| Status | |
|---|---|
| PR checks: lint, types, tests, database tests, migration safety, Docker builds | CI/CD Phase 1 |
| Required before merge (branch protection) | Not on GitHub Free |
| Images pushed and deployed | CI/CD Phase 4 |
The repo is private on GitHub Free, which can't enforce required checks. Don't merge a PR with a red check. Nothing stops you except this rule.
What runs
| Job | What it does | Runs when |
|---|---|---|
| changes | Picks the base commit (the PR's base, or the previous master commit) and lists affected packages and services with turbo ls --affected | always |
| migrations | scripts/ci/check-migrations.mjs: fails if an applied migration was edited or deleted, or if a new one is destructive without the contract-migration label | always |
| checks | turbo run lint check-types test --affected, the docs build (catches dead links), and the API's database tests against Postgres 15 | always; database tests only when api or one of its packages changed |
| helm | scripts/ci/check-helm.sh: lints both charts with every values file, renders every release, and validates the result against Kubernetes 1.34's schemas (kubeconform) | always |
| docker | Builds the image of each affected service (linux/amd64, not pushed), so a broken Dockerfile fails here instead of at deploy | when a deployable service changed |
Affected follows the dependency graph:
- A change in
packages/databasechecks every app that uses it. - A change to a root file (
turbo.json,pnpm-lock.yaml, the workflow itself) checks everything. - A change to one app's docs page checks only
docs.
A new push to a PR cancels that PR's previous run. Runs on master always finish.
Limits. GitHub's free runners for private repos have 2 vCPUs and 7 GB of memory. Turbo runs two tasks at a time (--concurrency=2); more than that runs slower, not faster, on a box that size. Every job has a timeout (checks 45 minutes, docker 30), so a stuck task fails instead of running for GitHub's 6-hour default and using up the month's minutes.
Reading the log. Each task's output prints as a block when that task finishes, with a pass/fail summary at the end. If the step seems quiet, the task that hasn't printed yet is the one still running.
Migration safety
Code rollback depends on migrations being additive (see Developing & Committing → Migrations). The check flags any new migration.sql that:
- drops a table, column or type
- renames something
- changes a column's type
- adds
SET NOT NULL - truncates or deletes rows
If it fails on a migration you meant to ship:
- Make sure it really is the contract step: the code that stopped using the old shape is already deployed.
- Add the
contract-migrationlabel to the PR (create it once under Issues → Labels). - Re-run the job: Actions → the run → Re-run failed jobs. Labels are read when the job runs.
- Take a backup before deploying it.
Editing or deleting an applied migration always fails. Production has its checksum, so write a new migration instead.
When a check fails
Open the failed job and expand the red step. Reproduce it locally with the same command:
pnpm --filter @playpals/db db:generate # once, after pulling schema changes
TURBO_SCM_BASE=master pnpm turbo run lint check-types test --affected # turbo's default base is main
pnpm --filter @playpals/api test:db # needs local Postgres, see Testing
node scripts/ci/check-migrations.mjs origin/master
docker build --platform linux/amd64 -f apps/<service>/Dockerfile .
scripts/ci/check-helm.sh # needs helm and dockerCI starts from a clean checkout, so it catches what your machine hides: generated files that are gitignored (like Expo's expo-env.d.ts), a missing package build, or a dependency that's only installed locally. When CI fails and your machine passes, suspect one of those first.
A known flaky database test
Now and then (about one run in four locally), one API database test fails and then passes on a re-run. Suites share one test database, and a few assertions depend on ordering or timing (vipPasses, adminCoins, tips). Re-run failed jobs once. If the same test fails twice, it's real.
Cost
GitHub Free includes 2,000 Actions minutes a month for private repositories. A typical PR uses:
- 5–10 minutes for the checks
- plus about 3–6 minutes per Docker image it affects
A change to a shared package or a root file affects all eight images. Check usage under Settings → Billing. If it gets close to the limit, run the Docker builds only on master, not on every PR push.
Changing the workflow
- Edit
.github/workflows/ci.ymlin a PR like any other change. It runs on that PR, so you see the result before merging. - Lint it first:
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7 -no-color. - Keep the jobs runnable locally with plain
pnpm,nodeanddockercommands. The workflow should only wire them together.
