> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuturuuu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# CI/CD Pipeline Optimization

> Centralized affected-path gating and smart caching to minimize unnecessary CI/CD runs.

<Info>
  **Prerequisite**: Read the [CI/CD Pipelines](/build/development-tools/ci-cd-pipelines) page to understand the basic workflow structure.
</Info>

## 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.

<Note>
  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](/platform/architecture/tanstack-rust-migration).
  The optimization model below applies to both stacks: every Vercel app target
  is gated the same way, regardless of framework.
</Note>

## 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:

```yaml theme={null}
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:

```yaml theme={null}
- 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.

```ts theme={null}
{
  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:

```yaml theme={null}
- 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.

<Note>
  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.
</Note>

## Race-Condition Handling

Production deployments guard against superseded commits. Before building, the
workflow checks whether a newer commit already landed on the production branch:

```yaml theme={null}
- 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:

```text theme={null}
Workflow: vercel-production-platform.yaml
Should run: false
Reason: vercel-production-platform.yaml is unaffected by the changed paths
```

or, when it does run:

```text theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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:

```yaml theme={null}
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:

```text theme={null}
~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.

<Note>
  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.
</Note>

## 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](/build/development-tools/ci-cd-pipelines)
* [TanStack + Rust migration](/platform/architecture/tanstack-rust-migration)
* [Turborepo: Remote Caching](https://turborepo.com/docs/core-concepts/remote-caching)
* [GitHub Actions: Reusing workflows](https://docs.github.com/en/actions/using-workflows/reusing-workflows)

## 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.
