October 5, 2024

How to publish packages to GitHub Packages

πŸ“œ Introduction

If you have ever copied and pasted code between projects, you know how tedious it can be. Publishing Node.js packages lets you reuse that code efficiently and share it with other developers. Having your code packaged makes it easy to drop into any project, ensuring consistency without repeating effort.

This article guides you through publishing a package to GitHub Packages, taking advantage of its integration with repositories and its free tier for private packages in organizations. On top of that, you'll learn to automate the process with GitHub Actions, so publishing happens automatically without any manual steps.

πŸ™ Preparing the GitHub account

The first thing we need to do is create an organization in our GitHub account.

Next, we'll create a personal access token, also known as a personal access token (classic), which is the one we'll use in the GitHub Actions workflow. When creating the token, we'll grant it the following permissions (save the token once created πŸ˜‰):

  • repo: Full control of private repositories
    • repo:status: Access commit status
    • repo_deployment: Access deployment status
    • public_repo: Access public repositories
    • repo:invite: Access repository invitations
    • security_events: Read and write security events
  • write:packages: Upload packages to GitHub Package Registry
    • read:packages: Download packages from GitHub Package Registry

Finally, we'll create a repository inside the organization and add the token to its settings as a secret. ❗️Remember the name you give the secret, since that's the name we'll reference from the GitHub Action; in my case I'll call it NPM_TOKEN❗️.

πŸš€ Creating the project

Let's start the project with the npm init -y command, which creates the package.json with the bare minimum. We'll need to add the following configuration to allow publishing it:

  • The name must be the organization name followed by the package name, for example @jucodev/my-utils. This tells npm which scope the package belongs to.
  • Add the repository information with repository.type and repository.url.
  • Add the GitHub Packages registry URL with publishConfig.registry.
  • If you use TypeScript, set main to the path of the compiled entry file and add the types property pointing to the generated declaration file.
  • If you want to add subpath exports so that later, when importing the package from another project (for example, import { ... } from '@jucodev/my-utils/math'), things are better organized, add the exports property with the different subpaths you'd like.
{
  "name": "@jucodev/my-utils",
  "version": "1.0.0",
  "private": false,
  "main": "src/index.js",
  "type": "module",
  "publishConfig": {
    "registry": "https://npm.pkg.github.com"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/jucodev/my-utils.git"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

If you want to check that the configuration works, you can pack the project locally with npm pack; this generates a .tgz archive at the root of your project. Then install it in another project by putting the dependency name in the package.json and, instead of a version, pointing to the path where it lives, for example:

{
  "dependencies": {
    "@jucodev/my-utils": "file:../my-utils/jucodev-my-utils-1.0.0.tgz"
  }
}

For this example we'll create something basic in src/index.js to export:

// src/index.js
 
export function sum(a, b) {
  return a + b;
}
 
export function subtract(a, b) {
  return a - b;
}

πŸ“€ Publishing GitHub Action

Now we'll create the GitHub Action that lets us publish, and we'll configure any change pushed to the main branch as the trigger. To do so, we'll create .github/workflows/npm-publish.yml with the following:

name: Publish NPM package
 
on:
  push:
    branches:
      - main
 
jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup node
        # The "setup-node" step creates the .npmrc file on the runner
        uses: actions/setup-node@v3
        with:
          node-version: '20.x'
          # GitHub Packages registry URL, set as "publishConfig.registry"
          registry-url: 'https://npm.pkg.github.com'
          # scope set in the package.json "name"
          scope: '@jucodev'
 
      - name: Install dependencies
        run: |
          npm ci
 
      # This step is only needed if you use TypeScript
      - name: Build package
        run: |
          npm run build
 
      - name: Publish package
        run: |
          npm publish
        env:
          # name of the secret configured in the repository, in my case "NPM_TOKEN"
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

The actions/setup-node@v3 step creates the .npmrc file automatically when you pass it the registry-url and scope parameters. This file holds the configuration needed to publish and install packages. If you want to learn more about it, here's the documentation.

From now on, every time we push something new to main it will generate a new package with the version stated in the package.json. But this can become tedious, since we'd have to bump the version manually and it's easy to forget. In the next step we'll see how to automate version generation (semantic versioning) so that, depending on what we develop, we release major, minor or patch versions.

πŸ”¨ Configuring the project to create the release

Almost there β€” we're close to fully automating everything and focusing on development 😎. In this step we'll see how to automate:

Before starting, I recommend having a git hooks tool such as husky to validate that the commits you write follow the conventional commits rules.

Let's get to the configuration. We'll start by installing the following @semantic-release dependencies in our project:

npm i -D @semantic-release/changelog @semantic-release/commit-analyzer @semantic-release/git @semantic-release/github @semantic-release/npm @semantic-release/release-notes-generator

Next we'll add the @semantic-release configuration to our package.json.

  • We skip publishing the package to respect the single responsibility of each GitHub Action.
  • We customize the commit message it creates with the new version and the changelog. It will follow this shape: chore(release): ${nextRelease.version} \n\n${nextRelease.notes}.
{
  "release": {
    "branches": ["main"],
    "repositoryUrl": "https://github.com/jucodev/my-utils.git",
    "plugins": [
      "@semantic-release/commit-analyzer",
      "@semantic-release/release-notes-generator",
      "@semantic-release/changelog",
      [
        "@semantic-release/npm",
        {
          "npmPublish": false
        }
      ],
      [
        "@semantic-release/git",
        {
          "assets": ["package.json", "CHANGELOG.md"],
          "message": "chore(release): ${nextRelease.version} \n\n${nextRelease.notes}"
        }
      ],
      "@semantic-release/github"
    ]
  }
}

Now we'll create the GitHub Action in .github/workflows/release.yml. Its purpose is to create a release by running all the @semantic-release configuration we added.

name: Release
 
on:
  push:
    branches:
      - main
 
jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    permissions: write-all
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 'lts/*'
      - name: Install dependencies
        run: npm ci
      - name: Release
        env:
          # Why we shouldn't use GITHUB_TOKEN: https://stackoverflow.com/questions/69063452/github-actions-on-release-created-workflow-trigger-not-working
          GITHUB_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npx semantic-release

Now that we're creating the release, we'll change the trigger of the package-publishing GitHub Action so that it only runs when a release has been created:

name: Publish NPM package
 
on:
  release:
    types: [published]
# ...

If you already published a package with the previous GitHub Action, you'll need to bump the package.json version manually the first time, because since it detects that no releases exist yet, it will create one with the current version.

Note: if we want to ship a major version we need to write a commit containing a BREAKING CHANGE. Here's an example:

git commit -m "feat: implement new authentication system" -m "BREAKING CHANGE: The old authentication API has been removed. The new system requires clients to use OAuth2."

Congratulations πŸ₯³, we now have everything wired up with CI/CD. Finally, let's test installing the package.

πŸ“¦ Installing the package

The moment has arrived β€” let's check whether all this configuration was worth it. To install the package we need to add the token (personal access token) created earlier in GitHub, plus the registry, to our npm configuration. In my case I use the global .npmrc file, but you can scope it to each project by creating a .npmrc at the root of each one.

To add the configuration to the global .npmrc, run the following commands:

# Set our organization scope followed by the GitHub Packages registry URL
# npm config set <scope>:registry=https://npm.pkg.github.com/
npm config set @jucodev:registry=https://npm.pkg.github.com/
 
# Set authentication with the "personal access token" generated in GitHub
npm config set //npm.pkg.github.com/:_authToken=<token>

Once configured, install the package from another project:

npm i @jucodev/my-utils

Package installation

Congratulations, you now have package publishing automated so you can focus on development!

I hope you found it interesting. Thanks a lot πŸ€—!

🀜 πŸ€›