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 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 publishfrom 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-utilsor post-tj-actions/changed-fileswho 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.
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@v0That 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:vectorsThen:
- Configure npm trusted publishing
on
registry.npmjs.orgfor your package. Point it at YOUR repo and YOURrelease.yml, not atforgesworn/anvil. See the "Trusted publisher caveat" section below for why. - Bump
package.jsonversion and add aCHANGELOG.mdentry. - 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 for a full feature comparison,
or jump straight to a migration guide:
semantic-release |
changesets |
release-please |
release-it |
np
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: verifyThis 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. It runs on push, parses conventional commits, determines the
bump, updates package.json and CHANGELOG.md, tags, and creates a
GitHub Release -- which triggers the main release pipeline.
Create .github/workflows/auto-release.yml:
name: auto-release
on:
push:
branches: [main]
permissions:
contents: write
jobs:
auto-release:
uses: forgesworn/anvil/.github/workflows/auto-release.yml@v0And keep your existing release.yml (the publish pipeline) alongside
it. Push conventional commits to main; releases happen automatically
when warranted. Zero dependencies, zero config files.
Note: The default GITHUB_TOKEN can create releases but cannot
trigger further workflow runs. If you need the auto-created release to
trigger release.yml automatically, use a GitHub App token or PAT
with contents: write scope.
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:
- Checkout your repo and this action at the pinned SHA
- Setup Node with OIDC registry configured
- verify-action-pins -- scan
.github/workflows/*.ymlforuses:lines that aren't 40-char SHA pinned. Warn-only by default; promote to hard-fail withstrict-action-pins: true npm cinpm run build --if-present- verify-tag -- git tag matches
package.jsonversion - verify-bump -- (only when
version-strategy: verify) parses conventional commits and fails if the manual bump is smaller than what the commit history implies - run-tests -- full test suite (
npm testby default) - verify-vectors -- your configured frozen-vector command (skipped if not set; any library with deterministic test vectors should set this)
- verify-audit --
npm audit --omit=dev-- runtime deps only - verify-exports -- every subpath in
package.json"exports"exists on disk - verify-secrets -- grep
dist/(and any paths in"files") for forbidden filenames and secret markers - record-tarball -- derive
SOURCE_DATE_EPOCHfromgit log, normalise mtimes across the working tree,npm packinto a known location, parse the--jsonoutput for filename and sha512 integrity, hash with sha256, writetarball.metaand upload it along with the.tgzas 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:
- publish-npm -- idempotent
npm publish --access publicvia 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 bypackage.jsonpublishConfig.provenance: truerather than a CLI flag (npm 11.6+ short-circuits toENEEDAUTHwhen--provenanceis passed explicitly). On a clean re-run the registry'sdist.integrityis compared to the recorded integrity: match -> silent skip, mismatch -> loud failure (registry tarball substitution alarm). - publish-jsr -- only if
jsr.jsonexists in your repo - update-release -- updates the GitHub Release body from the
matching
CHANGELOG.mdsection, appends an Artefact integrity block containing tarball filename, size, sha256, sha512, and acurl | shasumrecipe consumers can run to verify the registry tarball matches; uploads the canonical.tgzas 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.
| 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 |
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 |
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). |
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. |
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 format, that works too. No strict format is enforced.
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:
Reproducible build: byte-identical output verified across two independent CI runners.
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:
Artefact integrity
file: noble-hashes-1.4.2.tgz size: 87234 bytes sha256: 9a5ec1...e7c1 sha512-...Verify against the registry tarball:
curl -sLO https://registry.npmjs.org/noble-hashes/-/noble-hashes-1.4.2.tgz shasum -a 256 noble-hashes-1.4.2.tgz
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_EPOCHis opt-in for build tools. We can't forceesbuild/rollup/webpack/tscto 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 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:
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 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) |
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
it doesn't today. Jordan Harband (npm contributor) has recommended
against trusted publishing with reusable workflows for this reason -- see
npm/documentation#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/<name>, 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:vectorsThe 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 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 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
MIT. See LICENCE.