Skip to content

Add a Custom Release Source

GTB's self-update system is built around a pluggable provider registry. The six built-in source types (github, gitlab, bitbucket, gitea, codeberg, direct) cover most hosting platforms, but you can register your own provider for any backend โ€” private artifact stores, S3 buckets with custom layout, Nexus, Artifactory, or anything else.

This guide walks through:

  1. Implementing release.Provider
  2. Registering the factory with release.Register
  3. Wiring props.Tool.ReleaseSource in main.go
  4. Writing unit tests for the provider

Step 1: Implement release.Provider

The provider interface has four methods:

type Provider interface {
    GetLatestRelease(ctx context.Context, owner, repo string) (Release, error)
    GetReleaseByTag(ctx context.Context, owner, repo, tag string) (Release, error)
    ListReleases(ctx context.Context, owner, repo string, limit int) ([]Release, error)
    DownloadReleaseAsset(ctx context.Context, owner, repo string, asset ReleaseAsset) (io.ReadCloser, string, error)
}

If your backend has no concept of versioned releases or individual tags, return release.ErrNotSupported for those methods โ€” the update command handles this sentinel gracefully.

Create a new package, e.g. pkg/vcs/s3:

// Package s3 provides a release.Provider that fetches release metadata and
// assets from a private S3 bucket with a fixed object layout.
package s3

import (
    "context"
    "io"
    "net/http"

    "github.com/cockroachdb/errors"

    "gitlab.com/phpboyscout/go-tool-base/pkg/config"
    "gitlab.com/phpboyscout/go-tool-base/pkg/vcs/release"
)

type S3Provider struct {
    bucket     string
    region     string
    httpClient *http.Client
}

func NewProvider(src release.ReleaseSourceConfig, cfg config.Containable) (*S3Provider, error) {
    bucket := src.Params["bucket"]
    if bucket == "" {
        return nil, errors.New("s3: bucket param is required")
    }

    region := src.Params["region"]
    if region == "" {
        region = "us-east-1"
    }

    return &S3Provider{
        bucket:     bucket,
        region:     region,
        httpClient: &http.Client{},
    }, nil
}

func (p *S3Provider) GetLatestRelease(ctx context.Context, owner, repo string) (release.Release, error) {
    // Fetch the latest-version sentinel from S3 and return a synthetic Release.
    // ...
}

func (p *S3Provider) GetReleaseByTag(ctx context.Context, owner, repo, tag string) (release.Release, error) {
    // Construct a synthetic Release for the given tag without a network call,
    // or return release.ErrNotSupported if tags are not meaningful for this backend.
    // ...
}

func (p *S3Provider) ListReleases(ctx context.Context, owner, repo string, limit int) ([]release.Release, error) {
    return nil, errors.WithHint(
        release.ErrNotSupported,
        "ListReleases is not supported for the S3 provider.",
    )
}

func (p *S3Provider) DownloadReleaseAsset(ctx context.Context, _, _ string, asset release.ReleaseAsset) (io.ReadCloser, string, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.GetBrowserDownloadURL(), nil)
    if err != nil {
        return nil, "", errors.WithStack(err)
    }

    resp, err := p.httpClient.Do(req)
    if err != nil {
        return nil, "", errors.WithStack(err)
    }

    if resp.StatusCode != http.StatusOK {
        _ = resp.Body.Close()
        return nil, "", errors.Newf("S3 download failed: HTTP %d", resp.StatusCode)
    }

    return resp.Body, "", nil
}

You also need concrete types that implement release.Release and release.ReleaseAsset:

type s3Release struct {
    tagName string
    assets  []release.ReleaseAsset
}

func (r *s3Release) GetName() string                   { return r.tagName }
func (r *s3Release) GetTagName() string                { return r.tagName }
func (r *s3Release) GetBody() string                   { return "" }
func (r *s3Release) GetDraft() bool                    { return false }
func (r *s3Release) GetAssets() []release.ReleaseAsset { return r.assets }

type s3Asset struct {
    name string
    url  string
}

func (a *s3Asset) GetID() int64                  { return 0 }
func (a *s3Asset) GetName() string               { return a.name }
func (a *s3Asset) GetBrowserDownloadURL() string { return a.url }

Step 2: Register the Factory

Register the provider factory before any update operation runs. The cleanest place is main():

package main

import (
    "gitlab.com/phpboyscout/go-tool-base/pkg/vcs/release"
    "gitlab.com/phpboyscout/go-tool-base/pkg/config"

    "github.com/myorg/mytool/pkg/vcs/s3"
)

func main() {
    release.Register("s3", func(src release.ReleaseSourceConfig, cfg config.Containable) (release.Provider, error) {
        return s3.NewProvider(src, cfg)
    })

    // ... build and run the Cobra root command
}

If your project has multiple entry points, or you want the provider to be available as a library, you can use an init() function in your provider package instead:

// pkg/vcs/s3/init.go
package s3

import (
    "gitlab.com/phpboyscout/go-tool-base/pkg/vcs/release"
    "gitlab.com/phpboyscout/go-tool-base/pkg/config"
)

func init() {
    release.Register(release.SourceType("s3"), func(src release.ReleaseSourceConfig, cfg config.Containable) (release.Provider, error) {
        return NewProvider(src, cfg)
    })
}

Then import the package with a blank identifier in main.go to trigger the init():

import _ "github.com/myorg/mytool/pkg/vcs/s3"

Step 3: Wire props.Tool.ReleaseSource in main.go

Set Type to the string you registered, and pass any provider-specific parameters via Params:

tool := props.Tool{
    Name:    "mytool",
    Summary: "My developer tool",
    ReleaseSource: props.ReleaseSource{
        Type:  "s3",
        Owner: "myorg",
        Repo:  "mytool",
        Params: map[string]string{
            "bucket": "myorg-releases",
            "region": "eu-west-1",
        },
    },
}

setup.NewUpdater calls release.Lookup(src.Type) internally and forwards ReleaseSourceConfig (including Params) to your factory โ€” no other changes are needed.


Step 4: Write Unit Tests

Use httptest.NewServer to serve mock responses without any network access:

func TestS3Provider_GetLatestRelease(t *testing.T) {
    t.Parallel()

    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Serve a mock latest-version response
        w.Header().Set("Content-Type", "application/json")
        _, _ = w.Write([]byte(`{"tag_name":"v2.0.0"}`))
    }))
    defer srv.Close()

    src := release.ReleaseSourceConfig{
        Type: "s3",
        Params: map[string]string{
            "bucket":      "test-bucket",
            "version_url": srv.URL + "/latest.json",
        },
    }

    provider, err := s3.NewProvider(src, nil)
    require.NoError(t, err)

    rel, err := provider.GetLatestRelease(context.Background(), "myorg", "mytool")
    require.NoError(t, err)
    assert.Equal(t, "v2.0.0", rel.GetTagName())
}

Registering Multiple Variants

You can register multiple factories from the same package for different deployment configurations:

release.Register("s3-us", makeS3Factory("us-east-1"))
release.Register("s3-eu", makeS3Factory("eu-west-1"))

Each source type must be unique. Calling release.Register with an existing key overwrites the previous factory โ€” useful for overriding a built-in provider in tests.