Publish NPM packages using GitHub Actions: A How-To Guide

Written by stevekonves | Published 2020/12/18
Tech Story Tags: npm | continuous-integration | continuous-delivery | github | github-actions | software-development | publish-npm-packages | npm-automation-tokens-guide | web-monetization

TLDRvia the TL;DR App

On October 2nd, lost in the noise of everything that was 2020, NPM released automation tokens.
Prior, we had to choose between 2 Factor Authentication and publishing packages via automated CI workflows. This was because when 2FA was enabled for an account, the NPM CLI would request a One Time Password (OTP) in addition to being logged in with your normal password. One unintended side effect was developers having to choose between security or automation.
NPM automation tokens allow us to publish 2FA-protected packages from automation workflows.
Note that this is not a tutorial about all things security or NPM. I assume that you already have experience developing and publishing packages. I also assume that you understand what Continuous Integration is and why you need it. (Even if you don’t, feel free to keep on reading. There's just a lot of content that I wouldn’t be able to do justice in an introduction.)

1. Allow automation tokens for a package

By default, NPM doesn’t allow automation with 2FA. If you want to automate the publishing process, you have to turn on this feature on a package-by-package basis. Go to the "Settings" tab for your package (you'll be required to enter your password again, even if you're logged in) and then select "Require two-factor authentication or automation token."
When this option is selected, humans using the CLI will still be required to enter a One Time Password; however, "automation" tokens can be used to bypass this requirement. This leads to the second step.

2. Create an automation token

Now, from your account menu, go to the Access Tokens page. Previously we only had the option for Read-Only and Publish (write?) tokens. A read-only token was suitable for using in an automated CI workflow to load packages; however, even with a publish token, the workflow wouldn't be able to publish without human interaction, which kinda defeats the purpose of CI.
We now have the option to create "automation" tokens. Click "Generate New Token" and then choose "Automation."
As it says on the tin, NPM will not prompt for an OTP when a package is published with this token.
The risk of "publish" tokens, in general, is that anyone who has the token can put anything they want into the package. This is still true for automation tokens, so we must keep them secret.

3. Add the token as a GitHub secret

Go to the package repository on GitHub and open up the "Settings" tab and then choose the "Secrets" left nav item. You have the option of adding a secret to the repository or for an organization if that applies.
Create a repository secret called "NPM_TOKEN."
When you create the automation token in NPM, you won't be able to view it again after you leave the page. When you add the token to GitHub, you won't be able to view it again after you save the secret. The result is a value that GitHub and NPM can reference when publishing, but that is inaccessible to humans.
At this point, as long as you have your NPM account configured to use 2FA, attackers will not be able to gain access to an automation token (or create a new one).

4. Create the GitHub action

Now that we have GitHub and NPM on speaking terms, all we have to do is let GitHub know how and when to publish the package.
Here is a basic GitHub action that I use:
name: publish
on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+*'
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v1
        with:
          node-version: 14
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Let's break this down. This action will only run when a tag is pushed with a semver name (something like v0.15.5). It then installs packages and runs the publish command with the NPM_TOKEN passed in as an environment variable.

5. Publish

Even with automation, there is still a part of an irrevocable process like publishing a public package that requires human interaction.
Once your main branch is ready to publish, bump the package version by running
npm version [major|minor|patch]
. This automatically updates
package.json
and
package-lock.json
and commits those changes in a commit named after the new version. It also creates a new tag.
Then all that is left is to push those changes to GitHub using
git push origin {your branch} --follow-tags
. "Follow tags" allows you to push the "version commit" and the tag at the same time.
The publish action will see that the tag was pushed and that the tag name matches the server format and then publish the package.

There are a few caveats

With the process outlined above, you still have to bump the package version using the CLI. If you wanted a full-auto continuous delivery process, I suppose you could create another action or job that would bump the version on a successful trunk build. However, that would require some sort of heuristics to differentiate between major/minor/patch changes. That sounds like a really difficult problem to solve and would end up being counterproductive anyway.
This, in turn, means that your main branch can't be protected, or at least not fully. I generally find it helpful, even on personal projects, to protect my mainline branch -- everything should come in via pull requests. Bumping the package version on a local machine and pushing to main fundamentally requires the ability to push to main. Along with this is the perennial risk of bumping version without pulling the latest change.
There is also a weird race condition where, when you push the new version commit and tag, the tag starts the publish action, but the version commit starts the normal build pipeline. This means that it's possible for the package to be published before the build finishes.
Lastly, the publish process is not instantaneous. This means that if you have downstream projects that depend on the package, they must wait an unspecified amount of time before they themselves can be updated and deployed with the new changes. Not a deal-breaker, but it is something to be aware of.

A few final things to consider

I strongly recommend enabling 2FA on both your NPM and GitHub accounts. If you administer a GitHub organization, I also strongly recommend requiring 2FA for all org members. While GitHub's 2FA via SMS isn't perfect, it's leaps and bounds better than nothing.
If your package is written in Typescript (or some other language than needs a compilation step), make sure that you add a
prepack
script in your
package.json
file. This script should contain everything that needs to run between cloning the repo and running
npm publish
to mitigate human error. This ensures that what is in the package is guaranteed to be a deterministic output of the source code. I wrote an article last year that goes into detail about my thoughts on this: What if we could verify npm packages?
Also, before you think you're ready to publish, run
npm pack --dry-run
. This runs all of the pre- and post-pack scripts without actually creating the tarball. The console output shows info about the package such as a list of all of the files it contains, the size, sha, and so forth. It's a good way to sanity check the package contents to help keep package size down.
So as it turns out, this wasn't just an academic exercise! I've been working on a new project, DomainGraph, which provides both engineers and non-engineers a simple, beautiful experience for understanding a Domain Model by viewing a GraphQL schema. I've also been using the opportunity to level up my automation skills using GitHub actions. Feel free to poke around the GitHub action files and borrow anything that seems useful.
It's very much still a little baby project, but if you want to follow along, check it out on GitHub and maybe even give it a star if you think I've earned it! 😁
Have fun and stay safe!
Photo by Alex Knight on Unsplash

Written by stevekonves | I eat food and build things. Opinions are my own.
Published by HackerNoon on 2020/12/18