TL;DR: go-tool-base can stash credentials in your OS keychain. But some of the people building on it ship into regulated and air-gapped environments where the binary is not permitted to contain keychain or session-bus code at all. The answer wasn’t a build tag. The keychain backend lives in its own package, and importing it (even as a blank import _) is the thing that switches it on. Leave the import out, and Go’s linker dead-code elimination keeps the whole go-keyring dependency chain out of the artefact. Opt-in by linkage, not by flag.
A feature some users have to be able to not have
go-tool-base needs somewhere to keep secrets: AI provider keys, VCS tokens, the occasional app password. The best home for those on a developer’s machine is the operating system’s own keychain. macOS Keychain, GNOME Keyring or KWallet on Linux via the Secret Service, Windows Credential Manager. So I wanted go-tool-base to support all three.
The Go library for that is go-keyring, and it’s good. The catch is what it drags in behind it. On Linux it talks to the Secret Service over D-Bus, which means godbus. On Windows it pulls wincred. Perfectly reasonable dependencies for a desktop tool.
Now here’s the constraint that made this interesting. Some of the people building tools on go-tool-base don’t ship to developer laptops. They ship into regulated sectors and air-gapped deployments where a security review will scan the binary, enumerate every dependency, and ask pointed questions about anything that does inter-process communication. For those builds, “the keychain code is there but we never call it” is not an acceptable answer. The reviewer’s position, and it’s a fair one, is that code which isn’t in the binary cannot be a finding.
So I had a feature that most users want, and a minority of users must be able to provably not have. Same framework, same release.
Why I didn’t reach for a build tag
The obvious Go answer is a build tag. Compile with -tags keychain to get it, leave the tag off to not. I started down that road. I even spent a while on an inverted version — a nokeychain tag — on the theory that the regulated build should be the one that has to ask, so a forgotten flag fails safe.
It works. It also isn’t very nice. Build tags are invisible at the call site. Nothing in the source tells you that a file only exists in some builds. The two worlds drift, because the tagged-out path isn’t compiled in your normal editor session and quietly rots. And the ergonomics for a downstream consumer are poor: every tool built on go-tool-base would have to know the right magic incantation and thread it through their own release pipeline correctly, forever.
I tried a second approach too: pull the keychain backend out into a completely separate Go module. That genuinely solves the dependency question (a module you don’t require can’t contribute to your go.sum). But a separate module for one backend is clunky. Separate versioning, separate release, separate repo, all for a single file’s worth of behaviour. It felt like using a shipping container to post a letter.
The shape that actually fits: a registry and an init()
The version I’m happy with leans on two boring, well-worn Go mechanisms and lets them do something quietly clever together.
First, pkg/credentials defines a Backend interface and a registry. By default the registry holds a stub backend that politely returns “unsupported” for everything. The framework only ever talks to the registered backend, whatever that happens to be.
Second, the keychain implementation lives in its own package, pkg/credentials/keychain — still inside the same module, no separate release to manage. That package has an init() that registers its go-keyring-backed backend:
//nolint:gochecknoinits // registration via import is the whole point
func init() {
credentials.RegisterBackend(Backend{})
}
And go-keyring, godbus, wincred — the whole IPC dependency chain — are only imported by that package.
Now the trick. To switch keychain support on, you import the package. You don’t have to use anything from it. A blank import is enough, because a blank import still runs the package’s init():
// cmd/gtb/keychain.go — the entire file.
package main
import _ "gitlab.com/phpboyscout/go-tool-base/pkg/credentials/keychain"
That single line is the on/off switch for the shipped gtb binary. The blank import means init() runs, the keychain backend registers itself, and credential operations start routing through the OS keychain. No flag, no tag, no config.
The part that makes it provable
Here’s why this beats the build tag, and it comes down to one guarantee in the Go toolchain: the linker only includes packages that are actually imported.
If cmd/gtb/keychain.go exists, the keychain package is in the import graph, so go-keyring, godbus and wincred are linked in. Delete that one file and rebuild, and the keychain package is no longer reachable from main. The linker performs dead-code elimination, and the entire go-keyring chain is gone. Not dormant. Not present-but-unused. Absent from the binary.
That’s the bit a regulated build needs. It isn’t a promise that the code won’t run. It’s a structural fact that the code isn’t there, and you can hand a security reviewer an SBOM that proves it. go-keyring won’t appear, because it genuinely isn’t linked.
For a downstream tool built on go-tool-base the story is the same and just as cheap. Want keychain support? Add the one-line blank import to your own cmd package. Must ship keychain-free? Don’t. Your binary’s dependency graph follows your import graph, exactly as Go always promised it would. The default — no import — is the locked-down one, which is the right way round for a safety property.
Why I like this more than I expected to
Build tags hide a decision in the compiler invocation. This pattern puts the decision in the source, as an import, where it is greppable, obvious in code review, and impossible to get subtly wrong. There’s a real file called keychain.go whose entire content is one import, and it reads as exactly what it is: a switch.
It’s also just honest Go. No reflection, no plugin loader, no clever runtime. A registry, an init(), and the linker doing the one job it has always done. The cleverness, such as it is, is in the arrangement, not in any individual piece.
Stepping back
go-tool-base needed OS keychain support for the many, and a way to provably exclude it for the few. Build tags could express the toggle but hid it in the build invocation and rotted in the dark. A separate module solved the dependency question but was far too much machinery for one backend.
Putting the keychain backend in its own package, activated by a blank import _ that fires its init(), gets both: a one-line, in-source, code-reviewable switch — and, because the linker only links what’s imported, a build with the import omitted that contains none of the keychain dependency chain. Provable absence, not promised disuse.
If you’re carrying an optional dependency that some of your users need gone rather than merely idle, this is the pattern. Let the import graph be the feature flag.