Building and Releasing Software
Building and Releasing Software
The boring release is a single static-ish binary from go build, with a version string you can grep, built with CGO_ENABLED=0 unless you have a C dependency you cannot drop. You do not need a container to prove the desk CLI works. You need a file you can copy.
Mental model
go run compiles a temporary binary and executes it. go build writes a binary you keep. The same compiler is in both paths.
Cross-compilation is two environment variables:
GOOS— the target operating system (linux,darwin,windows, …)GOARCH— the target architecture (amd64,arm64, …)
ldflags passes linker flags. The one you will actually use is -X main.version=… (or another package-level string) so the binary can print what it is.
CGO_ENABLED=0 tells the toolchain not to call a C compiler. Pure Go then produces a binary that does not dynamically link libc. That is the boring default for a desk tool with no cgo.
Worked examples
Save go.mod in an empty directory:
module example.com/desk
go 1.27
Case 1: go build writes a file you can run
Save as main.go:
// main.go
package main
import "fmt"
func main() {
fmt.Println("desk is open")
}Build and run (names the output desk so you do not get a binary named after the directory):
go build -o desk .No output on success. Then:
./deskOutput:
desk is open
go run . and ./desk should print the same line. Ship the file desk, not your laptop’s GOPATH.
Case 2: Stamp a version with -ldflags -X
Save as main.go:
// main.go
package main
import "fmt"
var version = "dev"
func main() {
fmt.Printf("desk %s\n", version)
}Default build (no flags):
go build -o desk .
./deskOutput:
desk dev
Release build. -X needs the full package path plus the variable name. For package main in this module that is main.version:
go build -ldflags="-X main.version=1.2.3" -o desk .
./deskOutput:
desk 1.2.3
The source still says "dev". The linker overwrites the string in the binary. Use the git tag or CI build number in the flag, not a hand-edited constant you will forget.
Case 3: Cross-compile with GOOS and GOARCH
Same main.go as Case 2. Build a Linux amd64 binary from whatever OS you are on:
GOOS=linux GOARCH=amd64 go build -ldflags="-X main.version=1.2.3" -o desk-linux-amd64 .No output on success. You now have a file named desk-linux-amd64. If your machine is already linux/amd64, ./desk-linux-amd64 runs. If it is not, copy the file to a Linux amd64 host — do not expect macOS or Windows to execute it.
Windows as a target (you cannot run this on Linux without extra machinery; building it is enough):
GOOS=windows GOARCH=amd64 go build -o desk.exe .No output on success. The .exe suffix is a convention for Windows; Go does not require it, but operators will thank you.
List what Go knows:
go tool dist listThe output is a long list of os/arch pairs (linux/amd64, darwin/arm64, …). Pick the pair your operators actually run.
Case 4: CGO_ENABLED=0 as the boring default
Same program. Force cgo off so a pure-Go build does not pick up a C toolchain by accident:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-X main.version=1.2.3" -o desk .
./deskOutput (on linux/amd64):
desk 1.2.3
If the program never import "C" and never uses a package that does, this binary is the one you copy between Linux machines. When you do need cgo, this flag will fail the build — that failure is useful. See the later chapter on cgo; until then, keep it off.
You can combine the variables:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-X main.version=1.2.3" -o desk .That line is a complete release command for a pure-Go desk CLI.
Case 5: Strip debug info with -ldflags="-s -w"
By default, go build includes full symbol tables and DWARF debugging information so tools like gdb or delve can attach to the process. For production binaries, stripping these tables reduces binary size significantly (often by 30% to 40%) without affecting runtime performance or panic stack traces.
-sstrips the symbol table.-wdisables DWARF debugging info generation.
go build -ldflags="-s -w -X main.version=1.2.3" -o desk-small .Check the size difference:
ls -lh desk desk-smallTypical output:
-rwxr-xr-x 1 runner staff 2.4M Sep 10 12:00 desk
-rwxr-xr-x 1 runner staff 1.6M Sep 10 12:00 desk-small
Stack traces on panic will still print line numbers and function names because Go’s runtime uses a separate internal line table.
Case 6: Inspect embedded build metadata at runtime
Save as build_info.go. Starting in Go 1.18+, compiled binaries automatically contain VCS revision metadata, dirty status, and compiler flags embedded by the toolchain. Your program can read its own pedigree without custom linker flags.
// build_info.go
package main
import (
"fmt"
"runtime/debug"
)
func main() {
info, ok := debug.ReadBuildInfo()
if !ok {
fmt.Println("no build info available")
return
}
fmt.Println("Go version:", info.GoVersion)
fmt.Println("Path:", info.Path)
for _, s := range info.Settings {
if s.Key == "vcs.revision" || s.Key == "vcs.time" || s.Key == "CGO_ENABLED" {
fmt.Printf("%s: %s\n", s.Key, s.Value)
}
}
}Run:
go run build_info.goOutput:
Go version: go1.27.1
Path: command-line-arguments
CGO_ENABLED: 1
When built inside a git repository with go build, info.Settings automatically reports vcs.revision (the git commit hash) and vcs.modified (whether uncommitted changes were present).
The trap
Shipping whatever go build produced on a developer laptop, with cgo left on, is how you discover in production that glibc versions differ.
This program is still pure Go. The trap is the command, not the source:
// main.go
package main
import "fmt"
var version = "dev"
func main() {
fmt.Printf("desk %s\n", version)
}A sloppy release:
go build -o desk .You get a binary. It may link libc. It prints desk dev. Nobody can tell which commit it is.
The boring release for this file:
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=1.2.3" -o desk .
./deskOutput:
desk 1.2.3
Pin GOOS/GOARCH in CI to the machines you support. Put the same command in a README line, not in a 400-line Makefile that calls Docker to compile 80 lines of Go.
The boring rule
go build -o <name> .is the whole compile step for amainpackage.- Stamp
versionwith-ldflags="-s -w -X main.version=…". - Strip symbols with
-s -wfor production binaries to cut binary size. - Inspect
runtime/debug.ReadBuildInfo()for embedded toolchain and VCS revision information. - Set
GOOSandGOARCHin CI for each file you intend to publish. - Default
CGO_ENABLED=0for pure Go. Turn cgo on for a reason you can say out loud. - Do not commit the binary. Commit the command that builds it.
Try this
- Change
versionin source to"local"and rebuild without-X. Confirm./deskprintsdesk local. Then rebuild with-X main.version=9.9.9and confirm the source did not have to change. - Build
build_info.gowithgo build -o info_bin build_info.goand run./info_binto see the compiled binary’s build settings. - Compare file sizes between
go buildandgo build -ldflags="-s -w". Note the size reduction. - Run
GOOS=darwin GOARCH=arm64 go build -o desk-mac .thenfile desk-mac(orls -l desk-mac) so you see a file you cannot run on Linux. - Run
CGO_ENABLED=0 go env CGO_ENABLEDandgo env GOOS GOARCHso you know your machine’s defaults.