•
阅读时长 2 分钟

锁文件提交了以后,构建为什么还是要用 npm ci

目录

package.json 写了依赖版本,为什么还要提交 package-lock.json?因为版本范围和实际安装结果不是同一回事。

比如 ^4.17.21 通常允许 5.0.0 之前符合范围的版本,并不只允许 4.17.x。锁文件记录解析后的具体依赖树,让下一次安装不必重新从允许范围里挑一遍。

CI 不应该顺手改出一份新锁文件

开发时增加依赖,用 npm install 更新清单和锁文件。CI 希望使用已经审查过的结果,通常用:

npm ci
npm run build

npm ci 要求清单与锁文件匹配,会重新建立 node_modules,不以保留原有依赖目录为目标。影响依赖树的安装选项,例如 legacy-peer-deps,也需要在开发和 CI 之间保持一致。

命令行为参考 npm ci 文档。

不靠远程包更新,检查安装契约

把下面代码存成 lock.mjs,在空目录运行 node lock.mjs。它创建一个本地包,正常安装并生成锁,再故意让清单要求不存在的 2.0.0。

import { mkdir, writeFile, readFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import path from "node:path";
import assert from "node:assert/strict";
const root = path.resolve("lock-demo");
const pkg = path.join(root, "package");
const app = path.join(root, "app");
await mkdir(pkg, { recursive: true });
await mkdir(app, { recursive: true });
await writeFile(
  path.join(pkg, "package.json"),
  JSON.stringify({ name: "local-lock-demo", version: "1.0.0", main: "index.cjs" }),
);
await writeFile(path.join(pkg, "index.cjs"), "module.exports='v1';\n");
function npm(args, cwd) {
  const result = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", args, {
    cwd,
    encoding: "utf8",
    shell: process.platform === "win32",
  });
  if (result.error) throw result.error;
  return result;
}
assert.equal(npm(["pack", "--ignore-scripts"], pkg).status, 0);
const manifest = {
  name: "lock-consumer",
  version: "1.0.0",
  dependencies: {
    "local-lock-demo":
      "file:" + path.join(pkg, "local-lock-demo-1.0.0.tgz").replaceAll("\\", "/"),
  },
};
const save = () =>
  writeFile(path.join(app, "package.json"), JSON.stringify(manifest, null, 2));
await save();
const options = ["--ignore-scripts", "--no-audit", "--no-fund"];
assert.equal(npm(["install", ...options], app).status, 0);
const before = await readFile(path.join(app, "package-lock.json"), "utf8");
assert.equal(npm(["ci", ...options], app).status, 0);
assert.equal(await readFile(path.join(app, "package-lock.json"), "utf8"), before);
manifest.dependencies["local-lock-demo"] = "2.0.0";
await save();
const mismatch = npm(["ci", "--offline", ...options], app);
assert.notEqual(mismatch.status, 0);
console.log("不一致安装被拒绝,退出码:", mismatch.status);
manifest.dependencies["local-lock-demo"] =
  "file:" + path.join(pkg, "local-lock-demo-1.0.0.tgz").replaceAll("\\", "/");
await save();
console.log("清单已恢复");

第一次 ci 成功,并且没有改变锁文件文本。改动清单后的离线 ci 以非零退出码结束,最后清单恢复成原来的本地包地址。

这里检查的是不一致时安装失败,没有把某个版本的错误文案写死。离线选项用于避免为了这个失败场景请求远程包。

锁住依赖,不等于锁住整个构建环境

Node 和 npm 版本、系统平台、可选依赖、安装脚本,都可能影响结果。某些安装脚本还会从外部下载文件,锁文件不会把整个操作系统和网络内容存下来。

构建过程如果读当前时间、环境变量或者 Git 状态,两次产物也可能不同。因此 ci 成功说明依赖安装满足约定,不能直接证明整个应用产物字节完全相同。

更新依赖时,清单和锁一起提交,检查变更范围。遇到冲突不要习惯性删锁重装,那可能顺便更新大量无关依赖。固定工具版本后重新生成,再审查差异,比较容易知道这次究竟改了什么。