Module Versioning — Optimize¶
Table of Contents¶
- How to Use This File
- Exercise 1 — Shrink
go.sumChurn - Exercise 2 — Eliminate Unnecessary
replaceDirectives - Exercise 3 — Choose the Right Next Version
- Exercise 4 — Keep
go.modLean Under Lazy Loading - Exercise 5 — Replace Pseudo-Versions with Tagged Releases
- Exercise 6 — Reduce Major-Bump Frequency
- Exercise 7 — Streamline Pre-release Cycles
- Exercise 8 — Migrate Off
+incompatible - Exercise 9 — Optimise the Release Pipeline
- Exercise 10 — Detect Compatibility Breaks Earlier
- Exercise 11 — Reduce Cold-Cache Build Time
- Exercise 12 — Consolidate Multi-Module Repos
How to Use This File¶
These exercises improve versioning hygiene rather than runtime performance. Each describes a "before" state, a measurement to take, and a target. Apply them in priority order to a real codebase.
Exercise 1 — Shrink go.sum Churn¶
Before¶
A typical go.sum for a busy project grows by hundreds of lines per dependency upgrade. Every reviewer scans an unreviewable diff.
Measure¶
git log --oneline -- go.sum | head
git show <commit-hash>:go.sum | wc -l
git show HEAD:go.sum | wc -l
If go.sum line count grew by >50% in the last quarter without a corresponding feature explosion, you have churn.
Optimise¶
- Run
go mod tidybefore every commit. Manygo.sumlines are stale entries from old MVS runs.go mod tidyremoves them. - Avoid
go get -u ./...in mass. Upgrade in focused batches: one library and its transitives at a time. - Adopt lazy loading. Set
go 1.17+ingo.mod. The pruned graph means fewer unrelatedgo.modfiles contribute togo.sum. - Avoid pulling in dependencies "just in case." Each dep adds a tail of transitive
go.sumentries.
Target: go.sum growth correlates with feature growth, not with dependency churn.
Exercise 2 — Eliminate Unnecessary replace Directives¶
Before¶
A long-lived application has accumulated replace directives. Some are old workarounds; some are forks that have since been merged upstream; some are pins to specific versions that MVS would now pick anyway.
Measure¶
If your go.mod has more than 2 or 3 replace lines, audit.
Optimise¶
For each replace:
- Why does it exist? Local dev? Fork? Pin?
- Is the reason still valid? Has the upstream merged the fix? Has the fork been deprecated?
- Can it be removed? Try removing, run
go mod tidy, run tests. If everything passes, thereplacewas vestigial. - If it must stay, comment it. A bare
replaceline is a future bug; a commented one is an explicit decision.
Target: every replace directive has a comment explaining why and a follow-up issue tracking when it can be removed.
Exercise 3 — Choose the Right Next Version¶
Before¶
A maintainer often "guesses" the next version by intuition. Sometimes minor bumps include subtle breaks; sometimes patch bumps include features.
Measure¶
For your last 5 releases, ask: did gorelease agree with the bump category I chose?
Optimise¶
- Adopt Conventional Commits.
feat:,fix:,BREAKING CHANGE:prefixes in commit messages. A tool can compute the next version from commits since the last tag. - Run
goreleasein CI. Every PR that changes the public API surface is annotated with the recommended bump. - Default to safety. When in doubt, pick the higher category (minor > patch, major > minor). The cost of "I bumped major when I should have bumped minor" is small; the cost of the reverse is large.
- Document the bump in the CHANGELOG. Two lines explaining why, before tagging.
Target: every release has a clear, defensible reason for its version category.
Exercise 4 — Keep go.mod Lean Under Lazy Loading¶
Before¶
A project still declares go 1.16 in go.mod. The build is slow on cold caches because the loader walks the entire transitive go.mod graph.
Measure¶
Cold-cache download time on a large project can exceed 60 seconds with eager loading.
Optimise¶
- Bump the
godirective to 1.17 or later. Pruned graph activates. - Run
go mod tidy. This adds explicit// indirectlines for every used transitive dep, which is a prerequisite for pruning. - Remove any
// indirectlinesgo mod tidywould not regenerate — these are stale.
Target: cold-cache go mod download completes in <30s for projects with <300 transitive dependencies.
Exercise 5 — Replace Pseudo-Versions with Tagged Releases¶
Before¶
go.mod has lines like:
These were quick fixes that have aged.
Measure¶
Any non-zero result is debt.
Optimise¶
For each pseudo-version:
- Find why. Was it a fix awaiting a release? A branch pin? An experiment?
- Check if the upstream has tagged it.
go list -m -versions <path>shows available tags. - Upgrade.
go get <path>@<tagged-version>. Run tests. - If the fix is still unreleased, file an issue with the maintainer. Pseudo-versions are not a long-term strategy.
Target: zero pseudo-versions in committed go.mod of production code. (Local development or test fixtures are exceptions.)
Exercise 6 — Reduce Major-Bump Frequency¶
Before¶
A library has bumped major three times in two years (v1 → v2 → v3 → v4). Consumers are exhausted.
Measure¶
Major bumps per year. If >1 every two years for a stable library, you are bumping too often.
Optimise¶
- Audit the last three major bumps. For each, ask: could this have been expressed as a deprecation?
- Adopt the
Deprecated:workflow. Add new APIs alongside the old; deprecate the old; remove the old in a single future major. - Batch breaking changes. Hold breaking changes for a month or quarter; release them all at once in a single major bump.
- Pre-release before final. Two weeks of
v3.0.0-rc.1lets community testers find issues that would otherwise have triggered another major.
Target: ≤1 major bump per year for a mature library.
Exercise 7 — Streamline Pre-release Cycles¶
Before¶
Pre-releases sit untouched for months. Consumers do not know what to test; the maintainer loses momentum.
Measure¶
For your last few RCs: how many days between rc.1 and the final release? Did anyone outside the maintainer team test the RC?
Optimise¶
- Set a clear RC window. "RC.1 ships on day 0, final on day 14, unless major issues are reported." A predictable cycle invites tester engagement.
- Communicate the RC. Blog post, issue tracker pin, mailing list. "Please try
v2.0.0-rc.1." - Provide a clear "what changed" for testers. A bullet list of breaking changes plus a migration cheat sheet.
- Tag aggressively.
rc.1,rc.2,rc.3as fixes land. Each is cheap.
Target: every major bump has at least one published RC consumed by testers outside the maintainer team.
Exercise 8 — Migrate Off +incompatible¶
Before¶
Your library has v2.x.x+incompatible and v3.x.x+incompatible releases — you ignored SIV.
Measure¶
Optimise¶
- Plan a "SIV opt-in" release. Decide on subfolder vs branch layout.
- Update
moduleline.module github.com/yourorg/lib/v2(or whichever current major). - Update internal imports.
gomajorautomates this. - Tag
v2.X+1.0. This is a clean SIV release; consumers can migrate to it without+incompatible. - Communicate. "We have adopted SIV. New releases are at
github.com/yourorg/lib/v2." Ideally, ship av1.X.0release that documents the migration.
Target: no +incompatible versions in your latest line. Old versions remain in the proxy as history.
Exercise 9 — Optimise the Release Pipeline¶
Before¶
Releasing is a manual ritual: run tests, tag, push, write release notes, warm proxy. It takes 30 minutes; mistakes happen.
Measure¶
Time from "decide to release" to "consumers can go get @vX.Y.Z" — measure across last 5 releases.
Optimise¶
Automate. A release pipeline triggered by a git tag should:
- Run the full test suite on every supported platform.
- Run
goreleaseto confirm the version category. - Build any release artefacts (binaries, container images).
- Push tag to the canonical remote.
- Warm the proxy:
go list -m <path>@<tag>. - Update CHANGELOG.md from commit messages (Conventional Commits).
- Publish release notes to the platform (GitHub Releases).
- Optionally: notify a chat channel.
GoReleaser, GitHub Actions, GitLab CI, and goreleaser-action cover most of this.
Target: from "tag pushed" to "consumers can install" takes <5 minutes, fully automated.
Exercise 10 — Detect Compatibility Breaks Earlier¶
Before¶
Every release surprises someone. Consumers report breaks weeks after a minor bump.
Measure¶
For each release in the last year: how many consumers reported a "you broke me" bug within 2 weeks?
Optimise¶
- Add
goreleaseto CI. Every PR that changes the public API surface is checked. - Maintain example tests.
Example*functions in_test.goare runnable documentation. Broken examples fail CI; consumers see only working snippets. - Add API-snapshot tests. A test that records the public API surface (every exported symbol, every signature) and fails when it changes unexpectedly.
apidiffproduces such snapshots. - Run a downstream-build smoke test. Before tagging, build a representative consumer (e.g., your own service that uses the library) against the new HEAD. If it breaks, you have a problem that affects real consumers.
Target: zero "surprise breaks" reported within 2 weeks of a minor or patch release.
Exercise 11 — Reduce Cold-Cache Build Time¶
Before¶
A new contributor clones the repo. go mod download takes 90 seconds. CI cold-cache builds are slow.
Measure¶
Optimise¶
- Trim transitive deps. Audit
go.mod's indirect lines. Are there libraries pulling in 50 transitive deps for one helper function? Replace with stdlib or a smaller alternative. - Bump
godirective to enable lazy loading.go 1.17+activates the pruned graph; less to download. - Use a private proxy. A team proxy at
proxy.corp.example.comthat mirrors public modules is faster thanproxy.golang.orgfor local networks. - Cache
~/go/pkg/modin CI. Most CI systems support per-branch caching of the module cache. A warm cache means downloads are skipped. - Pin to a stable major. Constant minor bumps invalidate cache entries. A stable major rarely re-downloads.
Target: warm-cache build <5s startup; cold-cache <30s on a 1Gbps connection.
Exercise 12 — Consolidate Multi-Module Repos¶
Before¶
A repo has six modules, each with its own go.mod, each tagged separately. Consumers must remember six separate version namespaces. Maintenance is heavy.
Measure¶
If go.mod count > 3 in a single repo, ask "is this justified?"
Optimise¶
For each sub-module:
- Does it have an independent release cadence? If everything moves together, merge into one module.
- Does it have meaningfully different consumers? If only your own apps consume it, merge.
- Does it have a different maintainer team? If no, merge.
Merging:
- Move
submod/foo.gotopkg/submod/foo.go. - Delete
submod/go.mod. - Run
go mod tidyat the repo root. - Stop tagging
submod/v1.X.Y. Use root tagsvX.Y.Z.
Target: one module per repo unless multi-module is demonstrably needed (independent release cadences, separate consumer bases).
Summary¶
Versioning optimisation is mostly hygiene: audit pseudo-versions, eliminate stale replaces, automate releases, detect breaks early, reduce major-bump frequency. Each exercise pays off compounding interest: a clean go.mod today is a smoother release next year.
Pick the two or three exercises with the highest ROI for your codebase. Re-run them quarterly. Versioning debt is real; like any technical debt, it accumulates silently and costs you on release day.