Publishing by hand from a laptop works right up until you’re on a different machine, or you forget to run the build first, or you publish from a dirty working tree. Wiring it to GitHub releases takes about ten minutes and removes all three failure modes.
1. Create an npm token
The token needs to work without a human present, so it has to be an automation token — a normal token will fail if you have 2FA enabled on publishes, which you should.
From the CLI:
npm login
npm token create
npm token list
Or in the web UI: npm → Access Tokens → Generate New Token → Automation.
Automation tokens bypass the 2FA prompt specifically for CI. That’s the point of them, and it’s also why the token is worth guarding — it can publish on your behalf with nothing else.
2. Add it to the repo
In the GitHub repo: Settings → Secrets and variables → Actions → New repository secret.
Name it NPM_TOKEN.
3. The workflow
name: Publish to npm
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout repo
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org/
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Publish package
run: npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The parts that matter
on: release: types: [published] — publishing is tied to cutting a GitHub release, not to pushing a tag or merging to main. That gives you a deliberate human action as the trigger, and the release notes end up matching the version.
registry-url in setup-node — without this, setup-node doesn’t write the .npmrc that maps NODE_AUTH_TOKEN to the registry, and npm publish fails with a 401 while your token is perfectly valid. It’s the single most common reason this workflow doesn’t work first time.
id-token: write — required for --provenance. GitHub mints a short-lived OIDC token that npm uses to attest the package was built here, from this commit. Without the permission, the publish step fails outright.
--provenance — attaches that signed attestation to the published package, so the npm page shows which repo, workflow and commit produced the tarball. Free supply-chain evidence for one flag.
npm ci, not npm install — installs exactly what the lockfile says and fails if package.json and the lockfile disagree, rather than quietly resolving something new.
--access public — needed for scoped packages (@you/thing), which default to private and error out if you don’t have a paid account. Harmless on unscoped packages.
npm test before publish — a failing test aborts the job before anything reaches the registry. npm versions are immutable; unpublishing is restricted and disruptive. Catching it here is much cheaper than the alternative.