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/***
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
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.
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 --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
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
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: <npmjs.com>
The repository: <Dogwind221/img2img-studio>
1 reaction
Mentions
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/*** 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 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. 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 --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 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 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: <npmjs.com> The repository: <Dogwind221/img2img-studio>