Skip to content
academia.sh

Lesson 14 / 16

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.

Published

In this lesson

  • 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
Contents

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.

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

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close