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 成功说明依赖安装满足约定,不能直接证明整个应用产物字节完全相同。
更新依赖时,清单和锁一起提交,检查变更范围。遇到冲突不要习惯性删锁重装,那可能顺便更新大量无关依赖。固定工具版本后重新生成,再审查差异,比较容易知道这次究竟改了什么。