Ken Muse

Publishing npm Packages to GitHub Packages With Yarn


Sorry for the long break between posts. You may have noticed that there’s a lot of events coming up on my calendar in the next two months, so I’ve been busier than normal preparing for those. Now that things are settling down a bit, I wanted to share some details that others might find helpful. As part of my preparations, I needed to create and publish a package to GitHub Packages. There’s plenty of guidance for publishing with plain npm or classic Yarn, but far less that covers the details of using GitHub Actions and to publish packages from a project that uses Yarn 4.

What is yarn

Yarn is an open source package manager for JavaScript and TypeScript. It reimplements most of the npm functionality and improves on it, making the development experience a bit easier in my opinion. It supports parallel downloads of dependencies and a “PnP” mode that doesn’t require it to create a node_modules folder to expand the packages. Instead, it can read directly from the compressed packages, which can really make life easier when working with JavaScript.

It’s important to know that Yarn was changed significantly in version 2. However, there’s still a lot of documentation that focuses on version 1 (“classic”). This article focuses on version 4 (“berry”), which is the latest stable version as of this writing.

Publishing the package

To create and publish the package to GitHub Packages, you need to configure a few things. Knowing what to configure (and how) is the key to successfully publishing the package. Since this involves a few different files, I’ll walk through each of them.

package.json

This contains the metadata for the package. The most important field to configure is name. When publishing to GitHub Packages, the name must be in the format @OWNER/package, where OWNER is your GitHub username or organization name, and package is the name of the package. For example, if your GitHub username is kenmuse and your package is named my-package, the name would be @kenmuse/my-package. The package name and repository name don’t have to match, but it’s often best practice to keep them the same to avoid confusion.

Next, make sure you have configured the repository entry. This indicates where the source code should be located for your package. You’ll also want to link to the registry for the package. This is done with the publishConfig entry. For publishing to GitHub Packages, the entries will look like this:

 1{
 2 "name": "@kenmuse/my-package",
 3  "version": "0.0.1",
 4  "repository": {
 5    "type": "git",
 6    "url": "https://github.com/kenmuse/your-repo.git"
 7  },
 8  "publishConfig": {
 9    "registry": "https://npm.pkg.github.com"
10  }
11}

You may notice that the registry link in this case does not contain your username or organization name. This is because GitHub Packages uses a single registry for all packages, and the package name is used to determine the owner. This is why you must specify the owner in the package name. If you don’t, the publish step will fail.

.yarnrc.yml

This file contains the configuration for Yarn. The most important entry to configure is the npmScopes entry. This entry tells Yarn where to find packages for a specific scope. In this case, you need to configure it to use GitHub Packages when it references your username or organization (in my case, kenmuse).

As part of that configuration, you can provide an authentication token. It’s a bad practice to rely on hard-coded credentials, so you also want to make sure to pick up the value dynamically from your environment. To do that, you can use the ${VARIABLE_NAME} syntax. There is one issue with this approach, however. If that environment variable is missing, many Yarn operations will generate an error. This happens even after any dependent packages have been downloaded. The easiest way to avoid this is to provide a default value of an empty string. This way, if the environment variable is missing, it will simply use an empty string instead of generating an error. You do this by using the ${VARIABLE_NAME:-default_value} syntax. In this case, you want to use ${NODE_AUTH_TOKEN:-} to pick up the value of the NODE_AUTH_TOKEN environment variable, or an empty string if it’s not set.

The file will now include something that looks like this:

1npmScopes:
2  kenmuse:
3    npmRegistryServer: 'https://npm.pkg.github.com'
4    npmAuthToken: ${NODE_AUTH_TOKEN:-}
5    npmAlwaysAuth: false

The npmAlwaysAuth entry is optional. If it is set to true, Yarn will always send the authentication token when accessing the registry. This is not strictly required for GitHub Packages since the act of publishing will send the authentication token, so I wanted to show that it can be set to false.

.npmignore

This isn’t technically specific to Yarn, but it is very important. In fact – please don’t publish without this! It’s so easy to include files by accident that you didn’t intend to include. This file works similarly to .gitignore, allowing you to specify files and directories that should be excluded from the package when it is published. Without this file, everything in the project directory becomes fair game and might get bundled into the package.

I tend to use a deny-all approach, excluding everything by default and then adding back the items I want to include. This way, I can be sure that I’m only including what I intend to include. Here’s an example of what my .npmignore file looks like:

1*
2!dist/**/*

This will exclude everything except for the dist directory and its contents. This ensures that I don’t accidentally include any source files, configuration files, secrets, .env, or other files into my package by accident.

Creating the GitHub Actions workflow

Now that the project is configured, you can create a GitHub workflow to build and publish the package. There are a few Actions steps that you’ll want to include, so let’s walk through those.

First, make sure that you configure actions/checkout with persist-credentials set to false. This prevents it from storing the workflow’s GitHub token into the repository’s local configuration. This adds one more level of security in case you accidentally capture the .git directory in your package, especially if you forget to use a .npmignore file.

1- name: Checkout code
2  uses: actions/checkout@v4 # For more security, prefer a SHA over a version tag!
3  with:
4    fetch-depth: 1
5    persist-credentials: false

Next, set up the version of Node.js you want to use. I like to explicitly set the registry-url and scope so that as it creates some of the configuration files, it has the correct values in those files. Note that the scope is my username prefixed with @, just like in the package.json file.

1- name: Setup Node.js
2  uses: actions/setup-node@v4
3  with:
4    node-version: '24'
5    registry-url: 'https://npm.pkg.github.com'
6    scope: '@kenmuse'

Some runners may not have corepack and Yarn configured by default, so I like to explicitly install it. This ensures that the correct version is installed and available for use.

1- name: Enable Corepack
2  run: corepack enable && yarn set version berry
3  env:
4    COREPACK_ENABLE_DOWNLOAD_PROMPT: 0

I’m using COREPACK_ENABLE_DOWNLOAD_PROMPT to suppress the prompt that appears the first time you use Corepack to download a package manager. This is useful in a CI/CD environment where you want to avoid any interactive prompts that will break the build. If you want a more cross-platform

If you want to more explicitly ensure that you are only configuring the version in your package.json, you can use a command like this to dynamically retrieve the value, prepare the version of Yarn, and activate it:

1- run: |
2    YARN_VERSION=$(jq -r '.packageManager' package.json)
3    corepack prepare "${YARN_VERSION}" --activate

Finally, you need the actual publication step. It looks like this:

1- name: Publish to npm
2  run: |
3    yarn npm publish
4  env:
5    NODE_AUTH_TOKEN: ${{ github.token }}

This sets the environment variable NODE_AUTH_TOKEN to the value of the GitHub token for the workflow. This token is automatically generated by GitHub as part of the workflow. Since we’ve configured .yarnrc.yml to be able to use that value, it can handle the authentication. It just needs the necessary permissions to publish packages to GitHub Packages.

Permissions

Okay, so this isn’t its own file, but it is important to mention. For this to work, you will need some permissions configured for the workflow. I recommend breaking this into two parts.

First, before any of the jobs, add permissions: {}. This tells the workflow to start with no permissions. That means that if you forget to explicitly grant a permission to one of your jobs, it won’t have any permissions by default. That makes sure you are following the principle of least privilege and granting only the permissions that are absolutely necessary.

Next, grant the specific permissions you need for the publishing job:

1permissions:
2  contents: read
3  packages: write

Why do you need these?

  • contents: read
    This is needed to read the contents of the repository. This is required for the actions/checkout step to work.
  • packages: write
    This is needed to publish packages to GitHub Packages.

That’s it! With these files and configurations in place, you can run the workflow and publish your package to GitHub Packages.

Consuming packages with Yarn

To consume packages in Yarn, you need to make sure that your .yarnrc.yml file is configured to use the correct registry and authentication token. This is similar to the configuration needed for publishing. If it’s a private package, you will need to provide an authentication token as well. We can reuse the same approach we used for publishing to make this easier.

It will look like this:

1npmScopes:
2  kenmuse:
3    npmAlwaysAuth: false
4    npmAuthToken: '${NODE_AUTH_TOKEN:-}'
5    npmRegistryServer: 'https://npm.pkg.github.com'

And that’s everything you need to know. As you can see, publishing npm packages to GitHub Packages using Yarn is a straightforward process once you have the necessary configurations in place. By following the steps outlined in this post, you can make it easy to automate the process from your workflows.