Packaging and Publishing Go Binaries on NPM Registry
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?

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.”
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
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?
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
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.
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
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:
goreleaser
,publish-binaries
, andpublish-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.
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.
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
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:
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.