如何避免向 npm 发布错误的文件
避免在 npm 包中发布意外文件或机密信息。了解 .npmignore、.gitignore 与 package.json 的 files 字段如何交互,并用 npm pack --dry-run 检查包内容。
如果你的包中有 .npmignore 文件,npm 在决定哪些内容不打进 tarball 时就不再读取 .gitignore。即使 .npmignore 文件是空的,也是如此。
大多数人是在 tarball 体积远超预期,或者某个本不打算发布的点文件出现在 registry 上时,才发现这一点的。忽略规则看起来没问题,事实上也确实没问题——直到有人添加了第二个忽略文件。
本文介绍会让错误文件混入包中的各种陷阱:.gitignore 的回退机制、files 与 .npmignore 之间的相互作用、npm 固定的包含与排除列表,以及有问题的版本已经上线后该怎么办。阅读本文前,建议先阅读 How to Create and Publish an npm Package。
要点速览
- 只要存在
.npmignore文件(哪怕是空文件),npm 原本会应用到 tarball 上的所有.gitignore规则都会失效。 - npm 会读取每个子目录中的
.npmignore和.gitignore,因此目录树深处的一个忽略文件就可能改变发布内容。 - npm 的内置排除规则不涵盖
.env文件、私钥或证书。 - 如果密钥泄露到了 registry,首先要轮换密钥。撤销发布无法收回已被下载的副本。
- Provenance(来源证明)记录的是构建某个版本所用的仓库、提交和工作流,并不能证明代码是安全的。
npmignore 陷阱:新增一个文件,所有 gitignore 规则全部失效
npm 决定哪些内容不打进包时,只会使用 .npmignore 或 .gitignore 中的一个,绝不会同时使用两者。npm 关于将文件排除在包外的规则非常简短:没有 .npmignore 时,npm 应用 .gitignore;一旦存在任何 .npmignore,.gitignore 就不再生效。两个文件永远不会合并。
最常见的诱因是构建产物。dist/ 被 gitignore 忽略,因此 npm 不会把它打进 tarball。于是你添加了一个 .npmignore,好让 dist/ 能够发布出去。结果 dist/ 确实发布了,但原先被 .gitignore 隐藏的其他所有内容也一并发布了:.env.local、coverage/ 和 test/fixtures/。
npm 并不只查看根目录。它会从包内任意文件夹中读取这两类忽略文件,因此嵌套文件夹中一个误放的 .npmignore,就可能在没人改动根目录配置的情况下改变发布内容。
前后对比:文件列表发生了变化
下面的脚本构建了一个完全由 .gitignore 负责过滤的包:
mkdir pkg-trap && cd pkg-trap
npm init -y >/dev/null
mkdir -p src dist test/fixtures coverage
echo 'module.exports = 1' > src/index.js
echo 'module.exports = 1' > dist/index.js
echo 'API_KEY=replace-me' > .env.local
echo '{}' > test/fixtures/big.json
echo 'x' > coverage/lcov.info
printf 'dist/\n.env.local\ncoverage/\ntest/fixtures/\n' > .gitignore
npm pack --dry-run
npm 列出以下内容(仅显示路径,已精简):
package.json
src/index.js
dist/ 没有包含在内,于是你添加了一行 .npmignore,以便发布构建产物并去掉源码:
echo 'src/' > .npmignore
npm pack --dry-run
package.json
- src/index.js
+ dist/index.js
+ .env.local
+ coverage/lcov.info
+ test/fixtures/big.json
解决办法是把仍然需要的每一条规则都复制到 .npmignore 中:
printf 'src/\n.env*\ncoverage/\ntest/\n*.map\n' > .npmignore
files 与 .npmignore:白名单还是黑名单
package.json 中的 files 字段是白名单,而 .npmignore 是黑名单。如果两者同时使用,就没有任何一个文件能单独说明你的包里到底有什么。
| 机制 | 模式 | 被你遗忘的新文件 |
|---|---|---|
files | 白名单 | 不会发布 |
.npmignore | 黑名单 | 会被发布 |
.gitignore(回退) | 黑名单 | 会被发布,除非它也被 gitignore 忽略 |
问题往往出在 files 与 .npmignore 的重叠之处。根据 package.json 文档,根目录中的 .npmignore 无法剔除 files 已经添加的内容,但子文件夹中的 .npmignore 可以:
{ "name": "my-lib", "version": "1.0.0", "main": "dist/index.js", "files": ["dist"] }
# dist/.npmignore
*.map
这里 files 引入了 dist/,嵌套的忽略文件又从中过滤掉了 sourcemap。于是一份清单由两个文件共同定义。那些会自行写入输出目录的构建工具会让情况更糟,所以如果你按照我们的 pnpm 指南操作,请检查最终进入 dist/ 的内容。最简单的原则是:只选一种机制,删掉另一种。
npm 始终包含和始终排除的文件
无论如何配置,有些文件总会被包含或排除。根据 package.json 文档,npm 始终包含 package.json、README、LICENSE 或 LICENCE、main 中指定的文件以及 bin 中指定的所有文件。npm 始终排除版本控制文件夹、.npmrc、node_modules 和编辑器产生的杂项文件。密钥类文件不在这份列表中。
| 始终包含 | 始终排除(不完整列表) | 除非显式指定,否则不会排除 |
|---|---|---|
package.json、README(及其变体)、LICENSE / LICENCE、main 文件、bin 文件 | .git、.npmrc、node_modules(打包依赖除外)、.DS_Store、npm-debug.log、config.gypi、.*.swp、*.orig、package-lock.json | .env、.env.local、*.pem、*.key、fixtures、coverage |
为最右列的文件加一道防护:
npm pack --dry-run 2>&1 | grep -E '\.env|\.pem$|\.key$' && { echo "STOP: secrets in tarball"; exit 1; }
每次发布前运行 npm pack —dry-run
npm pack --dry-run 会打印出 npm 将要发布的确切文件列表,而不会真正生成 tarball,因此能发现上述所有陷阱。每当你修改任何忽略文件、files 字段或构建配置后,都应运行它,并在 CI 中将其放在 npm publish 之前。基本工作流请参阅 How to Create and Publish an npm Package。
npm pack --dry-run
已经发布了错误文件该怎么办
如果密钥泄露到了 registry,首先要轮换它,并将其永久视为已公开。撤销发布某个版本并不会删除已被下载或镜像的副本。之后,再清理 registry:
- 在凭据提供方处轮换凭据。
- 删除或标记有问题的版本。根据 npm 的撤销发布政策,只要没有其他公开包依赖该版本,你可以在发布后 72 小时内撤销发布。超过这个期限后,只有满足政策条件的包才能撤销发布。否则,请使用
npm deprecate。 - 发布一个干净的补丁版本。已经使用过的版本号永远不能再次使用,即使该版本已被撤销发布。
# Within 72 hours, subject to npm's unpublish policy
npm unpublish my-lib@1.4.2
# Otherwise
npm deprecate my-lib@1.4.2 "Contains a leaked file. Upgrade to 1.4.3."
# Fix the ignore rules, confirm, republish
npm pack --dry-run
npm version patch
npm publish
Provenance 证明的是来源,而非安全性
npm provenance 声明记录了构建某个包版本所用的仓库、提交和 CI 工作流。它既不能说明该提交中的代码是安全的,也不能说明 tarball 中只包含你预期的文件。在 GitHub Actions 中,工作流需要 id-token: write 权限:
name: publish
on:
release:
types: [published]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm pack --dry-run
- run: npm publish
该工作流假定你已经配置好了 trusted publishing(可信发布)。可信发布要求 npm CLI 11.5.1 或更高版本以及 Node 22.14.0 或更高版本,并且会自动生成 provenance,因此该任务既不需要 --provenance,也不需要 NODE_AUTH_TOKEN。如果改用令牌发布,npm publish --provenance 需要 npm 9.5.0 或更高版本。无论哪种方式,package.json 中的 repository 字段都必须与发布所用的仓库完全一致(区分大小写),并且该仓库必须是公开的。如果 npm ci 因 peer 依赖冲突而失败,请参阅 How to Fix npm ERESOLVE Errors。关于 2FA 和可信发布的配置,请参阅 npm Security Best Practices。
总结
npm 的忽略规则出问题时不会报错。仅仅添加一个文件,就会悄无声息地改变被发布的文件。在 files 和 .npmignore 中只选其一并删除另一个,不要在子目录中放置忽略文件,并在 CI 中每次 npm publish 之前运行 npm pack --dry-run 并配合密钥 grep 检查。
常见问题
npm pack --dry-run 和 npm publish --dry-run 有什么区别?
两者都会报告哪些文件将被打进 tarball,且不会上传任何内容,因此都能发现忽略文件的错误。npm pack --dry-run 是检查文件内容更简单的方式。npm publish --dry-run 则是对发布命令本身的演练。在当前的 npm 版本中,它还会向 registry 查询该版本是否已存在,若已存在则失败;但未登录只会产生警告。演练通过并不能证明上传一定会成功,因为登录状态和 2FA 只有在真正上传时才会被验证。
如何查看已发布版本中包含哪些文件?
使用 registry 规格运行 npm pack,例如 npm pack my-lib@1.4.2。npm 会获取已发布的 tarball,将其以 my-lib-1.4.2.tgz 的名称保存到当前目录,并打印其中的文件名。可以用 tar -tzf my-lib-1.4.2.tgz 列出其内容。如需获取 tarball 的 URL 以便配合 curl 或其他工具使用,请运行 npm view my-lib@1.4.2 dist.tarball。
npm deprecate 能阻止别人下载含有泄露文件的版本吗?
不能。npm deprecate 会在 registry 条目中添加一条警告信息,npm 会向安装该版本的任何人显示这条信息,但 tarball 仍然可以下载。任何锁定了该确切版本的人仍会拿到泄露的文件。只有撤销发布才能将该版本从 registry 中移除,而这受到 npm 撤销发布政策的限制。如果之后想取消弃用标记,可以再次运行 npm deprecate,并将消息设为空字符串。
package.json 中的 files 字段可以使用 glob 模式吗?
可以。你可以列出普通文件名、文件夹,或者 * 和 **/* 这样的 glob 模式,文件夹条目会引入其中的所有内容。其语法相当于反过来的 .gitignore:匹配的模式会把文件加入 tarball,而不是将其排除。npm 始终包含的文件(如 package.json、README、LICENSE 和 main 文件)无论是否出现在该数组中都会被发布。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k