forgesworn/anvil
GitHub: forgesworn/anvil
为 JS/TS 库提供基于 OIDC 与双构建校验的加固发布方案,聚焦可重现构建与供应链安全。
Stars: 0 | Forks: 0
# forgesworn/anvil
*一个用于强化 npm 发布的 GitHub Action —— 请勿与 Foundry 的 `anvil`(以太坊开发节点)或 [anvil.works](https://anvil.works)(低代码应用平台)混淆。*
[](https://github.com/forgesworn/anvil/actions/workflows/ci.yml)
[](https://github.com/sponsors/TheCryptoDonkey)
*依据 MIT 协议按“原样”提供,无任何担保。请参阅 [THREAT-MODEL.md](THREAT-MODEL.md) 了解受防御的攻击面以及明确未防御的攻击面。*
这是一款为 JavaScript 库作者提供的发布工具,适合那些清楚自己要发布什么版本,
并希望确保发布过程干净无暇的开发者。
你只需提升 `package.json` 的版本号并编写 CHANGELOG 条目。这个 action
会处理其他所有事情:OIDC trusted publishing、每次发布时的 SLSA provenance、
针对实际发布包集执行范围限定的 secret 扫描、
验证每个子路径在磁盘上确实存在的 exports-map 检查
([`publint`](https://publint.dev/rules) 明确跳过了此检查;
`arethetypeswrong` 执行的是类型解析而非文件存在性验证)、
仅限运行时的 `npm audit`(这样 devDep 的噪音就不会阻断发布)、
对使用者自身工作流中未固定版本的 `uses:` 引用进行默认发出警告的审计、
为具有确定性测试套件的库提供的可选 frozen-vector 门控,**以及一个多 runner 可复现构建证明机制:只有当两个独立的 CI 构建产生字节完全相同的 tarball 时才会发布**。
最后一点是 v0.4 的旗舰功能。目前 `semantic-release`、
`@changesets/cli`、`release-it`、`release-please` 或 `np` 均未提供此功能。
注册表 tarball 的哈希值也会被印刻在 GitHub Release 正文中,并作为发布资产上传,因此使用者有两个独立的字节来源(npm 注册表 + GitHub Releases),并且可以与其中任何一个进行哈希比对。
该 action 本身纯粹使用 `bash` + `jq` + `gh` + `npm`。不包含任何 Node 工具。
所有步骤脚本总计约 1600 行 bash 代码。三十分钟内即可完成审计 —— 这是一项硬性设计约束,而非口号。
## 适用人群
- 已经在手动提升版本号和编写 changelog,并希望拥有一个不会让他们感到紧张的发布流水线的库作者。
- 已经不满足于在工作站上执行 `npm publish`,但又不希望引入某个发布工具附带的 597 个传递性 devDependencies 的项目。
- 任何使用者需要信任其字节数据的库 —— 涉及身份验证、支付、密码学或基础设施的库。
- 任何在经历 `xz-utils` 或 `tj-actions/changed-files` 事件后,开始严肃对待供应链攻击面的人。
如果你希望由 CI 来决定版本号,
`semantic-release` 或 `release-please` 会是更好的选择。
这款工具是为希望由自己做出决定的作者准备的。
## 缘何存在
主流的 JS 发布工具 —— 如 `semantic-release`、`changesets` —
会引入数百个传递性 devDependencies。对于 CRUD 应用来说,这只是背景噪音。
但对于任何使用者需要信任其输出的库来说,这是作者本不应被迫接受的供应链攻击面。
`semantic-release` 还会根据 commit 消息前缀决定你的版本号。
这意味着你的公共 API 契约是由 commit 的规范性而非开发意图驱动的。
只要有某位贡献者将 `fix:` 写成了 `feat:`,你就会发布一个 minor 版本升级而不是 patch。
另一种选择是 —— 自己编写 changelog,自己提升版本号,让 CI 强制执行其他所有事情 —— 这正是此 action 所提供的功能。
许多库作者已经在这样做了,但缺乏安全保障:
在工作站上手动执行 `npm publish`、使用长期有效的 `NPM_TOKEN` 机密、没有 provenance、没有发布前门控。
即使是每周下载量达数千次的维护良好的库,通常也没有进行
secret 扫描、exports 检查或可复现构建验证。
此 action 将任何库作者都应该需要的门控,打包进了一个只需五行调用方 YAML 代码即可采用的可复用工作流中。
纯 bash 实现,零依赖,基于社区基础设施。
## 快速开始(可复用工作流)
有两种方案可供选择:
- **可复用工作流**(推荐,见下文)。内置完整四任务 DAG 以及多 runner 可复现构建门控。
- **Composite action**:在现有 job 中使用 `uses: forgesworn/anvil@v0`。不包含可复现构建门控(composite action 无法跨越多个 job)。请参阅[进阶:直接使用 composite action](#advanced-composite-action-directly)。
在你的库中创建 `.github/workflows/release.yml`:
```
name: release
on:
release:
types: [published]
permissions:
contents: write # update Release bodies + upload tarball asset
id-token: write # OIDC trusted publishing to npm
jobs:
release:
uses: forgesworn/anvil/.github/workflows/release.yml@v0
```
这就是完整的调用方工作流。无需配置文件,无需插件。
测试向量固定的库可以添加一个门控:
```
with:
vector-test-command: npm run test:vectors
```
### 前提条件
在首次运行之前,你的库必须具备:
- `package.json` 中包含 `"publishConfig": { "provenance": true }`(在不需要 `--provenance` CLI 标志的情况下驱动 SLSA provenance —— npm 11.6+ 在显式传递该标志时会短路报错 `ENEEDAUTH`,因此通过配置设定是唯一可靠的途径)。
- 为**你的仓库**和**你的调用方工作流文件**配置了 npm trusted publisher(确切的字段请参阅下文的“Trusted publisher 注意事项”)。该可复用工作流的 publish job 默认使用 `npm-publish` GitHub Environment;如果你想要设定审查者或分支/标签保护规则,请在 GitHub 中创建该环境。
- 该包至少已发布到 npm 一次。OIDC trusted publishing 要求包必须已存在于注册表中 —— 首次发布新包的一次性引导操作请参阅“首次发布新包”。
- 一条对应你要发布版本的 `CHANGELOG.md` 条目(该 action 会从中提取发布正文)。
然后:
1. 在 `registry.npmjs.org` 上为你的包配置 [npm trusted publishing](https://docs.npmjs.com/trusted-publishers)。**将其指向你自己的仓库和你自己的 `release.yml`**,而不是 `forgesworn/anvil`。原因请参阅下文的“Trusted publisher 注意事项”部分。
2. 提升 `package.json` 版本并添加一条 `CHANGELOG.md` 条目。
3. 提交、打标签(`v1.2.3`)、推送,并为该标签创建一个 GitHub Release。接下来的工作就交给工作流处理了。
已经在使用其他发布工具了?请参阅 [`docs/comparison.md`](docs/comparison.md) 获取完整的功能对比,或直接跳转到迁移指南:
[semantic-release](docs/migration-from-semantic-release.md) |
[changesets](docs/migration-from-changesets.md) |
[release-please](docs/migration-from-release-please.md) |
[release-it](docs/migration-from-release-it.md) |
[np](docs/migration-from-np.md)
## 版本策略
处理版本提升有三种模式。请选择符合你工作流的那一种。
### 手动(默认)
你负责提升 `package.json` 的版本、编写 CHANGELOG 条目、打标签并创建 GitHub Release。该 action 会验证标签是否匹配并运行所有门控。这就是上文快速开始中的工作流。
### 验证
你仍然手动提升版本,但该 action 会解析你的 conventional commits,并**在你的版本提升幅度小于 commit 记录所隐含的幅度时中断发布**。比如只有 patch 提升的 `feat:` commit 就会被拦截。
有意的超额提升(例如为一个小修复进行了 major 提升)会产生警告,但不会阻断发布。
```
with:
version-strategy: verify
```
这是一种折中方案:你保留了控制权,而该 action 会捕获那些会在 patch 中引入破坏性变更的过低版本提升。
### 自动
配套的 `auto-release.yml` 工作流可以完全替代 `semantic-release`。
在推送到 `main` 分支时,它会解析 conventional commits、提升 `package.json` 版本、更新 `CHANGELOG.md`、打标签、推送,并调度你的 `release.yml` 执行发布。
你的仓库中只需要两个文件。
**`.github/workflows/auto-release.yml`** —— 解析 commit、提升版本、打标签、调度:
```
name: auto-release
on:
push:
branches: [main]
permissions:
contents: write
actions: write # required to dispatch release.yml
jobs:
auto-release:
uses: forgesworn/anvil/.github/workflows/auto-release.yml@v0
```
**`.github/workflows/release.yml`** —— 运行门控、发布 npm、创建 GitHub Release。
必须声明 `workflow_dispatch` 触发器,以便 `auto-release.yml` 可以触发它:
```
name: release
on:
release:
types: [published] # manual flow: you create the Release
workflow_dispatch: # auto flow: auto-release dispatches
inputs:
tag:
description: Release tag to publish
type: string
required: true
permissions:
contents: write
id-token: write
jobs:
release:
uses: forgesworn/anvil/.github/workflows/release.yml@v0
with:
tag: ${{ inputs.tag || '' }}
vector-test-command: npm run test:vectors # optional
```
将 conventional commits 推送到 `main`;发布将自动进行。
零依赖,零配置文件,无需 PAT。npmjs.com 上的 Trusted-publisher 配置将继续指向 `release.yml` —— `workflow_dispatch` 桥接保留了 OIDC 入口点,因此你现有的设置可以继续工作。
**为什么不需要 PAT?** `auto-release.yml` 通过 `gh workflow run` 触发 `release.yml`,即触发一次 `workflow_dispatch` 事件。GitHub 的防递归规则会抑制由默认 `GITHUB_TOKEN` 创建的大多数事件,但 `workflow_dispatch` 和 `repository_dispatch` 是明确的例外情况,能够触发工作流运行。无需任何长期凭证。架构详情请参阅 [`docs/design/chained-workflows.md`](docs/design/chained-workflows.md)。
## 该 action 执行的操作
这个可复用工作流以四任务 DAG 的形式运行:
```
build-a ──────┐
(full gates + │
record) ├──> reproduce ──> publish
build-b ──────┘ (compare (publish-npm,
(build + sha256s) publish-jsr,
record) update-release)
```
按顺序执行:
**`build-a`** 在使用者提供的构建产物上运行所有门控:
1. **Checkout** 你的仓库和固定在特定 SHA 的此 action
2. **Setup Node** 并配置 OIDC 注册表
3. **verify-action-pins** —— 扫描 `.github/workflows/*.yml`,查找未使用 40 字符 SHA 固定的 `uses:` 行。默认仅发出警告;可通过设置 `strict-action-pins: true` 提升为严格失败
4. **`npm ci`**
5. **`npm run build --if-present`**
6. **verify-tag** —— git 标签需与 `package.json` 版本匹配
7. **verify-bump** —— (仅在 `version-strategy: verify` 时启用)解析 conventional commits,如果手动提升的版本小于 commit 历史所隐含的版本则失败
8. **run-tests** —— 完整的测试套件(默认为 `npm test`)
9. **verify-vectors** —— 你配置的 frozen-vector 命令(如果未设置则跳过;任何具有确定性测试向量的库都应该设置此项)
10. **verify-audit** —— `npm audit --omit=dev` —— 仅限运行时依赖
11. **verify-exports** —— `package.json` 中 `"exports"` 的每个子路径在磁盘上均存在
12. **verify-secrets** —— 在 `dist/`(以及 `"files"` 中的任何路径)中 grep 查找禁止的文件名和 secret 标记
13. **record-tarball** —— 从 `git log` 推导 `SOURCE_DATE_EPOCH`,标准化工作树的 mtimes,执行 `npm pack` 到已知位置,解析 `--json` 输出以获取文件名和 sha512 完整性,使用 sha256 进行哈希处理,编写 `tarball.meta` 并将其与 `.tgz` 一起作为 artifact 上传
**`build-b`** 在单独的 runner 上并行运行:checkout、setup、`npm ci`、build、`record-tarball`、upload。使用相同的 `SOURCE_DATE_EPOCH`、相同的标准化 mtimes、相同的 pack 操作 —— 生成的 tarball 必须在字节上完全一致。
**`reproduce`** 下载这两个 artifact 并运行 **compare-tarball-meta**,如果 sha256 匹配则退出代码为 0。在默认的 `reproducibility-mode: strict` 模式下,哈希不匹配被视为严重失败并阻断发布。在 `reproducibility-mode: warn` 模式下,不匹配会被记录到日志中,但发布过程会继续。在 `reproducibility-mode: off` 模式下,第二次构建及比对过程将被完全跳过(即 v0.3 的单 runner 行为)。
**`publish`** 从 `build-a` 下载标准的 tarball 并运行:
14. **publish-npm** —— 通过 OIDC 执行幂等的 `npm publish --access public`,发布上面下载的**确切** tarball(因此注册表上的字节数据就是复现门控签署通过的那部分)。Provenance 由 `package.json` 中的 `publishConfig.provenance: true` 驱动,而不是通过 CLI 标志(当显式传递 `--provenance` 时,npm 11.6+ 会短路报错 `ENEEDAUTH`)。在干净的重跑过程中,会将注册表的 `dist.integrity` 与记录的完整性进行比对:匹配 -> 静默跳过,不匹配 -> 报错失败。发布步骤还会拒绝长期有效的 npm token 认证以及缺失的 provenance 配置。
15. **publish-jsr** —— 仅当你的中存在 `jsr.json` 时执行
16. **update-release** —— 从匹配的 `CHANGELOG.md` 部分更新 GitHub Release 正文,追加一个包含 tarball 文件名、大小、sha256、sha512 以及使用者可运行的 `curl | shasum` 验证配方的 *Artefact integrity* 块;将标准的 `.tgz` 作为 GitHub Release 资产上传,以便使用者拥有两个独立的字节来源;并且如果复现 job 已运行且匹配成功,则会在完整性块上方添加一行 *"Reproducible build"* 的前缀。
如果任何门控失败,工作流就会失败,并且不会发布任何内容。
Composite action(`action.yml`)**不**包含 reproduce job —— composite action 是单个 job 内部的步骤平铺列表,无法定义多 job DAG。Composite action 仍作为供需要自定义 job 结构的高级用户使用的逃生通道;它提供的安全保证严格来说更弱(仅限单 runner 完整性锚点,没有可复现性检查)。请将可复用工作流作为默认选择。
## 输入项
下表中的所有输入项均属于 `release.yml`。`auto-release.yml` 仅处理“解析-提升-打标签-调度”的流程;它接受少量自身专用的输入项(`release-branch`、`release-workflow`、`package-json`、`changelog-file`、`dry-run`),其余不干涉发布时的配置 —— 因为 `workflow_dispatch` 桥接会将 `release.yml` 作为入口工作流触发,所以配置都留在 `release.yml` 中。
| 输入项 | 默认值 | 描述 |
|---|---|---|
| `node-version` | `24.11.0` | 用于 npm 操作的 Node 版本(必须自带 npm >= 11.5.1 以支持 OIDC trusted publishing) |
| `registry-url` | `https://registry.npmjs.org` | npm 注册表 |
| `test-command` | `npm test` | 完整测试套件命令 |
| `vector-test-command` | *(空)* | frozen-vector 门控命令 |
| `changelog-file` | `CHANGELOG.md` | CHANGELOG 的路径 |
| `package-json` | `package.json` | package.json 的路径 |
| `audit-level` | `low` | `npm audit` 的严重性底线 |
| `version-strategy` | `manual` | 仅限 `release.yml`。可选 `manual` 或 `verify`。`manual` 是默认值:你负责提升版本、打标签,action 负责发布。`verify` 会解析 conventional commits,如果你的版本提升幅度小于 commit 所隐含的幅度则会导致失败。如需完全自动化的版本控制,请改用配套的 `auto-release.yml` 工作流。 |
| `strict-action-pins` | `true` | 如果为 `true`(默认值),**verify-action-pins** 会在 `.github/workflows` 中存在任何未固定的 `uses:` 引用时导致发布失败。设置为 `false` 可开启仅警告模式。`forgesworn/anvil` 会被按名称豁免。 |
| `reproducibility-mode` | `strict` | 仅限可复用工作流。可选 `strict`、`warn`、`off`。`strict` 在两个并行构建产生不同的 sha256 时阻断发布。`warn` 记录不匹配情况但继续发布。`off` 完全跳过第二次构建(即 v0.3 单 runner 行为)。Composite action 会静默忽略此输入(它无法运行双构建 DAG;请参阅“进阶:直接使用 composite action”)。 |
| `tag` | *(空)* | 仅限 `release.yml`。显式发布标签(例如 `v1.2.3`)。由 `auto-release.yml` 的链式发布 job 用于传递新创建的标签。留空则默认使用 `github.event.release.tag_name`,保留遗留的 release-event 触发路径。 |
| `dry-run` | `false` | 跳过实际发布(用于冒烟测试) |
| `publish-environment` | `npm-publish` | 仅限可复用工作流。附加到发布 job 的 GitHub Environment。在 npm 的 trusted publisher Environment 字段中配置相同的值,以将 OIDC 发布绑定到受保护的发布环境。 |
| `debug` | `false` | 如果为 `true`,则在发布前运行诊断步骤,输出 npm 版本、经过脱敏处理的 `.npmrc`、OIDC 环境变量以及 `npm config list`。在调试 trusted-publisher 错误时开启此项 —— 请参阅“Trusted publisher 注意事项”。不会打印 token 值。 |
### 机密
| 机密 | 何时需要 |
|---|---|
| `JSR_TOKEN` | 仅在存在 `jsr.json` 时。JSR 目前尚不支持 OIDC。 |
| `GH_TOKEN` | 已弃用。以前用于在链式工作流出现前弥合 auto-release -> release.yml 的事件间隙。现已不再需要;在链式发布路径中会被静默忽略。可安全地从调用方工作流中移除。 |
### JSR_TOKEN 设置
如果你的包在发布到 npm 的同时也发布到 JSR,请在仓库根目录添加一个 `jsr.json` 并提供一个 `JSR_TOKEN` 机密。
1. 在 [jsr.io/account/tokens](https://jsr.io/account/tokens) 生成 token。选择具有特定包(或整个组织)的 `publish` 权限的 **Personal access token**。首选短期 token —— 方便时请轮换。
2. 在 Settings -> Secrets and variables -> Actions 下,将该 token 添加为名为 `JSR_TOKEN` 的仓库机密。
3. 在你的调用方工作流中将其传递进去:
```
jobs:
release:
uses: forgesworn/anvil/.github/workflows/release.yml@v0
secrets:
JSR_TOKEN: ${{ secrets.JSR_TOKEN }}
```
JSR 目前尚不支持 OIDC trusted publishing;如今该 token 是唯一的认证途径。当不存在 `jsr.json` 时,该 action 会完全跳过 JSR 发布,因此现有的仅使用 npm 的使用者不会受到影响。
## CHANGELOG 格式
提取器的规则设计得比较宽松。通过匹配同时包含以下内容的首个 Markdown 标题(H1、H2 或 H3)来查找你的 CHANGELOG 部分:
- 版本字符串(例如 `1.4.4`),**并且**
- 提取器识别为版本标题的点分数字模式
提取范围会持续到下一个版本标题为止。像 `### Features` 或 `### Bug Fixes` 这样的非版本标题会作为内容被提取。
这意味着你可以自由混用标题级别 —— `semantic-release` 那种“minor 用 H1,patch 用 H2”的怪异设定也能正常工作。
如果你使用 [Keep a Changelog](https://keepachangelog.com) 格式,这同样适用。不强制要求严格的格式。
## 可复现构建(v0.4 旗舰功能)
可复用工作流会在两个 GitHub Actions runner 上**并行运行两个独立的构建**。两者都使用标准化的 mtimes 和从 `git log` 推导出的 `SOURCE_DATE_EPOCH` 对构建产物进行打包。`reproduce` job 会下载这两个元数据文件并比对 sha256。
在默认的 `reproducibility-mode: strict` 模式下,哈希不匹配会被视为严重失败:发布会被阻断,打印出两个哈希值,并输出两个 tar 列表之间的差异,以便维护者查看是哪个文件的 mtime 或内容发生了偏移。失败消息中列出了常见原因 —— 构建输出中的 `Date.now()`、按文件系统排序的 glob、构建脚本中的随机 ID、source map 中的宿主机路径。
在 `reproducibility-mode: warn` 模式下,不匹配会被记录在日志中,发布过程使用 `build-A` 继续进行。在 `off` 模式下,第二次构建会被完全跳过,你将退回到 v0.3 的单 runner 行为。
当两个构建相匹配时,GitHub Release 正文会在顶部获得一行说明:
这比 SLSA provenance 的声明更为有力。Provenance 只能证明*某个* runner 构建*过*这些字节数据。而复现门控能够证明**两个**独立的 runner 构建同一个 commit 会得到*相同*的字节数据 —— 这才是库使用者真正关心的实际确定性属性,也是其他任何 JS 发布工具都不曾验证过的属性。
### 单 runner 完整性锚点(子功能)
无论是否开启可复现性,每个发布正文结尾都会包含一个 *Artefact integrity* 块,印刻标准 tarball 的文件名、大小、sha256 以及 npm 格式的 sha512,并附带一个 `curl | shasum` 验证配方:
相同的 `.tgz` 也会作为 GitHub Release 资产上传,因此使用者可以从 npm 或 GitHub Releases 中的任意一个获取,并与相同的已记录 sha256 进行哈希比对。拥有两个独立的字节来源严格来说比只有一个更有价值。
在对已发布的 release 进行干净的重跑时,`publish-npm` 会获取注册表的 `dist.integrity` 并将其与本地记录的值进行比较。匹配时静默退出。不匹配时会大声报错导致工作流失败:这种场景属于注册表 tarball 被替换,你应该在下一次 CI 运行时就察觉到,而不是等到以后才发现。
### 复现门控的局限性
- **仅限单一操作系统。** 两次构建均在 `ubuntu-24.04` 上运行。跨操作系统的可复现性是一个更强的声明,会给使用者增加正确性负担(他们的构建必须能在多个操作系统上运行);这不在 v0.4 的目标范围内。
- **两个运行样本量。** 以概率形式触发(千分之一)的非确定性来源在两次运行中不一定会显现。请将此视为 CI 分钟数的必要代价。
- **`SOURCE_DATE_EPOCH` 需要构建工具主动支持。** 我们无法强制 `esbuild`/`rollup`/`webpack`/`tsc` 遵循此设置。附加的 mtime 标准化缩小了文件时间戳的差异,但编译输出内部嵌入的时间戳仍然需要由使用者自行修复。
如果你是从 v0.3 升级,并希望在迁移期间使用更安全的 `warn` 折中方案,请参阅 [`docs/migration-from-v0.3.md`](docs/migration-from-v0.3.md)。
## 工作流固定版本审计
`verify-action-pins` 会遍历**你的**仓库中的 `.github/workflows/*.yml`,并且对于每一条 ref 不是 40 字符十六进制 SHA 的 `uses: owner/repo@ref` 行,都会**导致发布失败**。这默认是严格的。在迁移期间,可以在调用方工作流中设置 `strict-action-pins: false` 以开启仅警告模式。
原因在于 2025 年 3 月发生的 [`tj-actions/changed-files` 事件](https://github.com/tj-actions/changed-files/issues/2464):
如果破坏了 action 仓库或标签命名空间的攻击者重新指向了恶意代码,那么使用标签固定的 action 可能会被静默替换。SHA 固定将 action 绑定到特定的 commit,因此重新指向不会对现有的使用者产生影响。
`forgesworn/anvil` 本身**按名称豁免**于此
门控。如果没有这个豁免,每个使用者的发布都会在加载门控(`uses: forgesworn/anvil@v0`)的那一行失败。
希望对 anvil 本身也进行 SHA 固定的使用者,仍然应该在其调用方工作流中使用 40 字符的 SHA 进行固定;该豁免是按名称而非按 ref 生效的,因此工作流其余部分的 SHA 固定强制执行机制将完全按照你的预期工作。原因说明请参阅 [`THREAT-MODEL.md`](THREAT-MODEL.md)。
## Trusted publisher 注意事项(重要)
npm 的 trusted publisher 会与 OIDC token 的 **`workflow_ref`** 声明进行匹配 —— 这里指的是**调用方**工作流,而不是可复用工作流。
这意味着:当你通过可复用工作流模式使用 `forgesworn/anvil` 时,你的包的 trusted publisher 必须为**你自己的仓库**和**你自己的调用方工作流文件**配置,而不是为 `forgesworn/anvil/release.yml` 配置。
在 npmjs.com → 你的包 → Settings → Trusted Publisher 中进行配置:
| 字段 | 值 |
|---|---|
| Publisher | GitHub Actions |
| Organization or user | 你的 GitHub 组织/用户名 |
| Repository | **你自己的包仓库** |
| Workflow filename | **你自己的调用方工作流文件**(例如 `release.yml`) |
| Environment | `npm-publish`(或者你自定义的 `publish-environment` 输入值) |
在你的仓库中创建一个名为 `npm-publish` 的匹配 GitHub Environment,并在那里配置保护规则:设定必需的审查者、禁止自我审查,并为你的发布引用设定分支/标签限制。即使没有保护规则,工作流仍然可以运行,但环境是实施人工批准门控的地方。
### 首次发布新包
npm 的 trusted publisher 流程要求包必须已经存在于注册表中。对于从未发布过的全新包,请先进行一次手动发布:
```
# 从您的工作站,使用具有 publish 权限范围的 granular access token
npm publish --access public
```
然后,在 npmjs.com 上为所有后续发布配置 trusted publishing。首次发布后可以撤销手动 token 从那时起,一切交由 OIDC 处理。
在 trusted publishing 工作正常后,将 npm 的包发布访问权限设置为需要 2FA 并禁止 token 发布。如果发布期间环境中存在 `NPM_TOKEN`、真实的 `NODE_AUTH_TOKEN`、`NPM_CONFIG_PROVENANCE` 或 `.npmrc` 中的 npm 认证材料,Anvil 也会报错失败。字面上的 `actions/setup-node` 占位符(生成的 `.npmrc` 中的 `NODE_AUTH_TOKEN=XXXXX-XXXXX-XXXXX-XXXXX` 和 `_authToken=${NODE_AUTH_TOKEN}`)是被接受的;npm 11.5+ 在 OIDC 交换期间会忽略它们。
### 为什么采用调用方工作流信任模型
可复用工作流仍然能为你带来集中的门控逻辑 —— 只需在一个地方更新标签匹配、secret 扫描、exports 完整性检查、frozen-vector 检查、运行时审计等,即可作用于所有使用者。这才是真正的收益所在。
它**无法**提供的是:一个所有使用者都能指向的、位于 `forgesworn/anvil` 内部的单一 trusted-publisher 记录。这种模式需要 npm 去匹配 `job_workflow_ref`(可复用工作流),而目前它并不支持这样做。Jordan Harband(npm 贡献者)正是出于这个原因建议不要在可复用工作流中使用 trusted publishing —— 请参阅 [`npm/documentation#1755`](https://github.com/npm/documentation/issues/1755)。
它仍然可以正常工作;你只需要在使用者边界而非可复用工作流边界配置信任关系。
如果你看到 `npm publish` 失败并提示:
```
OIDC token exchange error - package not found
```
在 `/-/npm/v1/oidc/token/exchange/package/` 处,最可能的原因是 trusted publisher 配置在了错误的仓库。将 Repository 字段更改为你自己包的仓库即可。
如果这样仍然无法解决,请在调用方工作流的 `with:` 块中添加 `debug: true` 并重新运行。诊断步骤会输出 npm 版本、经过脱敏处理的有效 `.npmrc`、OIDC 环境变量是否存在以及 `npm config list` —— 这些依据足以判断是 npm 完全缺失了 OIDC 上下文,还是拥有上下文但无法与 trusted publisher 匹配。
## 进阶:直接使用 composite action
如果你需要自定义 job 结构或额外的预备步骤,可以绕过可复用工作流,在你自己的 job 中使用 composite action:
```
jobs:
release:
runs-on: ubuntu-24.04
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: forgesworn/anvil@v0
with:
vector-test-command: npm run test:vectors
```
Composite action 运行的步骤脚本与可复用工作流运行的完全相同。可复用工作流仍然是文档记载的默认选择,因为它内置了正确的 `permissions:` 块。
**注意事项:**composite action 无法运行多 job 的可复现构建 DAG(composite action 是单个 job 内部的步骤平铺列表)。在这个方案中,`reproducibility-mode` 会被静默忽略。如果你既需要 v0.4 的旗舰可复现性门控**又**需要自定义的预备步骤,目前可复用工作流尚不支持额外的预备步骤 —— 如果这个差距阻碍了你,请提交一个 issue。
## 固定版本
通过标签固定(在 MVP 阶段使用 `@v0`,稳定后使用 `@v1`)可获得稳定的锁定,或者通过 commit SHA 固定以实现最大程度的可复现性。Dependabot 可以自动升级固定版本。主版本号的升级意味着门控语义发生了变化 —— 升级固定版本前请务必进行审查。
`v0.x` 是 MVP 系列:门控集可能仍会根据真实世界的试点反馈进行调整。一旦此 action 在多个 forgesworn 库中投入生产使用,就会发布 `v1.0.0` 版本。
## 支持的注册表
| 注册表 | MVP | 备注 |
|---|---|---|
| npm | 是 | OIDC trusted publishing,每次发布均带有 provenance |
| JSR | 是 | 通过 `jsr.json` 选择性开启,使用 `JSR_TOKEN`(尚不支持 OIDC) |
| crates.io | 阶段 2 | 等待 Rust 对应的库 |
## 威胁模型
有关完整的安全契约,请参阅 [THREAT-MODEL.md](THREAT-MODEL.md):该 action 防御什么,明确不防御什么,信任边界以及 secret 扫描的已知局限性。摘要:该 action 可防止意外发布错误的版本、构建产物中包含 secret、被盗用的长期 token(通过 OIDC)以及损坏的 frozen vectors。它无法防御恶意的维护者、被入侵的 GitHub 或被入侵的注册表。
## 贡献
这个 action 是刻意保持小巧的。在添加功能之前,请先考量它是否符合 [THREAT-MODEL.md](THREAT-MODEL.md) 中的信任边界,以及总体的 bash 攻击面是否能保持在三十分钟的审计预算之内。
非目标:
- 从 commit 消息自动进行分析或确定 semver
- 将 changelog 生成作为阻断发布的步骤
- 在 action 本身内部使用基于 Node 的工具
- 引入默认 GitHub Actions runner 镜像中尚不存在的依赖项
## 资助
如果这个 action 帮你的发布流水线解决了一个令人头疼的问题,并且你想支持这项工作,你可以通过 [GitHub Sponsors](https://github.com/sponsors/TheCryptoDonkey) 或者在 [strike.me/thedonkey](https://strike.me/thedonkey) 使用 Lightning 进行赞助。资助款项将用于此 action 及更广泛的 forgesworn 技术栈的维护。
## 许可证
MIT。请参阅 [LICENCE](LICENCE)。
标签:Bash, CI/CD, CMS安全, JavaScript, SLSA证明, 供应链安全, 发布工具, 可复现构建