先分清两类失败

agent 写的代码出问题,通常不是语法错误,而是环境差异:本地装了某个全局包、Node 小版本不同、lockfile 被顺手升级、构建时把当前时间写进了产物。第一类靠测试能抓到,第二类只能靠「可复现」来抓——同一份提交,在任何机器上构建出来的东西必须一样。

门禁不需要一步到位。先守住三件事:依赖只从锁文件装、工具链版本固定、构建产物可比对。这三件做完,再考虑更严的签名与溯源。

第一步:CI 只认锁文件

Node 项目用 npm ci 而不是 npm install。npm ci 会严格按 package-lock.json 安装,锁文件与 package.json 不一致时直接失败;npm install 则可能悄悄改写锁文件,把 agent 的依赖变更混进提交。pnpm 用 --frozen-lockfile,Yarn 用 --immutable。

- uses: actions/checkout@v4
- uses: actions/setup-node@v4
  with:
    node-version-file: .nvmrc
    cache: npm
- run: npm ci --ignore-scripts
- run: npm run build

--ignore-scripts 不是永远适用:有些包靠 postinstall 下载二进制。更稳妥的做法是允许脚本但把网络限制在白名单内,或者把这类依赖换成预编译版本。关键是团队要知道哪些依赖会在安装阶段执行代码。

第二步:把工具链写进仓库

.nvmrc 固定 Node 主版本,package.json 的 packageManager 字段固定包管理器及其版本(Corepack 会按这个字段切换)。构建镜像不要再写 node:latest;用具体的小版本标签,并把镜像 digest 记下来。这样「本地能跑」和「CI 能跑」的差距会小很多。

{
  "name": "example",
  "packageManager": "npm@10.9.2",
  "engines": { "node": ">=22.11 <23" }
}

第三步:连续构建两次,比对哈希

最直接的可复现检查,是在同一个 job 里构建两次,分别计算产物目录的哈希。两次不一致,说明构建过程受到了时间、随机数、文件顺序或网络的影响。把差异文件打印出来,比只报一个「hash mismatch」有用得多。

npm run build
find dist -type f -print0 | sort -z | xargs -0 sha256sum > /tmp/build-1.txt
rm -rf dist
npm run build
find dist -type f -print0 | sort -z | xargs -0 sha256sum > /tmp/build-2.txt
diff -u /tmp/build-1.txt /tmp/build-2.txt

如果产物里确实需要时间戳,用 SOURCE_DATE_EPOCH 这类约定把时间固定住,而不是在比对时把它排除。排除项越多,门禁越像摆设。

把失败写回 PR

门禁失败时,只给一个红叉,agent 和人都不知道怎么修。让脚本输出三样东西:哪一步失败、差异文件的路径、最近一次通过的 commit。把这段贴进 PR 评论,agent 下一轮才有明确的修复目标。

  • 锁文件变更必须单独成一个 commit,评审时一眼能看到依赖动了什么。
  • 构建脚本禁止访问公网;需要下载的资源提前缓存,并校验哈希。
  • 每周至少跑一次「从零克隆 + 构建」,防止缓存把问题盖住。