Continuous Integration
What the GitHub Actions CI workflow validates, how the gate-and-concurrency pattern keeps stale PR runs from piling up, and what each job in ci.yml actually does.
A single GitHub Actions workflow, .github/workflows/ci.yml, validates every push to main and every pull request. It runs builds, type checks, formatting and linting, vitest, the Playwright e2e suite (Release Candidate PRs only), the Presidio service tests, and migration hygiene. The release tag and GitHub Release come from a separate workflow, release.yml, when a Release Candidate PR merges to main (see release).
CI does not deploy anything. Dashboard and marketing deploys go through Vercel's GitHub integration; database migrations go through Supabase Branching. See Deploy Automation for the full picture.
ci.yml is the validation workflow, not the only one. PR hygiene and the release handover live in the three referenta-bot-*.yml workflows (Referentautomat), and the Linear release sync lives in linear-release.yml (Release Automation). Those run independently of the gate job below, so none of the triggers, concurrency, or path filters on this page apply to them.
Triggers And Concurrency
The workflow listens to:
pushonmain(every commit landed on production).pull_requeston a fixed set of activity types —opened,synchronize,reopened,ready_for_review— not every activity type.ready_for_reviewis what starts the e2e suite on a Release Candidate that opened as a draft.closedis deliberately absent: onlyrelease.ymlreacts to a merge, so closing a PR no longer starts a CI run whose jobs all skip.
Concurrency is keyed by workflow + ref, and cancel-in-progress is true only for pull requests. A new PR commit cancels the previous in-flight PR run, but a new push to main queues behind any in-progress main runs instead of cancelling them. This keeps PR feedback fast without ever dropping a main-branch CI run mid-flight.
The Gate Pattern
Every job depends on a tiny gate job:
gate:
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 2
outputs:
presidio: ${{ steps.changes.outputs.presidio }}
steps:
- run: echo "ci enabled"
- name: Detect changed paths
id: changes
if: github.event_name == 'pull_request'
run: |
# queries the PR's changed files over the GitHub API and sets
# presidio=true when services/presidio/ or this workflow file itself
# changed; fails open (presidio=true) if the query comes back empty.
...On pull requests, the gate queries the GitHub API for the PR's changed files and exposes a presidio output, used by the presidio job's path filter (see below). The query fails open: if it returns nothing (API hiccup), the gate sets presidio=true rather than silently skipping coverage.
Jobs
build
Runs pnpm build end-to-end across the monorepo. The job's env exposes the three Supabase secrets the Next.js builds need to compile (public-default keys for build-time inlining, plus the service-role key for server-side build steps):
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEYSUPABASE_SERVICE_ROLE_KEY
TURBO_TOKEN and TURBO_TEAM are also exposed at the job level so Turborepo can read from and write to the remote cache. Timeout: 15 minutes.
quality
Runs biome ci . against the entire repo using biomejs/setup-biome@v2 pinned at 2.4.10. This is the same Biome version lefthook uses locally for the pre-commit hook, so failures here usually mean someone bypassed the hook with --no-verify. Timeout: 10 minutes.
test
Runs pnpm check-types (TypeScript across all packages via turbo run check-types), then a dedicated type-check of the Playwright e2e specs via pnpm --filter=dashboard check-types:e2e, then pnpm test (vitest). The middle step exists because the dashboard's own check-types excludes e2e/ — and since the e2e suite itself only runs on the Release Candidate PR (see e2e below), this is what stops spec rot on every other PR. SKIP_PRESS_MONITOR_TESTS=true is set on the pnpm test step because the press-monitor integration tests need external services that aren't provisioned in CI. The job passes TURBO_TOKEN/TURBO_TEAM like build, so check-types and test read the Turbo remote cache: a package whose inputs did not change replays its result instead of re-running. Timeout: 15 minutes.
e2e
Runs the Playwright suite against a locally started Supabase stack (migrations applied, users seeded) and a production build of the dashboard. It only runs — and blocks — on the Release Candidate PR (pull_request with base.ref == 'main'), and not while that PR is a draft. The Release Candidate workflow opens the RC as a draft, and it collects dev merges for a while. A draft cannot merge, so skipping the 45-minute suite there gates nothing. Marking the RC ready for review (ready_for_review) runs it, and every dev merge after that runs it again. Day-to-day PRs skip it entirely and should run it locally instead (apps/dashboard/e2e/README.md). The test job's e2e type-check above still covers every PR, so specs can't silently rot between RCs.
Steps: cache and install Chromium via Playwright, start Supabase with pnpm exec supabase start -x logflare,vector (dropping the analytics stack sidesteps a transient 502 nothing in the suite needs), reset and seed the database with up to three retries (the reset is idempotent), export the Supabase connection env (failing loudly if supabase status yields nothing, so a parsing miss can't silently ship blank NEXT_PUBLIC_* values), build the dashboard, run pnpm --filter=dashboard test:e2e, then upload the Playwright report as a 7-day-retention artifact whenever the job wasn't cancelled. Timeout: 45 minutes.
presidio
Builds the Presidio service test image with docker build --target test -t referenta-presidio:test services/presidio, then runs it. Tests live inside the Docker target so they don't need pnpm or Node setup at the job level. On pull requests this job is path-filtered by the gate's presidio output: it only runs when services/presidio/ or .github/workflows/ci.yml itself changed, since the service is self-contained and a PR that doesn't touch it can't break it. Pushes to main always run it. Timeout: 15 minutes.
migrations
Validates everything in supabase/migrations/ independently of Supabase Branching. Three checks run in sequence:
- Filename format. Every
*.sqlmust match^[0-9]{14}_[a-z0-9_]+\.sql$(14-digit timestamp prefix, snake_case name,.sqlextension). Misnamed files fail with a::error::annotation. - Duplicate timestamps. The 14-digit prefix must be unique across all migrations. Two migrations sharing a timestamp fails the job.
- Stray files. Anything in
supabase/migrations/that isn't.gitkeep,*.sql, or*.bakemits a::warning::. The Supabase CLI silently skips these, so the warning is meant to catch accidental commits (editor backups, leftover scratch files, etc.).
This is purely lexical hygiene; it does not run migrations. The actual migration apply happens on Supabase preview branches (see Database Workflow). Timeout: 5 minutes.
release
Lives in its own workflow, .github/workflows/release.yml, which listens only to pull_request closed into main and runs its one job when the PR was merged.
It extracts a version from the PR title (Release Candidate: vX.Y.Z, RC: vX.Y.Z, or Release: vX.Y.Z), creates an annotated git tag on the merge commit, and publishes a GitHub Release whose notes come from the PR body. Both the tag push and the release are made with a Referentautomat installation token minted in the job, so the tag and the releases page are attributed to the bot rather than to whichever maintainer owns a personal token. An app token also triggers the workflows that watch for tags, which the default workflow token cannot. The job fails if the app is missing the Contents: read and write permission. GH_TAGS_TOKEN still exists, but only as the read token the dashboard uses to fetch published releases.
A title that looks like a release (starts with Release Candidate:, RC:, or Release:) but doesn't match <Prefix>: vX.Y.Z fails the check loudly (exit 1) rather than silently producing no tag or release — so a near-miss like a missing v or missing space is caught instead of shipping unnoticed. A title that isn't release-shaped at all is ignored: the version-extract step sets an empty output, the downstream steps are skipped, and there's no error. Renaming a closed-and-merged PR after the fact won't retroactively trigger a release.
Full breakdown of the release flow lives in Release Automation.
Setup Composite Action
Most jobs use the shared ./.github/actions/setup composite that installs the pinned toolchain:
runs:
using: composite
steps:
- uses: pnpm/action-setup@v6.1.0
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
shell: bashCI uses Node 24 with pnpm's lockfile-strict install. If you update the lockfile locally, commit it; CI fails the install step otherwise.
The quality job is the exception. It uses biomejs/setup-biome@v2 directly because it doesn't need Node or pnpm installed.
What CI Does Not Do
- Deploy. Vercel does dashboard and www. Supabase Branching does database migrations. There are no longer
deploy-dashboard.ymlorpreview-dashboard.ymlworkflows. - Run migrations. The
migrationsjob validates lexical hygiene only. Migration application happens on Supabase preview, staging, and production branches. - Run press-monitor tests. Those need external infrastructure and are skipped via
SKIP_PRESS_MONITOR_TESTS=true. - Block merges by itself. GitHub branch protection rules decide which job results are required for merge; CI just reports them.
- Check PR hygiene. Area labels, Linear attachment, Release Candidate titles, and the pre-RC audit are Referentautomat's job, in its own workflows. Its runs appear as checks on the PR, but they report findings in a sticky comment rather than by failing, so a green check there means the bot ran, not that it found nothing.