零 token 发布 npm 包:Trusted Publisher + OIDC 实战(附五个真实的坑)

我的两个 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 对话

整个机制三步,没有任何需要你保管的秘密:

  1. GitHub Actions 证明自己的身份。 配置了 id-token: write 权限的 job,会拿到 GitHub 签发的 OIDC 令牌,里面写着这次运行的仓库、owner、workflow 文件名等信息。
  2. npm 校验对话对象。 你在包设置页预先告诉 npm:"我信任 lvyanyan/distguard 仓库的 release.yml 发起的运行"。对不上,拒绝。
  3. 短时授权 + 溯源存证。 校验通过,授予这一次运行的发布权限,顺便生成 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-noderegistry-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 的工作流用不上它------但那种工作流本身大概也该升级了。


链接

我正在系统性地给 Vue 生态提 PR,同时维护 distguard(构建产物凭证泄露扫描)和 huntbook(AI 会话可驱动的工作流工具)。欢迎来 GitHub 上交流。

相关推荐
编程快车17 分钟前
GitHub 从入门到精通(精炼版):30 分钟跑通建仓、提交与协作
github
WebInfra1 小时前
Rslib 1.0 正式发布:面向多场景的 JavaScript 库开发工具
前端·javascript·github
Tongsr1 小时前
别只混淆代码:用 Kaleido 加固整个 Android Release AAB
前端·算法·github
敢敢是只喵i1 小时前
WorkBuddy 开放生态之后,AI 真正进入业务系统还缺什么?
github
泡海椒1 小时前
响应自动序列化:JSON 响应一键转 Java 实体对象,JQuick-Curl 第三方接口调用不再手动解析
后端·github
凯程序猿1号3 小时前
ChatCut online 整理备份恢复演练录屏:快照、校验值与恢复时间怎样留证
程序人生·github·电脑
小芒果_014 小时前
从0到1,将pycharm上的项目上传到github
ide·pycharm·github
逛逛GitHub6 小时前
分享 2 个刚开源的数据集,一个是健身动作,一个是 CAD。
github
u1301307 小时前
GitHub 热榜项目:日榜(2026-09-02)
github