---
title: "Publish a GitHub Repo to npm with Tags & GitHub Actions"
slug: "publish-github-repo-to-npm-tags-github-actions"
published: "2026-06-02"
updated: "2026-06-04"
validated: "2026-06-04"
categories:
  - "Tools"
tags:
  - "publish GitHub repo to npm"
  - "GitHub Actions publish npm"
  - "tag-only publishing"
  - "npm trusted publishing"
  - "token-free npm publishing"
  - "pnpm"
  - "Corepack"
  - "npm version tag release"
  - "package.json exports main types"
  - "CI for npm packages"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "github actions"
  - "npm"
  - "pnpm"
  - "corepack"
  - "node.js"
status: "stable"
llm-purpose: "Publish a GitHub repo to npm with tag-only releases and GitHub Actions trusted publishing — set up CI, remove long‑lived npm tokens, and release securely…"
llm-prereqs:
  - "Access to GitHub Actions"
  - "Access to npm"
  - "Access to pnpm"
  - "Access to Corepack"
  - "Access to Node.js"
llm-outputs:
  - "Completed outcome: Publish a GitHub repo to npm with tag-only releases and GitHub Actions trusted publishing — set up CI, remove long‑lived npm tokens, and release securely…"
---

**Summary Triples**
- (CI, runs on, every push and pull request)
- (Publishing, is triggered only by, pushed version tags (e.g., v1.0.1))
- (Release model, separates, development (push/PR) from intentional releases (tags))
- (GitHub Actions, can publish to npm using, npm trusted publishing (avoids long-lived npm tokens))
- (First-time (bootstrap) publish, may require, an initial author action to claim the package name or one-time token)
- (Workflow trigger, should match, tags pattern such as 'v*.*.*' to perform npm publish)
- (package.json, must include, correct repository, name, version, and publishConfig/exports for predictable publishing)
- (Private packages, require, publishConfig.access set to 'restricted' (or 'private' depending on registry rules) and appropriate registry/auth)
- (Local verification, use, pnpm/Corepack + local test commands (Vitest, Playwright) before tagging)

### {GOAL}
Publish a GitHub repo to npm with tag-only releases and GitHub Actions trusted publishing — set up CI, remove long‑lived npm tokens, and release securely…

### {PREREQS}
- Access to GitHub Actions
- Access to npm
- Access to pnpm
- Access to Corepack
- Access to Node.js

### {STEPS}
1. Prepare package metadata and build output
2. Add expected npm files and verify tarball
3. Create CI workflow for branch pushes
4. Create tag-only publish workflow
5. Do the first manual publish
6. Configure npm trusted publishing
7. Release by bumping version and tagging
8. Verify release and troubleshoot

<!-- llm:goal="Publish a GitHub repo to npm with tag-only releases and GitHub Actions trusted publishing — set up CI, remove long‑lived npm tokens, and release securely…" -->
<!-- llm:prereq="Access to GitHub Actions" -->
<!-- llm:prereq="Access to npm" -->
<!-- llm:prereq="Access to pnpm" -->
<!-- llm:prereq="Access to Corepack" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:output="Completed outcome: Publish a GitHub repo to npm with tag-only releases and GitHub Actions trusted publishing — set up CI, remove long‑lived npm tokens, and release securely…" -->

# Publish a GitHub Repo to npm with Tags & GitHub Actions
> Publish a GitHub repo to npm with tag-only releases and GitHub Actions trusted publishing — set up CI, remove long‑lived npm tokens, and release securely…
Matija Žiberna · 2026-06-02

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

## LLM Response Snippet
```json
{
  "goal": "Publish a GitHub repo to npm with tag-only releases and GitHub Actions trusted publishing — set up CI, remove long‑lived npm tokens, and release securely…",
  "responses": [
    {
      "question": "What does the article \"Publish a GitHub Repo to npm with Tags & GitHub Actions\" cover?",
      "answer": "Publish a GitHub repo to npm with tag-only releases and GitHub Actions trusted publishing — set up CI, remove long‑lived npm tokens, and release securely…"
    }
  ]
}
```