Build Passes Locally but Fails in CI After an AI Edit

AI added a dependency without committing the lockfile, or used a Node feature your CI image doesn't have. Reproduce it in 5 minutes and pin the gap shut.

npm run build is green on your laptop. You push, and Vercel, Netlify, or GitHub Actions go red with Cannot find module 'some-lib', SyntaxError: Unexpected token, or error TS2307. This is the single most common “environment drift” bug in AI-assisted coding: Claude Code or Cursor installed a dependency locally but didn’t commit the lockfile, used a Node feature your CI image is too old for, or relied on a file your machine has and the runner doesn’t.

Fastest fix (works ~70% of the time): run the exact CI Node version with npm ci locally to reproduce, then make sure the lockfile is in your latest commit. Both checks take about two minutes:

node -v                                  # compare to the first lines of your CI log
git log --name-only -1                   # did package.json change but the lockfile didn't?

If those don’t explain it, work down the diagnosis table below. The whole loop is a 5-minute reproduce-and-pin flow, not a guessing game.

First, which bucket are you in?

Match your red CI line to the most likely cause, then jump to the matching cause section. Ordered by how often each one is the culprit.

CI error you seeMost likely causeJump to
npm ci “out of sync”, ERR_PNPM_OUTDATED_LOCKFILE, “lockfile not up to date”Lockfile not committed (or pnpm version drift)Cause 1
SyntaxError: Unexpected token, “is not a function” on a built-inCI Node older than your local NodeCause 2
command not found: tsx / vite / esbuildTool was global locally, not in devDependenciesCause 3
Cannot find module './button' but the file clearly existsFilename casing mismatch (Linux is case-sensitive)Cause 4
Value is undefined at build, or a PUBLIC_*/NEXT_PUBLIC_* var is emptyEnv var exists locally, missing in CICause 5
ENOENT: no such file or directory reading a config or fixtureDev-only mock or local-only file referenced in configCause 6

Common causes

1. Lockfile not committed (most common)

The agent ran npm install some-lib and committed package.json, but not package-lock.json / pnpm-lock.yaml / yarn.lock. CI runs npm ci (or pnpm install --frozen-lockfile), which refuses to touch an out-of-sync lockfile:

npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json or npm-shrinkwrap.json are in sync.
npm error Missing: some-lib@2.3.0 from lock file

pnpm fails the same way with a different string:

ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because
pnpm-lock.yaml is not up to date with package.json

If your CI runs a plain npm install instead of npm ci, it may silently resolve a different version than your laptop, which then breaks at build or runtime instead. That is harder to spot, which is exactly why CI should use npm ci.

A subtler variant for pnpm: the lockfile is committed, but it was generated by a different pnpm major than the one CI runs, so the format no longer matches. Pin the pnpm version too (see Step 3).

How to spot it: git log --name-only -1 on the latest commit. If package.json changed but the lockfile didn’t, this is it.

2. Local Node version is newer than CI

You’re on Node 24, the runner is on Node 20 (or older). The agent used a recent API such as Array.prototype.toSorted() (Node 20+), Object.groupBy() (Node 21+), or the built-in fetch/structuredClone globals, and CI throws. Stale boilerplate that still pins Node 18 is a frequent offender: Node 18 reached end of life in April 2025 and most platforms have moved off it, so an old .nvmrc or workflow can leave you on an unsupported runtime that behaves differently from your machine.

SyntaxError: Unexpected token 'await'
    at wrapSafe (node:internal/modules/cjs/loader:1378:18)

Current platform defaults as of June 2026, useful when nothing is pinned:

PlatformDefault NodeNotes
Vercel24.xDefault since Feb 2026; major-version only
Netlify22Default since Feb 2025
GitHub Actions setup-nodewhatever you setNo safe default; an unset version is a trap

How to spot it: run node -v locally and compare to the first lines of the CI log (where the runner prints its Node version). A major-version gap, or a CI that prints Node 18, is highly suspicious.

3. AI relied on a globally installed tool

The agent put tsx, esbuild, vite, or tsc directly in a build script, assuming it’s on PATH. You installed it globally once and forgot; CI starts from a clean image with only what’s in your lockfile, so it fails:

sh: 1: tsx: command not found

The fix is to add the tool to devDependencies and call it via npx or an npm script so it resolves from node_modules/.bin.

How to spot it: reproduce in a clean container (Step 5). The fastest signal is that a clean npm ci then npm run build fails locally too.

4. Filename casing mismatch

macOS and Windows are case-insensitive by default; Linux (every cloud CI image) is strict. The agent wrote import Button from './button' on macOS where the file is actually Button.tsx. Local resolves, CI fails:

Cannot find module './button' or its corresponding type declarations.

This also bites when a file was renamed only by case (button.tsx to Button.tsx): git may not record the change unless core.ignorecase is handled, so CI still sees the old name.

How to spot it: git ls-files | grep -i button shows the exact on-disk casing git is tracking. Compare it character-for-character with the import.

5. Env var exists locally, missing in CI

The agent added process.env.SOME_API_KEY. Your local .env has it; the deploy platform doesn’t. At build time it resolves to undefined, and if it was inlined into client code you get a runtime crash like undefined is not a function. Astro, Next.js, and Vite read env at build time, so CI usually fails immediately rather than at runtime. Client-exposed vars also need the right prefix or the bundler drops them.

How to spot it: grep the recent diff for every process.env.X and cross-check the deploy platform’s environment-variable panel.

6. AI left in a dev-only mock or local-only file

While debugging, the agent pointed next.config.js or astro.config.mjs at a fixture, a local JSON file, or a path that only exists on your disk. The reference is committed but the file isn’t, so the build can’t find it:

Error: ENOENT: no such file or directory, open '/vercel/path0/mocks/data.json'

How to spot it: git diff main -- '*.config.*' to review every config change, and check whether any path it references is actually committed (git ls-files | grep data.json).

Shortest path to fix

Ordered by ROI. Steps 1-2 fix the large majority of cases.

Step 1: Reproduce locally with CI’s exact Node version and npm ci

This is the highest-value action because it turns a slow push-and-pray loop into a local one:

# Read the runner's Node version from the first lines of the CI log, e.g. 22.14.0
nvm install 22.14.0
nvm use 22.14.0
rm -rf node_modules
npm ci          # ci, not install — this honors the lockfile exactly like CI
npm run build

If it reproduces, iterate locally until green. If local still passes, the gap is platform env vars or the CI image itself, so jump to Step 4.

Step 2: Check the lockfile is in the most recent commit

git log --name-only -5 | grep -E '(package-lock|pnpm-lock|yarn.lock)'

If the last few commits touched package.json but never the lockfile, fix it now:

npm install                                  # regenerate the lockfile from package.json
git add package.json package-lock.json
git commit -m "chore: sync lockfile after AI dependency add"

For pnpm use pnpm install and git add pnpm-lock.yaml; for yarn, yarn install and git add yarn.lock.

Step 3: Pin the Node (and pnpm) version everywhere

The clean way to avoid four copies of the version drifting apart is one source of truth: a .nvmrc file that CI reads.

echo "22.14.0" > .nvmrc

GitHub Actions, read that same file instead of hard-coding a version:

- uses: actions/setup-node@v4
  with:
    node-version-file: '.nvmrc'
    cache: 'npm'

Also declare the supported range in package.json so a wrong local Node fails loudly:

{
  "engines": {
    "node": ">=22.0.0 <23.0.0",
    "npm": ">=10.0.0"
  }
}

Vercel: Settings -> Build and Deployment -> Node.js Version, pick the major (e.g. 22.x); Vercel only exposes major versions and applies minor/patch security updates itself. Note that an engines.node major in package.json overrides this dropdown. Netlify: set NODE_VERSION in netlify.toml, which beats the UI:

[build.environment]
  NODE_VERSION = "22"

If you use pnpm, also pin it via the packageManager field so CI uses the same major that wrote the lockfile:

{ "packageManager": "pnpm@9.15.0" }

Step 4: Reconcile env vars

List every process.env.X in code and diff it against the deploy platform:

grep -rEo 'process\.env\.[A-Z0-9_]+' src/ | sort -u

Add anything the platform is missing, then redeploy. Client-visible vars need the right prefix or the bundler strips them at compile time: NEXT_PUBLIC_* (Next.js), PUBLIC_* (Astro), VITE_* (Vite). A wrong prefix reads as undefined.

Step 5: Reproduce in a clean container

The ultimate reproduction: a fresh image with no global tools and a strict Node version, just like the runner.

docker run --rm -it -v "$(pwd):/app" -w /app node:22.14.0 sh -c "
  npm ci &&
  npm run build
"

If this passes but CI still fails, the remaining difference is almost always platform env vars (Step 4) or a stale build cache, so clear the cache and redeploy.

How to confirm it’s fixed

You’re done when all three are true:

  1. A clean local run reproduces CI exactly: rm -rf node_modules && npm ci && npm run build is green on the pinned Node version.
  2. git status shows the lockfile committed alongside any package.json change (nothing staged or dirty after the build).
  3. The next CI run is green on the same Node version your .nvmrc declares (check the version line in the log).

Prevention

  • One source of truth for Node: a .nvmrc read by CI via node-version-file, mirrored by engines.node in package.json and the platform dropdown. Fewer copies, less drift.
  • Always run npm ci (not npm install) in CI so lockfile drift fails loudly instead of silently resolving a different version.
  • Add to CLAUDE.md / .cursorrules: “Installing a dependency requires committing the lockfile in the same commit as package.json.”
  • Pre-commit hook: if package.json changed, require the lockfile to have changed too.
  • Enforce case-exact import paths: enable ESLint import/no-unresolved with a case-sensitive resolver, or eslint-plugin-import casing checks.
  • Commit a .env.example and have CI verify every required env var is set before building.
  • Pin the package manager itself (packageManager field) so pnpm/yarn lockfile format stays consistent across machines.

FAQ

Why does npm ci fail when npm install succeeds? npm install will quietly update the lockfile to match package.json; npm ci refuses to and errors out if they disagree. That strictness is the point: CI should fail when the lockfile is stale rather than silently install a different dependency tree. Commit the regenerated lockfile and the error goes away.

My CI log doesn’t print the Node version. How do I find what it used? On GitHub Actions, the setup-node step logs the resolved version; without that step the runner uses its image default, which changes over time. Add a one-line - run: node -v step to make it explicit, then pin it. On Vercel and Netlify the version appears near the top of the build log.

I pinned Node 18 a year ago and it still “works” locally. Should I bump it? Yes. Node 18 reached end of life in April 2025, so it gets no security patches and platforms are retiring it (Vercel defaults to 24.x, Netlify to 22 as of June 2026). Move to an active LTS such as 22 or 24, run your full build on it locally first, and pin the new version in all the same places.

It only fails on CI, never locally, even with npm ci. What’s left? That points away from dependencies and toward the environment: a missing env var (Step 4), filename casing that only Linux enforces (Cause 4), or a file referenced in config that wasn’t committed (Cause 6). The Docker reproduction in Step 5 is the fastest way to surface casing and missing-file issues; env vars are the residual once Docker passes.

Can I just delete the lockfile to make the error stop? No. Deleting it makes every install non-reproducible, so CI and your laptop can drift to different versions on any run. The fix is to commit an up-to-date lockfile, not to remove the check.

Tags: #AI coding #Debug #Troubleshooting