NPM Publishing for Dummies
I recently published a few packages on NPM — solid-number-flow, bagon-hooks, and a couple more. The first time I did it, I was fumbling through docs, half-guessing what main vs module vs types meant in package.json, and manually running npm publish like a caveman.
Then I watched Matt Pocock's approach and it clicked. There's a really clean pipeline you can set up: tsup bundles your code, changesets handles versioning and changelogs, and GitHub Actions publishes automatically when you merge. Once it's wired up, you literally just write code, run one command to describe your change, push, and merge a PR. That's it.
This is my cheatsheet for setting that up. I use Bun here, but swap bun for pnpm or npm and it's the same thing.
The Quick and Dirty
If you just want to publish something right now:
# Login to NPM (one-time)
npm login
# Check who's logged in
npm whoami
# Publish
npm publish
# Scoped package? (e.g. @carloweb/my-package)
npm publish --access=publicThat works. But if you're maintaining a package and want a proper workflow, keep reading.
The Proper Setup
Here's what we're wiring up:
- tsup — bundles your TypeScript into CJS + ESM with type declarations
- Changesets — manages version bumps and generates changelogs
- GitHub Actions — runs CI on every push, auto-publishes on merge to main
1. tsup (Bundling)
tsup is the easiest way to bundle a TypeScript library. Zero-config for simple cases, but here's a solid default:
bun add -D tsupCreate tsup.config.ts:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.tsx'], // or src/index.ts
outDir: 'dist',
format: ['cjs', 'esm'],
dts: true,
splitting: true,
sourcemap: true,
clean: true,
});Then update your package.json — this is the part that trips people up:
{
"private": false,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"lint": "tsc",
"build": "tsup"
}
}main— CommonJS entry (forrequire())module— ESM entry (forimport)types— TypeScript declarationsfiles— only ship thedistfolder to NPM (keeps your package small)
Run bun run build and check that dist/ looks right before moving on.
2. Changesets (Versioning)
Changesets is what the big open-source projects use (Radix, Solid, etc). It lets you describe what changed, then it auto-bumps versions and writes changelogs for you.
bun add -D @changesets/cli @changesets/changelog-github
# Initialize (creates a .changeset/ directory)
bun changeset initAfter init, open .changeset/config.json and change "access" from "restricted" to "public" (otherwise scoped packages won't publish):
{
"access": "public"
}Add these scripts to package.json:
{
"scripts": {
"ci": "bun run lint && bun run build",
"publish-ci": "bun run lint && bun run build && changeset publish"
}
}How changesets work day-to-day
When you're ready to release:
bun changesetIt'll ask you:
- Which packages changed (for monorepos, or just hit enter for single packages)
- Is it a major, minor, or patch bump?
- Write a short summary of the change
This creates a markdown file in .changeset/ describing the change. Commit it with your code. The GitHub Action (next section) handles the rest.
3. GitHub Actions (CI + Auto Publish)
Two workflows: one for CI on every push, one for publishing on merge to main.
CI Workflow
.github/workflows/ci.yml:
name: CI
on:
push:
branches:
- '**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run ciPublish Workflow
.github/workflows/publish.yml:
name: Publish
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
id-token: write
pull-requests: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
package-manager-cache: false
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Trusted publishing (OIDC) needs npm ≥ 11.5.1. Bun alone leaves you
# on the runner's old npm → ENEEDAUTH.
- name: Update npm
run: npm install --global npm@11.5.2
- run: bun install --frozen-lockfile
- run: bun run build
# Changesets Action accepts a custom title but does not interpolate the
# next version. Derive it from the release plan; on the publish run there
# are no pending changesets, so fall back to package.json.
# Use a workspace-relative status file — under bun, changeset resolves
# absolute $RUNNER_TEMP paths incorrectly.
- name: Determine release pull request title
id: release-title
shell: bash
run: |
set -euo pipefail
status_file="changeset-status.json"
bunx changeset status --output "$status_file"
version="$(node -e '
const fs = require("node:fs");
const status = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const pkg = require("./package.json");
process.stdout.write(status.releases[0]?.newVersion ?? pkg.version);
' "$status_file")"
rm -f "$status_file"
echo "title=release: v$version" >> "$GITHUB_OUTPUT"
- name: Create release pull request or publish
id: changesets
uses: changesets/action@v1
with:
publish: bun run publish-ci
title: ${{ steps.release-title.outputs.title }}
commit: ${{ steps.release-title.outputs.title }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}No NPM_TOKEN. Auth is OIDC via id-token: write + npm Trusted Publishers. The essential piece for publish succeeding is Node 24 + npm@11.5.2 — changeset publish shells out to npm publish, and only modern npm speaks trusted publishing.
Changesets otherwise calls both the release PR and its version commit "Version Packages". title / commit fix that. The helper reads the next version from changeset status, then falls back to package.json on the publish run after pending changesets are consumed.
4. NPM Trusted Publishing + GitHub Setup
NPM's Trusted Publishers feature lets GitHub Actions publish securely through OIDC.
You have to publish the first 0.0.0 version though, so do this once:
npm login
npm pkg set version=0.0.0
npm publish --access publicNPM won't let you configure a trusted publisher until the package exists, which is why this first manual publish is necessary.
Now open your package on npmjs.com, go to Settings > Trusted Publisher, and choose GitHub Actions. Enter:
- Your GitHub user or organization
- Your repository name
publish.ymlas the workflow filename- npm publish as the allowed action
GitHub Permissions
In your repo > Settings > Actions > General:
- Set Workflow Permissions to "Read and write permissions"
- Check "Allow GitHub Actions to create and approve pull requests"
This lets the changesets action create release PRs automatically. The workflow's id-token: write permission is what lets NPM verify GitHub Actions without an NPM token — you do not need an NPM_TOKEN secret.
The Workflow (Once Everything's Set Up)
Here's what your day-to-day looks like:
- Write code, commit, push
- When you're ready to release, run
bun changeset— describe what changed - Commit the changeset file and push to main
- The publish workflow automatically creates a "Release PR" that bumps versions and updates changelogs
- Merge the Release PR — this triggers the publish workflow again, which runs
changeset publishand pushes to NPM
That's it. No manual npm publish, no forgetting to bump versions, no changelog maintenance. Just code, changeset, push, merge.