
发布 npm 包这件事最折磨人的从来不是写代码而是“怎么证明你有权限发”。早期的方案很简单粗暴生成一个 npm token塞进 GitHub Secrets然后在 CI 里用。但这个方案在我维护的几个开源小项目上反复翻车token 过期了 CI 悄悄变红、token 权限给大了心里发怵、一不留神密钥泄漏还得连夜轮换。后来我把发布流程整体切到 Trusted Publisher OIDC彻底把“长期 token”从发布链路里拿掉了。这篇不是概念科普是完整的实战复盘原理怎么走、npm 后台怎么配、GitHub Actions 怎么写以及我踩过的五个真实坑。1. 为什么我决定把 npm token 彻底扔进垃圾桶1.1 长期 token 时代的三个真实痛点先用我自己维护的两个小库举例。一个工具 npm 包一个组件库常年靠 GitHub Actions 发版。传统配置长这样先在 npm 后台生成一个 automation token然后把这个 token 放到 GitHub 仓库的 Secrets 里workflow 中通过NODE_AUTH_TOKEN注入给actions/setup-node最后npm publish完成发版。这套流程看起来顺滑实际用起来全是暗坑。第一token 泄漏的风险被很多人低估了。npm 的 automation token 长期有效只要进过 CI 日志、本地环境变量、或者某个.env文件理论上就等于有人拿到了一把永不过期的大门钥匙。npm 官方其实有审计和通知机制一旦检测到 token 出现在公开仓库里会主动撤销但这种“事后补救”完全靠运气真等被盗刷一次就够你喝一壶。第二token 的权限粒度不够舒服。npm 的 access token 分为几档最常用的 automation token 拥有发布当前账号下所有包的权限。假如你账号下有个人包、公司包、甚至帮朋友维护的包一个 token 全搞定那它就成了单点故障。更细粒度的 granular access token 能限包但创建和维护心智成本都不低。第三轮换成本看着小实际很烦。npm token 默认会有有效期到期后 CI 立刻红灯。你得重新登录、生成新 token、替换 secret遇到多个仓库共用 token 的情况还得一个个排查。遇到“为什么突然发布失败”的问题一半原因是 token 过期另一半是权限被人改了归根结底都是长期密钥的锅。1.2 Trusted Publisher 的核心思路短期凭证 边界绑定Trusted Publisher 的思路其实一句话就能说清楚不再把“长期密钥”交给仓库而是让 CI 每次发布前向 npm 证明“自己就是那个被授权的仓库和工作流”。这个证明用到的协议就是 OIDCOpenID Connect。打个比方。传统 token 相当于你给 CI 配了一把家里钥匙只要钥匙不丢谁拿到都能进门。Trusted Publisher 更像小区门口的人脸识别CI 每次来先刷一下脸OIDC 拿短期身份保安确认这人是本小区业主claims 匹配才放行而且这次刷脸几分钟后就失效下次还得重新刷。这个方案的直接好处有三个仓库 Secrets 里不再需要存 npm token删掉一个长期密钥等于缩小一圈攻击面权限被绑定到具体的仓库、工作流、甚至环境权限边界比“一个账号 token”清晰得多后续不需要轮换密钥每次发布都会自动获取的新身份过期就过期不影响任何东西。用到这套机制的不仅是 npmGitHub 对主流云厂商、HashiCorp Vault、AWS 等都开放了 OIDC 通道原理完全一致。你只需要理解一次就可以迁移到其他发布场景。2. OIDC 到底是怎么帮你“零 token”发布的2.1 一次 OIDC 握手的完整过程OIDC 的全称是 OpenID Connect它是构建在 OAuth 2.0 之上的身份认证层。GitHub Actions、npm registry、以及你配置的 Trusted Publisher三者之间会完成一次短期身份交换。整个握手过程可以拆成四步发布工作流执行时如果 job 声明了permissions: id-token: writeGitHub Actions 就会向自己的 OIDC provider 申请一个 JWTJSON Web Token。这个 JWT 不是乱发的里面携带了一组 claims比如仓库全名repository、工作流文件名workflow、GitHub 环境名environment、触发方式event_name等关键属性。npm publish执行时npm 客户端把 JWT 交给 npm registryregistry 先验签确认这个 JWT 确实由可信的 OIDC provider 签发再对比 JWT 里的 claims 是否匹配你预先配置的 Trusted Publisher 条目。验签通过、claims 匹配无误registry 才为本轮发布会话发放授权发布流程继续。这里的核心是JWT 是短期的通常几分钟内过期claims 是绑定了发布上下文的不匹配就拒绝。就算某个环节有人拿到了 JWT想用来二次发布也必须满足仓库、工作流、环境等多重条件跟长期 token“一把钥匙开所有门”完全两个量级。2.2 一张表看懂传统 token 和 OIDC 的差异对比维度传统 npm tokenTrusted Publisher OIDC凭证有效期长期有效可能 1 年甚至更长分钟级每次发布自动获取存储位置GitHub Secrets、本地 .npmrc无需存储任何密钥权限范围以账号或包为粒度账号下多个包会一刀切绑定仓库 工作流 environment泄漏影响找到 token 即可冒充发布JWT 短暂有效且受多重条件约束轮换成本定期手动重新生成不需要轮换审计性token 归属较模糊每次发布与具体 workflow 强绑定表格里有一点值得单独说权限范围。传统 token 哪怕选了 granular access token也只是“能发哪些包”的区别而 Trusted Publisher 的匹配条件更刁钻它要求 JWT 里的 repository 字段等于你配置的仓库workflow 字段等于你配置的工作流文件路径environment 也要一致三重匹配少一个都发不出去。这就是为什么标题敢叫“零 token 发布”因为发布凭据从“你拥有什么”变成了“你在哪个上下文里执行”。2.3 为什么 claims 匹配是整套方案的安全基石我刚开始接触 OIDC 时也犯过嘀咕JWT 是短期没错但它也是“一张纸”万一伪造怎么办其实不会。JWT 是带签名的npm registry 拿到后会先通过 OIDC discovery 机制找到 GitHub 的公钥验签不通过直接拒绝。常规篡改难度相当于你想伪造一张带芯片的身份证而不是手写一张纸条。再补一个容易忽略的细节GitHub 对 OIDC 的 JWT 里有aud字段也就是 audience。npm 文档要求配置的 audience 是registry.npmjs.org这个字段会被 npm 客户端在请求时带上进一步避免 JWT 被拿去请求别的服务。简单说JWT 不是通用的万能票它从头到尾就是奔着 npm registry 签发的。理解这些之后配置 Trusted Publisher 时才不会只停留在“照着文档点几下”的层面。你至少能应付一个常见问题为什么别人抄了你的 workflow 仍然无法发布。答案很明显——他们不是配置里的那个仓库claims 对不上自然被拒。3. 零 token 发布 npm 包从零到一完整实操3.1 前置准备版本、仓库、包名动手前先检查三样东西缺一不可。第一Node.js 版本。npm 客户端对 OIDC 和 provenance 的支持从 npm 9 开始才逐渐完善我建议直接用 Node.js 20 及以上版本它内置的 npm 10.x 用起来最省心。如果你还在用 Node 16后面--provenance参数几乎必踩坑具体见第五节的坑三。第二GitHub 仓库已经推到远端最好有一次成功跑通的 CI 记录。Trusted Publisher 配置需要填写仓库名和工作流文件名如果仓库本身都没有或者 workflow 还没提交后端校验肯定过不了。第三包名需要在 npm 上可用。如果你发布的是已经存在的包要确保自己的 npm 账号有该包的管理权限如果是首次发布新包先想好包名最好在本机用npm view package-name查一下是否已被占用避免发布时因为名字撞车而 403。另外提一个 Git 相关的细节每次发布前用git status确认工作区干净打 tag 后触发发布。这个过程我会在 workflow 里用 tag 事件控制避免每次 push 到主干都触发一次发版。3.2 在 npm 后台把仓库授权为 Trusted Publisher登录 npm 官网进入对应包的设置页。这里要注意Trusted Publisher 是配在包级别的不是配在账号级别所以一定要先切到你要发布的那个包。步骤如下打开https://www.npmjs.com/package/你的包名确认自己已登录且是该包的管理者。在页面里找到包的设置入口Package Settings 那一带找到 “Trusted Publishers” 区块。点 “Add Publisher” 或类似的新增按钮开始填写授权信息。Repository 一栏填 GitHub 仓库的全名格式是owner/repo例如zhangshan/awesome-cli不带.git后缀。Workflow name 一栏填你即将使用的 workflow 文件名例如publish.yml。注意这里要填完整的文件名.yml不是 workflow 内部的name字段也别带路径前缀。Environment name 是可选的。如果你准备在 GitHub Actions 里用 environment 做权限隔离比如生产环境叫npm-publish这里必须填完全相同的名字不填则表示匹配不限定 environment。提交之后npm 后台就多了一条 Trusted Publisher 记录。它会显示授权给哪个仓库、哪个工作流、哪个环境后续 npm registry 就是拿 JWT 里的 claims 和这条记录比对。这里有一个我刚才实际踩过的小细节如果你填完 Repository 还没保存页面可能提示需要先验证你对仓库的所有权。不同时期的 npm 后台校验方式不完全一样有的直接通过 GitHub OAuth 验证身份有的需要稍等片刻。正常情况下一两分钟内就能绑定成功。3.3 编写发布用的 GitHub Actions接下来是在仓库里创建.github/workflows/publish.yml工作流内容如下name: publish on: push: tags: - v* permissions: contents: read id-token: write jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkoutv4 - name: Setup Node uses: actions/setup-nodev4 with: node-version: 20 registry-url: https://registry.npmjs.org - name: Install dependencies run: npm ci - name: Publish to npm run: npm publish --provenance --access public逐行拆一下关键点。permissions区块是整个零 token 方案的灵魂。id-token: write是 GitHub Actions 允许 workflow 向 OIDC provider 请求身份令牌的总开关没有它后续所有 OIDC 行为都会失败。contents: read则是最小化权限确保 checkout 有读取权限的同时不让 workflow 乱动仓库。actions/setup-node这次不再需要token输入也没必要往 Secrets 里放NPM_TOKEN。它只需要配置好registry-urlnpm 客户端后续才会去向registry.npmjs.org做认证和发布。npm publish --provenance --access public里有两个参数都值得解释。--provenance开启构建来源证明npm 会在发布时通过 OIDC 生成的 JWT 与 sigstore 体系联动生成一份 SLSA 级别的来源声明这对开源包的用户来说等于多了一个“这包确实从这个仓库构建发布”的信任凭证。--access public则是对第一版发布到 npm 的包最稳妥的保险尤其是 scope 包不显式声明容易默认为 restricted免费账号下直接报错。触发条件用了push: tags: - v*意味着只有当你推送形如v1.0.0的 tag 时才会走发布流程。日常提交代码不会触发发布这个习惯在多仓库场景下非常重要。推送 tag 的命令是git tag v1.0.0 git push origin v1.0.0。3.4 发布之后的验证清单发布跑完之后不要只盯着绿色勾要主动验证几项。第一确认包确实已更新到 npm。执行npm view 包名 versions能看到最新版本出现即可。第二检查 provenance 是否生效。在 npm 官网包页面找到 “Provenance” 信息块能看到一个 “signed” 的状态和对应构建地址这表示发布链路确实走了 OIDC。第三顺手看下 GitHub 仓库里的 Secrets 页面确认没有任何 npm token 类变量这是“零 token”的最终证据。如果这三项都通过恭喜你发布链路已经彻底摆脱长期 token 了。4. 五个真实的坑请直接抄进你的 check-list4.1 坑一忘记了 id-token: writeOIDC 直接被拒这不是配置错误是权限设计导致的第一步就失败。我一开始把 workflow 里的permissions区块写成了传统发布方式的样子只有contents: read结果npm publish --provenance直接报错日志里明确提示The workflow is not allowed to access the OIDC token类似的字样。当时我还以为是 npm 后台配置没生效反复删了重加 Trusted Publisher折腾了快一个小时才发现是这里的问题。原因不复杂GitHub Actions 默认上下文中没有 OIDC 权限必须通过id-token: write显式授予。修复方式就是给 workflow 配置完整的 permissionspermissions: contents: read id-token: write如果你习惯用 GITHUB_TOKEN 做其他事也可以按需追加但id-token: write不能少。另外需要注意这个 permissions 是 job 级还是 workflow 级我建议直接写在 workflow 顶层让所有 job 都有统一预期除非你刻意隔离。4.2 坑二workflow 文件名对不上发布 403这个问题我帮朋友排查过一次他自己怎么也找不到原因。症状是npm publish报 403日志里的错误信息跟权限不足相关看了半天 npm 后台的 Trusted Publisher 配置也确实是配好的但就是发不出去。最后对来对去发现 npm 后台填的是publish.yml实际仓库里的文件是.github/workflows/release.yml。JWT 里的 workflow claim 是完整的文件名npm registry 拿它和你配置的字符串做严格比对差一个字母都不行。修复办法很简单去 npm 后台把 Workflow name 改成release.yml或者把仓库里的 workflow 文件重命名为publish.yml。我更推荐后者因为保持仓库内文件名和 Trusted Publisher 配置一致以后接手的人一眼就能对上。这里还衍生出另一个小坑如果你改过 workflow 文件名旧配置不会自动同步记得去后端删掉旧的 Trusted Publisher 记录再新增避免残留两条看似重叠又都不完全匹配的配置。4.3 坑三npm 版本太旧--provenance 不认账有一种报错特别容易误导人Unknown argument: provenance。你搜日志、改配置怎么想都不会想到是 Node 版本太低。我最初在某个旧项目的 GitHub Actions 里用了node-version: 16它内置的 npm 8.x 根本不认识--provenance参数所以 npm publish 直接拒绝执行。这种问题跟注册表配置无关跟权限无关纯粹是 CLI 版本不支持新特性。我的建议零 token 发布方案里Node.js 版本直接用20或22对应的 npm 10.x 对 OIDC 和 provenance 的支持最完整。如果你实在要维护老项目最低也别低于 Node 18npm 9.x再旧就建议你把发布流程单独拆出来用新的 Node 版本跑发布 job。在 workflow 里修改 node-version 后记得重新提交、打 tag 再试一次不要只在本地验证。本地 Node 版本没问题不代表 Actions 里的版本没问题CI 环境和你本地环境是两个世界。4.4 坑四残留 NPM_TOKEN 悄悄抢走了发布权这个坑最阴因为它根本不会报错。有一次我明明已经配好了 Trusted Publisherrelease workflow 也跑到了发布步骤但发布失败的信息是401 Unauthorized看起来就像 token 过期问题。排查到最后才发现旧 workflow 里actions/setup-node的with中残留了token: ${{ secrets.NPM_TOKEN }}这一行。setup-node 拿到这个 token 后会在.npmrc里写入//registry.npmjs.org/:_authTokentokennpm 客户端首先尝试用它认证认证失败或权限不符时并不会自动降级走 OIDC而是直接抛 401。这里的关键教训是只要.npmrc里有 tokennpm 客户端就会优先使用 tokenTrusted Publisher 的 OIDC 通道根本不会被触发。你照着文档配了一遍“零 token”实际跑的还是老一套只是 token 过期了才露出马脚。修复分两步第一步删掉 workflow 里 setup-node 的token输入第二步去 GitHub 仓库 Settings → Secrets and variables → Actions 删除NPM_TOKEN旧变量。顺手检查仓库根目录的.npmrc和~/.npmrc确保没有残留_authToken字段。4.5 坑五跨 job 复用产物时environment 不匹配项目复杂一点之后很多人会把构建和发布拆在两个 job 里先 build用 actions/upload-artifact 存产物再在 publish job 里 download最后发布。这个设计本身没问题但如果你给发布 job 指定了environment就要特别注意 Trusted Publisher 的一致性。我当时在 npm 后台配置 Trusted Publisher 时填了 environment 为npm-publishGitHub Actions 里也是这么写的jobs: publish: runs-on: ubuntu-latest environment: npm-publish needs: build steps: - run: npm publish --provenance --access public表面看没毛病报错却出现在 token exchange 阶段token exchange failed或subject mismatch。原因在于 OIDC JWT 里的 environment claim 和 Trusted Publisher 配置的 environment 必须完全一致而且 GitHub 侧执行该 job 时确实会注入 environment 信息。一旦你在 npm 后台写的是npm-publish但代码里写的是npm-publish-prod就会被拒。如果你确实不需要环境隔离最简单的做法就是不填 environment。但按我现在的经验发布这种高危操作用 environment 保护更稳妥建议配置一把锁去 GitHub 仓库 Settings → Environments 创建npm-publish环境可以加上保护规则比如仅允许从主分支发布。npm 后台 Trusted Publisher 的 Environment name 填npm-publish。workflow 里发布 job 显式带environment: npm-publish。这样 OIDC claims、GitHub environment、npm 后台配置三项统一不仅能正常发布还多一层环境级保护。跨 job 发布时还有个隐性优势发布 job 受 environment 保护规则约束别人想临时手动触发也得过环境关卡。5. 迁移到零 token 发布之后的几点体会整套流程切完之后我最直观的感受不是“省了一个 secret”而是 CI 报错归因变得特别干净。以前发布失败要查 token 是否过期、权限是否有变、secret 是否被误删现在只需要看 workflow 里 OIDC 链路的三板斧有没有id-token: write、npm 后台的 repository/workflow/environment 是否和实际一致、npm 版本够不够新。问题范围缩小一大半。还有一个小建议给维护团队。Trusted Publisher 授权的是仓库级发布权改动这份配置的权限比普通 workflow 改动重要得多。如果你用 CODEOWNERS 管理仓库建议把.github/workflows/publish.yml以及 npm 后台配置流程归到固定的 core maintainer 名下避免临时提 PR 的人顺手改掉发布边界。最后分享一个我后来发现的小技巧如果你有多个 npm 包想都改成零 token 发布不需要挨个在 npm 后台操作可以确认每个包在同一仓库中是否有相同的 Trusted Publisher 配置。只要仓库的发布 workflow 能同时构建多个包npm 后台每个包各自加一条 trusted publisher 即可配合npm publish --workspace能一次性发多个包发布入口也保持单一安全边界依然清晰。我把这套方案落地之后GitHub Secrets 里再也没有任何 npm 相关凭证新同事接手项目也不需要走“找 token”流程。零 token 并不是“没有认证”而是把认证从“长期密钥”变成了“每次实时证明身份”。这两种思路对发布链路的影响只有真正跑过一遍才会体会得到。