Building and Releasing Software

Updated

September 13, 2026

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:

./desk

Output:

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 .
./desk

Output:

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 .
./desk

Output:

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 list

The 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 .
./desk

Output (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.

  • -s strips the symbol table.
  • -w disables 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-small

Typical 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.go

Output:

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 .
./desk

Output:

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 a main package.
  • Stamp version with -ldflags="-s -w -X main.version=…".
  • Strip symbols with -s -w for production binaries to cut binary size.
  • Inspect runtime/debug.ReadBuildInfo() for embedded toolchain and VCS revision information.
  • Set GOOS and GOARCH in CI for each file you intend to publish.
  • Default CGO_ENABLED=0 for 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

  1. Change version in source to "local" and rebuild without -X. Confirm ./desk prints desk local. Then rebuild with -X main.version=9.9.9 and confirm the source did not have to change.
  2. Build build_info.go with go build -o info_bin build_info.go and run ./info_bin to see the compiled binary’s build settings.
  3. Compare file sizes between go build and go build -ldflags="-s -w". Note the size reduction.
  4. Run GOOS=darwin GOARCH=arm64 go build -o desk-mac . then file desk-mac (or ls -l desk-mac) so you see a file you cannot run on Linux.
  5. Run CGO_ENABLED=0 go env CGO_ENABLED and go env GOOS GOARCH so you know your machine’s defaults.