Answers you can trust, from Codeables
Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.
Explore CodeablesHow can we stop rebuilding and retesting unchanged projects in CI for a big repo?
Continuous integration pipelines for large monorepos often waste huge amounts of time and compute rebuilding and retesting projects that haven’t changed. If your team is waiting 20–40 minutes on every pull request, or your CI bill is exploding, you’re probably missing one key optimization: only building and testing what’s affected by the current changes.
This guide walks through practical strategies, patterns, and tools to stop rebuilding and retesting unchanged projects in CI for a big repo, with a focus on reliability and ease of adoption.
Why rebuilding everything is killing your CI
In a big repo with many services, libraries, and apps, running the full build and test suite on every commit leads to:
- Long feedback loops for developers
- Flaky pipeline failures due to sheer volume of tests
- Unnecessary compute costs
- Lower deployment frequency because people fear “breaking CI”
Most of this waste comes from treating the repo as a single unit, instead of a graph of interdependent projects. To fix it, you need to make CI aware of what really changed and what depends on it.
Core idea: Change-based builds and tests
The central concept is change-based execution:
- Detect what changed in the current commit or PR.
- Map those changes to projects (apps, services, libraries) in the repo.
- Understand the dependency graph between projects.
- Run builds and tests only for:
- Projects that changed, and
- Projects that depend on those changed projects.
Everything else can be skipped, or simply verified via cached artifacts.
To get there, you need three fundamentals:
- A clear project structure
- A way to describe dependencies between projects
- A mechanism to compute “affected” projects per change
Step 1: Organize your big repo into discoverable projects
First ensure your repository is structured so CI can reason about projects.
Common patterns:
-
Monorepo layout
/apps /web /api /admin /libs /ui /auth /data-access /tools -
Service-per-directory
/services /billing /payments /notifications /shared /logging /config
Best practices:
- Give each project a clear root directory.
- Store project-level config in that directory (e.g.,
package.json,pom.xml,build.gradle,go.mod). - Keep shared code in dedicated libraries (
libs/) rather than copying code across services. - Define explicit boundaries: avoid “reaching into” other projects’ directories.
A clear layout makes it possible to map a changed file path back to the owning project(s).
Step 2: Make dependencies between projects explicit
To know what to rebuild, you must know what depends on what.
Options for modeling dependencies
- Language-level / build-tool dependencies
- Node:
dependencies/devDependenciesinpackage.json - Java: Gradle/Maven inter-module dependencies
- .NET:
<ProjectReference>in.csproj - Go: module imports and
go.mod
- Node:
- Monorepo tools
- Nx, Turborepo, Bazel, Pants, Buck, Rush, Lerna, etc.
- These can generate and maintain a project graph.
- Manual dependency graph
- A simple YAML/JSON file like
project-graph.yml:web: dependsOn: [ui, auth] admin: dependsOn: [ui, data-access] api: dependsOn: [auth, data-access]
- A simple YAML/JSON file like
The graph is then used to compute downstream dependents when a library changes.
Step 3: Determine what changed in each CI run
Your CI system should identify changed files compared to a base revision:
Common patterns:
-
Git diff for PRs
git fetch origin main git diff --name-only origin/main...HEAD -
Git diff for branch builds
git diff --name-only HEAD~1...HEAD
Typical approach in CI:
- For GitHub Actions, GitLab CI, Azure DevOps, Jenkins, etc., you:
- Checkout the repo with full history or at least the base commit.
- Run a small script that lists changed files.
- Pass that list into your build orchestration logic.
Once you have changed files, you can:
- Map them to projects by directory prefix.
- Or let toolchains (Nx, Bazel, Turborepo, Pants) compute affected targets.
Step 4: Compute “affected” or “impacted” projects
Strategy A: Use a monorepo/tooling system that does it for you
For many stacks, the fastest path is to adopt a tool that has affected graph logic built-in.
Example: Nx (JS/TS, React, Node, NestJS, etc.)
Nx builds a project graph from your workspace and can run targeted commands:
-
Affected apps for the current changes
npx nx print-affected --target=build --base=origin/main --head=HEAD -
Run builds only for affected apps
npx nx affected -t build --base=origin/main --head=HEAD -
Run tests only for affected projects
npx nx affected -t test --base=origin/main --head=HEAD
Nx automatically tracks dependencies (including TS path imports), so if libs/auth changes, any app consuming it will be marked as affected.
Example: Bazel
Bazel’s entire model is incremental builds and tests:
-
Define
targetsinBUILDfiles. -
Bazel tracks dependencies and caches build/test results.
-
Use:
bazel test //... --test_output=errors
Under the hood, Bazel will skip targets whose inputs haven’t changed, due to its content-addressed cache and dependency graph.
Example: Turborepo (Node monorepos)
Turborepo uses a task pipeline and can skip tasks whose inputs haven’t changed:
-
Define tasks in
turbo.json:{ "pipeline": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] }, "test": { "dependsOn": ["build"] } } } -
Use:
npx turbo run test --filter=...[HEAD^1]
Or rely on its caching logic to skip unchanged packages.
Strategy B: Implement simple dependency logic yourself
If you don’t want a new tool yet, you can implement a straightforward system:
-
Create a project registry (e.g.,
projects.json):{ "web": { "root": "apps/web", "dependsOn": ["ui", "auth"] }, "api": { "root": "apps/api", "dependsOn": ["auth", "data-access"] }, "ui": { "root": "libs/ui", "dependsOn": [] }, "auth": { "root": "libs/auth", "dependsOn": [] }, "data-access": { "root": "libs/data-access", "dependsOn": [] } } -
Map changed files to projects
Pseudocode:
changed_files=$(git diff --name-only origin/main...HEAD) changed_projects=() for file in $changed_files; do for project in $(jq -r 'keys[]' projects.json); do root=$(jq -r ".\"$project\".root" projects.json) if [[ $file == $root/* ]]; then changed_projects+=("$project") fi done done changed_projects=$(printf "%s\n" "${changed_projects[@]}" | sort -u) -
Compute dependents
- Build a dependency graph in memory.
- For each changed project, find all projects that depend on it (transitively).
- Your final list:
changed + dependents.
-
Trigger builds/tests only for those
for project in "${final_projects[@]}"; do run_build_for "$project" run_tests_for "$project" done
This simple system can give you most of the value without heavy tooling.
Step 5: Introduce CI caching for unchanged artifacts
Even with great change detection, you’ll still rebuild some projects frequently. Caching lets you avoid redoing work when inputs are identical.
Types of caches to consider
- Dependency caches
- Node modules:
node_modules,~/.npm,~/.pnpm-store - Maven/Gradle caches
- NuGet, pip, cargo, etc.
- Node modules:
- Build cache
- Built artifacts (JARs, DLLs, bundles, compiled outputs)
- Test cache
- Some systems (Bazel, Nx with remote cache) can cache test results by input hash.
CI examples
GitHub Actions
- uses: actions/cache@v4
with:
path: |
~/.npm
**/node_modules
key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
deps-${{ runner.os }}-
- name: Build affected projects
run: npx nx affected -t build --base=origin/main --head=HEAD
GitLab CI
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .cache/
- node_modules/
build:
script:
- npx nx affected -t build --base=origin/main --head=HEAD
Remote/dedicated caches
Tools like Nx, Turborepo, Bazel support remote caches (S3, Redis, proprietary services). Benefits:
- Cache is shared across CI agents and local machines.
- CI reuses artifacts created on other runners or developer machines.
Step 6: Wire change-detection into your CI pipelines
How you integrate this depends on your CI platform, but the workflow is similar:
- Checkout repo and fetch base commit
- Compute changed files and/or affected projects
- Fail fast if the result is “none” (if you truly want to skip)
- Otherwise run builds/tests only for affected projects
Below are some example patterns.
GitHub Actions example
name: CI
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install deps
run: npm ci
- name: Determine affected projects
id: affected
run: |
npx nx print-affected --base=origin/main --head=HEAD > affected.json
cat affected.json
- name: Build affected
run: npx nx affected -t build --base=origin/main --head=HEAD
- name: Test affected
run: npx nx affected -t test --base=origin/main --head=HEAD
GitLab CI example
stages:
- build
- test
build:
stage: build
script:
- git fetch origin main
- npx nx affected -t build --base=origin/main --head=$CI_COMMIT_SHA
test:
stage: test
script:
- git fetch origin main
- npx nx affected -t test --base=origin/main --head=$CI_COMMIT_SHA
Step 7: Handle global changes and edge cases
Not all changes are local to a single project. Some require rebuilding and retesting more widely.
When to rebuild everything
You might trigger “full” builds/tests if:
- Core tooling changes:
package-lock.json/pnpm-lock.yamltsconfig.base.json,babel.config.jswebpack.config.js, general build scripts
- CI scripts or base Docker images change.
- Shared runtime configurations change, like:
.env.exampleconfig/directory
- Dependency graph metadata changes.
Implement this via a simple rule:
if git diff --name-only origin/main...HEAD | grep -E \
"(package-lock.json|tsconfig.base.json|tooling/|Dockerfile.base)"; then
export FORCE_FULL_BUILD=1
fi
Then in your CI logic:
if [ "$FORCE_FULL_BUILD" = "1" ]; then
run_full_build_and_test
else
run_affected_only
fi
Treat non-code changes differently
- Docs-only changes should not trigger builds/tests.
- Markdown, images, or changes under
/docscan skip heavy CI jobs.
Example:
changed=$(git diff --name-only origin/main...HEAD)
non_doc_changes=$(echo "$changed" | grep -vE '^docs/')
if [ -z "$non_doc_changes" ]; then
echo "Only docs changed, skipping builds/tests."
exit 0
fi
Step 8: Gradual rollout to avoid breaking everything
Moving a big repo from “build everything” to “build only changed” can feel risky. Make it incremental.
Phase 1: Observe only
- Add logging: compute affected projects, but still build everything.
- Compare:
- Which projects were actually affected?
- Could you have safely skipped some builds/tests?
Phase 2: Partial adoption
- Apply change-based execution only to:
- A subset of apps/services.
- Certain jobs (e.g., unit tests, not integration tests).
- Keep nightly or scheduled “full” CI runs for safety.
Phase 3: Full adoption with guards
- Fully switch per-PR CI to affected-only jobs.
- Maintain:
- Nightly “build and test all” pipelines.
- Manual “run full test suite” button for release branches.
- Add metrics and alerting for failures that might hint at missing dependencies in your graph.
Step 9: Make test suites more project-scoped
Skipping rebuilds helps, but if tests still load the entire world, you won’t see big gains.
To maximize impact:
- Scope unit tests to project boundaries.
- Ensure each project has its own test command:
npm test -- web./gradlew :billing:testdotnet test Billing.Tests.csproj
- Minimize cross-project global fixtures; prefer local test data.
- For integration / E2E tests:
- Only run suites that touch affected services by default.
- Keep full cross-system test suites for nightly or pre-release pipelines.
Step 10: Monitor speed, coverage, and flakiness
After implementing change-based builds/tests:
- Track metrics:
- Average CI time per PR before vs. after
- Test execution time
- Cache hit rates if available
- Watch for:
- Surprising production issues that might hint at missing dependencies.
- Test suites that are never triggered anymore (maybe they were misconfigured).
- Periodically review your project graph:
- Dependencies drift over time.
- New projects might not be listed correctly.
Tooling options worth considering
Depending on your tech stack, some tools make this much easier:
- Nx – Great for JS/TS monorepos, supports other languages via plugins, has robust “affected” and remote caching features.
- Turborepo – Strong for JS/TS with pipeline-based task orchestration and caching.
- Bazel – Language-agnostic, enterprise-grade incremental builds/tests, very powerful but more complex to adopt.
- Pants – Another multi-language build system with change-based execution.
- Gradle – Already has incremental build logic and build cache; use multi-module structure and
--continuestrategies. - Custom scripts – Use Git diff + a homegrown project graph for smaller or less complex repos.
Putting it all together: A practical blueprint
To stop rebuilding and retesting unchanged projects in CI for a big repo:
- Structure the repo into well-defined projects with clear roots.
- Model dependencies so you know what depends on what.
- Use Git diff to find changed files for each CI run.
- Compute affected projects (changed + dependents).
- Run build/test tasks only for affected projects, not the whole repo.
- Add caching so unchanged artifacts and test results are reused.
- Handle global changes and docs-only changes with simple rules.
- Roll out gradually, starting in “observe-only” mode and moving to full adoption.
- Continuously monitor and tune based on CI performance and reliability metrics.
By treating your big repo as a graph of projects instead of a single monolith, CI can scale with your codebase – giving developers faster feedback, reducing costs, and making your pipeline more resilient as the repo grows.