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¶
props.Tool.ReleaseSource = props.ReleaseSource{
Type: "s3",
Owner: "my-org",
Repo: "my-tool",
Params: map[string]string{"bucket": "releases"},
}
Params is free-form, so your factory reads whatever keys it needs. 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