我的两个 npm 包(distguard、huntbook)现在的发布管线里,不存在任何 npm token------不在本地 .npmrc 里,不在 GitHub Secrets 里,不在任何服务器上。发版 = 推一个 git tag,剩下的事由 GitHub Actions 和 npm 之间的一次 OIDC 对话完成,包页面上还会带上 Verified 标记和 SLSA 来源证明。
配置过程踩的坑比写的配置还多,所以这篇不是又一篇"新特性介绍",而是把五个真实卡住过我的坑全部摊开。先讲为什么,再讲怎么做,坑在第四节之后。
一、为什么要从管线里赶走 token
传统 npm 发布的信任模型,核心是一枚长期凭证 :automation token 或 granular token,躺在本地 .npmrc 或 GitHub Secrets 里,每次 npm publish 出示一下。
granular token 已经是巨大进步------可以限定包、限定权限、设有效期。但它仍然是 bearer credential :不验持有者是谁,只验证不验证。于是威胁模型里永远是同一句话:谁拿到它,谁就能以你的名义发版 。而"谁"的名单不短------钓鱼站点仿冒 npm 登录页骗 token、CI 日志不小心 echo 了 secret、依赖链里某个被投毒的包读了你的 .npmrc。近年多起供应链事件,起点都是一枚维护者凭证的失窃。
Trusted Publisher(可信发布者)换了一个模型:发布授权不锚在密钥上,锚在"一次可验证的 CI 运行"上。npm 不再问"你有没有 token",而是问"你是不是从那个仓库、用那个 workflow 文件跑出来的那次构建"。
二、工作原理:一次 OIDC 对话
整个机制三步,没有任何需要你保管的秘密:
- GitHub Actions 证明自己的身份。 配置了
id-token: write权限的 job,会拿到 GitHub 签发的 OIDC 令牌,里面写着这次运行的仓库、owner、workflow 文件名等信息。 - npm 校验对话对象。 你在包设置页预先告诉 npm:"我信任
lvyanyan/distguard仓库的release.yml发起的运行"。对不上,拒绝。 - 短时授权 + 溯源存证。 校验通过,授予这一次运行的发布权限,顺便生成 provenance(SLSA 格式的来源证明)挂在包上------npmjs.com 包页面那个 Verified 标记就是它。
令牌短时、不可转移、绑死在"某仓库的某 workflow"上。钓鱼网站偷不走它(它不经过你的手),CI 泄露无所谓(它十几分钟就过期),投毒的依赖读不到它(它只在 Actions 的令牌端点里)。
三、工作流文件:注意那行 publish 没有 token
这是 distguard 的完整 release.yml,触发条件是推 v* 标签(也留了手动入口):
yaml
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
# OIDC trusted publishing: no long-lived NPM_TOKEN secret involved.
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test
- run: pnpm build
- run: npm publish --provenance --access public
两处和"老配方"不一样的地方:
permissions里的id-token: write是一切的钥匙------没有它,Actions 拒签 OIDC 令牌,后面全免谈。- 最后那行
npm publish --provenance,没有NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}。传统教程里这行 env 是必备的;在这里它的缺席就是目的本身。npm CLI 11.5.1 起(配合 Node 22.14+)在受支持的 CI 环境里会自动完成 OIDC 交换,setup-node的registry-url把 registry 指向官方源即可。
一个版本提示:这套支持 2025 年 7 月底才 GA,不少 runner/Node 版本自带的 npm 低于 11.5.1 ,publish 时会报很迷惑的 404。如果你的 workflow 报 404,先在 publish 前加一步 npm install -g npm@latest,十有八九就是它。
发布即推标签:
perl
git tag v0.1.2 && git push origin v0.1.2
之后 lint → test → build → publish 一条龙,绿了就上线了。
四、包侧配置:一张表单
在 npmjs.com 的包页面 → Settings → Trusted Publisher,填一张表单:
| 字段 | 填什么 |
|---|---|
| Repository owner | GitHub 属主名 (我的是 lvyanyan) |
| Repository | 仓库名(distguard) |
| Workflow filename | release.yml(只要文件名,不带路径) |
| Environment | 留空(我没用 GitHub Environments) |
| Permissions | 只勾 Allow npm publish |
保存时会要求过一遍 2FA/通行密钥验证------这里是第一个坑的入口,往下看。
五、五个真实的坑
坑 1:"生成恢复码" ≠ "连接已建立"。 配置向导走完时,页面上出现了通行密钥恢复码,我下意识认为大功告成,去推 tag------workflow 红了,npm 返回无权发布。回包设置页才发现:恢复码只是你账号通行密钥的备份产物,Trusted Publisher 的连接根本还没建立。判定标准只有一个:设置页出现"已连接"状态和管理/移除入口。恢复码可以顺手存进密码管理器,但别把它当成功标志。
坑 2:owner 栏填 GitHub 属主,不是 npm 用户名。 我的 GitHub 账号是 lvyanyan,npm 登录名是 zuoqing------两边不一致的人在这里必死:凭直觉填了 npm 用户名,OIDC 令牌里的仓库属主对不上表单,校验永远失败,而且报错不会告诉你"只是名字填错了"。规则一句话:这张表单全部按 GitHub 侧的身份填,它校验的是 GitHub 的身份,不是 npm 的。
坑 3:Windows 上"此设备"通行密钥选项凭空消失。 建立连接要过通行密钥(passkey)验证,Windows 弹出的保存位置里只有手机和物理安全钥匙,没有"此设备"。原因不是玄学:这台电脑没设 Windows Hello PIN,系统不允许本机保管通行密钥。先去 设置 → 账户 → 登录选项 → PIN 设置好,重新走向导,"此设备"就出现了;还不行换 Edge 试(浏览器实现差异真实存在),再不行走手机扫码路线------但注意必须用系统相机扫,别用浏览器内扫码。
坑 4:本机 npm 源指向镜像时,publish 会发错地方。 国内环境的 .npmrc 通常把默认源设成了 npmmirror(装依赖快)。镜像源不能 publish,于是所有对官方源的显式动作都要带上参数:
ini
npm whoami --registry=https://registry.npmjs.org
npm publish --registry=https://registry.npmjs.org # 本地手动发版时才需要
(CI 里不受影响------workflow 里 registry-url 已经指死了官方源。这条只坑在本地手动操作,比如首次 npm login 验证账号的时候。)
坑 5:包的"第一个版本"走不了 OIDC------Trusted Publisher 只认已存在的包。 官方文档没有把它藏在深处,但足够反直觉:npmjs.com 的 Trusted Publisher 表单本身就要求包已经存在,OIDC 授权发生在"向已存在 的包发新版本"这个动作上。所以首次发布必须先用老办法(本地登录 + npm publish)把包立起来,然后把 Trusted Publisher 接上,从第二个版本开始享受无 token 管线。我的两个包都是手动发的 0.1.0、之后的版本才切的------当时还以为是仪式感问题,后来才知道这是机制边界(npm/cli#8544)。
六、发版日清单与收尾
管线跑通后的日常发版,就是三行:
perl
# 改 package.json version → commit →
git tag v0.1.3 && git push origin v0.1.3
# 盯一眼 Actions,全绿后验证:
npm view <包名> version
验证通过的标志:npm view 返回新版本号;包页面出现 Verified 标记(点开能看到 SLSA 来源证明,绑定到具体的仓库与提交)。
最后一步收尾别省:把旧 token 亲手 revoke 掉。revoke 之后我用它请求了一次 registry,拿到 401------彻底退役确认。顺手把包设置的 Publishing access 切到最严格档。从此你的发布管线的攻击面,收缩为"GitHub 账号本身"这一项------而它可以靠 2FA + 通行密钥守得住。
第二个包 huntbook 复用了整套管线:workflow 原样拷贝、表单照抄一遍,冒烟验证时故意推了个已存在的版本号,npm 返回"version already exists"而不是"无权发布"------授权通了,验证方式都省了。
七、值不值得
值得,而且是"一次配置,永久受益"的那种。单人维护的小包恰恰是最该上的:没有安全团队帮你管凭证,token 泄露的概率不比大厂低,响应能力却差几个量级。配置的全部成本就是一张表单加一次通行密钥,坑我已经替你们踩完了。
不适用的情况也说清楚:发到私有 registry / 企业内网源的包不走这套(Trusted Publisher 目前是 npmjs.com 官方源的特性);完全离线、纯手动 npm publish 的工作流用不上它------但那种工作流本身大概也该升级了。
链接
- distguard:GitHub · npm(包页可看 Verified 与 provenance 实物)
- huntbook:GitHub · npm
- 官方文档:Trusted publishing for npm packages · GA 公告(2025-07-31,GitHub Changelog)
我正在系统性地给 Vue 生态提 PR,同时维护 distguard(构建产物凭证泄露扫描)和 huntbook(AI 会话可驱动的工作流工具)。欢迎来 GitHub 上交流。