Written as a private engineering note, 12 July 2026, while shipping @chanmeng666/[email protected]. Republished here, lightly edited for a reader.
If you have never published to npm at all, start with A Complete Guide to Publishing Your First npm Package — this piece picks up where that one ends, at the point where you want the release to stop being something you do by hand.
The goal here is narrow and specific: let a coding agent — Claude Code, or any other — own the entire npm release lifecycle in any repository. Version, changelog, tag, publish, verify. Without a stored npm token, and without a 2FA prompt on any publish.
This is the setup that shipped @chanmeng666/[email protected] and @chanmeng666/[email protected] on 2026-07-12, first try after one recorded gotcha. Everything below is npm's sanctioned automation path — trusted publishing — not a security workaround. Publishes stop needing 2FA because they stop needing credentials: the CI run proves its own identity via OIDC.
Read this first — the honest boundary. npm deliberately splits the world in two. Publishing can be fully delegated to automation (this document). Account and package management cannot. Creating or deleting tokens, changing maintainers or package access, editing trusted-publisher config, and 2FA settings all require an interactive human with 2FA — token-based bypass of those is being removed, and no compliant setup can delegate them. Design your release process so management actions are rare, one-time events. An agent that "handles all npm operations" really means: agent publishes, human blesses the setup once.
Why not a token (the deprecation clock)#
| Date | What happened / happens |
|---|---|
| 2025-12-09 | Classic tokens (including the "Automation" type) permanently revoked. Only Granular Access Tokens (GATs) exist. |
| 2026-07-08 | npm v12 GA; deprecation of 2FA-bypass GATs announced. |
| ~2026-08 | Bypass-2FA GATs lose account/package/org management actions (interactive 2FA only). |
| ~2027-01 | Bypass-2FA GATs lose direct publish — reduced to staging a publish that a human 2FA-approves. |
There are three more reasons beyond the clock. A long-lived publish token is a standing secret on disk. There is an open npm/cli bug (npm/cli#9268) where correctly-configured bypass GATs still get EOTP/E403. And provenance — sigstore attestation — comes free with OIDC and does not with a token.
Tokens are the legacy path. Don't build on them.
The architecture#
agent (local) GitHub Actions registries
───────────── ────────────── ──────────
bump versions + CHANGELOG push release.yml (OIDC id-token) npm (trusted publisher,
commit → git tag vX.Y.Z ─────────► npm publish --provenance ───────► provenance-signed)
gh run watch <id> [optional: other registries [optional: MCP registry
via their own OIDC login] via github-oidc]
No secret exists anywhere. npmjs.com is told, once per package, "trust publishes coming from GitHub repo X, workflow file Y" — then each CI run exchanges its OIDC identity for a short-lived, publish-only credential minted on the spot.
One-time setup (per package; the only human steps)#
1. The package must already exist on npm. Trusted-publisher config attaches to an existing package, so a brand-new package's first version has to be published once by other means: a short-lived GAT with read+write on the package and "Bypass 2FA" checked, deleted right after; or a manual npm publish with an OTP. Everything after v0.0.1 is tokenless.
2. Register the trusted publisher. npmjs.com → the package → Settings → Trusted Publisher → GitHub Actions, then fill in exactly, case-sensitively:
- Organization or user: the GitHub owner — GitHub's casing, not the npm scope's lowercase
- Repository: the repo name
- Workflow filename: the filename only, with extension (
release.yml— not a path) - Environment name: leave empty, unless you gate releases behind a GitHub environment
- Allowed actions: check "Allow npm publish". Configs created after 2026-05-20 must select actions explicitly; leave "stage publish" unchecked unless you use staging.
3. npm will demand a 2FA OTP to save this. That is the by-design human step. A workable division of labor: the agent drives the browser, fills the form, and stops at the OTP prompt; the human types the six-digit code; the agent verifies the saved entry. One OTP per package, once, ever — until the config itself changes.
4. A monorepo publishing N packages needs N registrations, all pointing at the same repo and workflow file. One workflow run can publish all of them; OIDC mints a fresh credential per npm publish invocation.
The workflow (template)#
The load-bearing parts:
name: Release (npm via OIDC)
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
id-token: write # the OIDC exchange — without this you get an auth error
contents: read
jobs:
publish:
runs-on: ubuntu-latest # cloud-hosted runners only; self-hosted unsupported
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
registry-url: 'https://registry.npmjs.org' # REQUIRED for the OIDC token exchange
cache: npm
- run: npm ci # with the runner's bundled npm — see pin note below
- run: | # trusted publishing needs npm >= 11.5.1; pin 11.x, NOT @latest
npm install -g npm@^11.5.1
npm --version
# idempotency: skip any version already on the registry, so re-runs are safe
- id: check
run: |
v=$(node -p "require('./package.json').version")
echo "version=$v" >> "$GITHUB_OUTPUT"
on_reg=$(npm view "your-pkg@$v" version 2>/dev/null || true)
echo "exists=$([ "$on_reg" = "$v" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
- if: ${{ success() && steps.check.outputs.exists != 'true' }}
run: npm publish --provenance --access public
# workspaces: repeat guard + `npm publish -w packages/<name> --provenance --access public`Template notes, each learned the hard way or from the docs:
- Pin npm to
^11.5.1afternpm ci, nevernpm@latest. Latest is npm 12, whoseallowScripts-off default silently blocks dependency install scripts — esbuild's postinstall, native builds. And 11.5.1 is the trusted-publishing minimum. Upgrading afternpm cimeans install scripts already ran under permissive npm. registry-urlon setup-node is required. It writes the.npmrcplumbing the OIDC exchange rides on. Forgetting it looks like an auth failure.- Pass
--provenanceexplicitly (or setpublishConfig.provenance: true). The docs say it is automatic under trusted publishing; practice says be explicit.--access publicis needed for a scoped package. - Custom
if:conditions must includesuccess() &&. A bare customif:drops GitHub's implicit failure check, and a failed earlier publish would not stop later steps. - Make every publish idempotent with the
npm viewguard, so a partially-failed release is fixed by re-running the workflow (gh workflow run release.yml), not by hand-surgery. - Guard secondary-registry syncs by the secondary registry's state, not by "did npm publish happen this run" — otherwise an npm-ok/registry-failed partial run skips the sync forever on re-run. The MCP registry is one example:
mcp-publisher login github-oidc→mcp-publisher publishis likewise tokenless under the sameid-token: write.
Gotchas that will actually bite you#
1. Provenance exact-matches repository.url casing (E422). If package.json says github.com/chanmeng666/repo and the OIDC attestation says ChanMeng666/repo, the publish is rejected after the tarball uploads. The GitHub owner casing must be byte-exact in repository.url — and keep homepage and bugs consistent while you are there. If the fix lands after tagging: commit, then move the tag (git tag -f vX.Y.Z && git push -f origin vX.Y.Z) so the published source matches the tag.
2. The workflow filename is part of the trust contract. Renaming release.yml breaks publishing until a human re-registers the trusted publisher — a 2FA action. Pick a name and keep it.
3. Package-level "Publishing access". The option "Require two-factor authentication and disallow tokens" does not block trusted publishing — it blocks GATs. But while you still depend on any token anywhere, know that this radio silently EOTPs it.
4. npm view can lag a minute after publish on edge caches. Verify against the registry JSON (https://registry.npmjs.org/<pkg>) before declaring failure.
5. npm 12 on dev machines and CI. When it arrives, dependency install scripts stop running by default. Build an allowlist with npm approve-scripts and commit it, or installs of script-dependent toolchains break quietly.
What the agent does per release#
- Bump version(s) and changelog; run the repo's full gate locally; commit; push.
git tag vX.Y.Z && git push origin vX.Y.Z— this is the publish button.gh run watch <run-id> --exit-status. On failure, readgh run view <id> --log-failed, fix, push, move the tag if the fix affects published files, re-run.- Verify independently:
npm view <pkg> version dist-tags.latest, plus the sigstore transparency log URL npm prints — then truth-sync the repo's own status docs.
Fallback: staged publishing#
npm stage publish from a local machine stages the release without 2FA; a human then approves with one 2FA tap, from the CLI or the npmjs.com "Staged Packages" web UI. A phone works.
This is also what bypass-GATs get reduced to in 2027, so it is the sanctioned local path, not a downgrade. Agent stages, human taps — still zero secrets.
Copy-paste checklist for a new repo#
- Package(s) exist on npm (bootstrap a brand-new package once, then delete the token)
- Human registers the trusted publisher per package — owner / repo / workflow filename, exact case; allow "publish"; expect one OTP each
- Add
release.ymlfrom the template above:id-token: write,registry-url,npm@^11.5.1afternpm ci, idempotency guards,--provenance --access public -
repository.urlcasing matches the real GitHub owner in every publishedpackage.json - Delete every npm token from
~/.npmrc, CI secrets, and npmjs.com —npm whoamifailing locally is the desired end state - Document in the repo's CONTRIBUTING: tag push = release; an auth failure means redo the npmjs registration, never add a token