The Git tag go get couldn't find: managing tags in a Go multi-module monorepo
I tagged a Go module v1.0.0, pushed it, and watched a teammate’s go get come back with invalid version: unknown revision. The tag was sitting right there on GitHub. The commit existed. The go.mod was correct. Nothing about the failure made sense from the shell history in front of me.
It took me longer than I want to admit to find the actual rule, because most explanations of Go modules assume one module per repository and stop there. My repo had four. So I built a small, real monorepo to pin the rule down for good: go-multimodule-poc, four independently versioned Go modules in one Git repository, plus go-multimodule-poc-consumer, a downstream project that pulls specific versions from it. Every command below actually ran against that repo. Keep reading and you’ll be able to reproduce the exact failure - and the fix - yourself.
TL;DR: nested-module tags need their full directory path or
go getcan’t see them, a nestedgo.modis a hard boundary./...never crosses, Minimal Version Selection picks the ceiling of everyone’s declared floor rather than the newest tag anywhere, andgo.work/replaceare both local-only - no consumer ever sees either one. The rest of this post is where those rules come from and what breaks when you get them wrong.
Why put four modules in one repository
A Go module is a unit of versioning: one go.mod, one semver history, one set of Git tags. A repository is just a place to keep files. Go does not require these to be the same thing, and for a set of small, tightly related components that release independently but get worked on together, keeping them in one repo has real advantages: one CI pipeline, one issue tracker, atomic commits across boundaries during a refactor, and no cross-repo dependency dance while you’re mid-change.
The cost is that Git tags, which are global to a repository, now have to disambiguate which module a v1.1.0 refers to. That disambiguation is the entire mechanism this post is about.
My test repo has four modules under entities/:
github.com/abhi4u1947/go-multimodule-poc/entities/shared-lib- a small logger, config store, and string helpers, imported by everything else.github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc- a stand-in service with acmd/, aninternal/package, and its own dependency onshared-lib.github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargzand.../shopping-svc/ipfs- two more modules, each with their owngo.mod, physically living insideshopping-svc’s own directory tree.
That last detail is not an accident. It’s the part that breaks most people’s mental model, and it’s where I’ll start.
What’s actually in this repo
Here’s the real layout, from the repo root:
go-multimodule-poc/
├── go.work
├── entities/
│ ├── shared-lib/
│ │ ├── go.mod
│ │ └── logger/ config/ utils/
│ └── shopping-svc/
│ ├── go.mod
│ ├── cmd/shopping/ api/ internal/ pkg/ version/
│ ├── estargz/
│ │ └── go.mod # independent module
│ └── ipfs/
│ └── go.mod # independent module
└── tools/
└── goproxy-gen/
└── go.mod # a fifth module, deliberately unpublished
Notice what’s missing: there is no go.mod at the repository root. go-multimodule-poc is not itself a Go module. It’s a container - a directory that happens to hold five independent module graphs (four published, one internal tool). This is the thing people mean when they say “Go monorepo,” and it surprises people coming from ecosystems where the repo root usually is the package.
Each go.mod’s module line, read straight from the source:
| File | module directive |
|---|---|
entities/shared-lib/go.mod |
github.com/abhi4u1947/go-multimodule-poc/entities/shared-lib |
entities/shopping-svc/go.mod |
github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc |
entities/shopping-svc/estargz/go.mod |
github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz |
entities/shopping-svc/ipfs/go.mod |
github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/ipfs |
estargz and ipfs sit inside shopping-svc’s directory but are not part of the shopping-svc module. That’s not a comment I’m adding for effect - it’s mechanically true, and the next section shows exactly where Go draws that line.
Where does shopping-svc end and estargz begin: module boundaries in practice
Run go build ./... from inside entities/shopping-svc and watch what gets included. From that repo, with the workspace file disabled so it can’t paper over module boundaries:
$ cd entities/shopping-svc
$ GOWORK=off go list ./...
github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/api
github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/cmd/shopping
github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/internal
github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/pkg
github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/version
estargz/ and ipfs/ are physically sitting right there in the same directory tree. They do not appear. Not filtered out, not excluded by a config flag - Go’s ./... expansion walks down from the current module’s root and stops the instant it finds another go.mod. The directory becomes a hard wall. shopping-svc has to depend on estargz the same way an outside consumer would: a require line and a real version, never a relative import.
I wrote this up in more detail in the repo’s own module-discovery notes, including the walk in both directions - up to find “what module am I in,” down to expand ./.... The short version, as a picture:
This is also why go.work exists in this repo at all. Four independent modules means a normal go build in one of them can’t see uncommitted edits in the others without a real published version - exactly like an outside consumer. go.work tells the toolchain “use these local directories for these module paths” during development, without touching any go.mod. It’s a pure local override. The moment you leave this repository, go.work doesn’t exist as far as anyone else is concerned - which is exactly why the consumer repo needs a different tool for the same job, covered later.
The tag-naming rule
Here’s the rule, stated once, precisely: for a module whose go.mod lives in a subdirectory of the repository root, the release tag must be the module’s directory path relative to the repo root, followed by /vMAJOR.MINOR.PATCH. A module whose go.mod sits at the repository root uses a bare vX.Y.Z with no prefix - but none of this repo’s four modules qualify, since none of them live at the root.
Applied to this repo, using git ls-remote --tags against the live repository:
| Module | Directory | Real tags on GitHub right now |
|---|---|---|
entities/shared-lib |
entities/shared-lib |
entities/shared-lib/v1.0.0, v1.1.0, v1.1.1 |
entities/shopping-svc |
entities/shopping-svc |
entities/shopping-svc/v1.0.0, v1.1.0 |
entities/shopping-svc/estargz |
entities/shopping-svc/estargz |
entities/shopping-svc/estargz/v0.18.1, v0.18.2 |
entities/shopping-svc/ipfs |
entities/shopping-svc/ipfs |
entities/shopping-svc/ipfs/v0.18.1, v0.18.2 |
(There’s also a stray entities/shared-lib/v0.1.0 tag on the real remote, left over from a bug in the release automation that I’ll come back to. It’s the kind of thing this rule makes easy to create by accident and easy to spot once you know what you’re looking at.)
That entities/ prefix is the whole answer to the failure I opened with. A tag named shopping-svc/v1.0.0 - no entities/ - looks completely reasonable if you don’t know the module’s real path on disk. It is also not a tag that resolves to anything, because the actual directory is entities/shopping-svc. I proved this the hard way while building this repo: I created a tag at exactly that wrong, shorter prefix, pointing at real content that has a correct, matching tag elsewhere, and asked go get for it:
$ go get github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz@v0.18.4
go: github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz@v0.18.4:
invalid version: unknown revision entities/shopping-svc/estargz/v0.18.4
That transcript is genuine, captured while constructing this repo (full context in docs/experiments.md, experiment 4) - I did not leave the wrong-prefix tag on the public repo afterward, so a fresh git ls-remote today will not show it. I’m calling that out explicitly because I’d rather tell you a demonstration tag no longer exists than let you go looking for it and think the mechanism stopped working. go matches tags by exact ref name. There’s no fuzzy fallback, no “did you mean,” nothing that scans for a tag containing the right version number at the wrong location. If the ref isn’t there byte-for-byte, it doesn’t exist, even if a tag with the same suffix does exist three directories over.
The resolution direction works the same way in reverse. Given .../entities/shopping-svc/estargz@v0.18.2, here’s what go actually does, and I’ve annotated each step against the real repo:
requested: github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz @ v0.18.2
1. Try successively shorter prefixes of the module path as candidate
repository roots, longest first:
.../entities/shopping-svc/estargz -- not a repo root
.../entities/shopping-svc -- not a repo root
.../entities -- not a repo root
github.com/abhi4u1947/go-multimodule-poc -- yes, this is the repo
2. Subtract the repo root from the module path to get the subdirectory:
entities/shopping-svc/estargz
3. Append the requested version to get the expected tag:
entities/shopping-svc/estargz/v0.18.2
4. Look up that exact ref. It exists, pointing at commit 204b1d6.
Read entities/shopping-svc/estargz/go.mod from that commit's tree.
I confirmed step 4 independently of go with git show entities/shopping-svc/estargz/v0.18.2 --stat - the tag really does point at a commit containing exactly that subdirectory’s files. Full transcript in experiment 9.
Releasing one module without touching the others
Independent versioning is the entire point of splitting a repo into modules this way: a change to estargz should cut an estargz release, not a shopping-svc release, even though estargz lives inside shopping-svc’s directory. I automated this with .github/workflows/release.yml, and the interesting part is not the tagging - it’s correctly figuring out which module changed.
The workflow diffs the push against its parent commit, then attributes changed files to a module by checking directory prefixes longest first: shopping-svc/estargz and shopping-svc/ipfs before the bare shopping-svc. Skip that ordering and a change under entities/shopping-svc/estargz/ would get misattributed to the parent shopping-svc module, since entities/shopping-svc/estargz/foo.go also starts with entities/shopping-svc/. Once a module’s identified, the job builds and vets it standalone (GOWORK=off, so the local workspace file can’t hide a real problem), computes the next patch version from the last matching tag, tags it, and cuts a GitHub Release.
That “last matching tag” lookup taught me a sharper lesson than I expected. My first version used grep -Ev -- '-' to filter out pre-release-looking tags. It silently excluded every single tag for shared-lib, because the string shared-lib itself contains a hyphen. The fix was an exact-match regex - ^entities/shared-lib/v[0-9]+\.[0-9]+\.[0-9]+$ - not a “does it look weird” heuristic. That bug shipped one bad tag, entities/shared-lib/v0.1.0, before I caught it; it’s the stray tag mentioned in the table above, and it’s a small, honest illustration of how easy it is to get tag-matching subtly wrong even when you know the rule.
Three requirers, one winner
Before you scroll past this paragraph: three different modules in this repo each state a different minimum version of shared-lib. When a consumer pulls in all three, Go has to pick exactly one version of shared-lib for the build. Which one does it pick - the newest available anywhere, the oldest anyone asked for, or something else? Decide before you read the table.
| Requirer | states as its minimum |
|---|---|
shopping-svc (v1.1.0) |
shared-lib >= v1.1.0 |
shopping-svc/estargz (v0.18.2) |
shared-lib >= v1.0.0 |
shopping-svc/ipfs (v0.18.2) |
shared-lib >= v1.0.0 |
The rule is called Minimal Version Selection, and despite the name it does not pick the lowest number on the list. It picks, for each dependency, the maximum of every minimum anyone in the build graph declared - the lowest version that is still high enough to satisfy every stated requirement at once. Here that’s max(v1.1.0, v1.0.0, v1.0.0) = v1.1.0. Not the newest tag that exists anywhere (shared-lib has since gone on to v1.1.1), not the oldest - the ceiling of everyone’s floor.
I reproduced this from a clean scratch module against the real, live repository, requesting each dependency in the exact order a first-time consumer would:
$ go get github.com/abhi4u1947/go-multimodule-poc/entities/shared-lib@v1.0.0
go: added .../entities/shared-lib v1.0.0
$ go get github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc@v1.1.0
go: downloading .../entities/shared-lib v1.1.0
go: upgraded .../entities/shared-lib v1.0.0 => v1.1.0
go: added .../entities/shopping-svc v1.1.0
$ go get github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz@v0.18.2
go: added .../entities/shopping-svc/estargz v0.18.2
$ go get github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/ipfs@v0.18.2
go: added .../entities/shopping-svc/ipfs v0.18.2
The moment shopping-svc enters the graph, shared-lib jumps from v1.0.0 to v1.1.0 on its own - nobody ran a second command telling it to. Adding estargz and ipfs afterward, whose own stated floor is only v1.0.0, doesn’t pull it back down. MVS only ever moves a selected version up, never down, which is what makes builds reproducible from go.mod and go.sum alone: there’s no resolver out there that might hand you a different answer next Tuesday.
Two things worth being precise about, because I got tripped up by both while building this.
First: go mod graph prints the raw edges exactly as each go.mod declares them. estargz and ipfs still show shared-lib@v1.0.0 there, forever - that’s genuinely what their go.mod says. go list -m all prints the outcome of MVS instead: one line per module, the version actually selected. Read the wrong one and you’ll think there’s a bug where there isn’t.
Second: today, shared-lib’s real latest tag is v1.1.1, one patch ahead of what shopping-svc requires. Go treats the first sight of an unresolved import as “give me whatever’s newest,” so a totally fresh go mod tidy on a brand-new module lands on v1.1.1 directly, without walking through v1.1.0 first. Both routes are MVS-consistent - v1.1.1 still satisfies everyone’s stated floor - they just get there for different reasons. The consumer repo’s own committed go.mod sits at v1.1.1 for exactly this reason, even though its README still says v1.1.0 in a few places. The code moved on; the prose didn’t. Check git log, not the README.
Pseudo-versions are what you get when you ask for a commit that has no tag at all. I asked for the current, real, untagged tip of main in this repo:
$ go get github.com/abhi4u1947/go-multimodule-poc/entities/shared-lib@6f79cb92532cd48c86eb93c0974ad59d5bb55934
go: downloading .../entities/shared-lib v1.1.2-0.20260703130951-6f79cb92532c
Read the pieces: v1.1.2 is one patch past v1.1.1, the highest real tag reachable from that commit. A pseudo-version has to sort strictly after the release it follows, so Go bumps the patch component to make that true. 20260703130951 is the commit’s UTC timestamp. 6f79cb92532c is the first twelve hex characters of the real commit SHA. None of it is invented - it’s a deterministic encoding of “this exact commit, one step past the last release.”
One caveat cost me a few confused minutes: you can’t hand go get a branch name with a slash in it as the @version. Go rejects anything containing / in that position with disallowed version string - I confirmed this on both the go1.24.7 toolchain I verified everything else on and a later go1.26.1 build, so it’s not a version-specific quirk. Resolve the branch to a commit SHA first, then query by that.
For local development, this repo uses two different tools for two different situations, and it’s worth being clear that they don’t overlap. Inside go-multimodule-poc itself, go.work lets all four modules build against each other’s on-disk state at once. From outside the repo - which is where the consumer lives - go.work doesn’t apply, so the consumer instead uses a replace directive:
replace github.com/abhi4u1947/go-multimodule-poc/entities/shared-lib => ../go-multimodule-poc/entities/shared-lib
(from go.mod.replace-example in the consumer repo). With that line present, every build resolves shared-lib from the local checkout - unpublished, untagged, whatever’s on disk - and the version string in require becomes cosmetic while the directive is active. Remove the line and resolution snaps straight back to the tagged version in go.sum, with nothing else to change. I verified both states directly: with the replace line in, a locally patched log line showed up in the program’s output immediately; with it removed, the output went back to exactly what the tagged release produces.
Consuming it for real
The consumer repo’s main.go imports all four modules independently, sub-packages included, not just module roots:
import (
shopping "github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/version"
estargz "github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz/version"
ipfs "github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/ipfs/version"
estargzpkg "github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz/pkg"
ipfspkg "github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/ipfs/pkg"
"github.com/abhi4u1947/go-multimodule-poc/entities/shared-lib/logger"
)
Getting a specific version of one of them is a plain go get module@version - no special syntax for “this is a nested module,” because as far as the consumer is concerned it’s just another module path:
$ go get github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz@v0.18.2
go mod tidy afterward reconciles the full require block against what’s actually imported, adding anything missing and dropping anything unused, and writes the resolved content hashes into go.sum.
Four commands read the result back in different, non-overlapping ways, and mixing them up is an easy way to misdiagnose a version problem:
go list -m all- one line per module, the version MVS actually selected for the build. This is “what will I compile against.”go mod graph- every raw requirement edge, exactly as eachgo.moddeclares it.estargz’s edge toshared-libwill showv0.18.2 -> v1.0.0here forever, regardless of what version actually gets selected. This is “who asked for what.”go mod why -m <module>- the shortest import path from the main module to that dependency, or a note that it isn’t needed. Without-m,go mod why <path>asks a narrower question - “is this exact package path imported” - and will tell you a module “does not need” a package that lives at the module’s own root if you only ever import its subpackages, which is exactly this repo’s shape.go list -m -json <module>- the same information a proxy would hand back for that module, as structured JSON: path, version, commit time, on-disk cache location, and thego.sumhashes.
The consumer’s README walks all four with real transcripts against this exact dependency set, and I re-ran each of them fresh against the live repository while writing this post; they still match.
Staying current is where the two repos actually talk to each other. The consumer has Dependabot configured for the gomod ecosystem on a daily schedule - ordinary Dependabot behavior, it doesn’t know or care that the upstream is a monorepo. Layered on top, the producer’s release workflow fires a repository_dispatch event (module-released) at the consumer the moment it tags a new version, and the consumer’s auto-update.yml reacts by bumping exactly that module and opening a PR within seconds instead of waiting for the next scheduled check. If the dispatch never arrives - the producer’s CONSUMER_DISPATCH_TOKEN secret isn’t set, say - the same workflow still runs on its own daily cron and on manual dispatch, bumping every tracked module to @latest as a fallback. Dependabot is the safety net; the dispatch is the fast path. Neither one depends on the other working.
Three ways this breaks
Module path mismatch. go.mod’s module line is the only source of truth for a module’s identity - not the tag, not the URL you fetched it from. If a module gets fetched at path P but its own go.mod declares path Q, go refuses before it ever reads a line of code:
module declares its path as: github.com/abhi4u1947/go-multimodule-poc/estargz-wrong-path
but was required as: github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz
I built this exact scenario while constructing the repo - a copy of estargz with its module line pointed at the wrong path, tagged and fetched as if it were the real thing. The full transcript is in experiment 3. As with the wrong-prefix tag earlier, I didn’t leave that broken tag on the public repo afterward, so it isn’t something you can fetch from GitHub today - the error text above is genuine, captured output, not a guess at what the message would say. The check itself exists for a real reason: without it, a compromised or misconfigured module could quietly answer to a trusted import path it doesn’t actually own.
Wrong-prefix tags. Covered above, worth restating as its own failure mode because it’s the one I actually hit in production: a tag can contain the exact right version string and still resolve to nothing, because go matches refs by exact name, not by scanning for a tag that merely mentions the right number somewhere in the repo.
Module identity survives the tag; the tag doesn’t survive resolution. This repo has a genuinely interesting case of it: two different tags, entities/shopping-svc/estargz/v0.18.2 and entities/shopping-svc/ipfs/v0.18.1, point at the exact same commit (204b1d6) - that commit changed estargz and left ipfs untouched since its prior release, so ipfs’s existing tag and estargz’s new one both land there. I confirmed both resolve correctly and separately, live, against the real repo:
$ go list -m github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz@v0.18.2
github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz v0.18.2
$ go list -m github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/ipfs@v0.18.1
github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/ipfs v0.18.1
Same commit, two distinct, correctly identified modules, because each resolves through its own module path’s expected subdirectory and its own go.mod. The tag got you to the commit. After that, it’s gone - nothing in go.mod, go.sum, or a compiled binary ever refers back to the tag string again, only to the resolved version and content hash. You could rename every tag in this repo tomorrow and none of these modules’ identities would change.
Bonus: a GOPROXY you can hold in your hand
One more piece, kept short on purpose. The producer repo has a small Go program, tools/goproxy-gen, that generates a real, static implementation of the Go module proxy protocol - @v/list, @v/<version>.info, @v/<version>.mod, @v/<version>.zip, @latest - directly from this repo’s own tags. It’s built entirely on the official golang.org/x/mod packages: modfile to discover modules, a from-scratch exact-semver regex to find each module’s real tags (informed by the same hyphen bug from the release workflow), and zip.CreateFromVCS to build each version’s zip straight from Git history. That last function is worth calling out on its own: it automatically excludes nested modules from the zip - shopping-svc’s archive never contains estargz/ or ipfs/ - using the same module-boundary rule this whole post has been about, because it’s the real Go toolchain’s own logic, not something I had to reimplement.
A GitHub Actions workflow runs this on every push to main, resolves all four modules through the freshly generated tree with a real go get as a sanity check, and uploads the result as a build artifact. Point GOPROXY at the unzipped artifact and every command in this post works with zero network access and zero GitHub - which is a genuinely useful trick for air-gapped CI, not just a demo.
Try it yourself
Everything here is copy-pasteable against the real repositories:
# Clone and run the producer's own service via go.work
git clone https://github.com/abhi4u1947/go-multimodule-poc.git
cd go-multimodule-poc
go run ./entities/shopping-svc/cmd/shopping
# Clone the consumer and run it as-is
git clone https://github.com/abhi4u1947/go-multimodule-poc-consumer.git
cd go-multimodule-poc-consumer
go run .
# Pull one specific version of one specific nested module
go get github.com/abhi4u1947/go-multimodule-poc/entities/shopping-svc/estargz@v0.18.1
go mod tidy
# See what actually got selected, versus what was asked for
go list -m all
go mod graph
I ran each of these against the live repositories on Go 1.24.7 while writing this. Module resolution details - pseudo-version shape, exact error text, MVS output formatting - can shift slightly across Go releases, so if something on your machine looks a little different, check the version with go version first and cross-reference go.dev/ref/mod before assuming the mechanism changed.
Where this leaves us
That teammate’s go get failure from the top of this post was a tag missing its directory prefix - a bare v1.0.0 where the tooling needed entities/shopping-svc/v1.0.0. Small mistake, and completely invisible until someone tries to pull the module from outside the repo.
A nested module’s tag must carry its full directory path, or it doesn’t exist as far as go get is concerned. Not close, not fuzzy-matched - exact.
A nested go.mod is a hard wall. ./... stops there, in both directions, no configuration involved.
Minimal Version Selection means the ceiling of everyone’s floor, not the newest tag in existence. Read go list -m all for the outcome, go mod graph for who asked for what.
go.work and replace solve the same local-development problem from two different vantage points, and neither one ever leaves your own machine - consumers never see either.
The module line in go.mod is a module’s entire identity. The tag that found it is disposable the instant the commit is resolved.
If you’re carrying more than one Go module in a repository, or about to start, that’s the whole rulebook. I’d rather you learn it from this post than from a teammate’s confused Slack message about a tag that “should” be there.
Found this useful? I write about agentic AI and open source.
More posts · GitHub