---
title: "本地 npm install 成功但 CI 失败：复现锁文件漂移"
date: 2026-11-13
slug: npm-ci-lockfile-drift
canonical: https://kernelranch.com/blog/2026/11/npm-ci-lockfile-drift
markdown_url: https://kernelranch.com/blog/2026/11/npm-ci-lockfile-drift.md
tags: ["nodejs", "npm", "ci"]
---修改 package.json 后，本机能启动，干净的 CI 却在 npm ci 报错。先检查清单和锁文件是否同步提交。npm install 可以更新依赖解析结果，npm ci 会检查锁文件与清单并使用锁定结果安装。

## 1. 离线模拟漏提交锁文件

把代码保存为 npm_lock_lab.py，确保 npm 在 PATH 中，再运行 python npm_lock_lab.py。脚本创建两个纯本地依赖包并生成 tarball，测试过程中不访问 registry。

```python
import json
import os
from pathlib import Path
import subprocess
import tarfile
from tempfile import TemporaryDirectory

npm = "npm.cmd" if os.name == "nt" else "npm"

with TemporaryDirectory() as tmp:
    root = Path(tmp)
    for name in ("alpha", "beta"):
        source = root / "package"
        source.mkdir(exist_ok=True)
        (source / "package.json").write_text(json.dumps({
            "name": name, "version": "1.0.0",
        }), encoding="utf-8")
        with tarfile.open(root / f"{name}.tgz", "w:gz") as archive:
            archive.add(source, arcname="package")

    def write_manifest(names):
        (root / "package.json").write_text(json.dumps({
            "name": "lock-lab", "version": "1.0.0",
            "dependencies": {name: f"file:{name}.tgz" for name in names},
        }), encoding="utf-8")

    def run(*args):
        return subprocess.run(
            [npm, *args, "--offline", "--ignore-scripts", "--no-audit",
             "--no-fund", "--cache", str(root / "npm-cache")],
            cwd=root, capture_output=True, text=True, timeout=20,
        )

    write_manifest(["alpha"])
    run("install", "--package-lock-only").check_returncode()
    run("ci").check_returncode()
    print("initial lock: ci passed")
    write_manifest(["alpha", "beta"])
    result = run("ci")
    assert result.returncode != 0 and "beta" in result.stderr, result.stderr
    print("stale lock: ci rejected missing beta")
    run("install", "--package-lock-only").check_returncode()
    lock = root / "package-lock.json"
    original = lock.read_bytes()
    run("ci").check_returncode()
    assert lock.read_bytes() == original
    assert (root / "node_modules" / "beta" / "package.json").is_file()
    print("updated lock: ci installed beta; lock unchanged")
```

本地实测输出如下。

```text
initial lock: ci passed
stale lock: ci rejected missing beta
updated lock: ci installed beta; lock unchanged
```

## 2. 依次检查三份输入

| 检查项 | 常见问题 |
|---|---|
| package.json 和 package-lock.json | PR 只修改其中一个，或合并冲突后保留了错误的锁文件 |
| Node.js、npm 和项目 .npmrc | 本机和 CI 用了不同工具链或影响依赖树的参数 |
| 安装发生的目录 | monorepo 在子目录运行，读取的清单和锁文件不是预期的一对 |

确实要修改依赖时，在项目约定的工具链下更新锁文件，审查间接依赖变化，把两份文件一起提交，再从干净目录跑 npm ci。生成锁文件时如果使用了影响依赖树的参数，例如 legacy-peer-deps，CI 安装也要应用相同配置。

## 小结

让 CI 直接报告清单与锁文件的偏差，再在开发侧更新和评审锁文件。还原问题时保留 npm 版本、运行目录和项目配置。

## 数据来源与检索时间

检索时间 2026-11-13。

| 来源 | URL | 对应内容 |
|---|---|---|
| npm CLI | https://docs.npmjs.com/cli/v11/commands/npm-ci/ | 锁文件要求、干净安装与参数一致性 |

## 核验说明

- npm 11.17.0 实测本地包初次安装、漂移失败与更新后安装。私有 registry、平台依赖和生命周期脚本属于实际项目环境。
