Skip to content

[BUG] npm unpublish <pkg>@<version> deleted the whole package without --force #10116

Description

@meganemura

Is there an existing issue for this?

Related, not the same: #4924 and #7015 report a version-specific unpublish that removed every version, but with --force. Here --force was not given, and the "Refusing to delete the last version" check did not stop it.

This issue exists in the latest npm version

Reproduced on npm 11.19.0 and on latest of this repository (83ea1a1).

Current Behavior

A package of mine (name left out here) had three versions: 0.0.0-stage, 0.0.1, and 0.1.0. 0.1.0 was published about six minutes earlier, through a trusted publisher. Then:

npm unpublish <pkg>@0.0.1

printed - <pkg>@0.0.1 and exited 0, but the registry removed the whole package: all three versions. The debug log shows a whole-package DELETE, and no PUT:

16 http fetch GET 200 https://registry.npmjs.org/<pkg>?write=true 395ms (cache miss)
17 http fetch GET 200 https://registry.npmjs.org/<pkg>?write=true 194ms (cache miss)
18 http fetch DELETE 401 https://registry.npmjs.org/<pkg>/-rev/3-56bf641ba8f19a0f2c2bd1d5197c6040 221ms
...
25 http fetch DELETE 200 https://registry.npmjs.org/<pkg>/-rev/3-56bf641ba8f19a0f2c2bd1d5197c6040 814ms
30 verbose exit 0

(The 401 is the web login; the retry after it is the same request.)

The package can now not be published for 24 hours, and the three version numbers can never be used again.

The cause in the code:

  • lib/commands/unpublish.js refuses when versions.length === 1 && spec.rawSpec === versions[0]. It reads versions with pacote.packument(), which requests the abbreviated packument (Accept: application/vnd.npm.install-v1+json).
  • libnpmpublish's unpublish() reads the full packument (Accept: */*) and deletes the whole package when that document has one version or none.

The check and the deletion read two different documents. When the full one lists fewer versions than the abbreviated one, the check passes and the deletion removes the whole package. Request 16 in the log is the check and request 17 is libnpmpublish. We cannot see from the log why the full document had one version or none at revision 3. The new 0.1.0 not being in it yet is our guess.

A full packument with no versions also passes the check (0 !== 1) and deletes the whole package.

Expected Behavior

npm unpublish <pkg>@<version> without --force either removes that one version, or refuses with "Refusing to delete the last version of the package". It never deletes the whole package.

Steps To Reproduce

  1. Save this mock registry as mock-registry.mjs. Its abbreviated document lists 0.0.1 and 0.1.0; its full document lists only 0.0.1:

    import http from 'node:http'
    const v = (n) => ({ name: 'demo-pkg', version: n, dist: { tarball: `http://localhost:4873/demo-pkg/-/demo-pkg-${n}.tgz` } })
    const abbreviated = { name: 'demo-pkg', 'dist-tags': { latest: '0.1.0' }, versions: { '0.0.1': v('0.0.1'), '0.1.0': v('0.1.0') } }
    const full = { _id: 'demo-pkg', name: 'demo-pkg', _rev: '3-abc', 'dist-tags': { latest: '0.0.1' }, versions: { '0.0.1': v('0.0.1') } }
    http.createServer((req, res) => {
      console.log(req.method, req.url, (req.headers.accept ?? '').slice(0, 30))
      if (req.method === 'GET') {
        res.setHeader('content-type', 'application/json')
        return res.end(JSON.stringify(req.headers.accept?.startsWith('application/vnd.npm.install-v1') ? abbreviated : full))
      }
      res.end('{}')
    }).listen(4873)
  2. node mock-registry.mjs

  3. In another shell: echo '//localhost:4873/:_authToken=fake' > .npmrc and npm unpublish demo-pkg@0.0.1 --registry http://localhost:4873/ --userconfig ./.npmrc

  4. npm prints - demo-pkg@0.0.1. The registry log shows the same three requests as the real log above:

    GET /demo-pkg?write=true application/vnd.npm.install-v1
    GET /demo-pkg?write=true */*
    DELETE /demo-pkg/-rev/3-abc */*
    

Environment

  • npm: 11.19.0
  • Node.js: 26.7.0
  • OS Name: macOS (arm64)
  • npm config: registry https://registry.npmjs.org/, no other settings

Activity

  1. meganemura commented on Oct 12, 2026

    @meganemura
    Author

    Follow-up on the cause. The machine that ran the command uses Aikido safe-chain, which proxies npm's registry requests and removes every version newer than its minimum package age (48 hours by default) from package metadata, including the ?write=true reads. All three versions were under three hours old, so both GETs in the log returned versions: {}. That matches the log better than the abbreviated/full mismatch described above: the check saw 0 versions (0 !== 1), and libnpmpublish took its no-versions branch.

    #10117 covers this case too: it treats a packument with no versions as the last version, so the command is refused. The safe-chain side is reported separately: AikidoSec/safe-chain#672.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions