GitHub Packages vs npm โ should you publish to both?
We just shipped meridian-skills-mcp
v2.0.0 to both the public npm registry (as meridian-skills-mcp)
and GitHub Packages (as @luuow/meridian-skills-mcp) from a single
git tag v2.0.0 && git push origin v2.0.0. Whole thing
is one workflow file, one pre-existing NPM_TOKEN secret, and the
automatic GITHUB_TOKEN.
Worth noting because the conventional wisdom is "just publish to npm." That conventional wisdom is correct as far as it goes โ but GitHub Packages has earned its slot in our build, and the cost of dual-publishing is small enough that not doing it is the questionable choice now. Here's the case.
The publish workflow, in full
One file, two registries, three jobs:
# .github/workflows/publish.yml
name: Publish package
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: npm }
- run: npm ci
- run: node --test tests/*.test.mjs
npm:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- run: npm ci --omit=dev
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
ghp:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://npm.pkg.github.com'
scope: '@luuow'
- run: npm ci --omit=dev
- run: npm pkg set name=@luuow/meridian-skills-mcp
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Notice the npm pkg set name=@luuow/meridian-skills-mcp line in
the GitHub Packages job. That's the only piece of friction. GitHub Packages
requires scoped names, and the scope must match the GitHub user/org. So we
keep the canonical package.json with the unscoped public name
(meridian-skills-mcp โ what's already on npm) and just rewrite
the name field for one job.
Where GitHub Packages actually wins
1. The auth surface is the same as your code's
GITHUB_TOKEN is the token your CI already has. The token your
repo settings already manage. The token your branch protection already
respects. Publishing to GitHub Packages reuses it. No npm account, no
separate NPM_TOKEN rotation, no second dashboard.
For internal packages especially, this is huge. Your "who can publish"
question collapses into "who can push to this repo's main."
That's it. Same answer for source code, same answer for releases.
2. Provenance is automatic, not opt-in
Every package on GitHub Packages is linked to the exact commit SHA, exact workflow run, and exact runner that built it. The package page shows them. You click through to the build log from the version. The version is signed by the GitHub OIDC token of the runner.
npm gained provenance attestations in 2023, but they require explicit
npm publish --provenance + a publishConfig in
package.json + the package name to start with a scope. GitHub
Packages just does it.
3. One registry for npm, Docker, Maven, NuGet, RubyGems
ghcr.io/luuow/meridian-mcp (Docker image) and
npm.pkg.github.com/@luuow/meridian-skills-mcp (npm package) are
the same account, same auth, same UI. If you ship a CLI as both an npm
package and a Docker image โ increasingly common โ having them under one
roof means one auth dance, not two.
4. Private packages are free
GitHub Packages includes private packages on the free tier (within standard storage/bandwidth quotas). The npm equivalent โ npm Pro โ is $7/seat/month. For an internal-only package shared across a few repos, "publish to GitHub Packages, period" is the right answer. The minute the package is internal, npm has no advantage.
Where npm still wins
1. Anonymous install
This is the big one and it's not close. npm install
meridian-skills-mcp Just Works for anyone, anywhere, with zero auth.
npm install @luuow/meridian-skills-mcp from GitHub Packages
requires every consumer to have a .npmrc with
@luuow:registry=https://npm.pkg.github.com AND an auth token โ
even for public packages. That's a long-standing GitHub Packages
design choice and it's the single biggest reason it hasn't displaced npm
for public distribution.
Practically: if anyone you don't know is going to install your package, keep publishing to npm. Don't make a stranger configure registry settings to try your tool.
2. Default-tooling integration
Every package manager front-end, every CI cache, every "search npm"
surface, every npx invocation, every codesandbox dependency
resolver โ they all default to registry.npmjs.org. If you
only publish to GitHub Packages you're invisible to all of that.
3. Discoverability
npmjs.com's search is mediocre but it exists. GitHub Packages
doesn't have a global search; you find a package by knowing the
@owner in advance. For an open-source library you want people
to find, npm is the only choice.
So: publish to both
The honest conclusion isn't "switch to GitHub Packages." It's "the
marginal cost of also publishing to GitHub Packages is one
npm pkg set name line and one extra job in your existing
release workflow."
| npm public | GitHub Packages | |
|---|---|---|
| Anonymous install | โ | โ (auth required even for public) |
| Auth shared with code | โ | โ |
| Provenance (linked to SHA + run) | opt-in (--provenance) | automatic |
| Private packages on free tier | โ ($7/seat/mo) | โ |
| Same registry as Docker images | โ | โ
(ghcr.io) |
| Naming: scope required | optional | required, must match owner |
Default for npm install | โ | โ |
For a tool you want strangers to npm install: npm is required.
GitHub Packages is a great second location โ it gives you provenance,
cleaner auth for CI consumers inside your org, and a path to private without
paying.
The npm side is also doing a thing now
Worth flagging: npm now has provenance statements via Sigstore, npm Trusted Publishing (OIDC-based, no long-lived tokens), and tightened scope-by-default defaults. The gap to GitHub Packages on attestation is closing. The gap on "public install requires no auth" is not โ and probably never will, because that's GitHub's billing posture more than a technical decision.
So the publishing playbook for 2026, in our opinion:
- Public open-source npm package? Publish to both. Use
npm Trusted Publishing for the npm half (no
NPM_TOKENsecret to rotate). The dual-publish workflow above is your starting point. - Private internal package? GitHub Packages only. No reason to maintain an npm Pro subscription anymore.
- Hybrid (CLI + Docker)? Both registries for the npm
half,
ghcr.iofor the Docker half. Single auth surface for consumers who use both.
One last note: the rename trick
The reason we keep meridian-skills-mcp (unscoped) on npm and
@luuow/meridian-skills-mcp on GitHub Packages โ instead of
forcing the scoped name everywhere โ is install ergonomics. Most users
will install via:
npm install -g meridian-skills-mcp
claude mcp add meridian meridian-mcp
Renaming to @luuow/meridian-skills-mcp would break that one-liner
for everyone who isn't already configured for GitHub Packages. The
npm pkg set name=... rewrite at publish time avoids that โ
both registries get the build artefact you want, with the name each
registry's consumers expect.
It's three lines of YAML for an entire second registry. Hard to argue with that.
Want to ship your own MCP server end-to-end?
The full guide ships every line of code that powers Meridian's routing
pipeline โ including the dual-registry release workflow above and the
NPM_TOKEN / GITHUB_TOKEN setup that goes with it.
Both v2.0.0 packages went live on 2026-05-06 from this workflow run. Public on npmjs.com and GitHub Packages.