The Problem: Great Tools Die in Obscurity
You can build the fastest, most useful CLI tool in the world. But if installing it requires go install, you've already lost 90% of your potential users.
Most developers don't have Go installed. Most frontend engineers don't know what go install means. And most technical writers — the people who benefit most from a Markdown linter — will close the tab the moment they see a compile step.
Distribution is not a feature. Distribution is survival.
This is the story of how I took gomarklint — a Markdown linter written in Go — and made it installable via three ecosystems:
# For anyone — just download and run
curl -L https://github.com/shinagawa-web/gomarklint/releases/latest
# For macOS users
brew install shinagawa-web/tap/gomarklint
# For Node.js users
npm install -g @shinagawa-web/gomarklint
# For Go developers
go install github.com/shinagawa-web/gomarklint@latest
One binary. Four installation methods. Zero runtime dependencies.
Why Multi-Channel Distribution Matters
Let me share a real scenario.
A technical writer on your team wants to lint Markdown docs locally. They open the README, see go install ..., and immediately ask the engineering team for help. The engineer says "just install Go." The writer says "I just want to check my docs." Nothing happens.
Now imagine this instead:
npm install -g @shinagawa-web/gomarklint
gomarklint docs/
Done. No Go. No Homebrew. Just a tool they already know how to use.
Each distribution channel unlocks a different audience:
| Channel | Audience |
|---|---|
| GitHub Releases | CI/CD pipelines, DevOps engineers |
| Homebrew | macOS developers |
| npm | Frontend engineers, technical writers |
go install |
Go developers |
If your tool only lives in one ecosystem, you're leaving users on the table.
The Architecture: One Binary, Thin Wrappers
The core insight is simple: don't ship your binary inside the package. Download it at install time.
Here's how the npm distribution works:
npm install -g @shinagawa-web/gomarklint
│
▼
package.json (postinstall → node install.js)
│
▼
install.js detects OS/arch
│
▼
Downloads binary from GitHub Releases
│
▼
Verifies SHA-256 checksum
│
▼
cli.js (execFileSync → gomarklint binary)
The npm package contains no binary. It's three files:
-
package.json— metadata and postinstall hook -
install.js— platform detection and binary download -
cli.js— thin wrapper that invokes the binary
Total package size before install: under 5KB.
Step 1: Platform Detection (install.js)
The install script maps Node.js platform identifiers to GoReleaser archive names:
const PLATFORM_MAP = {
darwin: "Darwin",
linux: "Linux",
win32: "Windows",
};
const ARCH_MAP = {
x64: "x86_64",
arm64: "arm64",
};
This covers the six combinations that matter: macOS (Intel + Apple Silicon), Linux (x64 + ARM), and Windows (x64 + ARM).
When npm install runs, the postinstall script:
- Reads the version from
package.json - Maps
process.platformandprocess.archto archive names - Downloads the checksums file and the archive in parallel
- Verifies the SHA-256 checksum
- Extracts the binary with
tar - Sets executable permissions
No dependencies. Just Node.js built-ins: https, crypto, child_process, fs.
Step 2: The CLI Wrapper (cli.js)
The wrapper is intentionally minimal:
#!/usr/bin/env node
"use strict";
const { execFileSync } = require("child_process");
const path = require("path");
const ext = process.platform === "win32" ? ".exe" : "";
const bin = path.join(__dirname, "gomarklint" + ext);
try {
execFileSync(bin, process.argv.slice(2), { stdio: "inherit" });
} catch (e) {
process.exitCode = e.status || 1;
}
Key design decisions:
-
stdio: "inherit"— passes through stdin/stdout/stderr, so output formatting and piping work exactly like the native binary - Exit code forwarding — critical for CI usage where non-zero exit means lint failure
- No abstraction — the wrapper does nothing but proxy execution
Step 3: Supply Chain Security
Shipping binaries through npm raises legitimate security concerns. Two mechanisms address this:
SHA-256 Checksum Verification
GoReleaser generates a checksums file for every release. The install script downloads it and verifies the archive before extraction:
function verifyChecksum(data, expected) {
const actual = crypto.createHash("sha256").update(data).digest("hex");
if (actual !== expected) {
throw new Error(
`Checksum mismatch!\n Expected: ${expected}\n Actual: ${actual}`
);
}
}
If someone tampers with the binary on GitHub Releases, the install fails loudly.
npm Provenance
The publish step uses --provenance, which cryptographically proves the package was built from a specific GitHub Actions workflow:
- name: Publish to npm
run: npm publish --provenance --access public
working-directory: npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Users can verify this with npm audit signatures.
Step 4: Automated Publishing with GoReleaser
The entire release pipeline runs on a single trigger: pushing a version tag.
# .github/workflows/goreleaser.yml
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: goreleaser/goreleaser-action@v7
with:
args: release --clean
npm-publish:
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Set package version from tag
working-directory: npm
run: |
VERSION="${GITHUB_REF_NAME#v}"
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = '${VERSION}';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
- name: Publish to npm
run: npm publish --provenance --access public
working-directory: npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The npm package version is never manually managed. It's extracted from the git tag at publish time. v2.7.1 becomes npm version 2.7.1. Zero version drift.
The npm-publish job has needs: release, so it only runs after GoReleaser has finished uploading all binaries. If GoReleaser fails, npm doesn't publish a broken version.
Step 5: GoReleaser Configuration
One thing I learned the hard way: if you don't explicitly specify architectures, you might be missing builds that your npm users need.
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
Adding explicit arm64 support was essential. Apple Silicon is the default for new Macs, and ARM Linux servers are increasingly common. Without this, npm install would 404 on gomarklint_Darwin_arm64.tar.gz.
Gotcha: The Checksums Filename
Here's something that cost me a failed release.
I assumed GoReleaser generates checksums.txt. It doesn't. It generates gomarklint_2.7.0_checksums.txt — prefixed with the project name and version.
My first npm release failed with:
Error: Download failed: HTTP 404 for
https://github.com/.../releases/download/v2.7.0/checksums.txt
Always check your actual release assets before writing the download logic:
gh release view v2.7.0 --json assets --jq '.assets[].name'
The Result
One git tag + git push now triggers:
- GoReleaser builds binaries for 6 platform/arch combinations
- GitHub Release is created with all assets and checksums
- Homebrew formula is updated in the tap repository
- npm package is published with provenance attestation
Total human effort per release: two commands.
git tag v2.7.1
git push origin v2.7.1
Takeaways
- Distribution is a feature. The best CLI tool means nothing if people can't install it.
- Don't ship binaries in packages. Download them at install time. Your npm package stays tiny, and you don't need to rebuild for every platform in npm's ecosystem.
- Verify everything. SHA-256 checksums and npm provenance are table stakes for binary distribution.
- Automate the version. Extract from the git tag. Never manually sync versions across ecosystems.
-
Test with real installs.
npm install -gin a clean environment catches things unit tests never will.
If you're building a Go CLI and only distributing via go install, you're leaving users behind. The npm + Homebrew wrapper pattern takes an afternoon to set up and opens your tool to an entirely new audience.
Try it:
npm install -g @shinagawa-web/gomarklint
gomarklint .
Repository: https://github.com/shinagawa-web/gomarklint
npm: https://www.npmjs.com/package/@shinagawa-web/gomarklint
Documentation: https://shinagawa-web.github.io/gomarklint/
Top comments (0)