BuildWithMatija
  1. Home
  2. Blog
  3. Tools
  4. Publish a GitHub Repo to npm with Tags & GitHub Actions

Publish a GitHub Repo to npm with Tags & GitHub Actions

Complete guide to tag-only releases, GitHub Actions trusted publishing, CI checks, and token-free npm publishing for…

2nd June 2026·Updated on:4th June 2026··
Tools
Publish a GitHub Repo to npm with Tags & GitHub Actions

📚 Get Practical Development Guides

Join developers getting comprehensive guides, code examples, optimization tips, and time-saving prompts to accelerate their development workflow.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The recommended release model
  • Prerequisites
  • Before you start: what your repo should look like
  • Step 1: Prepare the package metadata
  • Step 2: Add the files npm expects
  • Step 3: Create a CI workflow
  • Step 4: Create a tag-only publish workflow
  • Step 5: Do the first publish manually
  • Step 6: Configure npm trusted publishing
  • Option A: Use the npm website
  • Option B: Use npm CLI
  • Step 7: Release by bumping version and pushing a tag
  • Step 8: Verify every release
  • Private npm package differences
  • The safest repeatable workflow
  • Troubleshooting appendix
  • The first trusted-publishing setup fails because the package does not exist
  • `npm publish` works locally but GitHub Actions gets `404 not found or you do not have permission`
  • What if the package name is already taken?
  • CI does not run on branch pushes
  • Tag push does not publish
  • Tag and package version do not match
  • Tests hang during publish
  • npm website still shows the old version
  • Minimal command summary
On this page:
  • The recommended release model
  • Prerequisites
  • Before you start: what your repo should look like
  • Step 1: Prepare the package metadata
  • Step 2: Add the files npm expects
Build with Matija logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit

Resources

  • Case Studies
  • How I Work
  • Blog
  • CMS Hub
  • E-commerce Hub
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • Multi-Tenant CMS
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Get in Touch

Ready to modernize your stack? Let's talk about what you're building.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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:

txt
your-package/
  src/
    index.ts
  dist/
  package.json
  tsconfig.json
  README.md
  LICENSE
  .github/
    workflows/
      ci.yml
      publish.yml

What each part means:

  • src/ is what you write
  • dist/ is what npm users receive
  • main, types, and exports should point at dist/
  • 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.

Example .github/workflows/ci.yml:

yaml
name: CI

on:
  push:
    branches:
      - main
      - master
  pull_request:

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Setup Node
        uses: actions/setup-node@v6
        with:
          node-version: '24'
          package-manager-cache: false

      - name: Enable Corepack
        run: corepack enable

      - name: Use pinned pnpm
        run: corepack use pnpm@11.5.1

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Lint
        run: pnpm lint ./src

      - name: Build
        run: pnpm build

      - name: Integration tests
        run: pnpm test:int

      - name: Install Playwright browser
        run: pnpm exec playwright install --with-deps chromium

      - name: End-to-end tests
        run: PLAYWRIGHT_PORT=3100 pnpm test:e2e

If you use npm instead of pnpm, the same workflow becomes:

yaml
      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Integration tests
        run: npm run test:int

A few notes worth keeping in mind:

  • 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: Publish Package

on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:
    inputs:
      tag:
        description: 'Release tag to validate against package.json, for example v1.0.1'
        required: true

concurrency:
  group: publish-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    env:
      COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Setup Node
        uses: actions/setup-node@v6
        with:
          node-version: '24'
          registry-url: 'https://registry.npmjs.org'
          package-manager-cache: false

      - name: Enable Corepack
        run: corepack enable

      - name: Use pinned pnpm
        run: corepack use pnpm@11.5.1

      - name: Verify tag matches package version
        run: |
          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: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm build

      - name: Run integration tests
        run: pnpm test:int

      - name: Publish to npm
        run: npm publish

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.

First validate locally:

bash
pnpm install
pnpm build
pnpm test:int
npm pack --dry-run
npm publish --dry-run

Then publish:

bash
npm publish --access public

For unscoped public packages, plain npm publish is often enough, but for scoped public packages --access public is the safe default.

Once it's live, verify with:

bash
npm view @your-scope/your-package

Step 6: Configure npm trusted publishing

Now that the package exists on npm, you can connect it to GitHub Actions so that future releases never need a stored npm token.

Option A: Use the npm website

On npm:

  1. Open the package page
  2. Go to Settings
  3. Open Trusted Publisher
  4. Add GitHub Actions publisher

Use:

  • Owner/User: your-org
  • Repository: your-repo
  • Workflow file: publish.yml

Option B: Use npm CLI

If your npm version supports it:

bash
npm trust github @your-scope/your-package \
  --repo your-org/your-repo \
  --file publish.yml \
  -y

Then verify:

bash
npm trust list @your-scope/your-package

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:

  1. Open PR
  2. Let CI pass
  3. Merge to your main branch
  4. Run npm version patch|minor|major
  5. Push branch
  6. Push tags
  7. Let GitHub publish
  8. 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

Cause:

  • npm web UI cache/propagation lag

Fix:

  • wait a few minutes and refresh
  • trust the registry API/CLI first

Minimal command summary

First release:

bash
pnpm install
pnpm build
pnpm test:int
npm pack --dry-run
npm publish --access public

Configure trusted publishing:

bash
npm trust github @your-scope/your-package \
  --repo your-org/your-repo \
  --file publish.yml \
  -y

Ongoing releases:

bash
npm version patch
git push origin main
git push origin --tags

Thanks, Matija