skip to content

How do you stamp a Go binary so a postmortem can prove which build was running?

level: seniorimportance: should knowfreq 34%

answer

  1. two stamps, not one
  2. one you inject, one the toolchain records
  3. read the file, or ask the process
  4. a dirty tree makes the revision a half-truth

basics

~20 s

Use two stamps: -ldflags -X for a human-readable version string the program logs at start-up, and the go command's automatic version-control stamping for the revision. Read them back with go version -m on the artefact, or from inside the process.

solid answer

~50 s

Two independent stamps travel with a Go binary and a postmortem wants both. The first is what you inject: `-ldflags "-X main.version=... -X main.commit=..."`, logged as one line at start-up and exposed behind a `--version` flag or a small endpoint, so every log stream and support ticket carries it. The second is what the toolchain records for you: when `go build` produces an executable from a package inside a version-controlled checkout it stamps `vcs.revision`, `vcs.time` and `vcs.modified` into the binary's build information, unless `-buildvcs=false` disabled it. To read them back, `go version -m ./daemon` prints the module path, dependencies and build settings straight from the file without running it, and `runtime/debug.ReadBuildInfo` gives the process the same data. The trap is `vcs.modified=true`: the tree was dirty, so the revision alone does not identify the source that was built.

code

text · 9 lines
text
$ go version -m ./daemon
./daemon: go1.27.0
	path	example.com/daemon
	build	-buildmode=exe
	build	GOOS=linux
	build	GOARCH=amd64
	build	vcs=git
	build	vcs.revision=9f2c1ab4d3e2b7a1
	build	vcs.modified=true

go deeper

for a junior

Know that a Go binary can carry its own identity and that go version -m prints it from the file. At this level, make sure the service logs its version string at start-up so it is visible at all.

for a middle

Explain the two sources of identity — the values you inject with -X and the version-control settings the go command records — and how each is read back from an artefact or from the running process.

for a senior

Show the operational chain end to end: identity in every log line, an endpoint or flag that reports it, and a release check that catches a build shipped without a revision before an incident does.

for a principal

Own what counts as proof of what shipped: which stamps are mandatory across services, what a dirty-tree build means for a release, and who is accountable when an artefact in production cannot be traced to source.

## The question a postmortem actually asks Something went wrong at 03:14. You have logs, a core of metrics, and an artefact. Before any analysis is worth anything you have to answer: *which build produced this behaviour, and what source was it?* Deployment records and image tags are evidence, but they are evidence about the deployment system, not about the binary. The binary should be able to answer for itself. Go gives you two independent stamps, and they fail in different ways, which is exactly why you want both. ## Stamp one: what you inject `-ldflags -X` writes strings into package-level variables at link time. The pipeline computes the version and the short commit sha and injects them: go build -ldflags "-X main.version=$VERSION -X main.commit=$COMMIT" -o daemon . This stamp is *for humans and for correlation*. It belongs in three places: 1. **One structured log line at start-up**, so any log query that finds the incident also finds the build. 2. **A `--version` flag**, so anyone holding the artefact can ask it directly. 3. **A small endpoint or metric label**, so monitoring can show a rollout in progress as two versions running side by side. Its weakness is that it is whatever the pipeline decided to write. It can be stale, it can be duplicated across two builds, and it can be attached to a tree that did not match. It is a label, not proof. ## Stamp two: what the toolchain records When `go build` links an executable from a package inside a version-controlled directory, the go command records version-control information into the binary's embedded build settings: the revision, the commit time, and whether the working tree was modified. The `-buildvcs` flag controls this, and the default behaviour includes the information when it is available. This stamp is *machine-produced from the checkout that was actually compiled*, which is what makes it useful as evidence. Nobody typed it. Its weakness is availability. In container builds it very often disappears, for two reasons: the version-control metadata directory was not copied into the build context, so there is nothing to read; or the pipeline explicitly passed `-buildvcs=false`, commonly because the version-control tool refused to report status inside the container and the build failed until someone turned it off. Either way the resulting images carry no revision at all — and you find out during the incident, which is the worst possible time. Run `go version -m` against a real production image once, deliberately, and see what is actually there. ## Reading the stamps back **From the file, without running it:** `go version -m ./daemon` prints the Go toolchain version, the module path, the dependency modules, and the recorded build settings — `GOOS`, `GOARCH`, `CGO_ENABLED`, the build mode, and the `vcs.*` entries. This works on a binary pulled out of an image, off a node, or out of an artefact store, and it does not require executing an artefact you may not trust. **From inside the process:** `runtime/debug.ReadBuildInfo` returns the same information to the running program, which is how a service can log its own recorded revision alongside its injected version, and how a `/version` handler can report both. ## The trap: vcs.modified `vcs.modified=true` means the working tree had uncommitted changes when the build ran. The revision is then a starting point rather than an identity: the source that produced the binary is not the source at that revision, and nobody knows exactly how it differed. For a release artefact this should be treated as a defect in the pipeline, not a curiosity. The usual cause is a build step that writes into the checkout before compiling. Fix it so that release builds are always clean, and the stamp becomes something you can rely on under pressure. ## Putting it together A defensible posture for a service you will one day have to write a postmortem about: - Inject version and commit; default them to a visible sentinel so an un-stamped build is obvious. - Log both injected values and the recorded revision in one start-up line, and expose them on demand. - Keep version-control stamping working in the container build rather than silencing it, and verify that on a real image. - Gate the release on the artefact: run the freshly built binary, check that it reports a real version, and check that the recorded revision is present and the tree was clean. The cost of all of that is a few lines in a build script. The cost of not having it is an incident where the first hour goes on establishing what was running.

  • Why does a binary built inside a container image often carry no revision at all?
    Because the version-control metadata was not visible where the build ran — the repository directory was excluded from the build context — or because the pipeline passed -buildvcs=false to get past a tool failure inside the image build. Either way the vcs.* settings are simply absent and the only identity left is what -X injected. Check `go version -m` on a real image before an incident forces you to.
  • Is the injected version string enough on its own?
    Rarely. It is whatever the pipeline chose to write, so it can be stale, reused across two builds, or attached to a tree that was not clean. The recorded revision is produced by the toolchain from the checkout it actually compiled. A postmortem wants the human label for correlation and the recorded revision for proof.
  • What do you do when go version -m reports vcs.modified=true on a release artefact?
    Treat the revision as a lead rather than an identity, then find out how an uncommitted tree reached a release build — usually a pipeline step that writes into the checkout before compiling. Change the pipeline so release builds compile a clean tree; until then no stamp from that pipeline can be trusted as evidence.
  • Why prefer go version -m over running the binary with --version?
    Because it reads the file rather than executing it, so it works on an artefact pulled from a store or a node, on a binary for another platform, and on one you would rather not run. It also shows what the toolchain recorded — module path, dependencies, build settings — which a --version flag only reports if the program was written to.

saying these in an interview costs you the question

  • Relies on the deployed image tag as the only build identity
  • Assumes a version-control revision is always recorded
  • Ignores vcs.modified and treats the revision as proof
  • Establishes the build by grepping the release branch source
  • Thinks go version -m has to execute the binary