DEV Community

Dogwind
Dogwind

Posted on

Three gates before your first npm publish: 2FA, provenance, and staged releases

I published a small plugin to npm for the first time last week. It is a DeepSeek Harness plugin called dsh-img2img-config — a composer image editor plus a provider settings panel. The code was done in a day; getting it onto the registry took three separate attempts, each blocked by a different gate.

None of the errors said what the gate was for. They said things like EUSAGE, E403, and 404 Not Found — the last one while publishing a package that did not exist yet. This post is the write-up I wish I had found first.

Gate 1 — 2FA is a browser flow, not a code

The account had 2FA set up with a security key, not a TOTP app. npm publish does not ask for a six-digit code in that case, so the first attempt looked like this:

npm error code EOTP
npm error This operation requires a one-time password.
npm error Open this URL in your browser to authenticate:
npm error   https://www.npmjs.com/auth/cli/***
Enter fullscreen mode Exit fullscreen mode

The URL is the actual gate. Open it in a browser that is already logged in to npm, confirm with the security key, and the waiting CLI continues. Two things make this confusing:

  1. The URL is masked in non-interactive output. Run npm publish from a TTY and the prompt is obvious. Run it from a script, a background job, or an agent, and you get *** and no way to complete the flow. If you are automating, you cannot be the thing that authorizes a new package by hand — that is what trusted publishing is for (Gate 2).
  2. An expired session token fails with 404, not 401. After the token in ~/.npmrc had been revoked, npm whoami returned E401 Unauthorized, but npm publish returned:
npm error 404 Not Found - PUT https://registry.npmjs.org/my-package - Not found
Enter fullscreen mode Exit fullscreen mode

For a brand-new package name, the registry does not tell you whether the name is taken or whether you are simply not authenticated. It returns 404 either way. If a publish fails with 404 on a name you know is free, check npm whoami before you start debugging the package.

One more detail worth knowing early: npm is tightening bypass-2FA tokens — account changes first, direct publishing later. The deprecation notice points at gh.io/npm-gat-bypass2fa-deprecation.

Gate 2 — provenance: true cannot run on your laptop

The package had this in package.json:

{
  "publishConfig": {
    "access": "public",
    "provenance": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Locally that fails immediately:

npm error code EUSAGE
npm error Automatic provenance generation not supported for provider: null
Enter fullscreen mode Exit fullscreen mode

null here means "I am not running inside a supported CI provider, so I have no OIDC identity to sign a build statement with". Provenance is a SLSA attestation: it says this tarball was built by this workflow, from this commit, in this repository. A laptop has no such identity to vouch for.

So you have two options, and they are not interchangeable:

  • Publish locally with --no-provenance. The package goes up without an attestation. Fine for a first version if you just need the name and a working tarball.
  • Publish from CI with --provenance and permissions: id-token: write. This is the one that produces the "Built and signed on GitHub Actions" badge on the package page.

The CI route is not much more work. A minimal workflow:

name: publish
on:
  workflow_dispatch:

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write          # required for provenance / trusted publishing
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
      - run: npm install -g npm@latest   # OIDC publishing needs a recent npm
      - run: npm publish --access public --provenance
Enter fullscreen mode Exit fullscreen mode

With that workflow present in the repository, configure the trusted publisher once:

npm trust github my-package \
  --file publish.yml \
  --repo my-org/my-repo \
  --allow-publish
Enter fullscreen mode Exit fullscreen mode

npm trust list my-package then shows what you granted:

type: github
file: publish.yml
repository: my-org/my-repo
permissions: publish, stage publish
Enter fullscreen mode Exit fullscreen mode

From that point on, a workflow_dispatch run publishes with a signed attestation and no long-lived token. That is the whole point: the gate moves from "a human proves presence with a security key" to "a workflow proves identity with OIDC".

You can check the result from the registry:

curl -s https://registry.npmjs.org/-/npm/v1/attestations/my-package@0.1.0 | jq '.attestations[].predicateType'
# "https://github.com/npm/attestation/tree/main/specs/publish/v0.1"
# "https://slsa.dev/provenance/v1"
Enter fullscreen mode Exit fullscreen mode

Gate 3 — stage-only tokens and the 0.0.0-stage placeholder

The first API token I created was allowed to publish, but only to the staging area. For an existing package that is fine — you stage a version, review it, approve it. For a package that has never been published, npm refuses:

npm error 403 Forbidden - PUT https://registry.npmjs.org/my-package
npm error Cannot publish "my-package": this token can only publish to a staging area,
npm error and "my-package" does not exist yet. Create it first with a direct-capable
npm error token, then use `npm stage publish`. (E_STAGE_REQUIRED)
Enter fullscreen mode Exit fullscreen mode

The message is oddly specific and worth reading twice: the first publish of a name needs a direct publish capability. Staged publishing is a mechanism for subsequent versions.

What you get when a staged path does run is a placeholder version:

versions: ["0.0.0-stage", "0.1.0"]
_dist-tags: { latest: "0.1.0" }
Enter fullscreen mode Exit fullscreen mode

0.0.0-stage is npm's own placeholder for staged publishing — its description literally reads "Temporary package placeholder for staged publishing". Do not unpublish it; it is the name reservation that lets the staged flow exist. Make sure you set latest to your real version:

npm dist-tag add my-package@0.1.0 latest
Enter fullscreen mode Exit fullscreen mode

Useful commands from this family: npm stage publish, npm stage list, npm stage view <id>, npm stage approve <id>, npm stage reject <id>.

The part nobody warns you about: propagation is not instant

The CI run printed:

npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access
npm notice publish Signed provenance statement with source and build information from GitHub Actions
+ my-package@0.1.0
Enter fullscreen mode Exit fullscreen mode

Green checkmarks, + my-package@0.1.0, done — except the version was not on the registry yet. For a few minutes, GET /my-package/0.1.0 returned 404, latest still pointed at the previous version, and the tarball URL 404'd. npm says as much, one line earlier:

npm notice Your package is being processed and may take a few minutes to become available.
Enter fullscreen mode Exit fullscreen mode

So if a "successful" publish does not show up immediately, wait and re-check the registry directly instead of re-running the publish:

curl -s -o /dev/null -w '%{http_code}\n' https://registry.npmjs.org/my-package/0.1.0
Enter fullscreen mode Exit fullscreen mode

Checklist I now run before publishing anything

  1. npm whoami — is the token still alive?
  2. Is this the first publish of the name? Then plan for a direct/interactive publish, not a staged one.
  3. Does package.json carry a dsh.bundle / framework manifest that the host can actually load? (For DeepSeek Harness plugins, dsh.client alone is not installable — the most common rejection reason on the community list.)
  4. Are there guessed upper bounds in peerDependencies? A >=1.0.0 <2.0.0 on a host package becomes a silent refusal on the next major release. See deepseek-ai/deepseek-harness#10776 for the failure mode: the module resolves, apply() never runs, and the host logs nothing. Pin "no upper bounds" with a test rather than a comment.
  5. Prefer the trusted-publisher path from day one: workflow + OIDC + --provenance. No stored token, and the supply-chain evidence is a side effect.

What actually shipped

Three attempts, one evening:

  • 0.1.0 — published by hand, with browser authorization and no provenance.
  • 0.1.1 — published from GitHub Actions through the trusted publisher, with a SLSA provenance attestation and a fixed peer range.

The gates are not arbitrary. 2FA asks "is a human here?"; provenance asks "which build produced this artifact, and can anyone check?"; staged publishing asks "is this name already owned, and by whom?". Each one is answered by a different mechanism, which is why the first publish feels like three unrelated failures instead of one publishing model.

The package: https://www.npmjs.com/package/dsh-img2img-config
The repository: https://github.com/Dogwind221/img2img-studio

Top comments (0)