Publishing Scoped Packages to GitHub Packages
Publish an npm package to GitHub Packages: the scope-must-match-owner rule, workflow permissions, GITHUB_TOKEN limits, and installing from another repository.
The first publish to GitHub Packages usually fails, and the error message points in the wrong direction:
npm error code E403
npm error 403 Forbidden - PUT https://npm.pkg.github.com/@myscope%2fmy-library
npm error 403 Permission permission_denied: write_package
That looks like a credentials problem. It is nearly always a naming rule: GitHub Packages requires the package scope to match the account or organisation that owns the repository, and no token can override that.
Root Cause
GitHub Packages namespaces every package under its owner. A repository at github.com/acme/my-library can publish @acme/my-library and nothing else — not @myscope/my-library, not an unscoped my-library. The registry derives the permitted namespace from the authenticated identity and the repository, so a mismatch is rejected as a permission failure rather than a validation error, which is why the message misleads.
Two further rules follow from the same design. The repository field in the manifest is used to associate the package with a repository, so an absent or incorrect one produces its own class of failure. And every read, including of public packages, requires authentication — GitHub Packages has no anonymous access tier.
Minimal Reproduction
{
"name": "@myscope/my-library",
"version": "1.0.0",
"publishConfig": { "registry": "https://npm.pkg.github.com" }
}
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Repository owner: acme. Package scope: @myscope. Result: 403, on every attempt, with any token.
The Ownership Rule
Step-by-Step Fix
1. Rename the package to match the owner
{
- "name": "@myscope/my-library",
+ "name": "@acme/my-library",
"version": "1.0.0"
}
If the package already exists on the public registry under the old name, this is a rename rather than a configuration change and needs its own migration — the two names are different packages to every consumer.
2. Add the repository field and pin the destination
{
"name": "@acme/my-library",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"repository": {
"type": "git",
"url": "git+https://github.com/acme/my-library.git"
}
}
3. Grant the workflow packages write permission
name: Publish
on:
push:
tags: ['v*']
permissions:
contents: read
packages: write # required — the default token cannot publish without it
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22.11.0'
registry-url: 'https://npm.pkg.github.com'
scope: '@acme'
- run: npm ci
- run: npm run build
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expected result: the publish succeeds and the package appears under the repository’s Packages tab.
HAZARD PREVENTION
Symptom: The publish succeeds from CI but fails from a maintainer’s laptop with
401 Unauthorized.Root cause: A local
~/.npmrchas a token for the public registry but none fornpm.pkg.github.com, and npm does not fall back between hosts.Fix: Add a per-host auth line locally (
//npm.pkg.github.com/:_authToken=${GH_PACKAGES_TOKEN}) referencing an environment variable, and prefer publishing from CI so the laptop path is rarely exercised at all.
4. Configure consumers, including for public packages
# consumer .npmrc
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GH_PACKAGES_TOKEN}
For a consuming workflow in a different repository, the built-in token is not enough — it has no rights to another repository’s packages — so a personal access token with read:packages, or an organisation-level token, has to be supplied as a secret.
Verification
# the package exists on the intended registry
npm view @acme/my-library version --registry=https://npm.pkg.github.com
# and not on the public one
npm view @acme/my-library version --registry=https://registry.npmjs.org 2>&1 | head -1
# a clean consumer install works with only the documented configuration
d=$(mktemp -d); cd "$d" && npm init -y >/dev/null
printf '@acme:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=%s\n' "$GH_PACKAGES_TOKEN" > .npmrc
npm install @acme/my-library --silent && echo "consumer install ok"
Expected: a version number, an E404 from the public registry, and consumer install ok.
Edge Cases / Gotchas
- Deleting a package version is permanent and rate-limited. GitHub allows deletion under conditions that differ from npm’s 72-hour window; treat a mistaken publish as permanent and recover forward.
- The Packages tab shows the repository association, not the source. A package published from a workflow in a different repository can appear under an unexpected project if the
repositoryfield disagrees. - Organisation package visibility is separate from repository visibility. A package can be private while its repository is public, and the defaults differ between organisation settings, which surprises people migrating from npm.
npm auditbehaves differently. Advisory data comes from the public registry, so vulnerabilities in packages hosted only on GitHub Packages are not reported by the standard audit workflow.- Installing in a fork requires the fork owner’s token. A contributor who forks the repository cannot install the package with their fork’s
GITHUB_TOKEN, because the namespace still belongs to the upstream owner — which makes external contribution workflows noticeably more awkward than they are on the public registry, and is worth mentioning explicitly in a contributing guide. - Scope renames break every consumer. Moving from
@myscopeto@acmeis a new package name; publish a final version of the old name that is deprecated with a pointer to the new one.
One last consideration before committing to this registry: everything above is configuration that every consumer inherits. For an internal package inside one organisation that cost is paid once by a platform team and forgotten. For a package intended to be adopted outside it, the authentication requirement is a permanent adoption barrier that no amount of documentation removes, and the public registry is almost always the better home.
Frequently Asked Questions
Why does my publish fail with a 403 even though the token is valid?
The scope must match the account or organisation that owns the repository. Any other scope returns a permission error that reads like authentication but is a naming rule.
Can the built-in GITHUB_TOKEN publish packages?
Yes, for packages owned by the same repository, provided the workflow grants packages: write. It cannot publish to another repository’s namespace or install private packages from a different repository.
Do consumers need authentication for public GitHub Packages?
Yes — a token is required even for public packages, so every consumer and pipeline needs credentials. This is the biggest practical difference to weigh before choosing it for externally-consumed code.
Does GitHub Packages support provenance attestations?
No. The --provenance flag targets the public npm registry’s attestation storage, so publishing here means giving up the signed link between tarball and commit.
Can one package be published to both GitHub Packages and npm?
Yes, by running publish twice with an explicit registry override each time. Keep versions identical and publish the same tarball, or the two histories diverge under one version number.
Related
- Publishing to Private and Alternative Registries — the surrounding configuration and the choice between registries.
- Configuring .npmrc Auth Tokens in CI — supplying the credentials this workflow needs.
- npm Provenance & Sigstore Attestation — what is given up by publishing here instead.