6 min read•0 views

NPM Publishing for Dummies

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=public

That 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 tsup

Create 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 (for require())
  • module — ESM entry (for import)
  • types — TypeScript declarations
  • files — only ship the dist folder 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 init

After 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 changeset

It'll ask you:

  1. Which packages changed (for monorepos, or just hit enter for single packages)
  2. Is it a major, minor, or patch bump?
  3. 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 ci

Publish 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 public

NPM 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.yml as 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:

  1. Write code, commit, push
  2. When you're ready to release, run bun changeset — describe what changed
  3. Commit the changeset file and push to main
  4. The publish workflow automatically creates a "Release PR" that bumps versions and updates changelogs
  5. Merge the Release PR — this triggers the publish workflow again, which runs changeset publish and pushes to NPM

That's it. No manual npm publish, no forgetting to bump versions, no changelog maintenance. Just code, changeset, push, merge.