自己写的 leetcode-cli(终端刷 LeetCode 的小工具,前一篇 给它加 pull 命令 讲过)一直只在本地 pnpm link 用。这次想正经发出去,让别人能 npm i -g 装。目标定得贪心:一次 tag 推送,同时发到 npm 公共仓库、GitHub Packages、再建一个带 tarball 的 GitHub Release。
结果发布这件事本身比写功能麻烦。记录一下,因为坑都集中在 npm 这两年主推的新机制 OIDC Trusted Publishing 上,而网上很多教程还停在 access token 的老写法。
第一个坑:包名被占,只能 scoped
package.json 里原本 "name": "leetcode-cli"。发之前先查一下名字在不在:
npm view leetcode-cli version# 2.6.2已经被别人占了(v2.6.2)。公共 npm 的非 scoped 名字是全局唯一的,没得商量。改成 scoped 名 @stevenli-phoenix/leetcode-cli:
npm view @stevenli-phoenix/leetcode-cli version# npm error 404 Not Found —— 没人用,可以这里有个容易忽略的点:包名 scoped 了,但 CLI 命令名不受影响。命令名由 package.json 的 bin 字段决定:
"bin": { "leetcode": "dist/index.js", "leetcode-cli": "dist/index.js"}所以用户装的是 @stevenli-phoenix/leetcode-cli,敲的还是 leetcode。顺手把发布需要的元数据补齐:repository / bugs / homepage、"publishConfig": { "access": "public" }(scoped 包默认私有,必须显式声明公开)、packageManager 锁 pnpm 版本,再加个 prepublishOnly 兜底——因为 dist/ 是 gitignore 的,防止哪次忘了 build 就发了个空包。
第二个决定:access token 还是 Trusted Publishing?
最初的 release.yml 是老写法:仓库里存一个 NPM_TOKEN secret,workflow 里 npm publish 时通过 .npmrc 注入。能用,但 npm 自己现在并不推荐这套——长期 token 一旦泄漏就是个长期风险。
npm 在 2025 年 7 月把 Trusted Publishing 正式 GA 了。原理是 OIDC:GitHub Actions 在每个 job 里临时签发一个 OIDC token,npm 用它换取一次性的发布权限,仓库里一个长期 token 都不用存,而且自动带 provenance(可追溯到具体哪条 workflow run 发的)。
改造 workflow,几个关键点(这些我没凭记忆,专门查了 npm 官方文档和 GitHub 的 GA 公告确认过):
- 必须声明
permissions: id-token: write,这是签发 OIDC token 的前提。 - npm CLI 要 ≥ 11.5.1。Node 20/22 自带的是 npm 10.x,太老,必须在 workflow 里
npm install -g npm@latest。 - 官方建议 Node ≥ 22.14,所以 job 用 Node 22。
npm publish会自动检测 GitHub Actions 的 OIDC 环境,不需要任何额外 flag;provenance 对公共仓库的公共包也是默认开。.npmrc里不需要任何_authToken行,只写 registry 即可。
核心步骤长这样:
permissions: id-token: write # 签发 OIDC token + provenance contents: write # 建 GitHub Release packages: write # 发 GitHub Packages
# ...- run: npm install -g npm@latest # OIDC 需要 npm >= 11.5.1# ...- name: Publish to npm (Trusted Publishing, OIDC) run: | printf 'registry=https://registry.npmjs.org/\n' > .npmrc npm publish --access public # 自动走 OIDC,无 tokenGitHub Packages 那步保持用 GITHUB_TOKEN——它是 job 级的短期凭证,不是长期 PAT,本来就是”好”的那类,没必要也换。
第三个坑:新包根本无法 bootstrap Trusted Publishing
写完正准备推 tag,查文档时发现一个致命的鸡生蛋问题:Trusted Publishing 的”信任关系”是在 npmjs.com 的包设置页面里配置的,而配置的前提是这个包已经存在于 registry 上。
可我这是个全新的包,还没发过任何一版。对应 npm/cli#8544 这个 issue:新包没法预先配置 trusted publisher。
所以首发绕不开 token。流程只能是:
- 先用 token 在本地手动发一次,把包推上 registry;
- 然后才能去网站上配 trusted publisher;
- 配完吊销 token,之后所有发布走纯 OIDC。
这一步没法自动化,是个一次性的人工 bootstrap。
第四个坑:bootstrap 发布撞上 2FA
本地拿 token 发首版。为了不让 token 值落进任何会提交的文件,用临时 userconfig + 环境变量引用,.npmrc 里只写 ${NODE_AUTH_TOKEN} 这个引用而非字面值:
TMPNPMRC="$(mktemp)"printf '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\nregistry=https://registry.npmjs.org/\n' > "$TMPNPMRC"NODE_AUTH_TOKEN='<TOKEN>' npm whoami --userconfig "$TMPNPMRC"# stevenli-phoenix —— token 身份确认,scope 匹配身份对了(账号 stevenli-phoenix 正好拥有 @stevenli-phoenix scope),直接发:
NODE_AUTH_TOKEN='<TOKEN>' npm publish --access public --userconfig "$TMPNPMRC"结果 403:
npm error code E403npm error 403 Two-factor authentication or granular access token withbypass 2fa enabled is required to publish packages.账号对”发布”开了强制 2FA,而这个 token 不是带 “bypass 2FA” 的 granular token。token 本身能认证(whoami 通过),但发布这个动作需要第二因素。两个解法:要么 npm publish --otp=<6位码> 现场补一个 OTP,要么换一个勾选了 “Allow this token to bypass 2FA” 的 granular token。我选了后者,换了个新 token 重发,过了:
+ @stevenli-phoenix/leetcode-cli@0.1.0第五个坑:发布成功,但 npm view 立刻 404
publish 打印了 + ...@0.1.0(成功),可紧接着验证却 404:
npm view @stevenli-phoenix/leetcode-cli version# npm error 404 Not Found一度怀疑是不是没真发上去。直接打 registry 的 packument API,连鉴权头都加上,还是 404;网站页面甚至返回 403。但 + name@version 这个成功标记是可信的。这其实是 registry 的写入路径和读取 CDN 之间的传播延迟——首次发布一个全新 scope 的包,读侧要等一会儿才可见。轮询验证:
for i in 1 2 3 4 5; do code="$(curl -s -o /dev/null -w "%{http_code}" \ "https://registry.npmjs.org/@stevenli-phoenix%2Fleetcode-cli")" echo "attempt $i: HTTP $code"; [ "$code" = "200" ] && break; sleep 15done# attempt 1: HTTP 404# ... attempt 4: HTTP 200 —— 约 45 秒后可见教训:刚发完新包别急着判死刑,给 registry 一两分钟传播。
第六个坑:配 trusted publisher,又是安全密钥 2FA
包在线了,去 npmjs.com/package/@stevenli-phoenix/leetcode-cli/access 配 trusted publisher。表单选 GitHub Actions,填三个字段(workflow 文件名大小写敏感):
- Organization or user:
StevenLi-phoenix - Repository:
leetcode-cli - Workflow filename:
release.yml - Environment name:留空
- Allowed actions:勾
npm publish
第一次点 “Set up connection”,页面直接跳到一个 {"message":"Not Found"} 的 JSON——又是传播没跟上,包刚发不到几分钟,trusted-publisher 后端还没认到它。回退重填、重提交,这次弹出了 Security key(WebAuthn) 验证。硬件密钥这一步只能本人按,按完才落库:
Successfully added new Trusted Publisher connection.StevenLi-phoenix/leetcode-cli · release.yml · Permissions: npm publish顺手把 Publishing access 从默认收紧到 “Require two-factor authentication and disallow tokens”——既然 OIDC 通了,干脆彻底封死 token 发布这条路。这个改动又触发一次安全密钥验证。
推 tag,看流水线全绿
npm 侧配好了。回到 workflow,有个细节我提前处理了:两个 publish 步骤都做了幂等保护——发之前先 npm view <pkg>@<version>,版本已存在就跳过。这样”本地 bootstrap 首发 → 再推 tag”不会让首次 workflow run 在 npm 步骤上红掉(0.1.0 已经存在,跳过即可)。
git tag v0.1.0 && git push origin v0.1.0gh run watch 看结果,32 秒全绿:
✓ release in 32s ✓ Verify version consistency (tag == package.json == src VERSION) ✓ Typecheck / Test / Build / Pack tarball ✓ Publish to npm (Trusted Publishing, OIDC) —— 0.1.0 已存在,幂等跳过 ✓ Publish to GitHub Packages ✓ Create GitHub Release三处产物逐一核实:npm registry 上 0.1.0、GitHub Packages 里 leetcode-cli、GitHub Release 带着 stevenli-phoenix-leetcode-cli-0.1.0.tgz。最后回 npmjs.com 把那个临时 bootstrap token 吊销,账号下归零。
NOTE这次 0.1.0 的 npm 步骤是被幂等跳过的,OIDC 发布路径其实要等下一个版本号(v0.1.1)才真正首次跑通。但 trusted publisher 已配好,下次
git tag一推就是纯 OIDC,无人工、无 token。
顺手补上 CI
整完发布,发现一个更基础的洞:仓库只有 tag 触发的 release workflow,平时 push/PR 没有任何自动校验。代码可能在打 tag 之前就坏了,到发版才暴露。补一个 ci.yml,push 到 main 和所有 PR 都跑 typecheck + test + build,Node 20(engines 下限)和 22(当前 LTS)矩阵:
on: push: { branches: [main] } pull_request:concurrency: group: ci-${{ github.ref }} cancel-in-progress: truestrategy: matrix: node-version: [20, 22]推上去立刻触发,两个 Node 版本都绿。这一跑还顺带验证了一件事:之前 release workflow 里有个 Node 20 弃用警告,我把几个 action 升到了跑在 Node 24 上的版本(checkout@v5、setup-node@v6、pnpm/action-setup@v6、gh-release@v3)——CI 用的是同一批版本,跑绿且警告消失,等于间接验证了那个修复。
经验总结
- 包名先查,被占就 scoped;
bin决定命令名,scoped 不影响用户怎么敲。 - 新包发布优先用 OIDC Trusted Publishing,但要知道它有 bootstrap 鸡生蛋:首版必须 token 手动发一次,配完信任关系再吊销 token。
- 强制 2FA 的账号,token 发布需要 granular + bypass 2FA,否则只能现场
--otp。 - 刚发的新包别急,registry 读侧有传播延迟,404 不等于没发上去。
- 发布和 CI 是两层关卡:release 守发版,CI 守日常 push/PR,缺一层就是裸奔。
- 凭证别落进会提交的文件,更别贴进任何会被记录的地方——临时 token 用完即焚。