A packed tarball proves the files are right. It does not prove the release is right — that the version resolves through a caret range, that the dist-tag lands where intended, that a workspace publishes in dependency order, that a postinstall script behaves on a real install. Those failures show up in production releases:

npm error notarget No matching version found for @acme/core@workspace:^

A disposable local registry lets the whole publish happen for real, against a host you can delete afterwards.


Root Cause

The gap between “the tarball is correct” and “the release works” contains everything the registry does: version indexing, range resolution, dist-tag assignment, and metadata that consumers resolve against. None of that is exercised by installing a tarball by path, because a path install bypasses resolution entirely.

A local registry closes the gap by being a real registry. Verdaccio, the common choice, stores what you publish and proxies everything else to an uplink, so a consumer project pointed at it can install your package and its dependencies without the public registry knowing anything happened.


Minimal Reproduction

npm pack --silent
d=$(mktemp -d) && mv ./*.tgz "$d/" && cd "$d"
npm init -y >/dev/null
npm install ./*.tgz --silent
node -e "require('@acme/my-library')"

This passes. Then the real publish fails, because the manifest still contained a workspace:^ range that npm pack preserved verbatim and a path install never had to resolve.


What Each Rehearsal Covers

Two rehearsals, different coverage Installing a packed tarball verifies file contents, exports resolution and runtime imports. A local registry additionally verifies the publish step, version range resolution, dist-tag assignment and multi-package publish ordering. The local registry covers the steps a tarball install skips pack and install file set in the tarball exports map resolution runtime import in both formats type resolution against the pack not covered: the publish itself local registry everything on the left, plus version range resolution dist-tag assignment workspace publish ordering and lifecycle scripts on install

Step-by-Step Fix

# verdaccio.config.yaml
storage: ./storage
uplinks:
  npmjs:
    url: https://registry.npmjs.org/
packages:
  '@acme/*':
    access: $all
    publish: $anonymous          # local only — never use this configuration elsewhere
  '**':
    access: $all
    proxy: npmjs
logs: { type: stdout, format: pretty, level: warn }
npx verdaccio --config verdaccio.config.yaml --listen 4873 &
sleep 2 && curl -sf http://localhost:4873/-/ping && echo "registry up"

The '**' block with proxy: npmjs is what makes a full consumer install work — anything the local registry does not hold is fetched from the public one and cached.

2. Publish for real, against the local host

npm publish --registry=http://localhost:4873
npm notice publishing to http://localhost:4873
+ @acme/[email protected]

If this fails, it fails for the same reasons a real publish would: an unresolved workspace: range, a missing access setting, a prepublishOnly script that does not pass.

3. Install as a consumer would, through a range

Consumer install through the local registry A test project installs the package through a caret range from the local registry, while its other dependencies are transparently proxied from the public registry and cached. One registry, two sources, a realistic install test project npm install @acme/my-library localhost:4873 stores what you publish local storage @acme/[email protected] uplink proxy everything else, cached
d=$(mktemp -d); cd "$d"
npm init -y >/dev/null
printf 'registry=http://localhost:4873\n' > .npmrc

npm install "@acme/my-library@^1.4.0" --silent
npm ls @acme/my-library
node --input-type=module -e "const m = await import('@acme/my-library'); console.log('ok:', Object.keys(m).length)"

Expected result: the range resolves to 1.4.0, dependencies install through the proxy, and the import succeeds.

4. Wire it into CI as a release rehearsal

- name: Start local registry
  run: |
    npx verdaccio --config .github/verdaccio.yaml --listen 4873 &
    npx wait-on http://localhost:4873/-/ping

- name: Publish to local registry
  run: npm publish --registry=http://localhost:4873

- name: Install and smoke-test as a consumer
  run: |
    mkdir -p /tmp/consumer && cd /tmp/consumer
    npm init -y
    npm install "@acme/my-library@${{ github.ref_name }}" --registry=http://localhost:4873
    node --input-type=module -e "await import('@acme/my-library')"

HAZARD PREVENTION

Symptom: The rehearsal passes, then the real publish fails with EPUBLISHCONFLICT.

Root cause: The version was already published to the real registry by an earlier run, and the rehearsal — being a different registry — had no knowledge of it.

Fix: Query the real registry for the version before publishing anywhere (npm view "$PKG@$VERSION" version), and treat the rehearsal as a check on process rather than on version availability.


Verification

# the registry holds what you published
curl -sf http://localhost:4873/@acme%2fmy-library | node -e '
  const d = JSON.parse(require("fs").readFileSync(0,"utf8"));
  console.log("versions:", Object.keys(d.versions).join(", "), "| latest:", d["dist-tags"].latest);
'

# and the public registry knows nothing about it
npm view @acme/[email protected] version --registry=https://registry.npmjs.org 2>&1 | head -1

# teardown leaves nothing behind
kill %1 2>/dev/null; rm -rf ./storage && echo "registry stopped and storage removed"

Expected: the version listed locally, an E404 from the public registry, and a clean teardown.


Edge Cases / Gotchas

  • Version immutability applies locally too. Republishing the same version fails until the storage directory is cleared, which is the correct behaviour and catches people mid-loop.
  • A global registry setting is easy to leave behind. Configure the local registry in a temporary project’s .npmrc, never with npm config set registry, or every unrelated install on the machine goes through a stopped server.
  • The proxy caches aggressively. A dependency published upstream during your session may not appear until the cache is cleared, which occasionally looks like a resolution bug in your own package.
  • Anonymous publish is a local-only convenience. The permissive configuration shown here is appropriate for a throwaway instance and dangerous anywhere reachable by other machines.
  • Provenance cannot be rehearsed. A local registry does not implement attestation storage, so --provenance fails against it; rehearse the rest and test provenance separately against the real registry.

Frequently Asked Questions

What does a local registry test that pack-and-install does not?

The publish itself and everything downstream: range resolution, dist-tag behaviour, workspace publish order, and lifecycle scripts on a real install.

Does a local registry need the real dependencies mirrored?

No. It proxies whatever it does not hold to a configured uplink, so a fresh instance serves the whole public registry while storing only what you publish.

Can the same version be published repeatedly during testing?

Not without clearing storage — immutability is enforced locally too. Delete the storage directory between runs, or publish a throwaway prerelease each time.

Is it safe to point the default registry at a local instance?

For a temporary test project, yes, and it is the point. Restrict it to that directory’s .npmrc rather than a global setting.

Does this work for a workspace with several packages?

Yes, and it is where the technique earns most — publishing every package in dependency order exercises the workspace protocol rewrite and the ordering, both invisible to a single-package rehearsal.



↑ Back to Publishing to Private and Alternative Registries