EngineeringLast Updated Nov 13, 202611 min read

Publishing npm packages with CircleCI

CircleCI

Continuous Integration and Delivery

This tutorial was originally published as a guest post by Armando Canals, co-founder at packagecloud. It was updated in October 2026 for CircleCI configuration version 2.1, a current Node.js LTS image, and npm trusted publishing.

This post will cover the necessary steps to implement a continuous deployment pipeline for Node.js projects using CircleCI.

We’ll go over setting up a project’s CircleCI configuration, running tests in CircleCI, and publishing packages to the official npm registry when a tagged commit is pushed to a git repository. The publish step uses npm trusted publishing, so there’s no npm token to store or rotate.

npm and npm packages

npm is a package manager for the JavaScript programming language, and it’s the default package manager bundled with the Node.js JavaScript run-time environment.

npm packages are used to distribute JavaScript code conveniently and reliably. The most common case for using npm packages is to distribute Node.js programs and easily install them on different systems.

Read more about npm packages on the npm docs site.

Why publish npm packages?

If you want to share Node.js programs and JavaScript libraries with other developers, business customers, or even Continuous Integration tools (like CircleCI), publishing npm packages to a registry is the easiest way to do so.

In this post, we’ll go over publishing packages to the official (public) npm registry and a private npm registry hosted on packagecloud.io.

Prerequisites

This tutorial requires:

Set up the example package

The example package is in the circleci-npm-example repository. It exports one function and has one Jest test.

Fork the repository to a GitHub account, then clone the fork:

git clone https://github.com/<your-github-username>/circleci-npm-example.git
cd circleci-npm-example

Package names are unique across the npm registry, so we’ll publish under our own npm scope. These commands set the package name and the repository field, then refresh the lockfile. Replace <your-npm-username> and <your-github-username> first:

npm pkg set name="@<your-npm-username>/circleci-npm-example"
npm pkg set repository.url="git+https://github.com/<your-github-username>/circleci-npm-example.git"
npm install
npm test

npm install updates the package name recorded in package-lock.json. The test script in package.json runs Jest. The files field in package.json lists hello.js as the only source file to publish, so the tests and the CircleCI configuration stay out of the published package. npm always includes package.json and README.md as well.

Commit and push the changes:

git add package.json package-lock.json
git commit -m "Publish under my npm scope"
git push

Configuring the npm registry with CircleCI

Add a .circleci/config.yml to your Node.js project

In the project root, create a folder called .circleci, and within that folder create a file called config.yml. The example repository already includes this file.

The following is the full example .circleci/config.yml file for this blog post:

version: 2.1

executors:
  node:
    docker:
      - image: cimg/node:24.22.0

jobs:
  test:
    executor: node
    steps:
      - checkout
      - restore_cache:
          keys:
            - npm-v1-{{ checksum "package-lock.json" }}
            - npm-v1-
      - run:
          name: Install dependencies
          command: npm ci
      - save_cache:
          key: npm-v1-{{ checksum "package-lock.json" }}
          paths:
            - ~/.npm
      - run:
          name: Run tests
          command: npm test
      - run:
          name: Pack the package
          command: |
            mkdir -p dist
            npm pack --pack-destination dist
      - persist_to_workspace:
          root: .
          paths:
            - dist

  publish:
    executor: node
    steps:
      - attach_workspace:
          at: .
      - run:
          name: Publish to npm with trusted publishing
          command: |
            NPM_ID_TOKEN="$(circleci run oidc get --claims '{"aud": "npm:registry.npmjs.org"}')"
            export NPM_ID_TOKEN
            npm publish ./dist/*.tgz --access public

workflows:
  test-and-publish:
    jobs:
      - test:
          filters: pipeline.git.branch or (pipeline.git.tag matches /^v[0-9]+\.[0-9]+\.[0-9]+$/)
      - publish:
          requires:
            - test
          filters: pipeline.git.tag matches /^v[0-9]+\.[0-9]+\.[0-9]+$/

The following sections explain the steps involved in each job.

Define an executor

The executors key defines the environment that both jobs run in, so the Docker image is declared only once:

executors:
  node:
    docker:
      - image: cimg/node:24.22.0

cimg/node is CircleCI’s Node.js convenience image. Pin a specific version tag so every build uses the same environment. cimg/node:24.22.0 runs Node.js 24, an LTS release, and includes npm 11.19.1. Trusted publishing from CircleCI requires npm 11.11.0 or later, so check the npm version before switching to a different image.

The circleci/node orb packages common Node.js jobs and commands as an alternative to writing these steps by hand. This tutorial uses plain steps so every command that runs before a publish is visible.

Job configuration

The jobs section of the config.yml defines the jobs that’ll run in the workflows, along with the steps for each job.

Test

The first job we’ve defined is the test job, which retrieves the project code, installs the dependencies, runs our tests, and packs the package:

jobs:
  test:
    executor: node
    steps:
      - checkout
      - restore_cache:
          keys:
            - npm-v1-{{ checksum "package-lock.json" }}
            - npm-v1-
      - run:
          name: Install dependencies
          command: npm ci
      - save_cache:
          key: npm-v1-{{ checksum "package-lock.json" }}
          paths:
            - ~/.npm
      - run:
          name: Run tests
          command: npm test
      - run:
          name: Pack the package
          command: |
            mkdir -p dist
            npm pack --pack-destination dist
      - persist_to_workspace:
          root: .
          paths:
            - dist

npm ci installs the exact dependency versions recorded in package-lock.json. The command run by npm test is defined in the scripts section of the package.json in the npm package.

The cache stores npm’s download cache, ~/.npm, rather than node_modules. The cache key uses {{ checksum "package-lock.json" }}, so CircleCI saves a new cache only when the locked dependencies change. Read more about caching npm dependencies in the CircleCI docs.

The last two steps build the package tarball with npm pack and save it to the workspace. The publish job then publishes exactly the tarball built from the tested code.

Publish

The publish job has several steps that run to authenticate with, and publish to, the official npm registry:

jobs:
  publish:
    executor: node
    steps:
      - attach_workspace:
          at: .
      - run:
          name: Publish to npm with trusted publishing
          command: |
            NPM_ID_TOKEN="$(circleci run oidc get --claims '{"aud": "npm:registry.npmjs.org"}')"
            export NPM_ID_TOKEN
            npm publish ./dist/*.tgz --access public
  • Step 1 - Attach to the workspace, which contains the dist directory that the test job created.

  • Step 2 - Request an OpenID Connect (OIDC) token from CircleCI for the npm:registry.npmjs.org audience and store it in NPM_ID_TOKEN. When that variable is set, the npm CLI exchanges it with the registry for a short-lived publish token for this package. Assigning the variable before exporting it makes the step fail right away if CircleCI can’t issue a token. CircleCI doesn’t mask this token in the job output, so don’t print it.

  • Step 3 - Publish the tarball. --access public makes a scoped package public.

The token exchange works only after trusted publishing is set up for the package, which we’ll do later in this tutorial. npm doesn’t currently generate provenance attestations for packages published from CircleCI.

Using CircleCI workspaces

We set up a workspace in the config.yml to share data between jobs. We use it to pass the packed tarball from the test job to the publish job.

As a step in the test job, we persist the dist directory to the workspace with the following stanza:

- persist_to_workspace:
    root: .
    paths:
      - dist

Then, in the subsequent publish job, we attach to the workspace with the following configuration as the first step in the job:

- attach_workspace:
    at: .

Read more about workspaces in the CircleCI docs.

Git tag job execution: publish on tagged commits

As part of our continuous delivery pipeline, we want to make sure the publish job runs only when a commit has been tagged with a version number and pushed.

workflows:
  test-and-publish:
    jobs:
      - test:
          filters: pipeline.git.branch or (pipeline.git.tag matches /^v[0-9]+\.[0-9]+\.[0-9]+$/)
      - publish:
          requires:
            - test
          filters: pipeline.git.tag matches /^v[0-9]+\.[0-9]+\.[0-9]+$/

The workflow configuration above runs the test job on every branch and for version tags, and runs the publish job only for version tags. CircleCI doesn’t run a job for a tag unless that job’s filter matches the tag, and the jobs that a tagged job requires need to match the tag too. That’s why the test job’s filter includes version tags as well as branches.

The filters values are expressions. pipeline.git.branch is set when a pipeline runs for a branch, and pipeline.git.tag is set when it runs for a tag. The regular expression matches version tags such as v0.1.1 and skips other tags, even ones that start with v.

Read more about running workflows for Git tags

Set up the project in CircleCI

Create a project in CircleCI for the fork. After that, every push starts a pipeline. For a branch, the workflow runs only the test job, and the publish job runs only for version tags.

Publish the first version

npm can only set up trusted publishing for a package that already exists, so publish the first version locally:

npm login
npm publish --access public

npm login opens the npm website to sign in and creates a session that expires after two hours. npm then asks for two-factor authentication to confirm the publish.

Set up trusted publishing on npm

Trusted publishing lets npm accept a publish from a specific CircleCI pipeline without a stored token.

Why not store an npm access token in CircleCI instead? npm revoked all classic tokens in December 2025, and the token that npm login creates now expires after two hours. Granular access tokens still work, but npm plans to remove direct publishing with them in January 2027. Read more in npm’s About access tokens.

npm identifies the pipeline by its CircleCI IDs. Collect these values in the CircleCI web app:

  • Organization ID: Select Org in the sidebar (it may be under More), then copy the organization ID.
  • Project ID: Open Project Settings for the project (from the … menu in the project’s row on the Projects page), then copy the project ID.
  • Pipeline definition ID: In Project Settings, select Project Setup, then select Copy definition ID for the pipeline.
  • VCS origin: The repository URL without https://, for example github.com/<your-github-username>/circleci-npm-example.

To restrict publishing to a CircleCI context, set up the context before configuring npm so its ID can go in from the start. The next section, “Restrict publishing to a protected context,” shows how.

Then, on npmjs.com:

  1. Open the package’s page and select the Settings tab.
  2. Under Trusted Publisher, select CircleCI.
  3. Enter the Organization ID, Project ID, Pipeline definition ID, and VCS origin. Add a Context ID if there is one.
  4. Under Allowed actions, make sure npm publish is selected, then save. If only npm stage publish is allowed, the npm publish command in the publish job fails.

npm doesn’t check these values on save. A wrong value shows up later as an ENEEDAUTH error when the publish job runs. A new trusted publisher configuration also expires if it isn’t used for a successful publish within two days, so release a version soon after setting it up.

On the command line, npm 11.15.0 and later can create the same configuration with npm trust circleci:

npm trust circleci @<your-npm-username>/circleci-npm-example \
  --org-id <organization-id> \
  --project-id <project-id> \
  --pipeline-definition-id <pipeline-definition-id> \
  --vcs-origin github.com/<your-github-username>/circleci-npm-example \
  --allow-publish

To restrict publishing to a context, add --context-id <context-id> to the command. npm trust needs a signed-in npm session, and the session from npm login expires after two hours, so run npm login again if it has expired.

After the first release from CircleCI succeeds, there’s no need to accept token-based publishes anymore. In the package’s Settings, under Publishing access, select Require two-factor authentication and disallow tokens. For an extra review step, npm also supports staged publishing. To use it, allow npm stage publish for the trusted publisher and change npm publish to npm stage publish in the publish job. A maintainer then approves each version with two-factor authentication before it goes live. See npm’s staged publishing docs for details.

Restrict publishing to a protected context

CircleCI contexts support restrictions on which jobs can use them. Only organization admins can add restrictions. To allow publishing only from tag pipelines, select Org in the CircleCI sidebar (it may be under More), select Contexts, and create a context named npm-publishing. Open the context, select Add expression restriction, and enter this expression:

pipeline.git.tag starts-with "v" and not job.ssh.enabled

The restriction fails for branch pipelines, which have no tag, and for jobs rerun with SSH. Attach the context to the publish job in the workflow:

- publish:
    context: npm-publishing
    requires:
      - test
    filters: pipeline.git.tag matches /^v[0-9]+\.[0-9]+\.[0-9]+$/

Finally, copy the context ID from the context’s page, and add it under Context IDs when setting up the trusted publisher. With a Context ID in the trusted publisher configuration, npm accepts tokens only from jobs that use that context. Read more about context restrictions and CircleCI’s guide to publishing to npm with trusted publishing.

Release a new version

To publish a new version, bump the version number and push the tag:

npm version patch
git push --follow-tags

npm version patch updates the version in package.json and package-lock.json, commits the change, and creates an annotated tag such as v0.1.1. git push --follow-tags pushes the commit and the tag. CircleCI then starts a pipeline for the tag that runs the test job and then the publish job.

Deploy to a private npm registry

Deploying packages to a private registry is very similar to publishing packages on the official one provided by npm. packagecloud’s npm registry authenticates with tokens, so this job uses a token instead of trusted publishing.

We’ll use an npm registry on packagecloud as an example. This is the fully-qualified URL to a packagecloud npm registry:

https://packagecloud.io/:username/:repo_name/npm/

Where :username is the user on the packagecloud npm registry and :repo_name is the name of the npm registry created by the packagecloud user.

To publish to packagecloud instead of npm, create a packagecloud API token. packagecloud API tokens can write to npm registries. In CircleCI, create a context named packagecloud, and add an environment variable named PACKAGECLOUD_TOKEN that contains the token.

The packagecloud context in CircleCI Organization Settings, with a PACKAGECLOUD_TOKEN environment variable whose value is masked

Next, replace the publish job with this version, and change YOUR_USERNAME and YOUR_REPO to match the registry:

jobs:
  publish:
    executor: node
    steps:
      - attach_workspace:
          at: .
      - run:
          name: Publish to packagecloud
          command: |
            echo '//packagecloud.io/YOUR_USERNAME/YOUR_REPO/npm/:_authToken=${PACKAGECLOUD_TOKEN}' >> ~/.npmrc
            npm publish ./dist/*.tgz --registry https://packagecloud.io/YOUR_USERNAME/YOUR_REPO/npm/

Then attach the context to the publish job in the workflow:

- publish:
    context: packagecloud
    requires:
      - test
    filters: pipeline.git.tag matches /^v[0-9]+\.[0-9]+\.[0-9]+$/

The single quotes write ${PACKAGECLOUD_TOKEN} into ~/.npmrc as literal text. npm replaces it with the environment variable’s value when it reads the file, so the token itself is never written to disk. The --registry flag sends the publish to packagecloud instead of the npm registry. Check out publishing npm packages on packagecloud.io for more information.

Here’s the example repository’s pipeline after a push to master. The test-and-publish workflow passes, and only the test job runs, because publish runs only for version tags:

The test-and-publish workflow passing in CircleCI for a push to master in the circleci-npm-example repository, with the test job successful

Conclusion

Automating manual processes in the software development lifecycle can significantly reduce errors, development time and user frustration. Using tools like CircleCI can allow your team to build a robust software distribution pipeline to get software to users quickly and reliably, without adding additional infrastructure.

Start publishing npm packages directly to any public or private registry using CircleCI.