---
title: 'Module Publishing'
source: 'https://academia.sh/en/courses/go-production/module-publishing'
course: 'Production Go: Testing, Tooling and Performance'
language: en
updated: '2026-09-14T10:59:32+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures whether a version change is compatible or incompatible by comparing the signatures of exported functions'
    - "Verifies the signature comparison's verdict by genuinely compiling an old caller against the new version"
    - 'Bounds the compatibility promise to signatures, by showing the same signature paired with two different behaviors'
---

# Module Publishing

A version number is declared openly in the source, but the compatibility promise that version carries is checked not by the source itself but by a tool: the signatures of exported functions are compared, counting the names added, changed, and removed. The static verdict is confirmed by genuinely compiling the same source. The limiting measurement shows, by running it, that the promise rests on signatures — that two implementations carrying the identical signature can give a completely different result, and the tool never sees this at all.

In the previous lesson the embedded value was never written in the source, and no tool ever
checked it. This lesson starts from the exact opposite kind of source: a version number is
declared openly, in the source itself. But this number written in the source guarantees nothing
by itself — what guarantees something is a promise that number carries, and a separate tool that
checks whether that promise held.

## Version in the Source, Compatibility from the Tool

A module's **compatibility promise** between successive versions says that a new version will
not break code using the old version. This promise is declared in the source as a number, but the
number itself performs no check; there is no mechanism watching whether the correct number was
written. What can be checked is not the promise's **behavioral** counterpart — what happens
inside the implementation — but its **signature** counterpart: has an exported function's name,
parameters, and return type changed from one version to another?

The measurement below builds a small comparator using the standard library's own parsing
packages — not a real publishing tool, but one modeled to answer this lesson's question. It
parses two source texts, extracts the signature of every exported function in each, and compares
the two sets.

The comparator itself has three steps. First each source is turned, by the language's own
parser, into a syntax tree — this step performs no type resolution at all, it only reads the
source's syntax. Then every function declaration in the tree is filtered: declarations with no
method receiver (that is, ordinary functions) and a capitalized name are kept, everything else —
methods, unexported helper functions — is discarded. In the last step, each remaining function's
type (its parameter list and return list) is printed back out into source form, and this printed
text is kept in a dictionary as that function's **signature**. The dictionary's key is the
function name, its value the signature text; the two versions' dictionaries can therefore be
compared directly.

```go
// helpers.go — the helpers this lesson shares: extracting signatures, setting up a temporary package, calling the tool
package main

import (
	"bytes"
	"fmt"
	"go/ast"
	"go/parser"
	"go/printer"
	"go/token"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
)

// show prints a fact, who states it, and its evidence on a single line.
func show(info, speaker, evidence string) {
	fmt.Printf("%-46s %-7s %s\n", info, speaker, evidence)
}

// signatures maps the name of every exported free function in the given source to its printed
// type signature. A small model built with the standard library's own parsing packages; not a
// real compatibility tool.
func signatures(source string) map[string]string {
	fset := token.NewFileSet()
	file, err := parser.ParseFile(fset, "source.go", source, 0)
	if err != nil {
		return nil
	}
	result := map[string]string{}
	for _, decl := range file.Decls {
		fd, ok := decl.(*ast.FuncDecl)
		if !ok || fd.Recv != nil {
			continue
		}
		name := fd.Name.Name
		if !ast.IsExported(name) {
			continue
		}
		var buf bytes.Buffer
		printer.Fprint(&buf, fset, fd.Type)
		result[name] = strings.TrimPrefix(buf.String(), "func")
	}
	return result
}

// compare returns the names added, changed, and removed between two signature sets.
func compare(previous, next map[string]string) (added, changed, removed []string) {
	for name, nextSig := range next {
		prevSig, existed := previous[name]
		if !existed {
			added = append(added, name)
		} else if prevSig != nextSig {
			changed = append(changed, name)
		}
	}
	for name := range previous {
		if _, exists := next[name]; !exists {
			removed = append(removed, name)
		}
	}
	sort.Strings(added)
	sort.Strings(changed)
	sort.Strings(removed)
	return
}

// compatible says, by looking only at signatures, whether code using the old version will also
// compile against the new version: no name may be removed and no signature may have changed.
func compatible(previous, next map[string]string) bool {
	_, changed, removed := compare(previous, next)
	return len(changed) == 0 && len(removed) == 0
}

// setupModule writes the given files into a new temporary module; the path is never printed.
func setupModule(files map[string]string) string {
	dir, err := os.MkdirTemp("", "publishing")
	if err != nil {
		return ""
	}
	for name, content := range files {
		os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755)
		os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
	}
	return dir
}

// compiles attempts to build the package in the given directory; the output is never printed.
func compiles(dir string) bool {
	cmd := exec.Command("go", "build", "-o", os.DevNull, "./...")
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	return cmd.Run() == nil
}
```

```go
// measurement1.go — version declared in the source, compatibility promise checked by a tool (signature comparison)
package main

import "fmt"

const versionV1 = "v1.0.0"
const versionV2Compatible = "v1.1.0"
const versionV2Incompatible = "v2.0.0"

const sourceV1 = "package sample\n\nfunc Double(x int) int { return x * 2 }\n"
const sourceV2Compatible = "package sample\n\nfunc Double(x int) int { return x * 2 }\n\nfunc Triple(x int) int { return x * 3 }\n"
const sourceV2Incompatible = "package sample\n\nfunc Double(x int, factor int) int { return x * factor }\n"

func main() {
	s1 := signatures(sourceV1)
	s2c := signatures(sourceV2Compatible)
	s2i := signatures(sourceV2Incompatible)

	fmt.Println("-- version in the source, compatibility from the tool --")
	show("version "+versionV1+" -> "+versionV2Compatible, "tool", fmt.Sprint("compatible=", compatible(s1, s2c)))
	show("version "+versionV1+" -> "+versionV2Incompatible, "tool", fmt.Sprint("compatible=", compatible(s1, s2i)))

	addedC, changedC, _ := compare(s1, s2c)
	show("added name count, compatible change", "tool", fmt.Sprint(len(addedC)))
	show("changed signature count, compatible change", "tool", fmt.Sprint(len(changedC)))

	_, changedI, _ := compare(s1, s2i)
	show("changed signature count, incompatible change", "tool", fmt.Sprint(len(changedI)))
}
```

```
-- version in the source, compatibility from the tool --
version v1.0.0 -> v1.1.0                       tool    compatible=true
version v1.0.0 -> v2.0.0                       tool    compatible=false
added name count, compatible change            tool    1
changed signature count, compatible change     tool    0
changed signature count, incompatible change   tool    1
```

**BD12.** All three version numbers are written in the source — this is the only fact in this
lesson that falls into the **source** class. But **what relationship** the versions hold to one
another — that `v1.1.0` does not break `v1.0.0`, that `v2.0.0` does — comes not from the numbers
themselves but from running the comparator over the signature sets. `v1.1.0` only adds a new
name, `Double`'s signature stays the same — this is why `compatible=true`. `v2.0.0` adds a second
parameter to `Double`; the name stays the same but the signature changed — this is why
`compatible=false`. The version numbers themselves (`v1.0.0`, `v1.1.0`, `v2.0.0`) are not a rule
but a **label**; the side that enforces the rule never reads the numbers, it only compares the
signature sets.

The **Go Fundamentals** course's Packages and Tooling topic measured which version of a module
gets selected, through the rule of minimal version selection; this lesson does not rebuild that
selection mechanism, it only measures the promise the **selected** version carries.

## Verified with a Real Build

The verdict above is entirely static: no source was genuinely compiled, only parsed and compared.
Whether this verdict matches a real build is a separate question, and the measurement below tests
this directly — it genuinely compiles a source calling `v1.0.0` in its old form, together with
both the `v1.1.0` and the `v2.0.0` package.

```go
// measurement2.go — verifying the static comparison with a genuine build
package main

import "fmt"

const sourceV2Compatible = "package sample\n\nfunc Double(x int) int { return x * 2 }\n\nfunc Triple(x int) int { return x * 3 }\n"
const sourceV2Incompatible = "package sample\n\nfunc Double(x int, factor int) int { return x * factor }\n"

// setup writes the old caller's source together with the given package version into a temporary module.
func setup(packageSource string) string {
	return setupModule(map[string]string{
		"go.mod":            "module trial\n\ngo 1.24\n",
		"sample/sample.go":  packageSource,
		"caller.go":         "package main\n\nimport \"trial/sample\"\n\nfunc main() {\n\t_ = sample.Double(5)\n}\n",
	})
}

func main() {
	compatibleDir := setup(sourceV2Compatible)
	incompatibleDir := setup(sourceV2Incompatible)
	defer func() { _, _ = compatibleDir, incompatibleDir }()

	fmt.Println("-- static verdict, confirmed with a genuine build --")
	show("old caller + compatible version compiled", "tool", fmt.Sprint(compiles(compatibleDir)))
	show("old caller + incompatible version compiled", "tool", fmt.Sprint(compiles(incompatibleDir)))
}
```

```
-- static verdict, confirmed with a genuine build --
old caller + compatible version compiled       tool    true
old caller + incompatible version compiled     tool    false
```

**BD13.** Both lines match the previous measurement's verdict exactly: in the version pair called
`compatible=true`, the old caller genuinely compiles; in the pair called `compatible=false`, it
genuinely does not. This match is not a coincidence — the signature comparison already models the
compiler's own rule: for a call to compile, the called function's name and parameter list must
match what is expected at the call site. The static comparator applies this rule without ever
running the compiler; the real build applies the same rule once more, through a genuine call, this
time **without a run, but with a genuine tool call**. Just as with the linker options, two
different paths arrive at the same answer here as well; the difference is that one reads the
source without ever compiling it, the other genuinely compiles it.

The fact that these two paths confirm each other justifies an important design choice in
practice: the static comparison works without ever needing a genuine caller source — it only
reads the two versions' own source, no "old code" example is needed. A real build is the exact
opposite: it can say nothing without a specific caller, but what it says is directly tied to that
caller's fate. In a publishing process the first path provides a cheap, general pre-screen, the
second a definitive proof against a specific consumer; this lesson measures that both arrive at
the same two conclusions, showing that the cheap path can stand in for the expensive one in this
narrow example.

## Limiting Measurement: The Promise Covers Signatures, Not Behavior

Both measurements above looked at signature changes. An implementation's **behavior** can change
even when a signature never changes at all — same name, same parameter, same return type, but a
different computation inside the body. The measurement below shows this directly: two real
functions named `Double` carry the identical signature, but one multiplies by two, the other by
three.

```go
// measurement3.go — limiting measurement: the compatibility promise covers signatures, not behavior
package main

import "fmt"

// doubleOld is the real implementation of the first version.
func doubleOld(x int) int { return x * 2 }

// doubleNewBehavior is a next-version implementation carrying the IDENTICAL signature but computing differently.
func doubleNewBehavior(x int) int { return x * 3 }

const sourceOld = "package sample\n\nfunc Double(x int) int { return x * 2 }\n"
const sourceNewBehavior = "package sample\n\nfunc Double(x int) int { return x * 3 }\n"

func main() {
	fmt.Println("-- limiting measurement: same signature, different behavior --")
	show("signature comparison", "tool", fmt.Sprint("compatible=", compatible(signatures(sourceOld), signatures(sourceNewBehavior))))
	show("Double(5) old implementation", "tool", fmt.Sprint(doubleOld(5)))
	show("Double(5) new implementation", "tool", fmt.Sprint(doubleNewBehavior(5)))
	show("same result from both implementations", "tool", fmt.Sprint(doubleOld(5) == doubleNewBehavior(5)))
}
```

```
-- limiting measurement: same signature, different behavior --
signature comparison                           tool    compatible=true
Double(5) old implementation                   tool    10
Double(5) new implementation                   tool    15
same result from both implementations          tool    false
```

**BD14.** The comparator classifies this change as `compatible=true`, because `Double`'s name,
parameter type, and return type never changed at all. But called with the same input (`5`), the
two implementations give different numbers — `10` and `15`. This is the limit of the
compatibility promise's **scope**: the promise says "your old code will keep compiling and
running," it does not say "your old code's **result** will stay the same." A caller's code
compiles and runs without trouble against both versions — no error, no panic — but the result it
produces changes with the version. The signature comparator can never see this difference at all,
because what it looks at is names and types; what is computed inside a body sits entirely outside
the layer this tool can reach.

The reason for this limit is not arbitrary: comparing a body's **meaning** between two versions
is a completely different problem from comparing its signature. Signature comparison was solved
in this lesson with a few lines of parsing and a dictionary comparison; behavior comparison, in
its general form, cannot be solved at all — testing whether two functions give the same result for
every input falls into a problem class that cannot be answered in finite time. This is why a
compatibility promise, in practice, is always built on signatures: something measurable,
decidable in finite time. The one remaining way to secure behavioral compatibility is the
table-driven tests and benchmarks measured in this course's first topic — running
the new version against the old version's expected inputs and comparing the results. This
completes what the signature comparator cannot do, through a genuine run; but that now falls
outside the `tool` class measured in this lesson, it belongs to the course's `run` class.

This shows that signature compatibility is a **necessary**, not a **sufficient**, condition in a
publishing process. If the signature is incompatible, the old code definitely does not compile —
this lesson confirmed this with a genuine build. If the signature is compatible, the old code
definitely does compile, but there is no guarantee whatsoever that the result it produces will be
the same as before. Raising a version number therefore carries two separate decisions at once:
whether the signatures changed (measurable by this lesson) and whether the behavior changed
(measurable only by a separate testing and observation process).

## Summary

- A version number is declared openly in the source, but the compatibility promise it carries is
  checked not by the source itself but by a tool comparing the signatures of exported functions.
- A change that only adds a new name is counted compatible, a change that modifies an existing
  signature is counted incompatible; the verdict comes not from the version number itself but
  from comparing the signature sets.
- The static signature verdict was confirmed by genuinely compiling an old caller against the new
  version; both methods arrived at the same two conclusions.
- Limiting measurement: two implementations carrying the identical signature can give a
  completely different result, and the signature comparator never sees this difference at all.
- The compatibility promise guarantees that a source **compiles and runs**, it does not guarantee
  that the result it produces stays **the same** from version to version.

## Next Step

The tool measured in this lesson read the source only at the level of names and types; it never
looked at what was inside the bodies at all. The next lesson descends into exactly this gap: a
tool that searches for specific patterns inside a source, reading the body itself rather than the
signature. The question is no longer "did the names and types change" but "is there a pattern
inside the source that has never been searched for before."
