JavaRush /Courses /Go SELF /Build reproducibility: versioning and artifact verificati...

Build reproducibility: versioning and artifact verification

Go SELF
Level 54, Lesson 4
Available

1. What reproducibility is and why it matters

If you are just starting to program, “build reproducibility” may sound like a term from the world of people who collect build logs and get joy from CI. But in practice it is a very down-to-earth thing: you want the built binary to be verifiable, comparable, and repeatable. Otherwise, the classics begin: “it works on my machine,” “it doesn’t on mine,” “which binary did you run?”, “is that definitely the right commit?”, “why is it a different size?”.

Reproducibility is not magic and not a promise that “all builds are always identical.” It is an agreement about two things. First, we must understand which inputs affect the final bytes of the executable. Second, we must be able to quickly and without ritualistic guessing answer the question: “is this file the exact artifact we expected?”. Ideally, in a way that still makes sense even a week later (when you have already forgotten what “the exact one” even meant).

By the way, a small but important note about the environment: modern versions of Go run in module-aware mode by default (historically, this was locked in starting with Go 1.16), meaning go build treats your project as a module with go.mod, and that affects how imports and dependencies are resolved.

2. Build inputs and binary bytes

To keep this topic from turning into mysticism, it helps to keep one simple model in mind: a binary is the result of a function of a set of inputs. Code is only one of them. Let’s sketch a mental flowchart so your brain stops thinking “but I didn’t change the code, why is the file different?”.

flowchart TD
    A[.go sources] --> B[go build: package compilation]
    C[Dependencies from go.mod/go.sum] --> B
    D[Go toolchain version] --> B
    E[GOOS/GOARCH] --> B
    F[-ldflags -X ...] --> B
    B --> G[Linking]
    G --> H[Binary: specific bytes]

Pay attention to -ldflags -X ...: this is a perfectly legitimate way to embed a “version” or a “commit” into the binary, and it is exactly what most often makes two builds “almost the same, but not byte-for-byte identical.”

Below is a table that helps you quickly “catch” the main sources of differences. This is not a memorization list, but a common-sense cheat sheet.

Build input Example What changes in the result
Sources
main.go
logic changes, bytes change
Dependencies
go.mod/go.sum
dependency behavior/code changes, bytes differ
Go version Go 1.25 vs another different compiler/linker — bytes may differ
Target linux/amd64 vs windows/amd64 by definition, a different binary
Ldflags -X Version=v1.2.3 embedded strings change, bytes differ
“Current time” Date=2026-01-16T... almost always makes bytes unique

The key takeaway: an “identical build” is when the inputs are identical. If you intentionally change Version or Date, do not expect identical bytes — that is a normal, explainable difference.

3. What to verify: identity vs semantic signature

When people first hear about reproducibility, they often want the impossible: “make any two builds always match.” But business reality (and human reality) is different: you almost always want the binary to be self-describing. That is, it should be able to say:

  • what version it is (or at least “dev”);
  • which commit;
  • which platform it was built for;
  • (sometimes) when it was built.

A question immediately comes up: if we embed the build date as “now,” then every build will be different. And that is not a bug. You just need to decide what matters more for your case.

If we say “verify that this is the exact same file,” then we compare bytes (via a checksum) and get a “yes/no” answer.

If we say “verify that this is the correct product version,” then we compare semantic identity: version + commit + target. It is convenient to call this short “semantic signature” a fingerprint (an identity fingerprint, but not a byte fingerprint).

And then, very engineering-like: you store the fingerprint inside the binary, and you compute the checksum from the file.

4. Fingerprint inside the binary

Now we will do the following in our todo: when the binary is run with --version, it prints not just “v1.2.3,” but a compact summary string: version, commit, platform. This is handy both for a developer (“what did I actually run?”) and for a user (“is this really that release?”).

Let’s start with the internal/buildinfo package. We already have it from previous topics, but we will shape it so it fits today’s idea.

package buildinfo

import (
	"fmt"
	"runtime"
)

var Version = "dev"
var Commit = "none"

func Fingerprint() string {
	return fmt.Sprintf("%s|%s|%s/%s", Version, Commit, runtime.GOOS, runtime.GOARCH)
}

The important point here is that runtime.GOOS and runtime.GOARCH describe the build target. That is, if you built for windows/amd64, then even if you “built on macOS,” the fingerprint will show Windows.

Now we’ll wire it into main and implement the usual --version as an early exit. This is good CLI hygiene: the version should print fast and without running the rest of the logic (otherwise “version” suddenly starts requiring config, files, network... and you end up printing a version through pain).

package main

import (
	"flag"
	"fmt"

	"example.com/todo/internal/buildinfo"
)

func main() {
	showVersion := flag.Bool("version", false, "print version and exit")
	flag.Parse()

	if *showVersion {
		fmt.Println(buildinfo.Fingerprint()) // dev|none|linux/amd64
		return
	}

	fmt.Println("todo: run a command") // todo: run a command
}

Yes, it looks simple. But that simplicity is what makes the feature reliable: if something breaks, it breaks not because of a “complex version mechanism,” but because of something more basic.

5. Why “build time = now” breaks reproducibility

Sometimes you want to add Date to buildinfo as well. And that is a reasonable desire: the date is useful for diagnostics. But it is easy to step on a rake here: if you substitute the current time every time, then every build will have unique bytes, even if the sources and dependencies are identical.

Here is the correct mental frame. The build date is an input that you either fix, or consciously accept that it changes. So the problem is not the presence of a date, but the fact that “now” makes artifact comparison impossible.

Let’s do it like this: the date exists, but by default it is “unknown.” And if you want reproducibility, you provide the date explicitly (for example, in CI, or manually).

package buildinfo

import "fmt"

var Version = "dev"
var Commit = "none"
var Date = "unknown"

func String() string {
	return fmt.Sprintf("%s (%s, %s)", Version, Commit, Date)
}

And now you choose the behavior. Want “always unique” — set Date to the current time. Want reproducibility — set a fixed string (for example, the release date) or do not set it at all.

It is also useful to notice one more psychological point: a beginner may think reproducibility is only needed by large companies. But in reality, you need it yourself when you rebuilt a binary, forgot about it, and then try to understand why tests “suddenly started failing.” If the artifact itself says it is from a different commit, you save hours of your life.

6. Artifact verification: checksum and sha256

Checksum: sha256 as a byte fingerprint

Now for the second part of the story: comparing bytes. We need a simple way to answer the question: “are file A and file B the same?”. You can compare size, you can compare “by eye” (a bad option), you can do a byte-by-byte comparison. But in practice, people almost always use a checksum: a short string computed from the file contents.

Important: today we are not studying cryptography. We use sha256 as a convenient function that produces a long “fingerprint” of the contents. If two files differ by even one byte, sha256 will almost certainly be different. For “compare artifacts” tasks, this is more than enough.

Let’s add a small utility to our project that computes a file’s sha256.

Practice in todo: compute a file sha256

Let’s create the internal/checksum package and the Sum256File function. Note: the code is short, but it contains everything important — open the file, stream the data, close it, handle errors.

package checksum

import (
	"crypto/sha256"
	"fmt"
	"io"
	"os"
)

func Sum256File(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return fmt.Sprintf("%x", h.Sum(nil)), nil
}

Notice io.Copy: it is “stream bytes through the hash,” without reading the entire file into memory. Even if the binary is large, this code does not turn your computer into a grill.

Now let’s add the checksum command to main. We will not build a complicated command dispatcher for half the course; we just need the idea. Let it be: todo checksum <path>.

package main

import (
	"fmt"
	"os"

	"example.com/todo/internal/checksum"
)

func main() {
	if len(os.Args) >= 3 && os.Args[1] == "checksum" {
		sum, err := checksum.Sum256File(os.Args[2])
		if err != nil {
			fmt.Fprintln(os.Stderr, "checksum error:", err)
			os.Exit(1)
		}
		fmt.Println(sum) // e3b0c44298fc1c149afbf4c8996fb924...
		return
	}

	fmt.Println("Usage: todo checksum 
  

Yes, it is minimalistic. But it helps: you can already compute sha256 for any file, including your built binary.

If you want a bit of “engineering convenience,” you can hash the executable itself via os.Executable(). But that is a bit more “nice,” so I will leave it as an idea: first, lock in the basic skill of “hash a file by path.”

A quick artifact verification protocol

Now let’s assemble everything into one clear protocol that you can repeat in a solo project or in a team.

First, you check “who are you?” via --version (fingerprint). This is fast and requires no external tools.

Then you check “do the bytes match?” via sha256. This takes a bit longer because the file must be read, but it is still usually fast.

flowchart TD
    A[You have a binary] --> B[Run: todo --version]
    B --> C{Fingerprint matches expected?}
    C -->|no| D[Stop: wrong version/platform]
    C -->|yes| E[Compute file sha256]
    E --> F{Checksum matches the reference?}
    F -->|yes| G[File is identical]
    F -->|no| H[File differs: rebuild/tampering/error]

Here the “reference” can be anything: a note in release notes, a value from CI, a hash your colleague sent you, or simply a hash you saved for later comparison.

And this is the moment when reproducibility stops being philosophy and becomes practice. You do not argue “it seems like it is the same file.” You say “fingerprint is X, sha256 is Y.”

7. Common mistakes

Mistake No. 1: confusing fingerprint and checksum and demanding that they solve the same task.
Fingerprint is a semantic signature (version/commit/platform) stored inside the binary to help a person quickly understand “what is running.” Checksum is a fingerprint of the file’s bytes. A fingerprint may match for two different files (for example, you built the same commit twice with different flags), while the checksum will reveal the difference.

Mistake No. 2: automatically embedding the current build time and then being surprised that artifacts are “impossible to compare.”
If Date is always “now,” then you almost certainly make every binary unique at the byte level. This is not “bad Go,” it is logic: you added a changing build input. If reproducibility matters, keep the default unknown and set the date consciously when needed.

Mistake No. 3: comparing binaries built for different GOOS/GOARCH as if they should match.
A binary for linux/amd64 and one for windows/amd64 must be different. These are different formats, different OS-level details, sometimes different parts of the runtime. So first look at the fingerprint (it shows GOOS/GOARCH), and only then discuss “why sha256 does not match.”

Mistake No. 4: making --version “heavy” and dependent on the application’s main logic.
If --version starts reading configs, opening files, hitting the network, or “bootstrapping the app,” then the version stops being a reliable diagnostic tool. As a result, at the moment you most need to know the version (for example, “the app won’t start”), you cannot get it.

Mistake No. 5: treating sha256 as “proof of security” instead of “a convenient byte fingerprint.”
For our task, sha256 is a way to quickly compare two files and notice changes. It is useful for integrity checks and build discipline. But secure delivery, binary signatures, and trusted publishing are a separate, grown-up conversation that should not be replaced with a single hash.

1
Task
Go SELF, level 54, lesson 4
Locked
Build fingerprint
Build fingerprint
1
Task
Go SELF, level 54, lesson 4
Locked
Embedded build metadata
Embedded build metadata
1
Task
Go SELF, level 54, lesson 4
Locked
Checksum
Checksum
1
Task
Go SELF, level 54, lesson 4
Locked
Artifact manifest
Artifact manifest
1
Survey/quiz
Build and version, 54 level, 4 lesson
Unavailable
Build and version
Build and version
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION