forgesworn/anvil

GitHub: forgesworn/anvil

为 JS/TS 库提供基于 OIDC 与双构建校验的加固发布方案,聚焦可重现构建与供应链安全。

Stars: 0 | Forks: 0

# forgesworn/anvil *A GitHub Action for hardened npm releases — not to be confused with Foundry's `anvil` (Ethereum dev node) or [anvil.works](https://anvil.works) (low-code app platform).* [![CI](https://static.pigsec.cn/wp-content/uploads/repos/2026/04/0308e29f16182339.svg)](https://github.com/forgesworn/anvil/actions/workflows/ci.yml) [![GitHub Sponsors](https://img.shields.io/github/sponsors/TheCryptoDonkey?logo=githubsponsors&color=ea4aaa&label=Sponsor)](https://github.com/sponsors/TheCryptoDonkey) *Provided as-is under MIT, no warranty. See [THREAT-MODEL.md](THREAT-MODEL.md) for the defended surfaces and the explicitly undefended ones.* A release tool for JavaScript library authors who know what version they are shipping and want to be sure it ships clean. You bump `package.json` and write the CHANGELOG entry. The action handles everything else: OIDC trusted publishing, SLSA provenance on every publish, a secret scan scoped to the actual publish pack set, an exports-map check that verifies every subpath exists on disk ([`publint`](https://publint.dev/rules) explicitly skips this check; `arethetypeswrong` does type resolution, not file presence), a runtime-only `npm audit` so devDep noise does not block releases, a warn-by-default audit of unpinned `uses:` references in the consumer's own workflows, an optional frozen-vector gate for libraries with deterministic test suites, **and a multi-runner reproducible-build attestation that publishes only when two independent CI builds produce byte-identical tarballs**. That last one is the v0.4 flagship. None of `semantic-release`, `@changesets/cli`, `release-it`, `release-please`, or `np` offers it today. The hash of the registry tarball is also stamped into the GitHub Release body and uploaded as a release asset, so consumers have two independent sources for the bytes (npm registry + GitHub Releases) and can hash-compare against either. Pure `bash` + `jq` + `gh` + `npm`. No Node tooling in the action itself. ~1600 lines of bash across every step script. Auditable in under thirty minutes -- a hard design constraint, not a slogan. ## 谁适合使用 - Library authors who already bump versions and write changelogs manually and want a publish pipeline that does not make them nervous. - Projects that have outgrown `npm publish` from a workstation but do not want 597 transitive devDependencies from a release tool. - Any library where consumers need to trust the bytes -- authentication, payments, cryptography, infrastructure. - Anyone post-`xz-utils` or post-`tj-actions/changed-files` who takes supply-chain surface area seriously. If you want your CI to decide the version number for you, `semantic-release` or `release-please` will serve you better. This tool is for authors who want to make that call themselves. ## 为什么存在 The dominant JS release tools -- `semantic-release`, `changesets` -- bring hundreds of transitive devDependencies with them. For a CRUD app that is background noise. For any library where consumers need to trust the output, it is supply-chain surface area the author should not have to accept. `semantic-release` also decides your version number from commit message prefixes. That means your public API contract is driven by commit discipline rather than intent. One contributor writes `feat:` instead of `fix:` and you ship a minor bump instead of a patch. The alternative -- write your own changelog, bump your own version, let CI enforce everything else -- is what this action provides. Many library authors already work this way, but without the safety net: manual `npm publish` off a workstation, long-lived `NPM_TOKEN` secrets, no provenance, no pre-publish gates. Even well-maintained libraries with thousands of weekly downloads typically have no secret scan, no exports check, no reproducible-build verification. This action packages the gates any library author should want into one reusable workflow you can adopt in five lines of caller YAML. Pure bash, zero dependencies, community infrastructure. ## 快速启动(可重用工作流) Two surfaces to choose from: - **Reusable workflow** (recommended, below). Full four-job DAG with the multi-runner reproducible-build gate baked in. - **Composite action**: `uses: forgesworn/anvil@v0` inside an existing job. No reproducible-build gate (composite actions cannot span jobs). See [Advanced: composite action directly](#advanced-composite-action-directly). Create `.github/workflows/release.yml` in your library: ``` 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 ``` That is the whole caller workflow. No config files, no plugins. Libraries with frozen test vectors can add a gate: ``` with: vector-test-command: npm run test:vectors ``` Then: 1. Configure [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) on `registry.npmjs.org` for your package. **Point it at YOUR repo and YOUR `release.yml`**, not at `forgesworn/anvil`. See the "Trusted publisher caveat" section below for why. 2. Bump `package.json` version and add a `CHANGELOG.md` entry. 3. Commit, tag (`v1.2.3`), push, and create a GitHub Release for the tag. The workflow takes over from there. Already using another release tool? See [`docs/comparison.md`](docs/comparison.md) for a full feature comparison, or jump straight to a migration guide: [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) ## 版本策略 Three modes for how version bumps are handled. Choose the one that matches your workflow. ### 手动(默认) You bump `package.json`, write the CHANGELOG entry, tag, and create a GitHub Release. The action verifies the tag matches and runs all gates. This is the quick-start workflow above. ### 验证 You still bump manually, but the action parses your conventional commits and **fails the release if your bump is smaller than what the commits imply**. A `feat:` commit with only a patch bump is caught. An intentional over-bump (e.g. major bump for a small fix) produces a warning but does not block. ``` with: version-strategy: verify ``` This is the middle ground: you keep control, the action catches under-bumps that would ship breaking changes in a patch. ### 自动 The companion `auto-release.yml` workflow replaces `semantic-release` entirely. On push to `main` it parses conventional commits, bumps `package.json`, updates `CHANGELOG.md`, tags, pushes, and dispatches your `release.yml` to publish. Two files in your repo. **`.github/workflows/auto-release.yml`** — parses commits, bumps, tags, dispatches: ``` 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`** — runs gates, publishes npm, creates the GitHub Release. Must declare a `workflow_dispatch` trigger so `auto-release.yml` can fire it: ``` 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 ``` Push conventional commits to `main`; releases happen automatically. Zero dependencies, zero config files, no PAT. Trusted-publisher config on npmjs.com continues to point at `release.yml` — the `workflow_dispatch` bridge preserves the OIDC entry-point, so your existing setup keeps working. **Why no PAT?** `auto-release.yml` fires `release.yml` via `gh workflow run`, i.e. a `workflow_dispatch` event. GitHub's anti-recursion rule suppresses most events created by the default `GITHUB_TOKEN`, but `workflow_dispatch` and `repository_dispatch` are explicit exceptions and do trigger workflow runs. No long-lived credential needed. See [`docs/d/chained-workflows.md`](docs/design/chained-workflows.md) for the architecture. ## 操作的作用 The reusable workflow runs as a four-job DAG: ``` build-a ──────┐ (full gates + │ record) ├──> reproduce ──> publish build-b ──────┘ (compare (publish-npm, (build + sha256s) publish-jsr, record) update-release) ``` In order: **`build-a`** runs every gate on the consumer-supplied artefact: 1. **Checkout** your repo and this action at the pinned SHA 2. **Setup Node** with OIDC registry configured 3. **verify-action-pins** -- scan `.github/workflows/*.yml` for `uses:` lines that aren't 40-char SHA pinned. Warn-only by default; promote to hard-fail with `strict-action-pins: true` 4. **`npm ci`** 5. **`npm run build --if-present`** 6. **verify-tag** -- git tag matches `package.json` version 7. **verify-bump** -- (only when `version-strategy: verify`) parses conventional commits and fails if the manual bump is smaller than what the commit history implies 8. **run-tests** -- full test suite (`npm test` by default) 9. **verify-vectors** -- your configured frozen-vector command (skipped if not set; any library with deterministic test vectors should set this) 10. **verify-audit** -- `npm audit --omit=dev` -- runtime deps only 11. **verify-exports** -- every subpath in `package.json` `"exports"` exists on disk 12. **verify-secrets** -- grep `dist/` (and any paths in `"files"`) for forbidden filenames and secret markers 13. **record-tarball** -- derive `SOURCE_DATE_EPOCH` from `git log`, normalise mtimes across the working tree, `npm pack` into a known location, parse the `--json` output for filename and sha512 integrity, hash with sha256, write `tarball.meta` and upload it along with the `.tgz` as an artifact **`build-b`** runs in parallel on a separate runner: checkout, setup, `npm ci`, build, `record-tarball`, upload. Same `SOURCE_DATE_EPOCH`, same normalised mtimes, same pack -- the resulting tarball must be byte-identical. **`reproduce`** downloads both artifacts and runs **compare-tarball-meta**, which exits 0 if the sha256s match. Under the default `reproducibility-mode: strict` a mismatch is a hard failure and the release is blocked. Under `reproducibility-mode: warn` the mismatch is logged and the publish proceeds. Under `reproducibility-mode: off` the second build and the comparison are skipped entirely (v0.3 single-runner behaviour). **`publish`** downloads the canonical tarball from `build-a` and runs: 14. **publish-npm** -- idempotent `npm publish --access public` via OIDC, publishing the **exact** tarball downloaded above (so the bytes on the registry are the bytes the reproduce gate signed off on). Provenance is driven by `package.json` `publishConfig.provenance: true` rather than a CLI flag (npm 11.6+ short-circuits to `ENEEDAUTH` when `--provenance` is passed explicitly). On a clean re-run the registry's `dist.integrity` is compared to the recorded integrity: match -> silent skip, mismatch -> loud failure (registry tarball substitution alarm). 15. **publish-jsr** -- only if `jsr.json` exists in your repo 16. **update-release** -- updates the GitHub Release body from the matching `CHANGELOG.md` section, appends an *Artefact integrity* block containing tarball filename, size, sha256, sha512, and a `curl | shasum` recipe consumers can run to verify the registry tarball matches; uploads the canonical `.tgz` as a GitHub Release asset so consumers have two independent sources for the bytes; and if the reproduce job ran and matched, prepends a *"Reproducible build"* line above the integrity block. If any gate fails, the workflow fails and nothing is published. The composite action (`action.yml`) does **not** include the reproduce job -- composite actions are flat lists of steps inside one job and cannot define a multi-job DAG. The composite remains as an escape hatch for power users who need custom job structure; it ships with a strictly weaker guarantee (single-runner integrity anchor only, no reproducibility check). Use the reusable workflow as the default. ## 输入 All inputs in the table below belong to `release.yml`. `auto-release.yml` only handles the parse-bump-tag-dispatch side; it accepts a small set of its own inputs (`release-branch`, `release-workflow`, `package-json`, `changelog-file`, `dry-run`) and otherwise stays out of release-time configuration — that lives on `release.yml` because the `workflow_dispatch` bridge fires `release.yml` as the entry-point workflow. | Input | Default | Description | |---|---|---| | `node-version` | `24.11.0` | Node version used for npm operations (must ship with npm >= 11.5.1 for OIDC trusted publishing) | | `registry-url` | `https://registry.npmjs.org` | npm registry | | `test-command` | `npm test` | Full test suite command | | `vector-test-command` | *(empty)* | Frozen-vector gate command | | `changelog-file` | `CHANGELOG.md` | Path to CHANGELOG | | `package-json` | `package.json` | Path to package.json | | `audit-level` | `low` | `npm audit` severity floor | | `version-strategy` | `manual` | `release.yml` only. One of `manual`, `verify`. `manual` is the default: you bump, you tag, the action publishes. `verify` parses conventional commits and fails if your bump is smaller than what the commits imply. For fully automatic versioning, use the companion `auto-release.yml` workflow instead. | | `strict-action-pins` | `true` | If `true` (the default), **verify-action-pins** fails the release on any unpinned `uses:` reference in `.github/workflows`. Set to `false` for warn-only mode. `forgesworn/anvil` is exempt by name. | | `reproducibility-mode` | `strict` | Reusable workflow only. One of `strict`, `warn`, `off`. `strict` blocks the release if the two parallel builds produce different sha256s. `warn` logs the mismatch but publishes. `off` skips the second build entirely (v0.3 single-runner behaviour). The composite action silently ignores this input (it cannot run the two-build DAG; see "Advanced: composite action directly"). | | `tag` | *(empty)* | `release.yml` only. Explicit release tag (e.g. `v1.2.3`). Used by `auto-release.yml`'s chained publish job to pass the freshly-created tag. Empty defaults to `github.event.release.tag_name`, preserving the legacy release-event trigger path. | | `dry-run` | `false` | Skip real publish (for smoke-testing) | | `debug` | `false` | If `true`, run a diagnostic step before publish that dumps npm version, redacted `.npmrc`, OIDC env vars, and `npm config list`. Flip this on when debugging trusted-publisher errors -- see "Trusted publisher caveat". Does not print token values. | ### 密钥 | Secret | When needed | |---|---| | `JSR_TOKEN` | Only if `jsr.json` exists. JSR does not yet support OIDC. | | `GH_TOKEN` | Deprecated. Previously bridged the auto-release -> release.yml event gap before chained workflows. No longer required; silently ignored in the chained publish path. Safe to remove from caller workflows. | ### JSR_TOKEN 设置 If your package publishes to JSR alongside npm, add a `jsr.json` in the repo root and provide a `JSR_TOKEN` secret. 1. Generate the token at [jsr.io/account/tokens](https://jsr.io/account/tokens). Choose **Personal access token** with the `publish` scope for the specific package (or `publish` on the whole org). Short-lived tokens are preferred -- rotate whenever convenient. 2. Add the token as a repo secret named `JSR_TOKEN` under Settings -> Secrets and variables -> Actions. 3. Pass it through from your caller workflow: ``` jobs: release: uses: forgesworn/anvil/.github/workflows/release.yml@v0 secrets: JSR_TOKEN: ${{ secrets.JSR_TOKEN }} ``` JSR does not yet support OIDC trusted publishing; the token is the only authentication path today. The action skips JSR publish entirely when `jsr.json` is absent, so existing npm-only consumers are unaffected. ## 变更日志格式 The extractor is intentionally loose. Your CHANGELOG section is found by matching the first Markdown heading (H1, H2, or H3) that contains: - The version string (e.g. `1.4.4`), **and** - A dotted numeric pattern the extractor recognises as a version heading Capture continues until the next version heading. Non-version headings like `### Features` or `### Bug Fixes` are passed through as content. This means you can freely mix heading levels -- `semantic-release`'s "H1 for minors, H2 for patches" quirk works fine. If you use [Keep a Changelog](https://keepachangelog.com) format, that works too. No strict format is enforced. ## 可重现构建(v0.4 旗舰版) The reusable workflow runs **two independent builds in parallel** on two GitHub Actions runners. Both pack the artefact with normalised mtimes and `SOURCE_DATE_EPOCH` derived from `git log`. The `reproduce` job downloads both meta files and compares the sha256s. Under the default `reproducibility-mode: strict`, a mismatch is a hard failure: the release is blocked, both hashes are printed, and the diff between the two tar listings is dumped so the maintainer can see which file's mtime or content drifted. Common causes are listed in the failure message -- `Date.now()` in build output, sorted-by-fs globs, random IDs in build scripts, host paths in source maps. Under `reproducibility-mode: warn` the mismatch is logged and the release proceeds with `build-A`. Under `off` the second build is skipped entirely and you fall back to v0.3 single-runner behaviour. When two builds match, the GitHub Release body gains a top line: This is a stronger claim than SLSA provenance. Provenance attests that *some* runner built these bytes *once*. The reproduce gate attests that **two** independent runners building the same commit arrive at the *same* bytes -- the actual determinism property that library consumers care about and that no other JS release tool verifies. ### 单运行器完整性锚点(子功能) Whether reproducibility is on or off, every release body still ends with an *Artefact integrity* block stamping the canonical tarball's filename, size, sha256, and npm-format sha512 plus a `curl | shasum` verify recipe: The same `.tgz` is also uploaded as a GitHub Release asset, so a consumer can fetch from either npm or GitHub Releases and hash-compare both against the same recorded sha256. Two independent sources for the bytes is strictly more valuable than one. On a clean re-run of an already-published release, `publish-npm` fetches the registry's `dist.integrity` and compares it to the local recorded value. A match exits silently. A mismatch fails the workflow loudly: that scenario is registry tarball substitution, and you want to know about it on the next CI run rather than discover it later. ### 可重现门限的限制 - **Single OS only.** Both builds run on `ubuntu-24.04`. Cross-OS reproducibility is a stronger claim that adds a correctness burden on consumers (their build must work on multiple OSes); it is not in scope for v0.4. - **Two-run sample size.** A non-determinism source that fires probabilistically (one in a thousand) won't reliably show up in two runs. Accept this as the cost of CI minutes. - **`SOURCE_DATE_EPOCH` is opt-in for build tools.** We can't force `esbuild`/`rollup`/`webpack`/`tsc` to honour it. Belt-and-braces mtime normalisation closes the file-stamp gap, but embedded timestamps inside compiled output are still the consumer's bug to fix. See [`docs/migration-from-v0.3.md`](docs/migration-from-v0.3.md) if you're upgrading from v0.3 and want the safer `warn` middle path during the migration. ## 工作流固定审计 `verify-action-pins` walks `.github/workflows/*.yml` in **your** repo and **fails the release** for every `uses: owner/repo@ref` line whose ref isn't a 40-character hex SHA. This is strict by default. Set `strict-action-pins: false` in your caller workflow for warn-only mode during migration. The reason is the [`tj-actions/changed-files` incident in March 2025](https://github.com/tj-actions/changed-files/issues/2464): a tag-pinned action can be silently re-pointed at malicious code by an attacker who compromises the action's repo or tag namespace. SHA pinning binds the action to a specific commit so re-pointing has no effect on existing consumers. `forgesworn/anvil` itself is **exempt by name** from this gate. Without the carve-out, every consumer's release would fail on the line that loads the gate (`uses: forgesworn/anvil@v0`). Consumers who want SHA-pinning of anvil itself should still do so in their caller workflow with a 40-char SHA pin; the exemption is by name, not by ref, so the rest of your workflow's SHA-pin enforcement works exactly as you'd expect. See [`THREAT-MODEL.md`](THREAT-MODEL.md) for the rationale. ## 受信发布者警告(重要) npm's trusted publisher matches against the OIDC token's **`workflow_ref`** claim -- the **caller** workflow, not the reusable workflow. That means: when you use `forgesworn/anvil` via the reusable workflow pattern, your package's trusted publisher must be configured for **your own repo** and **your own caller workflow file**, not for `forgesworn/anvil/release.yml`. Configure on npmjs.com → your package → Settings → Trusted Publisher: | Field | Value | |---|---| | Publisher | GitHub Actions | | Organization or user | your GitHub org/user | | Repository | **your package's repo** | | Workflow filename | **your caller workflow file** (e.g. `release.yml`) | | Environment | (leave empty) | ### 新软件包首次发布 npm's trusted publisher flow requires the package to already exist on the registry. For a brand-new package that has never been published, do a one-time manual publish first: ``` # 从您的工作站,使用限定范围的发布访问令牌 npm publish --access public ``` Then configure trusted publishing on npmjs.com for all subsequent releases. The manual token can be revoked after the first publish -- from that point on, OIDC handles everything. ### 为什么调用方-工作流信任模型 The reusable workflow still gets you centralised gate logic -- one place to update tag-match, secret scan, exports sanity, frozen-vector check, runtime audit, etc., across every consumer. That's the real benefit. What it does **not** give you is a single trusted-publisher record in `forgesworn/anvil` that every consumer points at. That pattern would require npm to match on `job_workflow_ref` (the reusable), which doesn't today. Jordan Harband (npm contributor) has recommended against trusted publishing with reusable workflows for this reason -- see [`npm/documentation#1755`](https://github.com/npm/documentation/issues/1755). It still works fine; you just configure the trust at the consumer boundary rather than the reusable-workflow boundary. If you see `npm publish` fail with: ``` OIDC token exchange error - package not found ``` at `/-/npm/v1/oidc/token/exchange/package/`, the most likely cause is the trusted publisher is configured for the wrong repo. Change the Repository field to your package's own repo. If that does not fix it, add `debug: true` to your caller workflow's `with:` block and re-run. The diagnostic step dumps npm version, the redacted effective `.npmrc`, OIDC env var presence, and `npm config list` -- enough ground-truth to tell whether npm is missing the OIDC context entirely or has it but cannot match the trusted publisher. ## 高级:直接使用复合操作 If you need custom job structure or extra pre-flight steps, you can bypass the reusable workflow and use the composite action in your own job: ``` 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 ``` The composite action runs the same step scripts the reusable workflow does. The reusable workflow remains the documented default because it bakes the correct `permissions:` block in. ## 固定 Pin by tag (`@v0` while MVP, `@v1` when stable) for stable pins, or by commit SHA for maximum reproducibility. Dependabot can bump pins automatically. Major version bumps indicate a change in gate semantics -- always review before upgrading the pin. `v0.x` is the MVP series: the gate set may still shift in response to real-world pilot feedback. A `v1.0.0` release will be cut once the action has been in production use across several forgesworn libraries. ## 支持的注册表 | Registry | MVP | Notes | |---|---|---| | npm | yes | OIDC trusted publishing, provenance on every publish | | JSR | yes | Opt-in via `jsr.json`, uses `JSR_TOKEN` (no OIDC yet) | | crates.io | phase 2 | Pending Rust counterpart library | ## 威胁模型 See [THREAT-MODEL.md](THREAT-MODEL.md) for the full security contract: what the action defends against, what it explicitly does not, the trust boundaries, and the known limitations of the secret scan. Summary: the action defends against accidentally publishing the wrong version, secrets in artefacts, stolen long-lived tokens (via OIDC), and broken frozen vectors. It does not defend against a malicious maintainer, a compromised GitHub, or a compromised registry. ## 贡献 This action is deliberately small. Before adding a feature, ask whether it fits within the trust boundaries in [THREAT-MODEL.md](THREAT-MODEL.md) and whether the total bash surface area stays under the thirty-minute audit budget. Non-goals: - Automated commit analysis or semver determination from commit messages - Changelog generation as a release-blocking step - Node-based tooling inside the action itself - Dependencies that are not already on the default GitHub Actions runner image ## 资金 If this action saves your release pipeline a headache and you want to support the work, you can sponsor via [GitHub Sponsors](https://github.com/sponsors/TheCryptoDonkey) or Lightning at [strike.me/thedonkey](https://strike.me/thedonkey). Funding goes toward maintenance of this action and the wider forgesworn stack. ## 许可 MIT. See [LICENCE](LICENCE).
标签:arethetypeswrong, bash脚本, GitHub Action, JS/TS库, npm audit, npm registry, npm发布, OIDC可信发布, publint, semgrep, SLSA, tarball哈希, 发布安全, 发布审计, 发布注释, 发布流水线, 发布证明, 发布锁定, 可重现构建, 多运行器一致性, 暗色界面, 构建可验证性, 版本一致性, 硬性发布门槛, 软件供应链安全, 远程方法调用, 零依赖, 预发布检查