本地 build 过、CI 挂——AI 改完最常见的坑

AI 装了依赖没提 lockfile,或用了你 CI 镜像没有的 Node 特性。5 分钟内复现,把这道缝彻底钉死。

你在本地跑 npm run build 一切正常,push 完发现 Vercel、Netlify 或 GitHub Actions 红了,报 Cannot find module 'some-lib'SyntaxError: Unexpected tokenerror TS2307。这是 AI 辅助编码里最高频的一类”环境漂移”问题:Claude Code 或 Cursor 在本地装了依赖却没提 lockfile、用了你 CI 镜像太旧不支持的 Node 特性、或引用了一个只有你本机才有、runner 上没有的文件。

最快的修法(约 70% 的情况能解决): 用 CI 一模一样的 Node 版本配 npm ci 在本地复现,再确认 lockfile 进了你最近的 commit。两步加起来大概两分钟:

node -v                                  # 和 CI log 最前面几行对比
git log --name-only -1                   # package.json 变了但 lockfile 没变?

如果这两条都解释不了,就照下面的对照表逐项排。整套流程是一个 5 分钟的”复现并钉死”循环,不是靠猜。

先确认你属于哪一类

把你看到的 CI 红字对到最可能的原因,再跳到对应小节。下面按命中率从高到低排序。

你看到的 CI 报错最可能的原因跳到
npm ci 报 “out of sync”、ERR_PNPM_OUTDATED_LOCKFILE、“lockfile not up to date”lockfile 没提(或 pnpm 版本漂移)原因 1
SyntaxError: Unexpected token、内置方法 “is not a function”CI 的 Node 比本地旧原因 2
command not found: tsx / vite / esbuild工具只在本地全局装了,没进 devDependencies原因 3
文件明明在,却报 Cannot find module './button'文件名大小写不一致(Linux 区分大小写)原因 4
build 时值是 undefined,或某个 PUBLIC_*/NEXT_PUBLIC_* 变量为空环境变量本地有、CI 没有原因 5
读某个 config 或 fixture 时报 ENOENT: no such file or directoryconfig 引用了只在本地的 mock / 文件原因 6

常见原因

1. Lockfile 没 commit(最常见)

Agent 跑了 npm install some-libpackage.json 进了 commit,但 package-lock.json / pnpm-lock.yaml / yarn.lock 没进。CI 上跑 npm ci(或 pnpm install --frozen-lockfile),它拒绝去动一个不同步的 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 是同样的失败,只是报错字符串不同:

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

如果你 CI 跑的是普通 npm install 而不是 npm ci,它可能悄悄装了一个和你本机不同的版本,结果改在 build 期或运行时才炸——这种更难发现,所以 CI 才应该用 npm ci

pnpm 还有个更隐蔽的变种:lockfile 提了,但它是由和 CI 不同的 pnpm 大版本生成的,格式对不上。这时连 pnpm 版本一起钉(见 Step 3)。

如何判断: git log --name-only -1 看最近一次 commit。如果 package.json 变了但 lockfile 没变,就是这个。

2. 本地 Node 版本比 CI 新

你本地是 Node 24,runner 是 Node 20(或更旧)。Agent 用了较新的 API,比如 Array.prototype.toSorted()(Node 20+)、Object.groupBy()(Node 21+),或内置的 fetch/structuredClone 全局,CI 上就抛错。还残留着 Node 18 固定值的老模板是常见元凶:Node 18 已于 2025 年 4 月停止维护(EOL),多数平台都已下线,一份过时的 .nvmrc 或 workflow 会把你留在一个行为和本机不一样的、不再受支持的运行时上。

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

截至 2026 年 6 月各平台的默认 Node 版本,在什么都没固定时很有用:

平台默认 Node备注
Vercel24.x2026 年 2 月起为默认;只能选大版本
Netlify222025 年 2 月起为默认
GitHub Actions setup-node看你怎么写没有安全默认值,不指定就是坑

如何判断: 本地跑 node -v,和 CI log 最前面几行(runner 打印它的 Node 版本那几行)对比。差一个大版本、或 CI 打印出 Node 18,都高度可疑。

3. AI 用了本地全局装的工具

Agent 在 build 脚本里直接写了 tsxesbuildvitetsc,默认它在 PATH 上。你某次全局装过就忘了;CI 从干净镜像起步,只有 lockfile 里的东西,于是失败:

sh: 1: tsx: command not found

修法是把工具加进 devDependencies,再通过 npx 或 npm script 调用,让它从 node_modules/.bin 里解析。

如何判断: 在干净容器里复现(Step 5)。最快的信号就是干净 npm ci 后再 npm run build 在本地也挂。

4. 文件名大小写不一致

macOS 和 Windows 文件系统默认不区分大小写,Linux(所有云端 CI 镜像)严格区分。Agent 在 macOS 上写了 import Button from './button',但文件实际叫 Button.tsx。本地能解析,CI 报:

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

只改了大小写的重命名(button.tsx 改成 Button.tsx)也会踩这个坑:如果 core.ignorecase 没处理好,git 可能不记录这次改动,CI 看到的还是旧文件名。

如何判断: git ls-files | grep -i button 看 git 实际追踪的磁盘大小写,逐字符和 import 对比。

5. 环境变量本地有、CI 没有

Agent 加了 process.env.SOME_API_KEY,你本地 .env 里有,部署平台没有。build 时它解析成 undefined,如果被内联进了客户端代码,运行时就报 undefined is not a function。Astro、Next.js、Vite 都在 build 期读 env,所以 CI 通常当场就挂、而不是运行时才挂。客户端可见的变量还得带对前缀,否则打包器会把它丢掉。

如何判断: grep 最近 diff 里所有 process.env.X,和部署平台的环境变量面板对账。

6. AI 残留了开发期 mock 或只在本地的文件

调试时 Agent 把 next.config.jsastro.config.mjs 指向了一个 fixture、本地 JSON 文件、或只有你磁盘上才有的路径。引用进了 commit,但文件没进,build 就找不到:

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

如何判断: git diff main -- '*.config.*' 看所有 config 改动,并确认它引用的路径是否真的提交了(git ls-files | grep data.json)。

最短修复路径

按收益排序。Step 1-2 通常能修掉绝大多数情况。

Step 1:本地用和 CI 一样的 Node 版本 + npm ci 复现

这是收益最高的动作,因为它把”push 完干等”的慢循环变成了本地循环:

# 从 CI log 最前面几行读出 runner 的 Node 版本,比如 22.14.0
nvm install 22.14.0
nvm use 22.14.0
rm -rf node_modules
npm ci          # 是 ci 不是 install——它会像 CI 一样严格按 lockfile 装
npm run build

如果本地能复现,就在本地一路改到绿。如果本地依然过,说明差异在平台 env vars 或 CI 镜像本身,跳到 Step 4。

Step 2:检查 lockfile 是否在最近 commit 里

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

如果最近几个 commit 动了 package.json 但从没动 lockfile,立刻补提:

npm install                                  # 按 package.json 重新生成 lockfile
git add package.json package-lock.json
git commit -m "chore: sync lockfile after AI dependency add"

用 pnpm 就 pnpm installgit add pnpm-lock.yaml;用 yarn 就 yarn installgit add yarn.lock

Step 3:把 Node(和 pnpm)版本到处钉死

要避免四份版本号各自漂移,干净的做法是单一事实来源:一个 CI 会去读的 .nvmrc 文件。

echo "22.14.0" > .nvmrc

GitHub Actions 里读这同一个文件,而不是硬编码版本:

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

同时在 package.json 里声明支持范围,这样本地 Node 版本不对会立刻报错:

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

Vercel: Settings -> Build and Deployment -> Node.js Version,选大版本(比如 22.x);Vercel 只暴露大版本,minor/patch 安全更新它自己来。注意 package.jsonengines.node 的大版本会覆盖这个下拉框。Netlify:netlify.toml 里设 NODE_VERSION,它优先于 UI:

[build.environment]
  NODE_VERSION = "22"

如果用 pnpm,再通过 packageManager 字段把 pnpm 也钉住,让 CI 用和写 lockfile 时一样的大版本:

{ "packageManager": "pnpm@9.15.0" }

Step 4:环境变量对账

把代码里所有 process.env.X 列出来,和部署平台对账:

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

平台缺哪个就补哪个,然后 redeploy。客户端可见的变量得带对前缀,否则打包器在编译期就把它剥掉:Next.js 是 NEXT_PUBLIC_*,Astro 是 PUBLIC_*,Vite 是 VITE_*。前缀写错就读成 undefined

Step 5:在干净容器里复现

终极复现:一个没有任何全局工具、Node 版本严格固定的全新镜像,和 runner 一样。

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

如果这里过了但 CI 还挂,剩下的差异基本就是平台 env vars(Step 4)或一份过时的 build 缓存——清缓存再 redeploy。

如何确认修好了

三条都成立才算修完:

  1. 本地干净跑能精确复现 CI:在固定的 Node 版本上 rm -rf node_modules && npm ci && npm run build 全绿。
  2. git status 显示 lockfile 已和任何 package.json 改动一起提交(build 后没有未暂存或脏文件)。
  3. 下一次 CI 在 .nvmrc 声明的同一 Node 版本上是绿的(去 log 里核对版本那一行)。

预防建议

  • Node 版本单一事实来源:一个 .nvmrc,CI 通过 node-version-file 去读,再用 package.jsonengines.node 和平台下拉框镜像它。份数越少,漂移越小。
  • CI 里始终用 npm ci(而不是 npm install),让 lockfile 漂移当场报错,而不是悄悄装了不同的版本。
  • CLAUDE.md / .cursorrules 里写:“装依赖必须和 package.json 在同一个 commit 里提交 lockfile。”
  • pre-commit hook 加一条:package.json 改了就要求 lockfile 也改了。
  • import 路径强制大小写一致:ESLint 开 import/no-unresolved 配大小写敏感的 resolver,或用 eslint-plugin-import 的大小写检查。
  • .env.example 提交进 repo,CI 在 build 前校验所有必需 env vars 都已设置。
  • 连包管理器本身一起钉(packageManager 字段),让 pnpm/yarn 的 lockfile 格式跨机器保持一致。

常见问题

为什么 npm install 能过、npm ci 却挂? npm install 会悄悄更新 lockfile 去匹配 package.jsonnpm ci 不会,两者对不上时它直接报错。这种严格正是关键所在:CI 应该在 lockfile 过期时失败,而不是默默装一棵不同的依赖树。把重新生成的 lockfile 提了,错误就没了。

我的 CI log 没打印 Node 版本,怎么知道它用了哪个? GitHub Actions 上,setup-node 步骤会打印解析出的版本;没这一步的话,runner 用镜像默认值,而这个值会随时间变。加一行 - run: node -v 让它显式打印出来,然后钉死。Vercel 和 Netlify 上,版本在 build log 顶部附近。

我一年前钉了 Node 18,本地现在还”能跑”,要升吗? 要。Node 18 已于 2025 年 4 月 EOL,不再有安全补丁,平台也在下线它(截至 2026 年 6 月,Vercel 默认 24.x、Netlify 默认 22)。换到一个活跃的 LTS,比如 22 或 24,先在本地用它把整套 build 跑通,再在所有同样的地方钉上新版本。

只在 CI 挂、本地从来不挂,连 npm ci 都过,还剩啥? 那说明问题不在依赖,而在环境:少了个 env var(Step 4)、只有 Linux 才较真的文件名大小写(原因 4)、或 config 引用了没提交的文件(原因 6)。Step 5 的 Docker 复现是把大小写和缺文件问题逼出来的最快办法;Docker 过了之后还剩的,就是 env vars。

直接删掉 lockfile 让报错消失行不行? 不行。删了之后每次 install 都不可复现,CI 和你本机随时可能漂到不同版本。正确做法是提交一份最新的 lockfile,而不是把这道检查去掉。

相关阅读

标签: #AI 编程 #排查 #排查