I recently shipped my first npm package and spent more time than I expected
figuring out the right release setup. The tooling exists, but the pieces are
spread across npm docs, GitHub Actions docs, and a handful of blog posts that
each cover a different slice. I wanted one place that covers the full picture —
from an empty repo to a live package with automated, token-free publishing.
This is that guide. It is useful when you are starting a new package from scratch
or when you have an existing package and want to make the release process more
deliberate. By the end you will have CI running on every push, releases that
only happen when you intentionally tag a commit, and a publishing setup that
does not require storing an npm token anywhere.
This guide shows a repeatable way to publish a Node package from a GitHub repo
to npm using:
CI on branch pushes and pull requests
releases triggered only by Git tags like v1.0.1
GitHub Actions trusted publishing instead of long-lived npm tokens
It covers:
public npm packages as the main path
private npm packages on npm as a small variation
the exact bootstrap problem for first publish
the GitHub Actions and npm trusted-publishing setup
the release flow that worked end to end
Use this if you want a package repo that is safe, repeatable, and easy for
other maintainers to operate.
The recommended release model
The core idea is a clean split between development activity and intentional
releases:
Every push and pull request runs CI.
Only a pushed version tag publishes to npm.
That means:
normal code changes do not publish
a version bump alone does not publish
only an explicit release tag like v1.0.1 publishes
This is safer than publishing on every package.json version change because the
tag becomes the intentional "ship this exact commit" signal. You get full CI
coverage on every commit, and releases happen only when you say so.
Prerequisites
Before starting, make sure you have:
a GitHub repository
an npm account with access to the package scope
2FA enabled on the npm account
Node.js installed locally
npm 11.10.0+ if you want to use npm trust
If you use pnpm, pin it with packageManager in package.json and use
Corepack in CI. If you use npm instead, the same flow still applies.
Before you start: what your repo should look like
A simple package repo should look roughly like this:
files: ["dist"] prevents publishing junk you did not mean to ship
You do not need this exact structure, but you do need the same idea:
source files for development, built files for publishing, and workflows in
.github/workflows/.
Step 1: Prepare the package metadata
Your package must publish built output, not raw source paths that only work in
your local repo.
Minimum package.json shape:
json
{"name":"@your-scope/your-package","version":"1.0.0","license":"MIT","type":"module","repository":{"type":"git","url":"git+https://github.com/your-org/your-repo.git"},"bugs":{"url":"https://github.com/your-org/your-repo/issues"},"homepage":"https://github.com/your-org/your-repo#readme","main":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":"./dist/index.js","types":"./dist/index.d.ts","default":"./dist/index.js"}},"files":["dist"],"scripts":{"build":"your-build-command","test:int":"your-non-watch-test-command","prepublishOnly":"npm run build && npm run test:int"},"publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"}}
Key rules to follow here:
main, types, and exports must point at publishable files like dist/*
test:int must be non-interactive in CI, for example vitest run, not
vitest
prepublishOnly should run the minimum reliable release checks
publishConfig.access should be public for scoped public packages
Do not rely on publishConfig to override exports, main, or types.
Those keys are not the right tool for that job.
Step 2: Add the files npm expects
Once the metadata is in shape, make sure you have the supporting files npm
looks for:
README.md
LICENSE
a build output directory such as dist/
Before the first publish, verify the tarball locally:
bash
npm pack --dry-run
npm publish --dry-run
Check for:
correct package name
correct version
correct tarball contents
no secrets or local junk
compiled output included
Step 3: Create a CI workflow
With the package ready, set up CI to run on pull requests and on pushes to
your main branch. This is what keeps development safe before any release
happens.
include both main and master if your repo may use either
disable Corepack prompts in CI
if your e2e stack needs a fixed port, make it explicit
if browser tests are too heavy for now, you can omit them from CI, but keep
the release workflow stricter than your local happy path
Step 4: Create a tag-only publish workflow
Now add the publish workflow. This is entirely separate from CI — it only runs
when you push a v* tag, which is the explicit signal that you want a release.
Example .github/workflows/publish.yml:
yaml
name:PublishPackageon:push:tags:-'v*'workflow_dispatch:inputs:tag:description:'Release tag to validate against package.json, for example v1.0.1'required:trueconcurrency:group:publish-${{github.ref}}cancel-in-progress:falsepermissions:contents:readid-token:writejobs:publish:runs-on:ubuntu-latestenv:COREPACK_ENABLE_DOWNLOAD_PROMPT:'0'steps:-name:Checkoutuses:actions/checkout@v6-name:SetupNodeuses:actions/setup-node@v6with:node-version:'24'registry-url:'https://registry.npmjs.org'package-manager-cache:false-name:EnableCorepackrun:corepackenable-name:Usepinnedpnpmrun:corepackusepnpm@11.5.1-name:Verifytagmatchespackageversionrun:|
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
REF_TAG="${{ inputs.tag }}"
else
REF_TAG="${GITHUB_REF_NAME}"
fi
TAG_VERSION="${REF_TAG#v}"
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
echo "Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)."
exit 1
fi
-name:Installdependenciesrun:pnpminstall--frozen-lockfile-name:Buildrun:pnpmbuild-name:Runintegrationtestsrun:pnpmtest:int-name:Publishtonpmrun:npmpublish
Why this shape is good:
tag-only publishing prevents accidental releases
manual dispatch gives you a controlled recovery path for an exact tag
tag/version validation stops mismatched releases early
id-token: write is required for npm trusted publishing
Step 5: Do the first publish manually
This is the non-obvious part, and it catches people by surprise.
You cannot fully bootstrap a brand-new package with trusted publishing alone,
because npm trusted publisher setup requires the package to already exist on the
registry. That creates a chicken-and-egg problem — so the first publish is
manual.
Expected output should show a GitHub trust relation for publish.yml and your
repository.
Step 7: Release by bumping version and pushing a tag
With trusted publishing in place, all future releases follow the same short
sequence.
Patch release:
bash
npm version patch
git push origin main
git push origin --tags
Minor release:
bash
npm version minor
git push origin main
git push origin --tags
Major release:
bash
npm version major
git push origin main
git push origin --tags
What npm version does for you:
bumps package.json
updates lockfiles where needed
creates a matching git tag like v1.0.1
What happens next:
GitHub sees the pushed v* tag
publish.yml runs
it verifies tag and version match
it builds, tests, and publishes to npm
If your default branch is master, push master instead of main.
Step 8: Verify every release
After GitHub Actions finishes, confirm the release landed:
bash
npm view @your-scope/your-package version dist-tags time --json
You can also check a specific version:
bash
npm view @your-scope/your-package@1.0.1 version
If the npm website still shows the older version for a few minutes, trust the
registry CLI first. The website can lag behind the actual registry state.
Private npm package differences
The same tag-and-GitHub-Actions flow works for private npm packages on npmjs.org.
The main differences are:
the package must be private in npm access terms
publishConfig.access should match your intended package visibility
installation and access depend on npm org/account permissions
Everything else stays basically the same:
first publish manually
then configure trusted publishing
then release from v* tags
For scoped packages on npm, make the access explicit.
Public package:
json
{"publishConfig":{"access":"public"}}
Private package:
json
{"publishConfig":{"access":"restricted"}}
If you are publishing to GitHub Packages instead of npmjs.org, use a different
workflow and authentication model. That is outside this guide.
The safest repeatable workflow
Once everything is set up, this is the standard maintainer flow for every
release going forward:
Open PR
Let CI pass
Merge to your main branch
Run npm version patch|minor|major
Push branch
Push tags
Let GitHub publish
Verify with npm view
That gives you:
CI on normal development
explicit releases only
no long-lived npm token in GitHub
reproducible version history
Troubleshooting appendix
The first trusted-publishing setup fails because the package does not exist
Symptom:
npm trust github ... cannot be configured yet
Cause:
npm requires the package to already exist on the registry
Fix:
do the first publish manually
configure trust only after that
npm publish works locally but GitHub Actions gets 404 not found or you do not have permission
Symptom:
GitHub Actions reaches npm publish and fails with npm 404
Cause:
trusted publishing is not configured yet, or is configured for the wrong
repo/workflow file
Fix:
verify npm trust list @your-scope/your-package
confirm the trusted publisher points to the exact repo and publish.yml
What if the package name is already taken?
Check the registry first:
bash
npm view @your-scope/your-package
If it returns package info, the name already exists. If it returns 404, the
name is either available or not visible to you because it is private or
otherwise inaccessible.
CI does not run on branch pushes
Symptom:
workflow exists, but branch pushes do not trigger it
Cause:
branch trigger mismatch, usually main vs master
Fix:
include the real branch name in ci.yml
Tag push does not publish
Symptom:
you pushed code, but nothing released
Cause:
only v* tags trigger publish
Fix:
push a tag like v1.0.1
Tag and package version do not match
Symptom:
publish workflow fails before npm publish
Cause:
tag is v1.0.1 but package.json is not 1.0.1
Fix:
bump version correctly before tagging
Tests hang during publish
Symptom:
release job never finishes or enters watch mode
Cause:
test script is interactive, for example vitest instead of vitest run
Fix:
make release-time test scripts non-interactive
npm website still shows the old version
Symptom:
npm view shows the new version, but the website does not