Go Publishing for Dummies
This is the Go twin of Cargo Publishing for Dummies. Same release shape, different tools.
I use this for CLIs like herdr-serve: one SemVer tag, GitHub Release binaries, Homebrew formula, optional NPM wrapper, and go install from the module path.
Go does not have crates.io. The package manager is the module proxy + git tags. That changes one piece of the Rust flow and leaves the rest intact.
How This Differs from Cargo
| Rust (Cargo) | Go |
|---|---|
Cargo.toml owns the version | VERSION file + git tag vX.Y.Z |
| crates.io publishes source | No registry — go install github.com/ORG/REPO/cmd/BIN@vX.Y.Z |
| cargo-dist builds binaries | GoReleaser builds binaries |
cargo publish in CI | Skip — tag is enough for modules |
| NPM wraps GitHub Release assets | Same |
| Homebrew via cargo-dist | Homebrew via GoReleaser brews: |
The mental model stays the same:
- One SemVer version.
- One git tag:
v[VERSION]. - GitHub Actions builds native binaries and creates a GitHub Release.
- Homebrew points at those binaries.
- NPM downloads those binaries in
postinstall. go install ...@v[VERSION]builds from source (so embedded assets must be in git).
Prerequisites
You need:
- A public GitHub repository for the CLI (mine are under
Blankeos/) - Go installed locally
- A GitHub account that can create releases
- Optional: an NPM account if you want
npm i -g APP_NAME - Optional: a Homebrew tap repo (mine is
Blankeos/homebrew-tap)
Replace these placeholders everywhere below:
APP_NAME— binary and package name (herdr-serve)ORG— GitHub owner (Blankeos)REPO— repository name (herdr-serve)MODULE— Go module path (github.com/Blankeos/herdr-serve)MAIN_PKG— main package (./cmd/herdr-serve)
1. Make the Module Path Match GitHub
go install resolves the module path as a URL. If the repo is github.com/Blankeos/APP_NAME, the module must be that too:
// go.mod
module github.com/Blankeos/APP_NAME
go 1.25.0Rewrite imports if you started with a different owner:
find . -name '*.go' -print0 | xargs -0 sed -i '' \
's|github.com/OLD/APP_NAME|github.com/Blankeos/APP_NAME|g'
sed -i '' 's|github.com/OLD/APP_NAME|github.com/Blankeos/APP_NAME|' go.mod2. Own the Version in a VERSION File
Cargo has Cargo.toml. Go modules only care about the git tag, but you still need a single bump target for:
- the binary's
version/--versionstring npm/package.json- optional plugin manifests
Create:
0.1.0Inject it at build time with -ldflags:
// cmd/APP_NAME/main.go
package main
import (
"fmt"
"runtime/debug"
"strings"
)
// Set via: -ldflags "-X main.version=..."
var version = "dev"
func resolveVersion() string {
if version != "dev" && version != "" {
return version
}
if info, ok := debug.ReadBuildInfo(); ok &&
info.Main.Version != "" &&
info.Main.Version != "(devel)" {
return strings.TrimPrefix(info.Main.Version, "v")
}
return version
}
func main() {
fmt.Printf("APP_NAME %s\n", resolveVersion())
}Local build:
go build -ldflags "-X main.version=$(cat VERSION)" -o bin/APP_NAME ./cmd/APP_NAMEGoReleaser will set the same ldflag from the tag.
3. Commit Embedded Assets (If You Embed a UI)
go:embed only sees files that exist in the module source. If web/dist is gitignored, go install builds a binary with an empty UI.
For herdr-serve I:
- Stop ignoring
web/dist - Rebuild UI inside
tag_and_release.shbefore the release commit - Still rebuild UI in GoReleaser's
before.hooksfor Release binaries
Prebuilt channels (Homebrew / NPM / install.sh) do not need source embeds — they download the Release binary. go install does.
4. Add GoReleaser (GitHub Releases + Homebrew)
Create .goreleaser.yml:
version: 2
project_name: APP_NAME
before:
hooks:
- go mod tidy
# If you embed a web UI:
# - sh -c "cd web && npm ci && npm run build"
builds:
- id: APP_NAME
main: ./cmd/APP_NAME
binary: APP_NAME
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
ldflags:
- -s -w -X main.version={{.Version}}
archives:
- id: default
formats: ["tar.xz"]
format_overrides:
- goos: windows
formats: ["zip"]
# Match cargo-dist / npm install.js target triples
name_template: '{{ .ProjectName }}-{{ if eq .Arch "amd64" }}x86_64{{ else if eq .Arch "arm64" }}aarch64{{ else }}{{ .Arch }}{{ end }}-{{ if eq .Os "darwin" }}apple-darwin{{ else if eq .Os "linux" }}unknown-linux-gnu{{ else if eq .Os "windows" }}pc-windows-msvc{{ else }}{{ .Os }}{{ end }}'
checksum:
name_template: sha256.sum
algorithm: sha256
changelog:
disable: true
release:
draft: false
replace_existing_draft: true
name_template: "v{{.Version}}"
brews:
- repository:
owner: Blankeos
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Formula
homepage: "https://github.com/Blankeos/APP_NAME"
description: "YOUR SHORT DESCRIPTION"
license: "MIT"
install: |
bin.install "APP_NAME"
test: |
system "#{bin}/APP_NAME", "version"Asset names look like cargo-dist on purpose:
APP_NAME-aarch64-apple-darwin.tar.xz
APP_NAME-x86_64-apple-darwin.tar.xz
APP_NAME-aarch64-unknown-linux-gnu.tar.xz
APP_NAME-x86_64-unknown-linux-gnu.tar.xz
APP_NAME-x86_64-pc-windows-msvc.zipThat lets the same npm/install.js pattern work for Rust and Go CLIs.
Release workflow
.github/workflows/release.yml:
name: Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+*"
workflow_dispatch:
inputs:
tag:
description: Existing tag to (re)release (for example, v0.1.0)
required: true
type: string
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}Create a classic PAT (or fine-grained token) with push access to Blankeos/homebrew-tap, then add it as the repo secret HOMEBREW_TAP_TOKEN.
5. Add the NPM Wrapper (Optional but Nice)
Same shape as Crabcode / lazygitrs:
npm/
package.json
bin.js
install.js
README.mdnpm/package.json:
{
"name": "APP_NAME",
"version": "0.1.0",
"bin": { "APP_NAME": "./bin.js" },
"scripts": { "postinstall": "node install.js" },
"files": ["bin.js", "install.js", "README.md"]
}install.js downloads:
https://github.com/Blankeos/APP_NAME/releases/download/v${VERSION}/APP_NAME-${target}.tar.xzKeep npm/package.json version identical to VERSION. The publish workflow asserts that.
Publish registries workflow (NPM only)
Unlike Cargo, there is no crates.io job. .github/workflows/publish-registries.yml:
- Verify tag ↔
VERSION↔npm/package.json - Wait until GoReleaser uploaded the expected archives
npm publish --access public --provenancewith Trusted Publishing (id-token: write)
Configure Trusted Publishing on npmjs.com for:
- Repository:
Blankeos/APP_NAME - Workflow:
publish-registries.yml - Environment: leave empty unless you add one
6. Add install.sh (curl | sh)
For users without Homebrew / Node / Go:
curl -sSL https://raw.githubusercontent.com/Blankeos/APP_NAME/main/install.sh | shIt resolves the latest GitHub Release, picks the host triple, and installs into /usr/local/bin (or $INSTALL_DIR).
7. Wire just tag
Install helpers:
brew install just git-cliff goreleaserjustfile recipe:
tag:
./tag_and_release.shtag_and_release.sh should:
- Refuse a dirty tree or a non-
mainbranch or a non-mainbranch - Ask patch / minor / major
- Bump
VERSION,npm/package.json, and any plugin manifests - Rebuild embedded UI (if any)
- Regenerate
CHANGELOG.mdwithgit cliff - Commit
release: APP_NAME v[VERSION] - Tag
v[VERSION]and push commit + tag
That tag triggers both workflows.
8. Cut a Release
go test ./...
# optional local smoke:
goreleaser release --snapshot --clean
just tagThen verify:
# GitHub Release assets exist
gh release view v0.1.0 --repo Blankeos/APP_NAME
# go install (source + embed)
go install github.com/Blankeos/APP_NAME/cmd/APP_NAME@v0.1.0
APP_NAME version
# Homebrew
brew install blankeos/tap/APP_NAME
# NPM
npm install -g APP_NAMECommon Problems
Tag and VERSION disagree
If the tag is v1.2.3, VERSION and npm/package.json must be 1.2.3. Fix before pushing the tag.
go install serves an empty UI
You forgot to commit web/dist (or whatever you go:embed). Prebuilt installs still work; source installs do not.
NPM publishes before binaries exist
Keep the wait-for-assets job. Without it, postinstall 404s on fresh tags.
Homebrew formula did not update
Check HOMEBREW_TAP_TOKEN, tap repo permissions, and the GoReleaser brews: block. The formula PR/commit lands in Blankeos/homebrew-tap, not in the CLI repo.
Module path ≠ GitHub URL
go install github.com/Blankeos/APP_NAME/... fails if go.mod still says github.com/someone-else/APP_NAME.
The Final Workflow
- Commit normal work; keep the tree clean.
- Run tests (and optionally
goreleaser release --snapshot). - Run
just tagand choose the SemVer bump. - Script bumps
VERSION+ npm, rebuilds embeds, commits, tags, pushes. release.yml→ GoReleaser → GitHub Release + Homebrew tap.publish-registries.ymlwaits for archives → publishes NPM.- Users install via brew / npm /
go install/install.sh.
Remember: the git tag is the Go registry. VERSION is just the human-editable twin of Cargo.toml's version, so every other channel stays aligned.