How to add a custom release source¶
Release providers live in the standalone
forge module, and a provider ships as your
own module. Nothing needs contributing to GTB or to forge.
The full authoring guide, including the contract, the credential chain, credential pinning and the conformance harness, is author a provider. This page covers only the GTB side: getting your provider used by a tool's self-update.
1. Register it¶
Your provider registers itself at init(), keyed by any source-type string:
func init() {
err := forge.Register("s3", func(src forge.ReleaseSourceConfig, cfg forge.Config) (forge.Provider, error) {
return New(SettingsFromConfig(src, cfg))
})
if err != nil {
panic("myforge: " + err.Error())
}
}
A duplicate source type is an error, not an overwrite
forge.Register returns ErrAlreadyRegistered rather than replacing an
existing factory: silent overwriting let one blank import displace another
with no diagnostic, and initialisation order decided the winner.
To deliberately replace a built-in, call forge.Unregister first. To register
conditionally, check forge.Registered.
2. Import it for the side effect¶
Blank-import your module in the tool's main, before any update runs:
GTB's own pkg/setup/providers.go does this for the first-party set. A tool that
supports only your forge can import just yours and shed the built-in clients
entirely.
3. Point the tool at it¶
Settings your provider needs beyond the connection go in configuration, under a subtree named for the source type:
Your factory receives a forge.Endpoint and the whole configuration, and scopes
it itself (ep.Section(cfg) resolves s3, or s3.<name> for a named source)
so it reads whatever keys it needs without a shared struct growing a field for
each one. The vcs.provider config key overrides Type at runtime.
Injecting directly, without the registry¶
The registry is process-wide mutable state, so mutating it from tests cannot run
under t.Parallel(). For tests, and for a provider constructed at runtime
rather than registered, inject it instead:
or set props.Tool.ReleaseProvider, which takes precedence over registry lookup.
The option wins over the field.
An injected provider is self-contained, so NewUpdater skips both the
registry lookup and the private-repository token gate that precedes it. That is
why a test double works against a tool configured with Private: true and no
credentials: worth knowing before you conclude your credential wiring is
correct because the tests pass.
GTB's own tests drive self-update this way, using the in-memory double from
forge/test.
A worked self-update test¶
func TestSelfUpdate_NoOpWhenLatest(t *testing.T) {
src := forgetest.New(
forgetest.WithRelease("v1.2.3", forgetest.TarGzAsset("mytool", "mytool", "#!/bin/sh\n")),
forgetest.WithLatestTag("v1.2.3"),
)
p := &props.Props{
Tool: props.Tool{
Name: "mytool",
ReleaseProvider: src, // injected: no registry, no token gate
},
Logger: logger.NewNoop(),
FS: afero.NewMemMapFs(),
Version: version.NewInfo("v1.2.3", "abc123", "2026-01-01"),
}
updater, err := setup.NewUpdater(t.Context(), p, "", false)
require.NoError(t, err)
// Already on the latest version: no download, no replacement.
require.NoError(t, updater.Update(t.Context()))
}
For the fuller picture: pkg/setup/update_e2e_test.go drives the verified
pipeline (checksum and signature, happy path and abort) over an in-memory
filesystem, and features/cli/update.feature covers the user-visible outcomes
end to end.
Related¶
- Author a provider: the contract, credential resolution, and the conformance harness
- Providers reference: the first-party set and their config keys
- Configure self-updating