Recently I built and deployed a new package to the npm package repository. In this post I’ll describe how to automate the process of building, versioning and publishing node packages to npm using GitHub Actions and Changesets

While building, versioning and deployment of packages can be done locally it takes time and the manual process can be error prone. Automating the process means CI/CD can handle the tasks of ensuring a package is tested and built properly before versioning and deploying it for us. This post presumes some familiarity with package.json and the node build process. All artifacts and implementation can be seen on the public repo I implemented this approach on.

Controlling Package Content

Before publishing we need to determine what’s going to be included in the package. To do this, build the project and then run:

npx npm-packlist

By default, the output might look something like this:

babel.config.js
dist/buildVariables.js
dist/Config.js
dist/generateHttpFile.js
dist/preset.js
.changeset/config.json
package.json
.vscode/settings.json
.vscode/tasks.json
tsconfig.json
CHANGELOG.md
.changeset/README.md
README.md
src/buildVariables.test.ts
src/buildVariables.ts
src/Config.ts
src/generateHttpFile.ts
src/preset.ts
codegen.yml
.github/workflows/pr.yml
.github/workflows/release.yml

It’s excluded node_modules and anything else in .gitignore but there’s still way too much in there. One option is to use .npmignore but this approach means duplicating what’s inside .gitignore and keeping .npmignore updated every time you add something you don’t want published.

I only want to ship the items in dist so a better approach is to use the files field of package.json.

{
  ...
  "files": ["dist"]
  ...
}

Manual Deployment And Versioning

The name field in your package.json controls whether your package is scoped or unscoped and the publish process is slightly different for each. Unscoped packages are named in the format my-package and are always publicly accessible. Scoped packages are named in the @my-user-or-org/my-package format and may be publicly or privately accessible. Privately accessible packages requires an npm paid subscription.

To deploy to the npm repository you’ll need an account on npmjs.com. Login with your account credentials using:

npm adduser

Once authenticated, build your project (npm run build or similar). For unscoped packages, deploy to npm with:

npm publish

Scoped packages default to private access, so deploy a publicly accessible scoped package with:

npm publish --access public

If you receive a 403 Forbidden result on your first publish, it’s likely you haven’t yet verified your email address on npmjs.com. 403 will also occur if you attempt to publish the same version of your package twice. To bump your version number using semantic versioning run:

npm version patch

This will increment your package.json’s version field, allowing you to publish again.

Automating The Process

We’ll use Changesets to automate publishing to npm using GitHub Actions. Changesets will also automate versioning and the creation of a CHANGELOG for us. The changeset tool can be added to our project with:

npm install -D @changesets/cli && npx changeset init

Check the .changeset/config.js generated - amongst the other fields, we’ll likely need to change those following from their default. When publishing to a public npm package, you may see Unexpected end of JSON input if access is set to its default private.

{
  ...
  "access": "public",   # defaults to private
  "baseBranch": "main", # defaults to master
  ...
}

Any time we make a change to the repo that will alter how the package operates, we can add a changeset using:

npx changeset

Doing so will prompt us for whether the change is major, minor or patch per Semantic Version and ask for a description of the change. This creates a .changeset file that we’ll commit to source and will be used later to automate the creation of a CHANGELOG and bump the package version. Installing the changeset bot to our repo will remind us on each Pull Request (PR) whether a .changeset file was committed or not. The versioning and publishing automation is taken care of by a Github Action that uses these changesets.

Add a workflow to your project under .github/workflows/release.yml that looks like the one below.

name: release

on:
  push:
    branches:
      - main

jobs:
  release:
    name: release
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@master
        with:
          # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
          fetch-depth: 0

      - uses: actions/setup-node@master
        with:
          node-version: 14.x

      - run: yarn
      - run: yarn test

      - id: changesets
        uses: changesets/action@master
        with:
          # This expects you to have a script called release which does a build for your packages and calls changeset publish
          publish: yarn release
        env:
          GITHUB_TOKEN: ${{ secrets.CHANGESET_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

This workflow will, for any PR merged (or commit pushed) to main, raise an automated PR named Version Packages containing the following changes:

  • Remove any .changeset files
  • Bump package.json’s version based on the the collated .changeset files
  • Append to the CHANGELOG based on the collated .changeset files

If the Version Packages PR is left open and other merges to main occur, then the PR will be updated to include any further .changeset files from those merges. If we merge the PR then our publish script will execute and the updated, versioned package will be published to npm.

An important difference between the above workflow and the one specified in changesets/action docs is the usage of secrets.CHANGESET_TOKEN, and only applies if you have automated checks running on your PRs. PRs created using secrets.GITHUB_TOKEN don’t trigger other workflows. If we have PR checks protecting our branches they will not run if triggered by GITHUB_TOKEN, and if they don’t run (and pass) we won’t be able to merge the Version Packages PR. The fix is to create a Personal Access Token (PAT) on our GitHub account with repo scope, add it as a secret to the repository, and have the workflow use our PAT instead of GITHUB_TOKEN.

The NPM_TOKEN can be generated via npmjs.com > Profile > Access Tokens and also added as secret to our GitHub repository.

Conclusion

Any PR merged to main or any commit pushed to main that includes a .changeset will create or update the Version Packages PR. Merging Version Packages versions and releases our package to npm.

For modifying the CHANGELOG format and other configurations check out the changesets docs.