<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dogwind</title>
    <description>The latest articles on DEV Community by Dogwind (@dogwind221).</description>
    <link>https://dev.to/dogwind221</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4208790%2Fd2c5669e-c0b2-432a-8fe3-7c961b63fedb.jpg</url>
      <title>DEV Community: Dogwind</title>
      <link>https://dev.to/dogwind221</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dogwind221"/>
    <language>en</language>
    <item>
      <title>Three gates before your first npm publish: 2FA, provenance, and staged releases</title>
      <dc:creator>Dogwind</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:25:37 +0000</pubDate>
      <link>https://dev.to/dogwind221/three-gates-before-your-first-npm-publish-2fa-provenance-and-staged-releases-4jcb</link>
      <guid>https://dev.to/dogwind221/three-gates-before-your-first-npm-publish-2fa-provenance-and-staged-releases-4jcb</guid>
      <description>&lt;p&gt;I published a small plugin to npm for the first time last week. It is a DeepSeek Harness plugin called &lt;a href="https://www.npmjs.com/package/dsh-img2img-config" rel="noopener noreferrer"&gt;&lt;code&gt;dsh-img2img-config&lt;/code&gt;&lt;/a&gt; — 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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Gate 1 — 2FA is a browser flow, not a code
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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/***
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The URL is masked in non-interactive output.&lt;/strong&gt; Run &lt;code&gt;npm publish&lt;/code&gt; from a TTY and the prompt is obvious. Run it from a script, a background job, or an agent, and you get &lt;code&gt;***&lt;/code&gt; 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).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An expired session token fails with 404, not 401.&lt;/strong&gt; After the token in &lt;code&gt;~/.npmrc&lt;/code&gt; had been revoked, &lt;code&gt;npm whoami&lt;/code&gt; returned &lt;code&gt;E401 Unauthorized&lt;/code&gt;, but &lt;code&gt;npm publish&lt;/code&gt; returned:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm error 404 Not Found - PUT https://registry.npmjs.org/my-package - Not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;npm whoami&lt;/code&gt; before you start debugging the package.&lt;/p&gt;

&lt;p&gt;One more detail worth knowing early: npm is tightening bypass-2FA tokens — account changes first, direct publishing later. The deprecation notice points at &lt;a href="https://gh.io/npm-gat-bypass2fa-deprecation" rel="noopener noreferrer"&gt;gh.io/npm-gat-bypass2fa-deprecation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate 2 — &lt;code&gt;provenance: true&lt;/code&gt; cannot run on your laptop
&lt;/h2&gt;

&lt;p&gt;The package had this in &lt;code&gt;package.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"publishConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"provenance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally that fails immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm error code EUSAGE
npm error Automatic provenance generation not supported for provider: null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;null&lt;/code&gt; 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 &lt;a href="https://slsa.dev/" rel="noopener noreferrer"&gt;SLSA&lt;/a&gt; attestation: it says &lt;em&gt;this tarball was built by this workflow, from this commit, in this repository&lt;/em&gt;. A laptop has no such identity to vouch for.&lt;/p&gt;

&lt;p&gt;So you have two options, and they are not interchangeable:&lt;/p&gt;

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

&lt;p&gt;The CI route is not much more work. A minimal workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;publish&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;          &lt;span class="c1"&gt;# required for provenance / trusted publishing&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
          &lt;span class="na"&gt;registry-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://registry.npmjs.org&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g npm@latest&lt;/span&gt;   &lt;span class="c1"&gt;# OIDC publishing needs a recent npm&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm publish --access public --provenance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that workflow present in the repository, configure the trusted publisher &lt;strong&gt;once&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm trust github my-package &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; publish.yml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repo&lt;/span&gt; my-org/my-repo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-publish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npm trust list my-package&lt;/code&gt; then shows what you granted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;type: github
file: publish.yml
repository: my-org/my-repo
permissions: publish, stage publish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;You can check the result from the registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/-/npm/v1/attestations/my-package@0.1.0 | jq &lt;span class="s1"&gt;'.attestations[].predicateType'&lt;/span&gt;
&lt;span class="c"&gt;# "https://github.com/npm/attestation/tree/main/specs/publish/v0.1"&lt;/span&gt;
&lt;span class="c"&gt;# "https://slsa.dev/provenance/v1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gate 3 — stage-only tokens and the &lt;code&gt;0.0.0-stage&lt;/code&gt; placeholder
&lt;/h2&gt;

&lt;p&gt;The first API token I created was allowed to publish, but only &lt;strong&gt;to the staging area&lt;/strong&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The message is oddly specific and worth reading twice: &lt;strong&gt;the first publish of a name needs a direct publish capability.&lt;/strong&gt; Staged publishing is a mechanism for &lt;em&gt;subsequent&lt;/em&gt; versions.&lt;/p&gt;

&lt;p&gt;What you get when a staged path does run is a placeholder version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;versions: ["0.0.0-stage", "0.1.0"]
_dist-tags: { latest: "0.1.0" }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0.0.0-stage&lt;/code&gt; 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 &lt;code&gt;latest&lt;/code&gt; to your real version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm dist-tag add my-package@0.1.0 latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful commands from this family: &lt;code&gt;npm stage publish&lt;/code&gt;, &lt;code&gt;npm stage list&lt;/code&gt;, &lt;code&gt;npm stage view &amp;lt;id&amp;gt;&lt;/code&gt;, &lt;code&gt;npm stage approve &amp;lt;id&amp;gt;&lt;/code&gt;, &lt;code&gt;npm stage reject &amp;lt;id&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody warns you about: propagation is not instant
&lt;/h2&gt;

&lt;p&gt;The CI run printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm notice Your package is being processed and may take a few minutes to become available.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if a "successful" publish does not show up immediately, wait and re-check the registry directly instead of re-running the publish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; https://registry.npmjs.org/my-package/0.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Checklist I now run before publishing anything
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  What actually shipped
&lt;/h2&gt;

&lt;p&gt;Three attempts, one evening:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;0.1.0&lt;/code&gt; — published by hand, with browser authorization and no provenance.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0.1.1&lt;/code&gt; — published from GitHub Actions through the trusted publisher, with a &lt;a href="https://registry.npmjs.org/-/npm/v1/attestations/dsh-img2img-config@0.1.1" rel="noopener noreferrer"&gt;SLSA provenance attestation&lt;/a&gt; and a fixed peer range.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The package: &lt;a href="https://www.npmjs.com/package/dsh-img2img-config" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/dsh-img2img-config&lt;/a&gt;&lt;br&gt;
The repository: &lt;a href="https://github.com/Dogwind221/img2img-studio" rel="noopener noreferrer"&gt;https://github.com/Dogwind221/img2img-studio&lt;/a&gt;&lt;/p&gt;

</description>
      <category>npm</category>
      <category>githubactions</category>
      <category>opensource</category>
      <category>supplychain</category>
    </item>
  </channel>
</rss>
