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/***
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:
-
The URL is masked in non-interactive output. Run
npm publishfrom 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). -
An expired session token fails with 404, not 401. After the token in
~/.npmrchad been revoked,npm whoamireturnedE401 Unauthorized, butnpm publishreturned:
npm error 404 Not Found - PUT https://registry.npmjs.org/my-package - Not found
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
}
}
Locally that fails immediately:
npm error code EUSAGE
npm error Automatic provenance generation not supported for provider: null
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
--provenanceandpermissions: 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
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
npm trust list my-package then shows what you granted:
type: github
file: publish.yml
repository: my-org/my-repo
permissions: publish, stage publish
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"
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)
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" }
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
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
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.
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
Checklist I now run before publishing anything
-
npm whoami— is the token still alive? - Is this the first publish of the name? Then plan for a direct/interactive publish, not a staged one.
- Does
package.jsoncarry adsh.bundle/ framework manifest that the host can actually load? (For DeepSeek Harness plugins,dsh.clientalone is not installable — the most common rejection reason on the community list.) - Are there guessed upper bounds in
peerDependencies? A>=1.0.0 <2.0.0on 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. - 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)