从 EOTP 到 E404:一次 npm 自动发布踩坑全记录(附完整修复路径)
简单需求:GitHub Actions 自动发布 npm 包。
结果踩了 6 个坑,差点放弃。这是一份完整复盘。
背景
给一个老 npm 包加自动发布:git push master → CI 跑 test → npm publish 上 registry。
workflow 一开始长这样(典型的旧式 token 发布):
yaml
publish:
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
看着没毛病。push 一下,CI 跑起来。
然后噩梦开始。
坑 1:CI publish 报 EOTP
css
npm error code EOTP
npm error This operation requires a one-time password from your authenticator.
含义:npm 账号开了 "Require 2FA for write actions",每次 publish 都要 OTP。CI 没法交互输 OTP。
直觉以为重建一个 token 就行。但实际上:
| Token 类型 | 能 bypass 2FA? |
|---|---|
| Classic - Publish | ❌ |
| Classic - Read-only | 不能 publish |
| Classic - Automation | ✅ 唯一可以 |
| Granular Access Token | ❌(默认) |
只有 Classic Automation Token 能绕开 2FA for writes。
坑 2:Classic Token 入口被 npm 下架了
打开账号 tokens 页面,按 "Generate New Token" ------ 只剩 Granular Access Token 选项。Classic 入口没了。
Granular Token 默认不 bypass 2FA,需要账号 2FA 设置里允许"token bypass 2FA"才行。配置层级又多一道。
------ 这条路堵了,换思路。
坑 3:disable 账号 2FA 后变 E403(死锁!)
那干脆关账号 2FA。CI rerun,错误变了:
vbnet
403 Forbidden - Two-factor authentication or granular access token
with bypass 2fa enabled is required to publish packages.
原来这个包被 npm 强制 enroll 进了包级 2FA 政策 (旧包 / 高下载量包会被自动 enroll)。账号 disable 2FA 后,包级仍然要求 2FA 或 bypass-2FA 的 token。
死锁:
markdown
账号 2FA disabled → 包级要求 2FA → publish 失败
↑ ↓
└── 但要创建 bypass-2FA token 必须先开账号 2FA ─┘
绕不开。唯一干净出路:Trusted Publishing (OIDC) ------ GitHub Actions 和 npm registry 通过 OIDC 互信,完全不用 token。
坑 4:改 workflow 文件 push 被 GitHub 拒
OIDC 路径要改 workflow:删掉 NODE_AUTH_TOKEN、加 permissions: id-token: write、加 --provenance。
本地 commit 完 push:
sql
! [remote rejected] master -> master (refusing to allow an OAuth App
to create or update workflow `.github/workflows/publish.yml`
without `workflow` scope)
关键认知:账户权限 ≠ OAuth app 权限。
我账户是 owner、什么都能做。但 gh CLI 拿的 OAuth token 只有 repo, gist, read:org, admin:public_key 这几个 scope,没有 workflow 。GitHub 把 workflow 文件单独锁在 workflow scope 后面 ------ 因为 CI workflow 直接控制自动化(能读 secrets、能跑任意命令、能发恶意版本),改一行就能注入后门。
解决方法两条:
gh auth refresh -s workflow -h github.com给 token 加 scope(要浏览器确认)- 浏览器直接在 GitHub 网页编辑 workflow 文件 + commit
坑 5:SSH key 推到另一个 GitHub 账号
公司机器不想动 OAuth scope,试 SSH push:
vbnet
$ git push origin master
ERROR: Permission to <owner>/<repo>.git denied to <another-account>.
机器有两个 SSH key:
- 默认
id_ed25519→ 对应账号 A(个人 / 工作账号) - 另一个
id_ed25519_personal→ 对应账号 B(仓库 owner)
诊断命令(很有用,多账号机器必备):
bash
ssh -T git@github.com
# Hi account-A! ...
ssh -T -i ~/.ssh/id_ed25519_personal git@github.com
# Hi account-B! ...
临时切 key 推:
bash
GIT_SSH_COMMAND="ssh -i ~/.ssh/id_ed25519_personal -o IdentitiesOnly=yes" \
git push origin master
IdentitiesOnly=yes 关键 ------ 否则 SSH agent 可能把别的 key 也试一遍,最先成功认证的就是用了哪个账号。
关键认知 :SSH 鉴权和 OAuth scope 完全是两套体系。SSH key 直接对应一个 GitHub 账号,账号有写权限就能推任何文件(包括 workflow),不存在 scope 限制。
坑 6:OIDC 配好了还报 E404(最阴的一个)
workflow 改成 OIDC 风格 + npm 端配好 Trusted Publisher(owner / repo / workflow filename / environment 留空)。push,CI 跑:
vbnet
npm notice Publishing to https://registry.npmjs.org/ ...
npm notice publish Signed provenance statement with source ... ← OIDC token ✓
npm notice publish Provenance statement published to transparency log ← sigstore ✓
npm error code E404
npm error 404 Not Found - PUT https://registry.npmjs.org/<pkg>
npm error 404 '<pkg>@x.y.z' is not in this registry.
包都打好了、provenance 都签了、sigstore 透明日志都记录了,最后 PUT 到 npm 返回"包不存在"???
这个错误信息是 npm 服务端给的误导性映射 ------ 实际是 OIDC token 没生效,npm 用了空 token 鉴权失败,错误码被映射成了 404 + "not in this registry"。
CI log 里有关键线索:
yaml
env:
NODE_AUTH_TOKEN: XXXXX-XXXXX-XXXXX-XXXXX
XXXXX 是 GitHub Actions 显示空 secret 的占位(NPM_TOKEN 被我删了)。
actions/setup-node@v4 配置 registry-url 时会自动写一份 .npmrc:
ini
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}
但 NODE_AUTH_TOKEN 是空值。
而 npm 10.x 不支持 OIDC trusted publishing :它看到 .npmrc 里有 _authToken 字段就用它(即便是空字符串)publish,根本不去做 OIDC token exchange。npm 服务端收到空 token → "无效"→ 返回 404 假装包不存在。
根因 :node 22.x 默认 npm 是 10.x,OIDC 不生效 。需要 npm 11.5.1+ ,它才会优先用 OIDC token 而忽略 .npmrc 里的空 token。
最终修复:node 22 → node 24
diff
- uses: actions/setup-node@v4
with:
- node-version: 22.x
+ node-version: 24.x
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --access public --provenance
node 24 自带 npm 11.x,OIDC 自动生效。一行 diff。
为什么不在 workflow 里 npm install -g npm@latest 升级 npm?
试过了。撞上经典的 npm 自我升级 bug:
lua
npm error code MODULE_NOT_FOUND
npm error Cannot find module 'promise-retry'
npm 升级自己的过程中,旧 npm 还在跑、新 npm 部分文件已经替换,依赖路径错乱。换 node 24 干净利落,绕开整个雷区。
完整可用 workflow
yaml
name: Test & Publish
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm test
publish:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
permissions:
id-token: write # ← OIDC 必需
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24.x # ← npm 11+ 必需
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --access public --provenance
加上 npm 端的 Trusted Publisher 配置(4 个字段:org / repo / workflow filename / environment-留空),就跑通了。
完整避坑清单
- 不要轻易 disable 账号 2FA ------ 会被包级 2FA 政策反咬,进死锁
- Trusted Publishing (OIDC) 是 2026 年最优解 ------ 无 token、有 provenance、符合 npm 包级 2FA 政策
- node 22.x 默认 npm 10.x,不支持 OIDC ------ 用 node 24.x(自带 npm 11.x)
- 不要在 CI 里
npm install -g npm@latest------ 撞 promise-retry MODULE_NOT_FOUND - 改 workflow 文件需要
workflowOAuth scope ------ gh CLI 默认没有;要么 refresh,要么走浏览器编辑,要么走 SSH - 多账号机器 ------
ssh -T git@github.com看 key 对应哪个账号;切 key 用GIT_SSH_COMMAND+IdentitiesOnly=yes - E404 "is not in this registry" ------ 在 OIDC 场景下基本等于 OIDC 没生效,先怀疑 npm 版本
.npmrc里有_authToken会旁路 OIDC ------ setup-node 设置registry-url时会自动写一份,npm 11+ 才会忽略它走 OIDC
三层认证体系总结
这次踩坑最大的收获是搞清楚 GitHub 上三套互不相通的认证:
| 通道 | 鉴权对象 | 受 scope 限制? | 用场景 |
|---|---|---|---|
| 账户 + 浏览器 | 你账户本人 | 否,账户全权 | 网页操作 |
| OAuth app (gh CLI / PAT) | 账户代理,token 上有 scope | 是 | 命令行 / API |
| SSH key | 直接关联账号,无 scope 概念 | 否 | git push |
任何一个走不通,换另一个试。
希望这份记录能让下一个走同样路径的开发者少踩几个坑。完整修复链路就 8 行 yaml diff + npm 网页配一下 Trusted Publisher,但搞清楚每个坑的根因花了我两个小时。