Prerequisite: Read the CI/CD Pipelines page to understand the basic workflow structure.
Overview
Our CI/CD pipelines avoid wasted builds, tests, and deployments through a centralized affected-path gate plus build caching. Instead of every workflow re-implementing its own change detection, each gated workflow calls a single reusable workflow (ci-check.yml) that computes the changed-file set
once and asks a shared TypeScript decision function whether the workflow is
actually affected.
This page documents how that gate works, how the per-app Vercel workflows are
wired, and how to debug skip decisions.
The platform is mid-migration from
apps/web (Next.js, port 7803) to
apps/tanstack-web (TanStack Start) plus apps/backend (Rust, port 7820).
See TanStack + Rust migration.
The optimization model below applies to both stacks: every Vercel app target
is gated the same way, regardless of framework.The Problem
Before centralized gating, workflows ran on every push regardless of:- Whether files relevant to that specific app or package actually changed
- Whether the change only touched an unrelated app
- Whether build artifacts were already cached
How Gating Works Today
Gated workflows do not rely on GitHub’s nativeon.push.paths filters.
Path filters are coarse (they cannot understand workspace dependency graphs) and
they would have to be duplicated and kept in sync across every workflow. Instead,
the logic is centralized in three pieces:
tuturuuu.ts— the source of truth. It exports acitoggle map (each workflow can be globally enabled/disabled), thevercelWorkflowTargetstable that maps each app to its preview/production workflow and package name, and thegetWorkflowDecision()function that decides whether a given workflow is affected by a set of changed files.scripts/ci/resolve-changed-files.ts— resolves the changed-file set for the current event (push payload, pull-request base/head, or git diff against the last successful deployment marker)..github/workflows/ci-check.yml— a reusable workflow that runs the two scripts and exposes ashould_runoutput.
The ci-check reusable workflow
Each gated workflow declares a check-ci job that calls ci-check.yml, then
gates its real job on the result:
ci-check.yml uses a sparse checkout (only the manifests and
scripts it needs), computes the changed files, and runs the decision script:
--experimental-strip-types, so the .ts sources
execute directly without a separate build step.
The decision function
getWorkflowDecision() in tuturuuu.ts resolves to should_run using this
order of precedence:
- Global toggle. If the workflow is
falsein thecimap, it never runs. workflow_dispatchbypass. Manual runs always proceed (affected-path gating is skipped), so you can force a deploy from the Actions UI.- Non-Vercel workflows. Workflows without an entry in
vercelWorkflowTargetsfall back to their staticcitoggle. - Unavailable change data. If the changed-file set cannot be resolved, the
gate stays open (
should_run = true) to fail safe. - Affected-path matching (Vercel workflows). The workflow runs only if at
least one changed file matches one of:
- a global affecting path (
bun.lock,package.json,turbo.json,tuturuuu.ts, or.github/workflows/ci-check.yml); - the workflow’s own workflow file;
- the target app’s directory (e.g.
apps/web/**for the platform target); - any package inside the target app’s workspace dependency closure. The
closure is built from
workspace:*dependencies, so editing a shared package only triggers the apps that actually depend on it.
- a global affecting path (
should_run = false and the heavy job is
skipped.
Vercel target table
Every Vercel app is registered once invercelWorkflowTargets. Adding a new app
means adding a single entry there (app slug, app path, package name, and the
preview/production workflow filenames) — the gating logic and changed-file
matching then apply automatically.
ci-check.yml.
Build Caching
Once a workflow decides it must run, the actual build is still accelerated by Turborepo’s content-based caching. Workspace dependencies are built with Turborepo before the app build:TURBO_TOKEN/TURBO_TEAM are configured), so unchanged dependency builds resolve
to cache hits instead of recompiling. This is content-aware: it understands the
dependency graph rather than relying on file timestamps.
Docker and Rust verification use the same principle at their native cache
layers. Docker setup jobs build through docker buildx build --load with
GitHub BuildKit cache scopes per image, and the Rust backend workflow restores
Cargo registry/git/target state before cargo fetch, clippy, test, Worker, and
native build steps. Keep those caches scoped to the service image or Rust crate
instead of adding a new shared cache store.
Earlier versions of these workflows ran an inline Turborepo
--dry-run=json
step to decide whether to skip a build. That per-workflow skip step has been
removed in favor of the centralized ci-check gate above. Turborepo is now
used for caching the build itself, not for the run/skip decision.Race-Condition Handling
Production deployments guard against superseded commits. Before building, the workflow checks whether a newer commit already landed on the production branch:steps.check_commits.outputs.skip_build != 'true',
so a stale run short-circuits instead of deploying an outdated artifact.
Edge Case Handling
resolve-changed-files.ts and the gate are written to fail safe.
Missing or unresolvable change data
If the changed-file set cannot be computed (e.g. an unusual event payload or a missing base commit),getWorkflowDecision() returns should_run = true. The
guiding principle: build when uncertain rather than skip incorrectly.
Initial commits and shallow clones
When a parent commit is unavailable (the very first commit, or a shallow clone without history), the changed-file resolver cannot diff against a base, so the gate opens and a full build runs. The workflow that needs deeper history checks out with an appropriatefetch-depth, and ci-check.yml uses fetch-depth: 0
with a sparse checkout so it always has enough history to diff.
Last-successful-deployment markers
For push events, the resolver can diff against the SHA of the last successful deployment (recorded as a GitHub deployment marker) rather than only the parent commit. This catches the case where intermediate commits never deployed, so the full diff since the last live deploy is considered.Monitoring & Debugging
Inspect the gate decision
check-workflow-config.ts logs its decision and the matched paths. In the
check-ci job logs you will see lines like:
Reproduce a decision locally
You can run the decision script directly to debug why a workflow did or did not run:Common Issues
Issue: A workflow never runs for legitimate changes
Likely causes:- The app is missing (or misconfigured) in
vercelWorkflowTargets. - The changed package is not in the target app’s workspace dependency closure
(the app does not actually
workspace:*-depend on it). - The workflow is toggled off in the
cimap intuturuuu.ts.
Issue: A workflow always runs
Likely causes:- A global affecting path (
bun.lock,package.json,turbo.json,tuturuuu.ts) changed — these intentionally trigger every Vercel target. - The change-data resolver failed, so the gate fell open by design.
Issue: Need to force a run
Use the workflow’s manual trigger —workflow_dispatch bypasses affected-path
gating entirely:
Performance Impact
The exact savings depend on the size of the app set and how localized each commit is, so treat the following as illustrative rather than a fixed figure. The monorepo currently registers 20 Vercel app targets, each with a preview and a production workflow (40 Vercel deploy workflows), out of ~80 total workflow files. Without gating, a single-app change would attempt to run every target’s preview/production deploy. With affected-path gating, a change scoped to one app skips the other ~38 Vercel deploys, plus any unrelated CI workflows. For a change that touches only one app:check-ci job. Changes to shared roots
(package.json, bun.lock, turbo.json, tuturuuu.ts) intentionally fan out
to all targets, so headline skip rates will vary with how often those roots
change.
To measure real-world impact, watch the Actions tab: gated runs that skip
resolve in the
check-ci job within seconds, while full deploys take minutes.
Counting skipped vs. full runs over a representative window is the most
reliable estimate for your change patterns.Best Practices
1. Keep tuturuuu.ts as the source of truth
When adding an app or workflow, register it in tuturuuu.ts (the ci map and,
for Vercel apps, vercelWorkflowTargets) instead of hand-editing path filters in
individual workflows. The gate and the tests key off that file.
2. Trust the fail-safe
When change data is missing, the gate opens and the workflow runs. This is intentional — it is safer to build unnecessarily than to skip a real change. Don’t add ad-hoc skip logic that defeats it.3. Model dependencies with workspace:*
The dependency-closure matching only works because shared packages are wired as
workspace:* dependencies. If an app consumes shared code without declaring the
dependency, the gate will not know to run it when that package changes.
4. Use workflow_dispatch to debug
To bypass gating for a one-off investigation, trigger the workflow manually from
the Actions UI rather than touching the gate logic.
5. Update tests when changing the gate
getWorkflowDecision() and the resolver are covered by unit tests under
scripts/ci/. Update them alongside any change to the matching rules so the
behavior stays pinned.
Further Reading
- CI/CD Pipelines
- TanStack + Rust migration
- Turborepo: Remote Caching
- GitHub Actions: Reusing workflows
Summary
Our CI/CD optimization centralizes change detection so each workflow stays thin:- A single reusable
ci-check.ymlworkflow computes the changed-file set once. tuturuuu.tsdecides, per workflow, whether the change is relevant — including workspace dependency-closure awareness for shared packages.- Turborepo caches the actual build once a run is warranted.
- The gate fails safe (builds on uncertainty), and
workflow_dispatchalways bypasses gating for manual runs.