Solving Monorepo CI Speed and Deployment Automation Simultaneously with Turborepo + Changesets
When operating a monorepo, two pains tend to arrive at the same time. One is "why is CI so slow?", and the other is "which packages need to be bumped to which versions in this release?" When I first started managing a monorepo with 6 apps and 12 shared packages, as CI times crept past an average of 7–8 minutes, I tried solving these two problems separately. Build optimization scripts on one side, versioning scripts on the other — in the end, all I had was a pile of scripts that didn't talk to each other, and a vicious cycle of rebuilding everything from scratch at every release.
Things changed when I started using Turborepo and Changesets together. At first I thought I was simply introducing two tools in parallel, but once I actually used them, I found their responsibilities were completely separate. One handled "how do we build fast," and the other handled "what gets deployed, when, and at which version." Turborepo owns build speed through hash-based caching, and Changesets manages release intent — once you understand this separation of concerns, you can see why the two tools don't conflict but actually interlock. This article walks through that connection step by step, and also covers the pitfalls you're most likely to fall into in real-world use.
Target audience: This is written for developers who are already running a pnpm workspace-based monorepo or considering adopting one. If
pnpm-workspace.yamlandworkspace:*dependency notation are unfamiliar, you may want to skim through pnpm workspace setup first before returning here. All code examples assume a pnpm workspace environment.
TL;DR — Declare a task pipeline in
turbo.jsonto enable caching, use.changeset/files to declare version intent at the PR stage, then automate Version PR creation and npm publishing withchangesets/action@v1. The key is that the two tools don't overlap — Turborepo handles build speed, Changesets handles release intent.
Core Concepts
How Turborepo Executes Tasks
Turborepo understands the tasks in a monorepo as a dependency graph. It re-runs only the changed packages and their downstream dependents, and restores previously computed cache for everything else. Configuration is declared in turbo.json.
Starting with Turborepo 2.x, the pipeline key has been renamed to tasks — be careful not to get confused by older documentation.
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"publish-packages": {
"dependsOn": ["^build", "^test"],
"cache": false
}
}
}The ^ symbol in dependsOn might look unfamiliar: ^build means "before running this package's build, first run build in all packages it depends on." Writing ["build"] without ^ refers to a different task within the same package.
Notice that publish-packages has "cache": false. It is always correct to disable caching for deployment tasks. This prevents the previous deployment result from being restored from cache, causing it to be skipped as "already deployed" — a situation where npm publish is never actually executed.
outputsdeclaration is the key to caching — You must list the build artifact paths accurately in theoutputsarray so that Turborepo can store and restore those files in the cache. If you omit a path, even a cache hit won't restore the actual files, causing "file not found" errors in subsequent tasks. We'll revisit this later.
How Changesets Manages Release Intent
Changesets is a tool that helps contributors explicitly declare "what version bump does this change represent?" at the PR stage. When opening a PR, contributors add a single markdown file to the .changeset/ folder.
---
"@myorg/ui": minor
"@myorg/utils": patch
---
Added `variant` prop to Button component, fixed formatDate utility bugOnce these files accumulate, the release manager runs changeset version to update each package's package.json version and CHANGELOG.md in one shot. Then changeset publish deploys to npm. changeset publish assumes that changeset version has already been run and the package.json versions have already been bumped. In practice, mixing up this order leads to the absurd situation of running the publish command without any version bump, so be careful.
Internal inter-package dependency versions are also updated automatically — when you bump packages/ui, you don't need to manually find and update the @myorg/ui version listed in packages/web's dependencies.
Base configuration lives in .changeset/config.json.
{
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}"commit": false means that running changeset version will not automatically commit the version bump. Setting it to true creates a git commit immediately upon execution, but when using changesets/action in CI — which directly creates and manages the PR — setting it to false ensures more predictable behavior.
updateInternalDependencies— The policy for updating versions between internal packages. When set to"patch", it automatically patch-bumps the version range of consuming packages whenever a dependency is released. Setting this to"patch"is the common choice for stable internal dependency management.
SemVer (Semantic Versioning) — A versioning scheme in the format
major.minor.patch. Breaking changes bump major, backwards-compatible new features bump minor, and bug fixes bump patch. Changesets guides PR authors to explicitly declare which level of change they're making.
Practical Application
Setting Up Directory Structure and Base Scripts
The project structure looks roughly like this. apps/ holds deployable applications, and packages/ holds internally shared libraries.
monorepo/
├── packages/
│ ├── ui/ # shared components
│ ├── utils/ # shared utilities
│ └── config/ # shared config
├── apps/
│ ├── web/
│ └── docs/
├── turbo.json
├── .changeset/
│ └── config.json
└── package.jsonRegistering two scripts in the root package.json in advance makes them convenient to use from GitHub Actions.
{
"scripts": {
"version-packages": "changeset version",
"publish-packages": "turbo run build --filter='./packages/*' && changeset publish"
}
}There's a reason publish-packages uses --filter='./packages/*'. The applications in apps/ are not packages to be published to npm — they're applications to be deployed to servers. It's sufficient for changeset publish to push only the libraries in packages/ to npm; including the apps can cause unintended publishes.
The key flow in this script is: Turborepo builds first, and Changesets' publish consumes those artifacts. In an environment with Remote Cache enabled, packages already built once in CI are restored from cache, making subsequent build steps pass almost instantly.
Remote Cache — A feature that shares the local cache to a remote storage so that team member B, or a CI server, can reuse build artifacts that team member A already built, without rebuilding. Without it, the same commit gets built twice — once locally and once in CI.
Connecting GitHub Actions
changesets/action@v1 plays the central role here. When .changeset/ contains changeset files, this action automatically creates or updates a "Version PR"; when there are no changeset files (i.e., after the Version PR has been merged), it runs publish. This removes the need to manually judge the timing, which is quite convenient in practice.
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build (with cache)
run: pnpm turbo run build --affected
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
publish: pnpm run publish-packages
version: pnpm run version-packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}It may look redundant that the "Build (with cache)" step builds with --affected, and then publish-packages runs turbo run build --filter='./packages/*' again. But this is intentional. Packages already built in the previous step remain in the Turborepo cache, so the second step passes almost instantly on a cache hit. Think of the first step as "cache warming + affected package validation," and the second step as "guaranteeing build state immediately before publish."
If fetch-depth: 0 is omitted, the --affected flag won't work correctly. Turborepo references Git history to compute the diff against the base branch (baseBranch: "main"), and in a shallow clone it can't find the commit to compare against. That is, --affected detects changed packages based on the merge base between the current branch and origin/main. This is a situation you'll encounter frequently in practice, so it's well worth bookmarking.
| Setting | Role |
|---|---|
TURBO_TOKEN |
Vercel Remote Cache authentication token |
TURBO_TEAM |
Remote Cache team identifier |
--affected |
Selectively runs only changed packages + downstream based on diff against origin/main |
fetch-depth: 0 |
Fetches full Git history (required for affected detection) |
Separating PR CI and Release CI Responsibilities
There's no need to build everything on every PR. Checking only the changed parts with the --affected flag is sufficient. Adding a step to PR CI that verifies a changeset file exists can also catch contributors accidentally forgetting to include one.
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# lint, test only changed packages + downstream
- name: Lint and Test (affected only)
run: pnpm turbo run lint test --affected
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}You may wonder why build is not explicitly run in this CI. In fact, builds don't completely skip. Thanks to the test task's dependsOn: ["^build"] setting, Turborepo follows the pipeline and runs upstream package builds first. Even without calling build directly in PR CI, any build required by the dependency graph is triggered automatically.
# when checking directly from local
pnpm turbo run lint test --affected
# create changeset file (interactive prompt)
pnpm changesetWith this setup, PR CI clearly handles "quick checks on only what changed," while release CI handles "build only the packages Changesets designated, then deploy."
Pros and Cons Analysis
Pros
| Item | Details |
|---|---|
| Build speed | With Remote Caching, you can skip 90%+ of repeated builds. There are cases where CI run time dropped from 6 minutes to 45 seconds |
| Selective execution | --affected can cut CI time by 60–80% for single-package PRs |
| Release stability | Declaring version intent at the PR stage reduces omissions and incorrect version bumps at deploy time |
| Automatic internal dependency management | No need to manually track cascading version updates across packages |
| CHANGELOG automation | Per-package CHANGELOG.md files are generated automatically, greatly reducing the burden of writing release notes |
| Team collaboration | Each contributor writes a changeset, and the release manager performs version bump + publish in one go |
Drawbacks and Caveats
There's one more thing to watch out for in projects that make heavy use of environment variables in CI. Turborepo can include environment variable values when computing task hashes — if CI-only variables (e.g., CI=true, GITHUB_RUN_ID) are included in the hash, local builds and CI builds will end up with different cache keys. This is the "environment variable cache pollution" problem. You can maintain predictable caching behavior by explicitly managing which variables are included in the hash via the env field in turbo.json.
| Item | Details | Mitigation |
|---|---|---|
| Cache configuration errors | Incorrect outputs paths risk cache misses or using stale cache |
Confirm build artifact paths accurately before declaring them |
| Missing changesets | If a contributor forgets the file, that change is omitted from the release | Register the changeset-bot GitHub App as a PR reviewer, or check for file existence in CI |
| Remote Cache cost | Large teams may exceed Vercel's free tier | Consider self-hosting with ducktors/turborepo-remote-cache backed by S3/GCS |
| Non-GitHub platforms | changesets/action@v1 is optimized for GitHub |
Manual CI script writing is required for GitLab/Bitbucket environments |
--affected false positives |
Changing the root package.json causes all packages to be detected as affected |
Tracking in Turborepo GitHub issue #11144. Treating root changes as a full run is the pragmatic strategy |
| Environment variable cache pollution | CI-only environment variables included in the hash cause unexpected cache misses | Explicitly manage hash-included variables via the env field in turbo.json |
The Most Common Mistakes in Practice
This section is actually the most important. Avoiding these pitfalls matters far more than getting the configuration right.
-
Missing
outputspaths — When I first set this up, I left it as an empty array[]instead ofdist/**, and spent over an hour debugging why cache hits were registering but the next task kept failing with "file not found." Turborepo needs to know where files will be generated in order to cache them. Framework-specific output paths like.next/**are especially easy to miss, so I recommend inspecting the actual directory structure produced after a build before declaring them. -
Forgetting
cache: falseonpublish-packages— A completed deployment remains in cache, and subsequent deployments get skipped as "restored from cache," meaningnpm publishis never actually executed. Always set deploy tasks tocache: falsefor safety. -
Using
--affectedwithoutfetch-depth: 0— In a shallow clone, Turborepo can't find the comparison base commit, causing it to treat all packages as affected or error out. Always pairingfetch-depth: 0withactions/checkout@v4will preemptively eliminate this problem.
Closing Thoughts
The structure covered in this article can be summarized in one sentence: Turborepo owns "how to build fast," Changesets owns "what to deploy, when, and at which version," and because that boundary is clear, the two tools don't interfere with each other — they interlock. That's why these two problems in a monorepo — build speed and release stability — can be solved with a single stack.
Three steps you can start with right now:
- Add a
tasksblock toturbo.jsonand declaredependsOnandoutputsfor each task. Even setting up justbuildandtestlets you feel the caching effect immediately. Runpnpm turbo run buildtwice and see how much faster the second run is — that will make it immediately clear why this configuration matters. - Install Changesets with
pnpm add -D @changesets/cli, generate.changeset/config.jsonwithpnpm changeset init, then runpnpm changesetto experience the changeset file authoring flow firsthand. - Add the
release.ymlintroduced above to.github/workflows/and configure theTURBO_TOKENandNPM_TOKENsecrets. At first, setaccess: "restricted"in.changeset/config.jsonto avoid accidentally publishing to public npm, and validate the workflow itself first — that's a much safer approach.
If you'd like to be notified when the next article is published, following via RSS or GitHub will get it to you right away.
References
- Turborepo Official Docs - Publishing Libraries
- Turborepo 2.0 Release Notes
- Turborepo 2.1 Release Notes (official introduction of the --affected flag)
- Turborepo Official Docs - Caching
- Turborepo Official Docs - Constructing CI
- Changesets Official GitHub
- Changesets Official Docs
- Monorepo Architecture with pnpm Workspace, Turborepo & Changesets | DEV Community
- Supercharging Monorepo Workflows with Turborepo, Vite, and Changesets | Medium
- Using Turborepo's --affected Flag in CI
- Advanced monorepo management with Turborepo 2.0 | LogRocket
- Streamlining Releases with Changesets | Prosopo