Skip to content

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:

import _ "example.com/myforge"

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",
}

Settings your provider needs beyond the connection go in configuration, under a subtree named for the source type:

s3:
  bucket: releases

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:

setup.NewUpdater(ctx, props, "", false, setup.WithReleaseProvider(myProvider))

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.