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/setupcomposite action primes twoactions/cacheentries — the pnpm content-addressable store and Turbo's local cache (pinned to.turbo/cacheviaTURBO_CACHE_DIR). - The
buildjob runs first; every other job declaresneeds: build. The Turbo cache key is the head commit SHA, so on a given runbuild's key is a miss and it is the only job that writes the cache. Every other job restores that exact key and — peractions/cache's documented behaviour on an exact-key hit — skips saving. Theirpnpm buildstep 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 thebuildjob 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-artifactis not on it, which is why build outputs are shared throughactions/cacherather 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/cacheonly, preserving the fork-PR posture in supply chain (fork PRs get cold caches by design). - Least-privilege token.
ci.ymlgrantsGITHUB_TOKENonlycontents: read; the caches use the runner's cache runtime token, notGITHUB_TOKEN. Checkout steps setpersist-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 --affectedpackage-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.