Packaging and Publishing Go Binaries on NPM Registry

Posted on May 6, 2025

Building software is only part of the job — making it accessible is just as important. It often involves packaging, publishing, and distribution challenges that developers don’t always anticipate. After I built forky and got it ready for public use, the first thing that came to mind was: how do I make this available to a broader developer audience?

packages

Photo by Claudio Schwarz on Unsplash

Initially, I published forky to Homebrew, which worked well for macOS and some Linux users — but that left Windows developers out. I needed a package registry that was platform-agnostic and familiar to most developers. That’s when I thought, “Why not publish it on NPM? Most developers are already familiar with it.”

💡 Why Publish on NPM?
NPM is the largest software registry in the world — and also one of the most criticized. Still, most developers already have NPM installed on their machines, which makes it a convenient distribution channel. By publishing to NPM, we can make our CLI app more accessible to a larger developer audience.

In this article, we’ll walk through how to build, package, and publish a CLI application to the NPM registry. We’ll use GoReleaser to compile the app for multiple targets (i.e., different architectures and operating systems), and Github Actions to fully automate the process of compiling and publishing.

All the code can be found here: https://github.com/thetnaingtn/dofy

Overview

Let’s say we have a CLI application called dofy — short for “day off yet?” — that calculates how many days are left until the next upcoming public holiday.

Here is how the dofy is structured:

.
├── .github
│   └── workflows
│       └── release.yaml # GitHub Actions workflow for building and publishing
├── .gitignore
├── .goreleaser.yaml # GoReleaser configuration file
├── calendarific
│   └── calendarific.go # Handles API calls to Calendarific
├── cmd
│   └── dofy
│       └── main.go # Entry point for the dofy CLI application
├── dofy.go
├── go.mod
├── go.sum
├── makefile
├── npm
│   ├── dofy
│   │   ├── package.json # Base package.json for dofy (declares optionalDependencies)
│   │   ├── pnpm-lock.yaml
│   │   ├── src
│   │   │   └── index.ts # Script that runs the correct binary based on environment
│   │   └── tsconfig.json
│   └── package.json.tmpl # Template for generating platform-specific package.json files
└── ui
    ├── app.go
    ├── commands.go
    ├── item.go
    ├── list.go
    ├── messages.go
    └── style.go

dofy is built with Go and bubbletea, a terminal UI (TUI) framework developed by the Charm Bracelet team. To fetch the list of holidays for a specific country, we use the Calendarific API, which supports a wide range of countries. You can view the full list of supported countries here. For this project, we’ll be using the free plan, which allows up to 500 API calls per month.

calendarific

Calendarific

Once we’ve published dofy to NPM, it can be installed and run like this:

npx dofy@latest -api-key <calendarific_api_key>

Pretty straighforward isn’t?

🔎 Tip
To see the full list of options that dofy supports, run: dofy -help

Here’s what it gonna look like when the above command run:

dofy demo

dofy

⚠️ Warning
Just a heads-up: don’t use the same API key from the demo — it’ll stop working once I rotate it 😬😬.

Supporting Platforms and Architectures in Brief

Let’s briefly talk about the platforms and architectures we’ll target for dofy. The app will support three major operating systems: macOS (darwin), Linux, and Windows. For each of these, we’ll build binaries for two architectures: amd64 and arm64.

Go comes with a powerful toolchain that makes it easy to cross-compile executables for any supported platform and architecture. For example, we can build a Windows executable for the amd64 architecture using the following command:

GOOS=windows GOARCH=amd64 go build .

Since we need to build for six different targets, using GoReleaser is much more convenient than running go build manually for each one.

🔎 Tip
GoReleaser is an amazing tool, and the examples we’ll cover here only scratch the surface of what it can do. I highly recommend checking out the official documentation to explore its full capabilities: https://goreleaser.com/

GoReleaser

We’ll use GoReleaser as our build tool, which will generate a matrix of all platform and architecture combinations, and compile an executable for each one.

GoReleaser required a .goreleaser.yaml file for configuration. To get started, we need to run the goreleaser init command — it will create a .goreleaser.yaml file populated with sensible defaults that we can then customize:

goreleaser init

For our use case, we’ll only update the goos and goarch fields in the builds section of the .goreleaser.yaml file to specify the platforms and architectures we want to support.

builds:
  goos:
    - linux
    - windows
    - darwin
  goarch:
    - arm64
    - amd64

We’ll also populate id, main, and binary with the following values. We’ll leave the other configurations at their default values.

builds:
  id: dofy
  main: ./cmd/dofy
  binary: dofy

Here’s what the updated builds section of the .goreleaser.yaml file looks like after applying these changes:

builds:
  env:
    - CGO_ENABLED=0
  goos:
    - linux
    - windows
    - darwin
  goarch:
    - arm64
    - amd64
  id: dofy
  main: ./cmd/dofy
  binary: dofy
🔎 Tip
To explore other configuration options available in the builds section, check out the official documentation: https://goreleaser.com/customization/builds/go

After that we will run the following command in order to check whether our configuration is valid:

goreleaser check

If the above command doesn’t throw any error, we can build our application by using the following command:

goreleaser build --snapshot --clean

After the build process completes, a new dist folder will be created containing six subfolders — each corresponding to one of the target platform and architecture combinations. For example, dofy_linux_amd64 will contain the executable for Linux with the amd64 architecture.

dist
├── artifacts.json
├── config.yaml
├── dofy_darwin_amd64_v1
│   └── dofy
├── dofy_darwin_arm64_v8.0
│   └── dofy
├── dofy_linux_amd64_v1
│   └── dofy
├── dofy_linux_arm64_v8.0
│   └── dofy
├── dofy_windows_amd64_v1
│   └── dofy.exe
├── dofy_windows_arm64_v8.0
│   └── dofy.exe
└── metadata.json

Now that we have the binaries ready for publishing, there’s one important detail to consider: how do we tell the npx command to install the correct executable based on the user’s operating system and architecture? That’s what we’ll explore in the next section.

NPM

Publishing to NPM involves two parts. The first part is packaging each target-specific executable and publishing it to NPM as a separate package. For each package, we’ll specify the os and cpu fields in the package.json to ensure that the correct executable is installed based on the user’s operating system and architecture. For example, the dofy-linux-amd64 package will have a package.json that looks like this:

{
  "name": "dofy-linux-amd64",
  "version": "0.0.1",
  "os": [
    "linux"
  ],
  "cpu": [
    "amd64"
  ]
}

NPM will then use the os and cpu fields defined in each package’s package.json to decide which one to install based on the user’s environment. For example, if we run npx dofy@latest on a Linux machine with amd64 architecture, NPM will pick and install the dofy-linux-amd64 package inside node_modules.

For each package, instead of manually creating six different folders and package.json files, we’ll use a template to generate them at runtime during the continuous deployment workflow. Here’s what the template file looks like:

// package.json.tmpl
{
  "name": "${node_pkg}",
  "version": "${version}",
  "os": [
    "${node_os}"
  ],
  "cpu": [
    "${node_arch}"
  ]
}

We’ll place the binary file inside a bin folder for each package. Here’s what it will look like after generating a package for dofy-linux-amd64:

npm
├── dofy
│   ├── package.json
│   ├── pnpm-lock.yaml
│   ├── src
│   │   └── index.ts
│   └── tsconfig.json
├── dofy-linux-amd64
│   │── bin
│   │    └── dofy
│   │── package.json
├── package.json.tmpl

The second part involves creating a base package that lists all the target-specific packages as optionalDependencies. NPM will then install the appropriate package based on the user’s environment. Here’s the package.json file for the base package — some fields are stripped out for clarity:

{
  "optionalDependencies": {
    "dofy-darwin-amd64": "0.0.1",
    "dofy-darwin-arm64": "0.0.1",
    "dofy-linux-amd64": "0.0.1",
    "dofy-linux-arm64": "0.0.1",
    "dofy-windows-amd64": "0.0.1",
    "dofy-windows-arm64": "0.0.1"
  }
}

Now that we know the target-specific package will be installed alongside the base package, the next step is figuring out how to execute the installed binary inside node_modules. Let’s take a look at the src/index.ts file, which will handle running the correct executable:

#!/usr/bin/env node

import { spawnSync } from "child_process"

function getExePath() {
  const arch = process.arch;
  let os = process.platform as string;
  let extension = '';
  if (['win32', 'cygwin'].includes(process.platform)) {
    os = 'windows';
    extension = '.exe';
  }
  try {
    return require.resolve(`dofy-${os}-${arch}/bin/dofy${extension}`)
  } catch (e) {
    throw new Error(`Couldn't find dofy binary inside node_modules for ${os}-${arch}`)
  }
}

function run() {
  const args = process.argv.slice(2)
  const processResult = spawnSync(getExePath(), args, { stdio: "inherit" })
  process.exit(processResult.status ?? 0)
}

run()

The line require.resolve(...) will locate and return the path to the executable file inside node_modules. Finally, the run function will execute that file with the given arguments.

All that’s left is to ensure the index.ts file can be executed when we run npx dofy@latest. Since NPM expects a javascript entry point, we first build index.ts into index.js by running pnpm build. Then, we use the bin field in package.json to point to the generated index.js file, which will act as the CLI entry.

Here’s the final version of the base package’s package.json file after applying the changes. Some fields have been stripped out for clarity:

{
    "name": "dofy",
    "version": "0.0.1",
    "bin": "lib/index.js",
    "scripts": {
        "typecheck": "tsc --noEmit",
        "lint": "eslint .",
        "lint:fix": "eslint . --fix",
        "build": "tsc",
        "dev": "pnpm build && node lib/index.js"
    },
    "devDependencies": {
        "@types/node": "^18.19.86",
        "@typescript-eslint/eslint-plugin": "^5.62.0",
        "@typescript-eslint/parser": "^5.62.0",
        "typescript": "^4.9.5"
    },
    "optionalDependencies": {
        "dofy-darwin-amd64": "0.0.1",
        "dofy-darwin-arm64": "0.0.1",
        "dofy-linux-amd64": "0.0.1",
        "dofy-linux-arm64": "0.0.1",
        "dofy-windows-amd64": "0.0.1",
        "dofy-windows-arm64": "0.0.1"
    }
}

Automating the Compiling,Packaging,and Publishing Processes

Now that we’ve covered everything we need — from building binaries with GoReleaser to creating the necessary NPM packages — all that’s left is to automate the all these processes. We’ll use GitHub Actions to handle this automation, triggering the workflow whenever a new tag is created.

Let’s create a release.yaml file inside .github/workflows, which will define the workflow. The release.yaml contains three jobs:

  1. goreleaser,
  2. publish-binaries, and
  3. publish-dofy.

Let’s take a closer look at each job.

goreleaser

For this job, we’ll use the GoReleaser’s goreleaser/goreleaser-action@v6 to compile our app into target-specific executables and publish them to GitHub. We’ll also use the actions/upload-artifact@v4 to upload the resulting output as artifacts, which we’ll need in the next job, publish-binaries.

goreleaser:
  runs-on: ubuntu-latest
  steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        fetch-depth: 0

    - name: Set up Go
      uses: actions/setup-go@v5
      with:
        go-version: stable

    - name: Run GoReleaser
      uses: goreleaser/goreleaser-action@v6
      with:
        distribution: goreleaser
        version: "~> v2"
        args: release --clean
      env:
        GITHUB_TOKEN: ${{ secrets.PUBLISHER_TOKEN }}

    - name: Upload Binaries
      uses: actions/upload-artifact@v4
      with:
        name: binaries
        path: dist/

publish-binaries

Instead of manually creating six separate jobs for publishing target-specific executables to NPM, we’ll use GitHub Actions’ matrix feature to automate the process. We’ll define two arrays — one for os and one for arch — and GitHub Actions will automatically generate a job for every combination. For example, it will create a job for linux/amd64, another for linux/arm64, and so on, without us having to write each one manually.

publish-binaries:
  needs: goreleaser
  strategy:
    matrix:
      os: ["windows", "linux", "darwin"]
      arch: ["arm64", "amd64"]
  runs-on: ubuntu-latest
  name: Publish binaries for different architectures and platforms to NPM
  steps:
    - name: Checkout
      uses: actions/checkout@v4
      with:
        fetch-depth: 0

    - name: Install Node
      uses: actions/setup-node@v4
      with:
        node-version: 20
        registry-url: "https://registry.npmjs.org"

    - name: Download uploaded binaries
      uses: actions/download-artifact@v4
      with:
        name: binaries
        path: ./dist/

    - name: Set the release version
      shell: bash
      run: echo "RELEASE_VERSION=${GITHUB_REF:11}" >> $GITHUB_ENV

    - name: Install jq
      uses: dcarbone/install-jq-action@v3

    - name: Publish to NPM
      shell: bash
      run: |
        cd npm
        bin="dofy"
        version="${{ env.RELEASE_VERSION }}"
        export version
        node_os="${{ matrix.os }}"
        export node_os
        node_arch="${{ matrix.arch }}"
        export node_arch
        export node_pkg="${bin}-${node_os}-${node_arch}"
        mkdir -p "${node_pkg}/bin"
        envsubst < package.json.tmpl > "${node_pkg}/package.json"
        binary_path=$(cat ../dist/artifacts.json | jq -r ".[] | select(.type == \"Binary\") | select(.goos == \"${{ matrix.os }}\") | select(.goarch == \"${{ matrix.arch }}\") | .path" )
        install -D ../${binary_path} ${node_pkg}/bin/${bin}
        cd "${node_pkg}"
        npm publish --access public        
      env:
        NODE_AUTH_TOKEN: ${{secrets.NPM_ACCESS_TOKEN}}

After that, we’ll install the necessary dependencies — including Node.js, PNPM, and jq — using the corresponding GitHub Actions. Then, we’ll use actions/download-artifact@v4 to download everything we uploaded in the previous goreleaser job into the dist folder on the runner machine.

💡 Why We Download Artifacts into the dist Folder?
As you’ll see later, the path field in artifacts.json expects the compiled binary to exist within the dist folder. Downloading the artifacts directly into dist makes it easier for us to locate and copy the binary into the appropriate package.

The final step — Publishing to NPM — involves creating a folder to hold the executable and generating a package.json file for each package using envsubst and our template file, package.json.tmpl. All that’s left is to copy the appropriate binary from the dist folder into the generated package folder.

To locate the path of each binary within the dist folder, we’ll use the artifacts.json file generated by GoReleaser.

💡 Using artifacts.json to Locate the Compiled Binary File
We use the artifacts.json file to locate the binary inside the dist folder instead of relying on folder names like dofy_linux_amd64_v1. This is because these folder names can vary between GoReleaser versions and aren't guaranteed to remain consistent. The GoReleaser documentation recommends using artifacts.json as the reliable way to retrieve the path to each binary. You can read more about this here: https://goreleaser.com/customization/builds/go/#a-note-about-directory-names-inside-dist

Here’s a sample of the artifacts.json file generated by GoReleaser. It’s an array of objects, where each object contains metadata about a generated artifact — including its type, target platform (goos), architecture (goarch), and the file path of the compiled binary:

[
  {
    // other fields
    "type":"Binary",
    "goos":"linux",
    "goarch":"arm64",
    "path":"dist/dofy_linux_arm64_v8.0/dofy",
  },
  {
    // other fields
    "type":"Metadata"
  }
]

To parse this file, we’ll use jq. Here’s what the command looks like after substituting in the current matrix.os and matrix.arch values:

jq -r ".[] | select(.type == "Binary") | select(.goos == "linux") | select(.goarch == "arm64") | .path"

For each job, we instruct jq to extract the value of the path field for objects where type is Binary and the goos and goarch match the current matrix.os and matrix.arch. Here’s an example of what the returned value looks like:

dist/dofy_linux_arm64_v8.0/dofy

Next, we use the install command to copy the executable into the generated package folder and publish it to NPM. Here’s what that process looks like after substituting the appropriate values:

binary_path=$(jq -r '.[] | select(.type == "Binary") | select(.goos == "linux") | select(.goarch == "arm64") | .path')
node_pkg="dofy-linux-arm64"
install -D "../${binary_path}" "${node_pkg}/bin/${bin}"
cd "${node_pkg}"
npm publish --access public

The next step is publishing a base package to NPM. Let’s take a look at what it gonna look like.

publish-dofy

This is the final step: here we’ll build and publish the base package. Note that it depends on the previous job, publish-binaries, to complete successfully.

publish-dofy:
  needs: publish-binaries
  name: Publish base package to NPM
  runs-on: ubuntu-latest
  permissions:
    contents: write
  steps:
    - name: Checkout
      uses: actions/checkout@v4

    - name: Install pnpm
      uses: pnpm/action-setup@v4
      with:
        version: 8

    - name: Install Node
      uses: actions/setup-node@v4
      with:
        node-version: 20
        registry-url: "https://registry.npmjs.org"

    - name: Publish to NPM
      shell: bash
      run: |
        cd npm/dofy
        pnpm install --no-frozen-lockfile
        pnpm build
        npm publish --access public        
      env:
        NODE_AUTH_TOKEN: ${{secrets.NPM_ACCESS_TOKEN}}

Now that everything is in place, go to the project folder, create a Git tag, and push it. This will trigger the workflow to start the compilation and publishing to NPM:

git tag -a v0.0.1 -m "release v0.0.1"
git push origin v0.0.1
🚨 Alert
If you’re following along, you’ll need to choose a different package name instead of dofy, since all package names on NPM must be unique. You can check whether your chosen name is available using this tool: https://remarkablemark.org/npm-package-name-checker

After that, you should see that our packages have been successfully published to the NPM registry:

published packages on NPM

Alternative Approach

Starting from version 2.8, GoReleaser supports publishing binaries to NPM — but this feature is only available in the Pro version. You can read the announcement here.

Further Reading

The idea of publishing target-specific packages and having a base package list them as optional dependencies was inspired by an excellent article: Packaging Rust Applications for the NPM Registry by Orhun Parmaksız. Even if you’re not working with Rust, I highly recommend the article—the concepts are well explained and broadly applicable to any language that compiles to native binaries, including Go.

That’s it. Until next time.