All notes

npm Trusted Publishing: what to set up first

Publishing to npm from GitHub Actions without a token takes a few steps in a fixed order. This is the order that worked for @spatz/cli.

You want GitHub Actions to publish your package to npm. The old way is an npm token stored as a repository secret, and a token works for whoever holds it. Trusted publishing removes the token, but the setup has an order, and one step can't happen until another is done.

This is the order that worked for the @spatz/cli packages of spatz (opens in a new tab): one launcher package and five platform packages.

The idea

With trusted publishing, npm keeps a short description of who may publish a package: this GitHub owner, this repository, this workflow file, and optionally this environment. When the workflow runs, GitHub gives it a signed ID for that run through OpenID Connect (OIDC, a standard way for one service to prove to another who it is). npm checks the ID against the description and lets the publish through.

Think of a guest list at a door instead of a spare key. A key works for whoever holds it. A guest list only lets in the people it names, and they show an ID at the door. Where it stops fitting: the ID belongs to one workflow run, not to a person.

Own the scope

A scoped package name like @spatz/cli belongs to the scope @spatz. The scope is either your npm username or an npm organization you're a member of. For spatz, spatz is an organization, and I'm its owner. Create the organization on npmjs.com before anything else. You can check who is in it:

npm org ls spatz

Publish a placeholder first

npm only lets you configure a trusted publisher for a package that already exists on the registry. The CLI help for npm trust says so in its prerequisites. So the first version of each package has to come from your own machine.

For spatz, I published a 0.0.0 placeholder of all six packages by hand. Each one holds only a package.json and a readme, with the description "Placeholder for spatz. Real releases are published from GitHub Actions." A minimal package.json is enough:

{
  "name": "@your-scope/your-package",
  "version": "0.0.0",
  "description": "Placeholder. Real releases come from GitHub Actions."
}

Then publish it from that folder:

npm publish --access public

Scoped packages are private by default, so --access public matters here. A free account can't publish private scoped packages, and npm answers with an HTTP 402 error. If your account uses two-factor authentication (2FA), npm asks for a one-time password.

The placeholder stays on the registry. The spatz release workflow counts 0.0.0 as the lowest version, so the first real release still becomes latest.

Add the trusted publisher

Now configure each package. You can do it in the package settings on npmjs.com, or with npm trust in npm 11.15.0 or newer. For spatz, the values were:

FieldValue
GitHub ownerlorenzh
Repositoryspatz
Workflow filenpm-publish.yml
Environmentnpm

On the command line, that's one call per package:

npm trust github @spatz/cli \
  --file npm-publish.yml \
  --repo lorenzh/spatz \
  --env npm \
  --allow-publish

--file is the workflow's file name, not its path. --allow-publish is the permission: the help requires at least one permission flag. Your account needs 2FA turned on for npm trust, and the first call asks for it. A package can have only one trusted publisher. To change it, list it with npm trust list, remove it with npm trust revoke, and create the new one.

That matters, because the environment wasn't there from the start. The first version of the spatz setup left it empty, and a later commit added it.

Turn off tokens

Once the trusted publisher works, tokens are only a risk. npm's trusted publishing docs recommend the package setting "Require two-factor authentication and disallow tokens". On the command line:

npm access set mfa=publish @spatz/cli

In the npm CLI source, mfa=publish means 2FA is required and tokens can't skip it. People still publish by hand with 2FA, and the workflow publishes through the trusted publisher.

The workflow

The publish job needs three things:

  • The permission id-token: write. Without it, GitHub doesn't hand the job an OIDC ID.
  • npm 11.5.1 or newer. npm's docs name that as the minimum for trusted publishing. The spatz workflow checks the version and installs 11.5.1 if the runner's npm is older.
  • No npm token. The spatz workflow uses no NPM_TOKEN secret.

The job settings look like this:

publish:
  runs-on: ubuntu-latest
  environment: npm
  permissions:
    contents: read
    id-token: write

Each publish gets provenance, a signed record of which repository and workflow built the package. npm's docs say trusted publishing adds it by default. The spatz workflow also passes --provenance. On the registry, @spatz/cli@0.1.5 has a provenance record, and the hand-published 0.0.0 has none.

Lock it to main

The trusted publisher checks the workflow's file name, not its content. So who can change that file matters.

A GitHub environment is a named deployment target with its own rules. The spatz environment npm only allows deployments from main. Because the trusted publisher names that environment, npm only accepts runs that use it. A copy of the workflow edited on another branch can't use the environment, so it can't publish.

Set this up in the repository settings under Environments: create the environment, then limit its deployment branches to main. Then add the environment name to the trusted publisher and to the job.

Back to top