PR CI pipeline

August 3, 2026 · View on GitHub

This page documents how the pull-request CI pipeline (.github/workflows/ci.yml) is structured for cost — how it avoids redoing work within a run, across runs, and on PRs whose changes cannot affect the result — and the constraints that shaped those choices. It captures the why; the workflow file is the what.

What runs on a PR

ci.yml runs eight jobs. Seven do real verification — Build, Type Check, Lint, Test, E2E Tests, Integration Tests, Coverage — and a small Detect inert diff job classifies the diff (below). A separate DCO check and the preview-publish workflow also run. main is governed by a repository ruleset that requires the verification contexts plus DCO and uses the strict "branch must be up to date" policy. Two consequences drive the whole design:

  • A required status check that never reports its result wedges the merge. So a job that is required can never be skipped at the job level — it must always launch and report.
  • The ruleset is a fixed constraint. The pipeline is designed around it; CI changes never edit the ruleset or the set of required check names.

Build once per run, and across runs

Every job needs the workspace built. Rather than have each job rebuild the 92-package graph independently, the pipeline builds once and shares the result through a single-writer Turbo cache:

  • A .github/actions/setup composite action primes two actions/cache entries — the pnpm content-addressable store and Turbo's local cache (pinned to .turbo/cache via TURBO_CACHE_DIR).
  • The build job runs first; every other job declares needs: build. The Turbo cache key is the head commit SHA, so on a given run build's key is a miss and it is the only job that writes the cache. Every other job restores that exact key and — per actions/cache's documented behaviour on an exact-key hit — skips saving. Their pnpm build step becomes a cache hit (FULL TURBO) instead of a real rebuild, so the build runs for real exactly once per run.
  • A restore-key (turbo-<os>-) warms the cache from previous runs, so even the build job is incremental across pushes.

The single-writer rule is load-bearing for both correctness and safety: because only build ever writes, the persisted cache contains build outputs only — test and coverage results can never leak into it. (An earlier shape that let every job write the same key let a non-building job win the save race and persist a build-less cache, which made downstream jobs rebuild anyway. Making every job needs: build is what fixes it.)

Test, e2e, integration, and coverage results are never cached. Their pass/fail is not a pure function of Turbo's declared inputs (services, pass-through env), so a stale cached "pass" could mask a real regression. Tests always execute for real.

Skip the heavy work on inert diffs

A PR that only edits documentation should not boot Postgres and run the full test matrix. The Detect inert diff job emits an inert boolean, and the expensive steps of Test / E2E Tests / Integration Tests / Coverage / Fixtures guard on it:

- name: Run Integration tests
  if: needs.changes.outputs.inert != 'true'
  run: pnpm test:integration

Because required jobs cannot be skipped at the job level (they would never report), the jobs always launch and report green; only their steps are gated. On an inert PR those jobs run their checkout + cache-restore and then skip the heavy work, finishing in seconds. Type Check and Build always run but are near-free via the Turbo cache, and Lint always runs in full — it is exactly what validates the docs/rules/skills/README changes an inert diff is made of.

The inert predicate

The classification lives in one place — the .github/actions/detect-inert-diff composite — so ci.yml and preview-publish.yml share a single allow-list rather than two copies that can drift. It is an allow-list that fails safe toward running: a diff is inert only if every changed file matches a known-harmless pattern (markdown anywhere, docs/, projects/, skills-contrib/, .agents/, .cursor/, .claude/, LICENSE). A single unrecognized path — source, package.json, the lockfile, turbo.json, anything under .github/workflows/ — makes the whole diff non-inert and runs everything. Off a pull request (e.g. push to main) it always reports non-inert.

preview-publish.yml's "Publish preview" is not a required context, so there the whole job is skipped at the job level on inert PRs rather than gating each step.

Constraints that shaped this

  • Hardened Allowed-Actions policy. The repository only permits an explicit SHA-pinned allow-list of actions. actions/download-artifact is not on it, which is why build outputs are shared through actions/cache rather than uploaded/downloaded as artifacts; any new action (including first-party ones) must be added to the allow-list before it can run. See supply chain.
  • No third-party cache action or remote-cache token. Caching is first-party actions/cache only, preserving the fork-PR posture in supply chain (fork PRs get cold caches by design).
  • Least-privilege token. ci.yml grants GITHUB_TOKEN only contents: read; the caches use the runner's cache runtime token, not GITHUB_TOKEN. Checkout steps set persist-credentials: false.

Adjacent workflows

A second PR workflow (.github/workflows/bundle-size.yml) reports the gzipped cost of @internal/postgres and @internal/mongo for a single-table contract against both authoring styles (no-emit vs. emitted contract.json). It runs andresz1/size-limit-action against the bundles produced by examples/bundle-size; the action checks out the head and the base ref, runs pnpm size:build (workspace Turbo build + esbuild) in each, and posts/updates a PR comment with the head-vs-base delta for the four entries.

The workflow is not a required check — bundle size is a signal, not a gate, and a required check holding pull-requests: write would widen the trust surface on every run. It reuses the same Detect inert diff composite so docs-only PRs skip the build entirely. The job scopes pull-requests: write to itself; the workflow-wide baseline stays at contents: read to match ci.yml.

The action is SHA-pinned and must be added to the repository's allowed-actions list (Settings → Actions → "Allow specified actions and reusable workflows") before the workflow can execute — see supply chain.

Deliberately out of scope

  • turbo run test --affected package-scoped test selection — would run only the affected packages, but correctness depends on the package dependency graph being complete, which needs its own audit.
  • A merge queue / relaxing the strict up-to-date policy — likely the largest remaining source of avoidable CI, but the safe fix is a merge queue, which is its own change.