{"id":48754673,"url":"https://github.com/forgesworn/anvil","last_synced_at":"2026-04-18T14:12:36.994Z","repository":{"id":350638448,"uuid":"1207699183","full_name":"forgesworn/anvil","owner":"forgesworn","description":"anvil: forge-hardened npm publishing for JS/TS libraries. Reproducible builds, OIDC trusted publishing, hard pre-publish gates. Pure bash, zero dependencies.","archived":false,"fork":false,"pushed_at":"2026-04-12T22:43:22.000Z","size":338,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-13T00:47:15.303Z","etag":null,"topics":["github-actions","npm","npm-publish","oidc","provenance","release","reproducible-builds","slsa","supply-chain-security","trusted-publishing"],"latest_commit_sha":null,"homepage":"https://github.com/forgesworn/anvil#readme","language":"Shell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/forgesworn.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":null,"code_of_conduct":null,"threat_model":"THREAT-MODEL.md","audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"TheCryptoDonkey","custom":["https://strike.me/thedonkey"]}},"created_at":"2026-04-11T09:24:35.000Z","updated_at":"2026-04-12T22:43:24.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/forgesworn/anvil","commit_stats":null,"previous_names":["forgesworn/release-action","forgesworn/anvil"],"tags_count":30,"template":false,"template_full_name":null,"purl":"pkg:github/forgesworn/anvil","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/forgesworn%2Fanvil","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/forgesworn%2Fanvil/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/forgesworn%2Fanvil/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/forgesworn%2Fanvil/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/forgesworn","download_url":"https://codeload.github.com/forgesworn/anvil/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/forgesworn%2Fanvil/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31971585,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-18T00:39:45.007Z","status":"online","status_checked_at":"2026-04-18T02:00:07.018Z","response_time":103,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["github-actions","npm","npm-publish","oidc","provenance","release","reproducible-builds","slsa","supply-chain-security","trusted-publishing"],"created_at":"2026-04-13T00:47:13.909Z","updated_at":"2026-04-18T14:12:36.980Z","avatar_url":"https://github.com/forgesworn.png","language":"Shell","funding_links":["https://github.com/sponsors/TheCryptoDonkey","https://strike.me/thedonkey"],"categories":[],"sub_categories":[],"readme":"# forgesworn/anvil\n\n*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).*\n\n[![CI](https://github.com/forgesworn/anvil/actions/workflows/ci.yml/badge.svg)](https://github.com/forgesworn/anvil/actions/workflows/ci.yml)\n[![GitHub Sponsors](https://img.shields.io/github/sponsors/TheCryptoDonkey?logo=githubsponsors\u0026color=ea4aaa\u0026label=Sponsor)](https://github.com/sponsors/TheCryptoDonkey)\n\n*Provided as-is under MIT, no warranty. See [THREAT-MODEL.md](THREAT-MODEL.md) for the defended surfaces and the explicitly undefended ones.*\n\nA release tool for JavaScript library authors who know what version\nthey are shipping and want to be sure it ships clean.\n\nYou bump `package.json` and write the CHANGELOG entry. The action\nhandles everything else: OIDC trusted publishing, SLSA provenance on\nevery publish, a secret scan scoped to the actual publish pack set,\nan exports-map check that verifies every subpath exists on disk\n([`publint`](https://publint.dev/rules) explicitly skips this check;\n`arethetypeswrong` does type resolution, not file presence), a\nruntime-only `npm audit` so devDep noise does not block releases,\na warn-by-default audit of unpinned `uses:` references in the\nconsumer's own workflows, an optional frozen-vector gate for\nlibraries with deterministic test suites, **and a multi-runner\nreproducible-build attestation that publishes only when two\nindependent CI builds produce byte-identical tarballs**.\n\nThat last one is the v0.4 flagship. None of `semantic-release`,\n`@changesets/cli`, `release-it`, `release-please`, or `np` offers it\ntoday. The hash of the registry tarball is also stamped into the GitHub\nRelease body and uploaded as a release asset, so consumers have two\nindependent sources for the bytes (npm registry + GitHub Releases) and\ncan hash-compare against either.\n\nPure `bash` + `jq` + `gh` + `npm`. No Node tooling in the action\nitself. ~1600 lines of bash across every step script. Auditable in\nunder thirty minutes -- a hard design constraint, not a slogan.\n\n## Who this is for\n\n- Library authors who already bump versions and write changelogs\n  manually and want a publish pipeline that does not make them nervous.\n- Projects that have outgrown `npm publish` from a workstation but do\n  not want 597 transitive devDependencies from a release tool.\n- Any library where consumers need to trust the bytes --\n  authentication, payments, cryptography, infrastructure.\n- Anyone post-`xz-utils` or post-`tj-actions/changed-files` who\n  takes supply-chain surface area seriously.\n\nIf you want your CI to decide the version number for you,\n`semantic-release` or `release-please` will serve you better.\nThis tool is for authors who want to make that call themselves.\n\n## Why this exists\n\nThe dominant JS release tools -- `semantic-release`, `changesets` --\nbring hundreds of transitive devDependencies with them. For a CRUD app\nthat is background noise. For any library where consumers need to\ntrust the output, it is supply-chain surface area the author should\nnot have to accept.\n\n`semantic-release` also decides your version number from commit\nmessage prefixes. That means your public API contract is driven by\ncommit discipline rather than intent. One contributor writes `feat:`\ninstead of `fix:` and you ship a minor bump instead of a patch. The\nalternative -- write your own changelog, bump your own version, let\nCI enforce everything else -- is what this action provides.\n\nMany library authors already work this way, but without the safety\nnet: manual `npm publish` off a workstation, long-lived `NPM_TOKEN`\nsecrets, no provenance, no pre-publish gates. Even well-maintained\nlibraries with thousands of weekly downloads typically have no\nsecret scan, no exports check, no reproducible-build verification.\n\nThis action packages the gates any library author should want into\none reusable workflow you can adopt in five lines of caller YAML.\nPure bash, zero dependencies, community infrastructure.\n\n## Quick start (reusable workflow)\n\nTwo surfaces to choose from:\n\n- **Reusable workflow** (recommended, below). Full four-job DAG with\n  the multi-runner reproducible-build gate baked in.\n- **Composite action**: `uses: forgesworn/anvil@v0` inside an existing\n  job. No reproducible-build gate (composite actions cannot span jobs).\n  See [Advanced: composite action directly](#advanced-composite-action-directly).\n\nCreate `.github/workflows/release.yml` in your library:\n\n```yaml\nname: release\non:\n  release:\n    types: [published]\npermissions:\n  contents: write   # update Release bodies + upload tarball asset\n  id-token: write   # OIDC trusted publishing to npm\njobs:\n  release:\n    uses: forgesworn/anvil/.github/workflows/release.yml@v0\n```\n\nThat is the whole caller workflow. No config files, no plugins.\nLibraries with frozen test vectors can add a gate:\n\n```yaml\n    with:\n      vector-test-command: npm run test:vectors\n```\n\n### Preconditions\n\nBefore the first run, your library must have:\n\n- `\"publishConfig\": { \"provenance\": true }` in `package.json` (drives\n  SLSA provenance without requiring a `--provenance` CLI flag — npm\n  11.6+ short-circuits to `ENEEDAUTH` when the flag is passed\n  explicitly, so the config path is the only reliable one).\n- npm trusted publisher configured for **your repo** and **your\n  caller workflow file** (see \"Trusted publisher caveat\" below for\n  the exact fields).\n- The package published to npm at least once. OIDC trusted publishing\n  requires the package to exist on the registry — see \"First publish\n  of a new package\" for the one-time bootstrap.\n- A `CHANGELOG.md` entry for the version you are releasing (the\n  action extracts the release body from it).\n\nThen:\n\n1. Configure [npm trusted publishing](https://docs.npmjs.com/trusted-publishers)\n   on `registry.npmjs.org` for your package. **Point it at YOUR repo and\n   YOUR `release.yml`**, not at `forgesworn/anvil`. See the\n   \"Trusted publisher caveat\" section below for why.\n2. Bump `package.json` version and add a `CHANGELOG.md` entry.\n3. Commit, tag (`v1.2.3`), push, and create a GitHub Release for the\n   tag. The workflow takes over from there.\n\nAlready using another release tool? See\n[`docs/comparison.md`](docs/comparison.md) for a full feature comparison,\nor jump straight to a migration guide:\n[semantic-release](docs/migration-from-semantic-release.md) |\n[changesets](docs/migration-from-changesets.md) |\n[release-please](docs/migration-from-release-please.md) |\n[release-it](docs/migration-from-release-it.md) |\n[np](docs/migration-from-np.md)\n\n## Version strategy\n\nThree modes for how version bumps are handled. Choose the one that\nmatches your workflow.\n\n### Manual (default)\n\nYou bump `package.json`, write the CHANGELOG entry, tag, and create a\nGitHub Release. The action verifies the tag matches and runs all gates.\nThis is the quick-start workflow above.\n\n### Verify\n\nYou still bump manually, but the action parses your conventional\ncommits and **fails the release if your bump is smaller than what the\ncommits imply**. A `feat:` commit with only a patch bump is caught.\nAn intentional over-bump (e.g. major bump for a small fix) produces a\nwarning but does not block.\n\n```yaml\n    with:\n      version-strategy: verify\n```\n\nThis is the middle ground: you keep control, the action catches\nunder-bumps that would ship breaking changes in a patch.\n\n### Auto\n\nThe companion `auto-release.yml` workflow replaces `semantic-release`\nentirely. On push to `main` it parses conventional commits, bumps\n`package.json`, updates `CHANGELOG.md`, tags, pushes, and dispatches\nyour `release.yml` to publish.\n\nTwo files in your repo.\n\n**`.github/workflows/auto-release.yml`** — parses commits, bumps, tags,\ndispatches:\n\n```yaml\nname: auto-release\non:\n  push:\n    branches: [main]\npermissions:\n  contents: write\n  actions: write            # required to dispatch release.yml\njobs:\n  auto-release:\n    uses: forgesworn/anvil/.github/workflows/auto-release.yml@v0\n```\n\n**`.github/workflows/release.yml`** — runs gates, publishes npm, creates\nthe GitHub Release. Must declare a `workflow_dispatch` trigger so\n`auto-release.yml` can fire it:\n\n```yaml\nname: release\non:\n  release:\n    types: [published]       # manual flow: you create the Release\n  workflow_dispatch:         # auto flow: auto-release dispatches\n    inputs:\n      tag:\n        description: Release tag to publish\n        type: string\n        required: true\npermissions:\n  contents: write\n  id-token: write\njobs:\n  release:\n    uses: forgesworn/anvil/.github/workflows/release.yml@v0\n    with:\n      tag: ${{ inputs.tag || '' }}\n      vector-test-command: npm run test:vectors   # optional\n```\n\nPush conventional commits to `main`; releases happen automatically.\nZero dependencies, zero config files, no PAT. Trusted-publisher config\non npmjs.com continues to point at `release.yml` — the `workflow_dispatch`\nbridge preserves the OIDC entry-point, so your existing setup keeps\nworking.\n\n**Why no PAT?** `auto-release.yml` fires `release.yml` via\n`gh workflow run`, i.e. a `workflow_dispatch` event. GitHub's\nanti-recursion rule suppresses most events created by the default\n`GITHUB_TOKEN`, but `workflow_dispatch` and `repository_dispatch` are\nexplicit exceptions and do trigger workflow runs. No long-lived\ncredential needed. See [`docs/design/chained-workflows.md`](docs/design/chained-workflows.md)\nfor the architecture.\n\n## What the action does\n\nThe reusable workflow runs as a four-job DAG:\n\n```\n   build-a ──────┐\n   (full gates +  │\n    record)       ├──\u003e reproduce ──\u003e publish\n   build-b ──────┘    (compare      (publish-npm,\n   (build +           sha256s)       publish-jsr,\n    record)                          update-release)\n```\n\nIn order:\n\n**`build-a`** runs every gate on the consumer-supplied artefact:\n\n1. **Checkout** your repo and this action at the pinned SHA\n2. **Setup Node** with OIDC registry configured\n3. **verify-action-pins** -- scan `.github/workflows/*.yml` for `uses:`\n   lines that aren't 40-char SHA pinned. Warn-only by default; promote\n   to hard-fail with `strict-action-pins: true`\n4. **`npm ci`**\n5. **`npm run build --if-present`**\n6. **verify-tag** -- git tag matches `package.json` version\n7. **verify-bump** -- (only when `version-strategy: verify`) parses\n   conventional commits and fails if the manual bump is smaller than\n   what the commit history implies\n8. **run-tests** -- full test suite (`npm test` by default)\n9. **verify-vectors** -- your configured frozen-vector command (skipped\n   if not set; any library with deterministic test vectors should set this)\n10. **verify-audit** -- `npm audit --omit=dev` -- runtime deps only\n11. **verify-exports** -- every subpath in `package.json` `\"exports\"` exists\n    on disk\n12. **verify-secrets** -- grep `dist/` (and any paths in `\"files\"`) for\n    forbidden filenames and secret markers\n13. **record-tarball** -- derive `SOURCE_DATE_EPOCH` from `git log`,\n    normalise mtimes across the working tree, `npm pack` into a known\n    location, parse the `--json` output for filename and sha512\n    integrity, hash with sha256, write `tarball.meta` and upload it\n    along with the `.tgz` as an artifact\n\n**`build-b`** runs in parallel on a separate runner: checkout, setup,\n`npm ci`, build, `record-tarball`, upload. Same `SOURCE_DATE_EPOCH`,\nsame normalised mtimes, same pack -- the resulting tarball must be\nbyte-identical.\n\n**`reproduce`** downloads both artifacts and runs **compare-tarball-meta**,\nwhich exits 0 if the sha256s match. Under the default\n`reproducibility-mode: strict` a mismatch is a hard failure and the\nrelease is blocked. Under `reproducibility-mode: warn` the mismatch\nis logged and the publish proceeds. Under `reproducibility-mode: off`\nthe second build and the comparison are skipped entirely (v0.3\nsingle-runner behaviour).\n\n**`publish`** downloads the canonical tarball from `build-a` and runs:\n\n14. **publish-npm** -- idempotent `npm publish --access public` via OIDC,\n    publishing the **exact** tarball downloaded above (so the bytes on\n    the registry are the bytes the reproduce gate signed off on).\n    Provenance is driven by `package.json` `publishConfig.provenance: true`\n    rather than a CLI flag (npm 11.6+ short-circuits to `ENEEDAUTH`\n    when `--provenance` is passed explicitly). On a clean re-run the\n    registry's `dist.integrity` is compared to the recorded integrity:\n    match -\u003e silent skip, mismatch -\u003e loud failure (registry tarball\n    substitution alarm).\n15. **publish-jsr** -- only if `jsr.json` exists in your repo\n16. **update-release** -- updates the GitHub Release body from the\n    matching `CHANGELOG.md` section, appends an *Artefact integrity*\n    block containing tarball filename, size, sha256, sha512, and a\n    `curl | shasum` recipe consumers can run to verify the registry\n    tarball matches; uploads the canonical `.tgz` as a GitHub Release\n    asset so consumers have two independent sources for the bytes; and\n    if the reproduce job ran and matched, prepends a *\"Reproducible\n    build\"* line above the integrity block.\n\nIf any gate fails, the workflow fails and nothing is published.\n\nThe composite action (`action.yml`) does **not** include the\nreproduce job -- composite actions are flat lists of steps inside one\njob and cannot define a multi-job DAG. The composite remains as an\nescape hatch for power users who need custom job structure; it ships\nwith a strictly weaker guarantee (single-runner integrity anchor only,\nno reproducibility check). Use the reusable workflow as the default.\n\n## Inputs\n\nAll inputs in the table below belong to `release.yml`. `auto-release.yml`\nonly handles the parse-bump-tag-dispatch side; it accepts a small set\nof its own inputs (`release-branch`, `release-workflow`, `package-json`,\n`changelog-file`, `dry-run`) and otherwise stays out of release-time\nconfiguration — that lives on `release.yml` because the `workflow_dispatch`\nbridge fires `release.yml` as the entry-point workflow.\n\n| Input | Default | Description |\n|---|---|---|\n| `node-version` | `24.11.0` | Node version used for npm operations (must ship with npm \u003e= 11.5.1 for OIDC trusted publishing) |\n| `registry-url` | `https://registry.npmjs.org` | npm registry |\n| `test-command` | `npm test` | Full test suite command |\n| `vector-test-command` | *(empty)* | Frozen-vector gate command |\n| `changelog-file` | `CHANGELOG.md` | Path to CHANGELOG |\n| `package-json` | `package.json` | Path to package.json |\n| `audit-level` | `low` | `npm audit` severity floor |\n| `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. |\n| `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. |\n| `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\"). |\n| `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. |\n| `dry-run` | `false` | Skip real publish (for smoke-testing) |\n| `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. |\n\n### Secrets\n\n| Secret | When needed |\n|---|---|\n| `JSR_TOKEN` | Only if `jsr.json` exists. JSR does not yet support OIDC. |\n| `GH_TOKEN` | Deprecated. Previously bridged the auto-release -\u003e release.yml event gap before chained workflows. No longer required; silently ignored in the chained publish path. Safe to remove from caller workflows. |\n\n### JSR_TOKEN setup\n\nIf your package publishes to JSR alongside npm, add a `jsr.json` in the\nrepo root and provide a `JSR_TOKEN` secret.\n\n1. Generate the token at [jsr.io/account/tokens](https://jsr.io/account/tokens).\n   Choose **Personal access token** with the `publish` scope for the\n   specific package (or `publish` on the whole org). Short-lived tokens\n   are preferred -- rotate whenever convenient.\n2. Add the token as a repo secret named `JSR_TOKEN` under\n   Settings -\u003e Secrets and variables -\u003e Actions.\n3. Pass it through from your caller workflow:\n\n```yaml\njobs:\n  release:\n    uses: forgesworn/anvil/.github/workflows/release.yml@v0\n    secrets:\n      JSR_TOKEN: ${{ secrets.JSR_TOKEN }}\n```\n\nJSR does not yet support OIDC trusted publishing; the token is the\nonly authentication path today. The action skips JSR publish entirely\nwhen `jsr.json` is absent, so existing npm-only consumers are\nunaffected.\n\n## CHANGELOG format\n\nThe extractor is intentionally loose. Your CHANGELOG section is found by\nmatching the first Markdown heading (H1, H2, or H3) that contains:\n\n- The version string (e.g. `1.4.4`), **and**\n- A dotted numeric pattern the extractor recognises as a version heading\n\nCapture continues until the next version heading. Non-version headings\nlike `### Features` or `### Bug Fixes` are passed through as content.\nThis means you can freely mix heading levels -- `semantic-release`'s\n\"H1 for minors, H2 for patches\" quirk works fine.\n\nIf you use [Keep a Changelog](https://keepachangelog.com) format, that\nworks too. No strict format is enforced.\n\n## Reproducible builds (v0.4 flagship)\n\nThe reusable workflow runs **two independent builds in parallel** on\ntwo GitHub Actions runners. Both pack the artefact with normalised\nmtimes and `SOURCE_DATE_EPOCH` derived from `git log`. The\n`reproduce` job downloads both meta files and compares the sha256s.\n\nUnder the default `reproducibility-mode: strict`, a mismatch is a\nhard failure: the release is blocked, both hashes are printed, and\nthe diff between the two tar listings is dumped so the maintainer can\nsee which file's mtime or content drifted. Common causes are listed\nin the failure message -- `Date.now()` in build output, sorted-by-fs\nglobs, random IDs in build scripts, host paths in source maps.\n\nUnder `reproducibility-mode: warn` the mismatch is logged and the\nrelease proceeds with `build-A`. Under `off` the second build is\nskipped entirely and you fall back to v0.3 single-runner behaviour.\n\nWhen two builds match, the GitHub Release body gains a top line:\n\n\u003e **Reproducible build**: byte-identical output verified across two\n\u003e independent CI runners.\n\nThis is a stronger claim than SLSA provenance. Provenance attests\nthat *some* runner built these bytes *once*. The reproduce gate\nattests that **two** independent runners building the same commit\narrive at the *same* bytes -- the actual determinism property that\nlibrary consumers care about and that no other JS release tool\nverifies.\n\n### Single-runner integrity anchor (sub-feature)\n\nWhether reproducibility is on or off, every release body still ends\nwith an *Artefact integrity* block stamping the canonical tarball's\nfilename, size, sha256, and npm-format sha512 plus a `curl | shasum`\nverify recipe:\n\n\u003e **Artefact integrity**\n\u003e\n\u003e ```\n\u003e file:      noble-hashes-1.4.2.tgz\n\u003e size:      87234 bytes\n\u003e sha256:    9a5ec1...e7c1\n\u003e sha512-...\n\u003e ```\n\u003e\n\u003e Verify against the registry tarball:\n\u003e\n\u003e ```sh\n\u003e curl -sLO https://registry.npmjs.org/noble-hashes/-/noble-hashes-1.4.2.tgz\n\u003e shasum -a 256 noble-hashes-1.4.2.tgz\n\u003e ```\n\nThe same `.tgz` is also uploaded as a GitHub Release asset, so a\nconsumer can fetch from either npm or GitHub Releases and hash-compare\nboth against the same recorded sha256. Two independent sources for\nthe bytes is strictly more valuable than one.\n\nOn a clean re-run of an already-published release, `publish-npm`\nfetches the registry's `dist.integrity` and compares it to the local\nrecorded value. A match exits silently. A mismatch fails the workflow\nloudly: that scenario is registry tarball substitution, and you want\nto know about it on the next CI run rather than discover it later.\n\n### Limitations of the reproduce gate\n\n- **Single OS only.** Both builds run on `ubuntu-24.04`. Cross-OS\n  reproducibility is a stronger claim that adds a correctness burden\n  on consumers (their build must work on multiple OSes); it is not\n  in scope for v0.4.\n- **Two-run sample size.** A non-determinism source that fires\n  probabilistically (one in a thousand) won't reliably show up in\n  two runs. Accept this as the cost of CI minutes.\n- **`SOURCE_DATE_EPOCH` is opt-in for build tools.** We can't force\n  `esbuild`/`rollup`/`webpack`/`tsc` to honour it. Belt-and-braces\n  mtime normalisation closes the file-stamp gap, but embedded\n  timestamps inside compiled output are still the consumer's bug to\n  fix.\n\nSee [`docs/migration-from-v0.3.md`](docs/migration-from-v0.3.md) if\nyou're upgrading from v0.3 and want the safer `warn` middle path\nduring the migration.\n\n## Workflow pin auditing\n\n`verify-action-pins` walks `.github/workflows/*.yml` in **your** repo\nand **fails the release** for every `uses: owner/repo@ref` line whose\nref isn't a 40-character hex SHA. This is strict by default. Set\n`strict-action-pins: false` in your caller workflow for warn-only mode\nduring migration.\n\nThe reason is the [`tj-actions/changed-files` incident in March 2025](https://github.com/tj-actions/changed-files/issues/2464):\na tag-pinned action can be silently re-pointed at malicious code by\nan attacker who compromises the action's repo or tag namespace. SHA\npinning binds the action to a specific commit so re-pointing has no\neffect on existing consumers.\n\n`forgesworn/anvil` itself is **exempt by name** from this\ngate. Without the carve-out, every consumer's release would fail on\nthe line that loads the gate (`uses: forgesworn/anvil@v0`).\nConsumers who want SHA-pinning of anvil itself should still\ndo so in their caller workflow with a 40-char SHA pin; the exemption\nis by name, not by ref, so the rest of your workflow's SHA-pin\nenforcement works exactly as you'd expect. See\n[`THREAT-MODEL.md`](THREAT-MODEL.md) for the rationale.\n\n## Trusted publisher caveat (important)\n\nnpm's trusted publisher matches against the OIDC token's **`workflow_ref`**\nclaim -- the **caller** workflow, not the reusable workflow.\n\nThat means: when you use `forgesworn/anvil` via the reusable\nworkflow pattern, your package's trusted publisher must be configured\nfor **your own repo** and **your own caller workflow file**, not for\n`forgesworn/anvil/release.yml`.\n\nConfigure on npmjs.com → your package → Settings → Trusted Publisher:\n\n| Field | Value |\n|---|---|\n| Publisher | GitHub Actions |\n| Organization or user | your GitHub org/user |\n| Repository | **your package's repo** |\n| Workflow filename | **your caller workflow file** (e.g. `release.yml`) |\n| Environment | (leave empty) |\n\n### First publish of a new package\n\nnpm's trusted publisher flow requires the package to already exist on\nthe registry. For a brand-new package that has never been published,\ndo a one-time manual publish first:\n\n```sh\n# From your workstation, with a granular access token scoped to publish\nnpm publish --access public\n```\n\nThen configure trusted publishing on npmjs.com for all subsequent\nreleases. The manual token can be revoked after the first publish --\nfrom that point on, OIDC handles everything.\n\n### Why the caller-workflow trust model\n\nThe reusable workflow still gets you centralised gate logic -- one place\nto update tag-match, secret scan, exports sanity, frozen-vector check,\nruntime audit, etc., across every consumer. That's the real benefit.\n\nWhat it does **not** give you is a single trusted-publisher record in\n`forgesworn/anvil` that every consumer points at. That pattern\nwould require npm to match on `job_workflow_ref` (the reusable), which\nit doesn't today. Jordan Harband (npm contributor) has recommended\nagainst trusted publishing with reusable workflows for this reason -- see\n[`npm/documentation#1755`](https://github.com/npm/documentation/issues/1755).\nIt still works fine; you just configure the trust at the consumer\nboundary rather than the reusable-workflow boundary.\n\nIf you see `npm publish` fail with:\n\n```\nOIDC token exchange error - package not found\n```\n\nat `/-/npm/v1/oidc/token/exchange/package/\u003cname\u003e`, the most likely\ncause is the trusted publisher is configured for the wrong repo.\nChange the Repository field to your package's own repo.\n\nIf that does not fix it, add `debug: true` to your caller workflow's\n`with:` block and re-run. The diagnostic step dumps npm version, the\nredacted effective `.npmrc`, OIDC env var presence, and `npm config\nlist` -- enough ground-truth to tell whether npm is missing the OIDC\ncontext entirely or has it but cannot match the trusted publisher.\n\n## Advanced: composite action directly\n\nIf you need custom job structure or extra pre-flight steps, you can\nbypass the reusable workflow and use the composite action in your own\njob:\n\n```yaml\njobs:\n  release:\n    runs-on: ubuntu-24.04\n    permissions:\n      contents: write\n      id-token: write\n    steps:\n      - uses: actions/checkout@v4\n      - uses: forgesworn/anvil@v0\n        with:\n          vector-test-command: npm run test:vectors\n```\n\nThe composite action runs the same step scripts the reusable workflow\ndoes. The reusable workflow remains the documented default because it\nbakes the correct `permissions:` block in.\n\n**Caveat:** the composite action cannot run the multi-job\nreproducible-build DAG (composite actions are flat lists of steps\ninside one job). `reproducibility-mode` is silently ignored on this\nsurface. If you need the v0.4 flagship reproducibility gate **and**\ncustom pre-steps, the reusable workflow does not yet support extra\npre-steps — open an issue if this gap blocks you.\n\n## Pinning\n\nPin by tag (`@v0` while MVP, `@v1` when stable) for stable pins, or by\ncommit SHA for maximum reproducibility. Dependabot can bump pins\nautomatically. Major version bumps indicate a change in gate semantics\n-- always review before upgrading the pin.\n\n`v0.x` is the MVP series: the gate set may still shift in response to\nreal-world pilot feedback. A `v1.0.0` release will be cut once the\naction has been in production use across several forgesworn libraries.\n\n## Supported registries\n\n| Registry | MVP | Notes |\n|---|---|---|\n| npm | yes | OIDC trusted publishing, provenance on every publish |\n| JSR | yes | Opt-in via `jsr.json`, uses `JSR_TOKEN` (no OIDC yet) |\n| crates.io | phase 2 | Pending Rust counterpart library |\n\n## Threat model\n\nSee [THREAT-MODEL.md](THREAT-MODEL.md) for the full security contract:\nwhat the action defends against, what it explicitly does not, the trust\nboundaries, and the known limitations of the secret scan. Summary: the\naction defends against accidentally publishing the wrong version,\nsecrets in artefacts, stolen long-lived tokens (via OIDC), and broken\nfrozen vectors. It does not defend against a malicious maintainer, a\ncompromised GitHub, or a compromised registry.\n\n## Contributing\n\nThis action is deliberately small. Before adding a feature, ask whether\nit fits within the trust boundaries in [THREAT-MODEL.md](THREAT-MODEL.md)\nand whether the total bash surface area stays under the thirty-minute\naudit budget.\n\nNon-goals:\n\n- Automated commit analysis or semver determination from commit messages\n- Changelog generation as a release-blocking step\n- Node-based tooling inside the action itself\n- Dependencies that are not already on the default GitHub Actions runner image\n\n## Funding\n\nIf this action saves your release pipeline a headache and you want to\nsupport the work, you can sponsor via\n[GitHub Sponsors](https://github.com/sponsors/TheCryptoDonkey) or\nLightning at [strike.me/thedonkey](https://strike.me/thedonkey). Funding\ngoes toward maintenance of this action and the wider forgesworn stack.\n\n## Licence\n\nMIT. See [LICENCE](LICENCE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fforgesworn%2Fanvil","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fforgesworn%2Fanvil","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fforgesworn%2Fanvil/lists"}