npm包发布测试避坑指南:从本地能跑到真正可用 提到“发布测试”很多人第一反应是代码都写完了测试也跑过了发布就是点一下按钮的事有什么好测的。但我第一次认认真真做发布测试就是在这一步翻车的。本地调得好好的工具真正发出去之后别人在新的项目里安装直接报模块找不到。当时的我连error日志长什么样都没耐心看更别提去查registry里的包内容和本地目录有什么区别。这篇文章就记录那一整天的折腾过程。我以npm包发布为例写但其中关于发布链路、版本管理、回滚策略、自动化预检的思路换成小程序提审、桌面应用分发、站点部署同样通用。如果你准备第一次正式发布一个包或者发过几次但方向还是靠运气这篇内容可以直接照抄。1. 为什么非要专门做一次发布测试1.1 从“本地能跑”到“别人能用”中间缺了整整一条链路在我犯过的错误里最有代表性的一次就是入口文件没随包发布出去。当时我的项目在本地是这样跑的node src/index.js路径没问题代码没问题。package.json里的main也指向了src/index.js。我理所当然地认为发布之后别人在项目里执行require(my-tool-kit)也能找到入口。可实际发布的包里根本没有src目录只是打进去了一堆自动生成的文件。消费者安装之后模块解析走到main字段指向的路径发现文件不存在直接报出模模糊糊的“Cannot find module”。为什么会出现这种“本地什么都好发布后立刻翻车”的情况关键在于本地运行程序时Node模块解析会从当前目录一层一层向上找node_modules你本地当然能找到自己的源文件但别人安装你包的时候他能看到的只是registry上那个压缩包也就是你用files字段或.npmignore选中的那一堆文件。如果你的入口文件不在这个包里无论本地怎么跑别人的世界里都不会存在这个文件。为了把“本地”和“发布后”的差异说清楚我总结过一个对比表检查项本地代码目录发布后的registry包入口文件只要路径正确就能找到必须被包含在文件清单中依赖本地node_modules什么都有只依赖dependencies声明文件内容实时可读发布时刻的压缩快照错误反馈编辑器直接报错安装后运行时报错这个表里的每一行后来都变成我在发布测试里要重点验证的场景。能解决这些问题发布测试才有了意义。1.2 发布测试真正要测的是四个维度能装、能跑、能升级、能回滚很多人把发布测试理解为“发布一次成功”这个理解太窄了。发布测试的对象不是发布动作本身而是安装者对版本变更的完整体验。我一般会把发布测试拆成四个验证维度能装。从一个全新的空项目里执行安装命令包能被拉取下来依赖能装齐。能跑。严格按照包入口去引用能拿到预期的导出或执行效果不会报目录缺失。能升级。已经被旧版本占据的项目里执行升级后能拿到新版本并且API行为符合预期。能回滚。新版本出问题后有办法让用户远离这个版本同时不破坏已安装项目的完整性。这四个维度里能装和能跑是最基础的两件事大多数人都记得能升级和能回滚是被忽略的两个高危雷区。我第一次发布测试恰恰只做了前两件事。后来同事在旧项目里升级包因为我的版本范围写得太宽直接拉到了还没完全验证过的新版本原本的同步方法被改成了异步一切静默地就坏了。从那以后我每次发布测试都会准备四个独立模拟目录fresh-install、old-version、upgrade-test、rollback-test。每个目录只验证一条链路用脚本记录执行结果。这个习惯救了我很多次下面几个部分的内容基本都是在这个框架里踩出来的经验。2. 发布测试前置的本地清单一个都不能少2.1 初始化package.json时最容易被忽略的字段发布测试不是从执行npm publish那一刻开始的它在package.json里就开始埋伏了。我见过太多项目package.json里只有name、version和script三个字段能不能发布能发但发布出来的包往往不好用。这里有四个字段特别容易被忽略第一是main和exports。main决定了默认入口exports则进一步控制了哪些子路径可以被外部访问。如果你在exports里只写了.那用户只能require(my-tool-kit)而require(my-tool-kit/helper)会被拦下来。第一次发布的人可以专注于把main写对但要意识到exports会成为一个权限边界。第二是files。它直接决定哪些文件会进入最终发布包。files是白名单机制只有明确列出的目录或文件会被打进tar包。不配置files也可以发布npm会默认带上一堆文件但你会失去对包内容的控制。第三是engines。它声明了包的运行环境比如Node.js版本。不写这个字段的问题不是不能装而是装了之后可能因为语法或API差异直接报错用户查原因要查半天。发布一个面向Node.js用户的包engines至少要把最低版本写清楚。第四是scripts.prepublishOnly。这个钩子会在npm publish命令真正执行之前自动运行。你可以把测试、打包、预检都挂在这里防止缺失验证的包被发出去。后面我会专门写这个脚本的细节。一个比较推荐的初始化示例{ name: my-tool-kit, version: 0.1.0, description: a small utility kit for testing publish flow, main: lib/index.js, exports: { .: ./lib/index.js, ./utils/*: ./lib/utils/*.js }, files: [lib], scripts: { prepublishOnly: npm test }, engines: { node: 14.0.0 } }需要说明的是files里我写的是lib这意味着发布前要先执行构建把源码编译到lib目录。如果忘了构建发出去的包同样会是空的。第一次做发布测试的人最容易栽在这种“声明和实际内容不一致”的坑里。2.2 用npm pack和npm link把发布内容提前暴露出来在真正执行publish之前有两个本地验证工具值得反复用npm link和npm pack。npm link适合快速开发联调。在包目录执行npm link再到消费项目执行npm link my-tool-kit它会把当前开发目录以软链接的方式放进消费项目的node_modules。好处是改动即时生效不用反复重新安装。但它的局限性也在这里——软链接指向的是当前目录不是最终被打包出来的压缩包所以它验证不了“发布包是否完整”这个问题。npm pack则更接近发布后的真实状态。它会按照发布规则把当前目录打包成一个.tgz压缩文件这个文件的内容和发布到registry里的内容基本一致。执行方式npm pack打包完成后会在项目根目录生成my-tool-kit-0.1.0.tgz。然后再去一个空项目里直接安装这个压缩包cd /tmp/smoke npm init -y npm install /path/to/my-tool-kit-0.1.0.tgz安装完成后正常写引用代码测试是否能跑通。我在这个环节抓到过很多次“入口文件不在发布包内”“目录结构变了但路径没更新”之类的问题。更省事的是先用npm pack --dry-run看看文件列表不实际生成tgz先检查包内容npm pack --dry-run这条命令会打印出最终会被发布的所有文件。第一次使用的人建议一定要跑一次因为它会打破你对自己项目目录结构的幻觉。2.3 files白名单比.npmignore更可控但不等于写了就万事大吉控制发布包内容有两条路files白名单和.npmignore黑名单。我的建议是优先用files。原因很简单白名单是可控的黑名单需要你不断维护“哪些文件会泄露进来”一旦有新文件类型出现黑名单就可能漏。files字段支持以目录为单位也可以写具体文件。比如files: [lib, README.md]如果留空npm默认会带上package.json、README.js、LICENSE但还有一堆可能不该发的文件比如测试目录、配置文件、编辑器记录等。这些文件本身不致命但会让包变臃肿。曾经有人发出去的包里有.env配置文件的样例还带了一份内部的接口文档这种泄露只要发生一次影响面就很广。.npmignore的作用和.gitignore类似但它的优先级低于files。如果同时写了files和.npmignorefiles会赢。这也是我推荐用files的另一个原因它能直接盖掉黑名单里可能出现的逻辑漏洞。最后无论用哪种方式发布前都必须实际查看一遍包内文件清单。npm pack --dry-run输出的列表应该是你逐行扫过一遍的列表而不是“应该没问题吧”的假设。这一步花不了三分钟却能把很多尴尬直接拦在发布之前。3. 第一次执行publish的完整记录登录、确认、发布3.1 账号登录和token先确认不是401再动手发布的第一步是身份验证。很多第一次发布的人都卡在这个位置注册好了账号本地npm login也通过结果真正npm publish时却报权限问题。最常见的原因是登录态过期。npm的token本质上是一串带有效期的凭据过期之后需要重新登录。检查当前身份的命令是npm whoami这条命令能直接告诉你当前配置的用户名。如果退出或过期会提示未登录。处理方式很简单重新执行npm login输入用户名、密码和邮箱就好。token的保存位置也很重要。个人开发者默认写在~/.npmrc里这条配置会在所有项目里生效。如果把token写在项目自己的.npmrc那这个文件一定不能被提交到代码库。就算项目是私有的也建议把token放环境变量而不是硬编码。之前有人把token提交到仓库几分钟内就被扫描脚本盗走后果就是恶意发布。这点对第一次发布测试尤其要注意哪怕只是测试也别心存侥幸。命名冲突是另一个前置检查点。你想注册的包名可能已经被其他人占用了。现在很多团队会直接用作用域包比如my-scope/my-tool-kit。作用域包的好处是命名空间独立重复概率小而且可以在注册后台按团队管理权限。不过它也有代价消费项目里安装路径会变成my-scope/my-tool-kit引用时要写完整路径。对第一次发布来说这个代价值得理解。3.2 当npm publish真正执行时它做了哪些事发布不是从本地上传一个文件那么简单。当npm publish被调用时它至少会经历这些步骤读取package.json拿到name和version。根据files、.npmignore、目录结构组合出发布内容。本地生成tar包准备发送。将tar包和元信息发送到registry。registry校验当前用户是否有权限上传这个包名。检查version是否已经存在如果存在则发布失败。全部通过写入新版本并自动把该版本关联到latest标签除非指定了其它标签。有一个点很值得强调如果你的prepublishOnly脚本设定了运行测试那测试会在真正上传之前执行只要脚本退出码不是0发布就会中断。这意味着发布测试的很多自动化检查都可以挂在这个钩子上。第一次发布成功时终端会打印一行类似下面的内容 my-tool-kit0.1.0看到这个就完事了吗远远没有。它只说明你的包被registry接受至于别人能不能正常使用还需要回到第一部分说的四个验证维度去测。3.3 版本号不能随机写0.x是一个避风港我第一次发布时曾纠结要不要直接打1.0.0。后来我选择了0.1.0。这个选择不是因为1.0.0不够好看而是0.x能明确告诉使用者这个API还没稳定后续可能会有兼容性变化。这对第一次发布的小工具尤其友好因为你在0.x阶段完全可以随时调整API不用被semver的大版本约束压得太紧。语义化版本的核心规则说穿了很简单主版本号不兼容的API变更。次版本号向后兼容的功能新增。补丁版本号向后兼容的问题修复。发布测试过程中每改一次代码、重新发布都必须先更新版本号。很多人第一次发布报“版本号已存在”错误都是因为没有修改版本号重新执行了同一版本的发布。这个错误很常见规避方式也很简单把版本更新动作写进发布checklist或者用npm version命令自动递增。还有一个关键词是--tag。如果不加参数新版本会被标记为latest也就是所有不稳定安装的首选。如果我发一条测试包更好的选择是npm publish --tagbeta这样包会进入beta标签默认安装者不会被打扰。等到验证完毕再通过npm dist-tag把它提升为latest。这套操作我在后面的自动化流程里实际上已经离不开。4. 发布成功后的真实验证与善后4.1 三个安装场景必须分别跑一遍新项目、旧项目、全局环境发布成功后的第一件事不是去庆祝而是去三个真实场景里安装。我建议至少准备三个目录新项目npm init -y之后直接npm install my-tool-kit然后在测试脚本里引用包执行一遍。这个场景验证的是“第一次使用你的人”的体验。如果入口文件、依赖声明有问题这里一定会爆。旧项目先把老版本装进去写一个能正常运行的调用示例然后再执行升级命令看老代码是否仍然能跑。这个场景验证的是“升级用户”的体验。我第一次在ES Module和CommonJS上翻车就是在这个场景里暴露出来的0.1.0的导出方式改成了export default旧项目用require拿到的对象完全不对运行结果直接变成undefined。全局环境如果你的包设计成命令行工具必须在全局安装后执行一遍命令验证命令是否被识别、路径是否正确。很多CLI包在node_modules/.bin里压根没有生成对应的软链原因往往是没有配置bin字段。这种问题在全局安装时会立刻暴露。这三个场景不要混在一个目录里执行。分开的好处是每个目录的状态都干净报错时能立刻定位是哪一环出了问题。我现在的做法是写一个verify-after-publish.sh脚本依次创建目录、安装、运行并把每个步骤的退出码记录下来。4.2 发错版本了unpublish和deprecate要分清发布测试这个环节大概率会遇到发错版本的情况。代码有问题、入口写错了、依赖声明漏了……什么都可能发生。这时候有两把处理工具unpublish和deprecate。unpublish是把某个版本彻底删掉。听起来很爽但它不是后悔药。如果已经有人下载过这个版本删除操作会直接破坏对方的node_modules解析。registry出于安全考虑会限制删除行为很多时候你根本删不掉或者删掉了也要等很长时间才能生效。我在这里的教训是不要一慌就去unpublish除非你的包还没有任何人下载过而且你确认自己离开官网后台已经删除成功。更稳妥的做法是deprecate。执行npm deprecate my-tool-kit0.1.0 这个版本有严重问题请升级到0.2.1这个命令不会删除任何文件但会在所有安装这个版本的用户终端里显示一条警告。用户看到后会收到明确提示而不是面对一个无法解析的谜。破坏性会小得多。如果问题版本已经发布正确顺序是先deprecate问题版本再发布一个修复版本必要时把代码回退到旧行为。发布机制是只能前进的你无法让时光倒流只能带着错误标记继续走。接受了这个设定回滚流程反而清晰了。4.3 发布后验证不只是“能装”还要确认版本列表和日志在完成安装验证之后我还会用registry的命令做一次外部确认npm view my-tool-kit versions npm view my-tool-kit dist-tags这两条命令分别显示已发布版本列表和当前标签情况。它们能帮你确认新版本确实存在并且latest标签是否指向了正确版本。如果发布时用了--tagbeta这里会清楚地看到beta和latest指向不同版本这比靠记忆判断靠谱得多。日志方面第一次发布不必上一整套监控系统。但至少要保证一点包在运行时如果出现问题能有一个地方可以看到报错。对于一个小工具来说最简单的办法是在入口函数里包一层try/catch把报错信息输出到标准错误如果是CLI至少让无参数执行时打印使用说明和当前版本号。这些事都不是为了分析用户行为而是为了在发布测试结束后你仍然能判断自己发出去的包是不是还在正常运转。这一章的最后一句话是发布测试的“测试”二字覆盖的是发布后全生命周期的可用性不只是publish那一下。5. 把第二次发布测试变成可复用流程的自动化建议5.1 用dist-tag把不成熟版本挡在正式通道外第一次发布测试我是在latest标签上直接发的。等发现问题时所有执行npm install my-tool-kit的人都已经拿到了问题版本。第二次发布我学乖了先发到beta标签验证没问题再转正。具体操作是npm publish --tagbeta npm dist-tag add my-tool-kit0.2.0 latest npm dist-tag rm my-tool-kit0.2.0 beta先说第一行--tagbeta会把本次发布的版本挂到beta标签下。消费者想体验测试版需要明确安装npm install my-tool-kitbeta。普通用户安装my-tool-kit时拿到的仍然是旧版本latest因此不会受到测试版本不稳的影响。后面两行是把某版本提升为latest同时把它的beta标签去掉。第一次做的时候建议用npm dist-tag ls my-tool-kit多查看几次当前标签分布。标签是给用户导航用的用好了可以让发布测试无痛化用不好则会成为“用户为什么莫名装了旧版本”的元凶。只有当某个版本被标记为latest它才会成为默认安装目标这一点务必牢记。5.2 写一个pubish前的预检脚本挂在prepublishOnly上自动化预检是第二次发布测试和第一次最大的区别。我现在的做法是把下面这些检查写进一个脚本然后在package.json中把脚本挂到prepublishOnly#!/usr/bin/env bash set -e echo checking version... node -e const prequire(./package.json); console.log(version:, p.version) echo running tests... npm test echo packing package... rm -rf /tmp/pkg-build mkdir -p /tmp/pkg-build TGZ$(npm pack --pack-destination /tmp/pkg-build | tail -1) echo smoke installing... rm -rf /tmp/smoke mkdir -p /tmp/smoke cd /tmp/smoke npm init -y /dev/null npm install /tmp/pkg-build/$TGZ --no-save /dev/null node -e require(my-tool-kit).hello() echo publish precheck done这个脚本做的事情就是把前面说的本地预检全部串起来检查版本号、运行测试、打包、安装本地tgz、执行冒烟测试。任何一环失败都会因为set -e而直接中断。注意最后一步冒烟测试里的包名需要替换成你自己的真实包名。另外脚本里用到npm pack --pack-destination它会生成一个临时目录脚本结束后可以留着手动检查也可以删掉。把这个脚本挂到prepublishOnly时package.json里的scripts变成scripts: { prepublishOnly: bash ./scripts/pre-publish-check.sh }这样不管你手动执行npm publish还是以后接入CI后自动发布预检都会强制执行。这是我认为第一次发布测试最值得留下的产物。5.3 一份照抄就能用的发布checklist最后分享我现在每次发布前都会逐项确认的checklist。这些条目全部来自实际翻车案例不是网上常见的通用建议而是每一次发布测试里曾经让我栽跟头的具体点。版本号是否已经递增package.json里的version能用npm version自动更新别用记事本手改。files字段是否只声明了必要的目录尤其确认入口文件一定在发布文件清单里。是否跑过npm pack --dry-run并逐行看文件列表是否在本地用tgz安装做过冒烟测试入口导出格式是否和exports声明保持一致CommonJS用户能不能拿到导出dependencies和peerDependencies是否区分清楚是否把应当由使用方提供的依赖错写成了普通依赖本次版本是走beta标签还是直接上latest发布前是否准备好了deprecate文案万一出错第一句话应该提醒用户怎么做。发布后是否在三个场景里各装过一次新项目、旧项目、全局环境是否用npm view验证过版本列表和dist-tag指向这些条目永远会随着项目沉淀而增加。但在第一次发布测试时它们已经是足够可靠的护栏。另外多说一句发布测试这事情绝不能只做一次就再也不看。包的API稳定之后在真正上线前把测试流程在模拟消费者里走一遍仍然值得。每次记录下来的报错和解决方式最好回到checklist里补一条新的坑才不会反复踩。