Build Tools — Professional¶
1. How goreleaser actually orchestrates a release¶
goreleaser release is not magic; it is a deterministic pipeline that you can name step by step:
- Setup — read
.goreleaser.yaml, runbefore.hooks(typicallygo mod tidy). - Build matrix — for every
(goos, goarch, goamd64)tuple inbuilds, fork an isolated build env (its ownGOOS/GOARCH/CGO_ENABLED) and callgo buildwith the configured-ldflags,-trimpath,-tags. Result: N binaries indist/. - Universal binaries (optional) — for Darwin, fuse
amd64+arm64into one fat Mach-O. - Archive — for each binary, produce
tar.gz/zipper template, including extras (LICENSE, README, completions). - Linux packaging — call
nfpmto produce.deb/.rpm/.apkfrom each binary. - Checksums — produce
checksums.txt(SHA256 by default). - SBOM — call
syftto emit SPDX/CycloneDX for each artifact. - Signing — call
cosign/gpgto sign archives, checksums, SBOMs. - Container images (optional) — call
koordockerto build/push images for each platform. - Manifest — combine per-arch images into a multi-arch OCI index.
- Publish — upload to GitHub Releases (or GitLab, Gitea), push container manifests, post to Slack/Discord, update Homebrew/Scoop taps.
Every step is opt-in via config. Everything except step 1 is parallelisable. --snapshot skips publishing; --skip=sign,sbom skips named steps; --single-target builds only the host platform. The artifact set is durably described by dist/artifacts.json for later analysis.
2. ko — how it builds an OCI image without a Dockerfile¶
ko build ./cmd/server:
- Resolve
./cmd/serverto a Go import path. - For each requested platform (
--platform, defaultlinux/amd64,linux/arm64), callgo buildwithCGO_ENABLED=0and-trimpath, writing to a temp dir. - Pull the base image (default
cgr.dev/chainguard/static) by digest from the configured registry (cached locally). - Construct a new OCI image as
(base image layers) + (single new layer containing only the Go binary at /ko-app/<name>). The binary layer's content is the deterministic tar of one file; its hash is reproducible. - Set
Entrypoint: ["/ko-app/<name>"], copyUser,Envfrom base, add labels (org.opencontainers.image.source, etc.). - For multi-arch, build per-arch images then a manifest list referencing all digests.
- Push to
$KO_DOCKER_REPOover the OCI distribution API (nodockerdaemon).
Why this is faster than docker build:
- No
Dockerfileparser, no layer cache miss fromCOPY . ., no shell. - The new layer is just one file → trivially deterministic.
- Cross-arch is built on the host with Go cross-compilation, not QEMU.
Why this is more deterministic:
- Base pinned by digest (you should pin yours in
.ko.yaml). - Single content-addressed layer per platform.
- Same source + same ko + same base = same image digest. You can audit it.
3. mage — how it actually runs¶
A magefile.go is a normal Go program with a magic build tag. When you run mage build:
magescans the working directory for files with//go:build mage.- It generates a
main.goshim that imports your magefile package and switches onos.Args[1]to call your exported functions. - It writes the generated file to a temp dir, runs
go buildon(your magefile + the shim), producing a binary. - The binary is cached at
~/.magefile/<hash>keyed on the source content. Subsequent runs reuse it (mage -compilelets you ship this binary). - The binary executes the requested target.
mg.Deps(F)runsFfirst (each target runs at most once per invocation, like make).
Why //go:build mage is mandatory: without it, go build ./... in your repo would try to compile your magefile (which has functions named Build, Test, etc.) as part of your real program, producing duplicate symbols. The tag hides the file from normal Go tooling.
Why it is fast in practice: the binary is cached. mage build after the first invocation is essentially "exec a tiny Go binary," not "compile Go then exec."
4. bazel rules_go — reimplementing the Go build graph¶
Stock go build discovers dependencies dynamically by walking imports. Bazel cannot tolerate that — it needs the dependency graph declared up front so it can hash inputs and decide what to rebuild.
rules_go therefore reimplements Go compilation as a Bazel rule set:
go_library— corresponds to one Go package. Declaressrcs,deps,importpath.go_binary— links one main package + its transitivego_librarydeps.go_test— likego_binary, but for*_test.go.
Bazel feeds each go_library to the Go compiler in isolation (only its declared deps are on the import path). Output is a .a archive that becomes an input to the next go_library. The link step assembles archives into a binary.
Hermeticity guarantees:
- Toolchain is downloaded by Bazel at a pinned version (
go_register_toolchains). - Every input is content-addressed; cache hits are cross-machine via RBE.
CGO_ENABLED=0by default; cgo requires a declared C toolchain.
The cost: a BUILD.bazel per directory. gazelle generates and updates them from your Go imports, but you must run bazel run //:gazelle after adding/removing imports. Forgetting this is the #1 broken-build cause in rules_go shops.
5. buf — what it does that protoc does not¶
protoc is a single-file compiler; everything else (lint, breaking-change detection, dependency management) was DIY shell scripts. buf provides:
buf.yaml— declares modules (proto roots), lint rule set, breaking rule set.buf.lock— pins remote proto dependencies (deps: [buf.build/googleapis/googleapis]).buf lint— applies a rule set (e.g.,STANDARD) consistent across the team.buf breaking --against— compares current protos to a baseline; fails CI on breaking changes (per theFILE/PACKAGE/WIRErule families).buf generate— invokes plugins (local or remote BSR) perbuf.gen.yaml. Output paths are explicit.
Under the hood, buf parses .proto files itself (no protoc required), then either calls local plugin binaries or remote BSR plugins for code generation. The advantage is that everyone in the repo runs the same plugin version because buf.gen.yaml pins it.
6. Reproducibility expectations of each tool¶
| Tool | Reproducible by default? | What you must do |
|---|---|---|
go build | No (build paths, file timestamps) | -trimpath, -buildvcs=false, fixed go.mod, fixed toolchain |
goreleaser | Mostly | Pin goreleaser version in CI; do not use @latest; lock Makefile to same -trimpath/-ldflags |
ko | Yes for the binary layer | Pin base image by digest, not tag; pin Go version; freeze KO_DEFAULTPLATFORMS |
mage | Inherits Go's | Same flags as go build; pin tool deps in go.mod |
bazel rules_go | Yes by design | Use MODULE.bazel with pinned versions; enable --remote_cache for cross-machine reuse |
buf | Yes for codegen | Pin plugin versions in buf.gen.yaml; commit buf.lock |
task | Inherits the commands it runs | Use sources:/generates: for content-based skip; pin tool versions in commands |
The recurring lever: pin everything. The toolchain, the build tool itself, the plugins, the base images, the dependencies. @latest is the enemy of reproducibility.
7. CI integration patterns¶
# .github/workflows/release.yml
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
id-token: write # for cosign keyless
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # goreleaser needs full history
- uses: actions/setup-go@v5
with: { go-version: '1.24' }
- uses: sigstore/cosign-installer@v3
- uses: anchore/sbom-action/download-syft@v0
- uses: goreleaser/goreleaser-action@v6
with:
version: v2.5.0 # PINNED
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Notes: - fetch-depth: 0 is non-negotiable for goreleaser changelogs. - goreleaser/goreleaser-action@v6 is the action version; version: v2.5.0 is the goreleaser version. Both pinned. - The id-token permission is for keyless cosign with Sigstore.
For ko:
- uses: ko-build/setup-ko@v0.7
- run: ko build --bare ./cmd/server
env:
KO_DOCKER_REPO: ghcr.io/${{ github.repository }}
8. Build graph composition¶
When you have multiple tools, draw the graph explicitly:
proto sources ── buf generate ──► gen/go/*.pb.go
│
▼
go build ──► bin/server
│
▼
goreleaser archive + checksum
│
▼
ko build (image) + cosign sign
The seams are well-defined: buf writes gen/go/, Go reads from it. goreleaser calls go build. ko is invoked from goreleaser (via the kos: stanza) or as a separate step.
If any seam becomes implicit ("the Makefile happens to also call protoc somewhere"), find it and make it explicit. Implicit edges in a build graph are how you get the "works on my machine" bug.
9. Detecting non-reproducibility¶
Build twice, diff binaries:
go build -trimpath -ldflags='-buildid=' -o a ./cmd/server
go build -trimpath -ldflags='-buildid=' -o b ./cmd/server
sha256sum a b
diffoscope a b # if hashes differ, this tells you why
For ko:
ko build --push=false --tarball=a.tar ./cmd/server
ko build --push=false --tarball=b.tar ./cmd/server
sha256sum a.tar b.tar
For goreleaser:
If the hashes drift, the usual culprits are: embedded timestamps, embedded paths (need -trimpath), runtime.buildVersion differences (different go toolchain), or the build tool itself injecting non-deterministic metadata (e.g., release date).
10. Summary¶
goreleaser is a deterministic build-matrix → archive → SBOM → sign → publish pipeline; every stage is opt-in and replaceable. ko cross-compiles Go and stacks the binary on a base image as a single content-addressed layer, no Dockerfile needed. mage compiles your build script into a cached Go binary and runs it like make. bazel rules_go reimplements the Go build graph with declared dependencies for hermetic, cross-machine caching. buf replaces protoc and DIY shell with a unified proto workflow (lint, breaking-check, codegen). The reproducibility lever across all of them is pin everything: toolchain, tool version, plugins, base images, deps. In CI, make the build graph explicit and verify by hashing the artifacts.
Further reading¶
- GoReleaser pipeline reference: https://goreleaser.com/customization/
- ko design doc: https://github.com/ko-build/ko/blob/main/docs/configuration.md
- Mage internals: https://github.com/magefile/mage/blob/main/README.md
- rules_go architecture: https://github.com/bazelbuild/rules_go/blob/master/docs/go/core/rules.md
- buf CLI overview: https://buf.build/docs/cli/
- Reproducible builds: https://reproducible-builds.org/
- Sigstore + Go: https://docs.sigstore.dev/cosign/signing/signing_with_blobs/