Skip to main content
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
With a large monorepo of apps and shared packages, this meant a single commit to one app could trigger dozens of unrelated build and deploy workflows, slowing feedback and congesting the Actions queue.

How Gating Works Today

Gated workflows do not rely on GitHub’s native on.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:
  1. tuturuuu.ts — the source of truth. It exports a ci toggle map (each workflow can be globally enabled/disabled), the vercelWorkflowTargets table that maps each app to its preview/production workflow and package name, and the getWorkflowDecision() function that decides whether a given workflow is affected by a set of changed files.
  2. 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).
  3. .github/workflows/ci-check.yml — a reusable workflow that runs the two scripts and exposes a should_run output.

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:
jobs:
  check-ci:
    uses: ./.github/workflows/ci-check.yml
    with:
      workflow_name: vercel-production-platform.yaml
    permissions:
      contents: read
      deployments: read

  Deploy-Production:
    needs: [check-ci]
    if: github.ref == 'refs/heads/production' && needs.check-ci.outputs.should_run == 'true'
    runs-on: ubuntu-latest
    # ...
Internally, ci-check.yml uses a sparse checkout (only the manifests and scripts it needs), computes the changed files, and runs the decision script:
- name: Compute changed files
  id: changed_files
  env:
    GITHUB_TOKEN: ${{ github.token }}
    WORKFLOW_NAME: ${{ inputs.workflow_name }}
  run: node --experimental-strip-types scripts/ci/resolve-changed-files.ts

- name: Check Configuration
  id: check_config
  env:
    CHANGED_FILES_FILE: ${{ steps.changed_files.outputs.changed_files_path }}
    WORKFLOW_NAME: ${{ inputs.workflow_name }}
  run: node --experimental-strip-types scripts/ci/check-workflow-config.ts
The scripts run with Node’s --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:
  1. Global toggle. If the workflow is false in the ci map, it never runs.
  2. workflow_dispatch bypass. Manual runs always proceed (affected-path gating is skipped), so you can force a deploy from the Actions UI.
  3. Non-Vercel workflows. Workflows without an entry in vercelWorkflowTargets fall back to their static ci toggle.
  4. Unavailable change data. If the changed-file set cannot be resolved, the gate stays open (should_run = true) to fail safe.
  5. 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.
If nothing matches, the decision is should_run = false and the heavy job is skipped.

Vercel target table

Every Vercel app is registered once in vercelWorkflowTargets. 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.
{
  app: 'platform',
  appPath: 'apps/web',
  packageName: '@tuturuuu/web',
  previewWorkflow: 'vercel-preview-platform.yaml',
  productionWorkflow: 'vercel-production-platform.yaml',
}
Applied to: all Vercel app workflows (currently 20 preview + 20 production = 40 workflows) plus the other gated CI workflows wired through 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:
- name: Build workspace dependencies
  run: bun turbo:local run build --filter=@tuturuuu/types --filter=@tuturuuu/supabase --filter=@tuturuuu/internal-api --filter=@tuturuuu/masonry --filter=@tuturuuu/devbox
Turborepo hashes inputs and reuses cached task outputs (via the remote cache when 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:
- name: Check for newer commits
  id: check_commits
  run: |
    git fetch origin production || { echo "Remote branch not found, continuing with build"; echo "skip_build=false" >> $GITHUB_OUTPUT; exit 0; }
    LATEST_COMMIT=$(git rev-parse origin/production 2>/dev/null || echo "")
    CURRENT_COMMIT=${GITHUB_SHA}
    if [ -n "$LATEST_COMMIT" ] && [ "$LATEST_COMMIT" != "$CURRENT_COMMIT" ]; then
      echo "Newer commit found on production branch. Skipping build."
      echo "skip_build=true" >> $GITHUB_OUTPUT
    else
      echo "skip_build=false" >> $GITHUB_OUTPUT
    fi
Every subsequent step is gated on 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 appropriate fetch-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:
Workflow: vercel-production-platform.yaml
Should run: false
Reason: vercel-production-platform.yaml is unaffected by the changed paths
or, when it does run:
Should run: true
Reason: vercel-production-platform.yaml is affected by 3 changed path(s)
Matched paths:
- apps/web/src/app/page.tsx
- packages/ui/src/button.tsx

Reproduce a decision locally

You can run the decision script directly to debug why a workflow did or did not run:
CHANGED_FILES="apps/web/src/app/page.tsx" \
WORKFLOW_NAME="vercel-production-platform.yaml" \
node --experimental-strip-types scripts/ci/check-workflow-config.ts
There are also unit tests covering the gating logic:
bun test scripts/ci/check-workflow-config.test.js
bun test scripts/ci/resolve-changed-files.test.js

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 ci map in tuturuuu.ts.
Fix: Verify the target entry and the dependency edges, then re-run the local reproduction above to confirm the decision.

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.
This is usually correct behavior; only investigate if it persists for changes that clearly do not touch shared roots.

Issue: Need to force a run

Use the workflow’s manual trigger — workflow_dispatch bypasses affected-path gating entirely:
on:
  workflow_dispatch:

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:
~40 Vercel deploy workflows
- 2 that actually deploy the touched app (preview + production)
= ~38 deploy workflows skipped at the cheap check-ci gate
Each skipped deploy avoids several minutes of install + build + deploy time and only pays the cost of the lightweight 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

Summary

Our CI/CD optimization centralizes change detection so each workflow stays thin:
  • A single reusable ci-check.yml workflow computes the changed-file set once.
  • tuturuuu.ts decides, 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_dispatch always bypasses gating for manual runs.
The result: a one-app change skips the ~38 other Vercel deploys at a cheap gate, while shared-root changes still correctly fan out everywhere they matter.