Skip to content

Release Provider

Package: pkg/vcs/release

Defines the three interfaces that abstract over platform-specific release APIs, and the provider registry that lets consuming code (and downstream tools) work with any backend without importing platform packages directly.


Interfaces

Provider

[!NOTE] See pkg.go.dev/gitlab.com/phpboyscout/go-tool-base/pkg/vcs for the full API definition.

DownloadReleaseAsset returns (io.ReadCloser, redirectURL string, error). The redirect URL is populated by the GitHub implementation when the API redirects to a CDN; all other providers return an empty string.

Not all providers support every method

GetReleaseByTag and ListReleases return ErrNotSupported for providers whose platform has no versioned-release concept (Bitbucket, Direct). Check for this sentinel before treating it as a fatal error.

Release

[!NOTE] See pkg.go.dev/gitlab.com/phpboyscout/go-tool-base/pkg/vcs for the full API definition.

ReleaseAsset

[!NOTE] See pkg.go.dev/gitlab.com/phpboyscout/go-tool-base/pkg/vcs for the full API definition.

ChecksumProvider (Optional Interface)

ChecksumProvider is an opt-in interface — providers implement it when they can retrieve a checksums manifest by a route other than a standard release-asset download. The update flow does a runtime type assertion; providers that don't implement it fall back to locating checksums.txt by filename within the release's asset list.

[!NOTE] See pkg.go.dev/gitlab.com/phpboyscout/go-tool-base/pkg/vcs for the full API definition.

Implementations must:

  • Return release.ErrNotSupported when the current configuration disables retrieval (e.g. Direct's checksum_url_template unset, Bitbucket's downloads list has no checksums.txt). The caller treats this identically to "not implemented" and falls back.
  • Cap the response at maxBytes to protect against a hostile server streaming indefinitely.
  • Return a wrapped error for transport failures. The caller respects update.require_checksum to decide fail-open vs fail-closed.

Built-in implementations:

Provider Source When ErrNotSupported is returned
direct Expands checksum_url_template against the release version and HTTP-fetches it. checksum_url_template is empty.
bitbucket Looks up checksums.txt by exact filename in the repository's downloads list. No checksums.txt download exists.

github, gitlab, gitea, and codeberg do not implement ChecksumProvider: they rely on the default asset-list lookup, which works cleanly because those platforms expose the GoReleaser-produced checksums.txt as an ordinary release asset.

SignatureProvider (Optional Interface)

SignatureProvider mirrors ChecksumProvider exactly, for the detached OpenPGP signature over the checksums manifest (checksums.txt.sig). It is the provider half of Phase 2 signature verification: the updater verifies this signature against a trust set before parsing the manifest.

[!NOTE] See pkg.go.dev/gitlab.com/phpboyscout/go-tool-base/pkg/vcs for the full API definition.

The same opt-in semantics apply: providers that don't implement it fall back to locating the signature asset by filename in the release's asset list. Implementations return release.ErrNotSupported when retrieval is not configured, and cap the response at maxBytes.

Built-in implementations:

Provider Source When ErrNotSupported is returned
direct Expands signature_url_template against the release version and HTTP-fetches it. signature_url_template is empty.
bitbucket Looks up checksums.txt.sig by exact filename in the repository's downloads list. No checksums.txt.sig download exists.

As with checksums, github, gitlab, gitea, and codeberg rely on the default asset-list lookup — a GoReleaser-signed release exposes checksums.txt.sig as an ordinary asset.


Sentinel Errors

var (
    // ErrProviderNotFound is returned by Lookup when no factory is registered
    // for the requested source type.
    ErrProviderNotFound = errors.New("no release provider registered for source type")

    // ErrNotSupported is returned by provider methods not applicable to the
    // underlying platform (e.g. ListReleases on Bitbucket).
    ErrNotSupported = errors.New("operation not supported by this release provider")

    // ErrVersionUnknown is returned by the direct provider when neither
    // version_url nor pinned_version is configured.
    ErrVersionUnknown = errors.New("cannot determine latest version: configure version_url or pinned_version in Params")

    // ErrReleaseNotFound is returned when a requested release — by tag, or the
    // latest — does not exist. The releasetest double returns it; aligning the
    // built-in providers behind this sentinel is a tracked follow-up.
    ErrReleaseNotFound = errors.New("release not found")
)

Provider Registry

The registry maps source type strings to factory functions. All built-in providers register themselves at package init via blank imports in pkg/setup/providers.go — no manual wiring is needed.

Built-in source type constants

const (
    SourceTypeGitHub    = "github"
    SourceTypeGitLab    = "gitlab"
    SourceTypeBitbucket = "bitbucket"
    SourceTypeGitea     = "gitea"
    SourceTypeCodeberg  = "codeberg"
    SourceTypeDirect    = "direct"
)

Registering a custom provider

Call release.Register in your main() before any update operations. The registry is backed by a sync.RWMutex and is safe to call concurrently.

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

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

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

ReleaseSourceConfig

Passed to every ProviderFactory. Populated from props.ReleaseSource by NewUpdater.

[!NOTE] See pkg.go.dev/gitlab.com/phpboyscout/go-tool-base/pkg/vcs for the full API definition.

Querying registered types

types := release.RegisteredTypes() // sorted []string of all registered source types

Built-in Providers

GitHub — pkg/vcs/github

Uses the go-github SDK. Supports GitHub Enterprise via ReleaseSource.Host.

Authentication: GITHUB_TOKEN env var or github.auth.value / github.auth.env config keys.

GitLab — pkg/vcs/gitlab

Uses the gitlab-org/api/client-go SDK. Supports self-hosted GitLab via ReleaseSource.Host (defaults to https://gitlab.com/api/v4).

Authentication: GITLAB_TOKEN or gitlab.auth.* config keys.

Bitbucket Cloud — pkg/vcs/bitbucket

Uses the Bitbucket Downloads API (/2.0/repositories/{workspace}/{repo}/downloads). Bitbucket has no native "Releases" concept — version information is inferred from asset filenames using a configurable regular expression.

Authentication: HTTP Basic auth. Credentials are read in order:

  1. bitbucket.username + bitbucket.app_password config keys
  2. BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD environment variables

Params keys:

Key Description Default
filename_pattern Go regex for asset matching. Capture group 1 = version string. GoReleaser convention (see below)
workspace Bitbucket workspace slug, if different from Owner same as Owner

Default filename pattern matches GoReleaser output:

tool_v1.2.3_Linux_x86_64.tar.gz  →  version = "v1.2.3"
tool_Linux_x86_64.tar.gz          →  version = RFC3339 upload timestamp

The most recently uploaded set of matching assets is returned as the "latest release". GetReleaseByTag and ListReleases return ErrNotSupported.

Example configuration:

props.Tool{
    ReleaseSource: props.ReleaseSource{
        Type:    "bitbucket",
        Owner:   "my-workspace",
        Repo:    "my-tool",
        Private: true,
    },
}

Gitea / Forgejo — pkg/vcs/gitea

Uses the Gitea REST API v1 ({host}/api/v1/repos/{owner}/{repo}/releases). Compatible with any Gitea or Forgejo instance.

Authentication: GITEA_TOKEN or gitea.auth.* config keys. Token is sent as Authorization: token <value>.

Params keys:

Key Description Default
api_version API path version segment v1

ReleaseSource.Host is required and must be the full base URL of the instance (e.g. https://git.example.com).

Example configuration:

props.Tool{
    ReleaseSource: props.ReleaseSource{
        Type:  "gitea",
        Host:  "https://git.example.com",
        Owner: "my-org",
        Repo:  "my-tool",
    },
}

Codeberg — pkg/vcs/gitea

Codeberg (https://codeberg.org) runs Forgejo and is registered as a first-class source type. The Host field defaults to https://codeberg.org — no extra configuration is needed.

Authentication: CODEBERG_TOKEN or codeberg.auth.* config keys.

Example configuration:

props.Tool{
    ReleaseSource: props.ReleaseSource{
        Type:  "codeberg",
        Owner: "my-org",
        Repo:  "my-tool",
    },
}

Direct HTTP — pkg/vcs/direct

For tools distributed via arbitrary HTTP servers — S3, GCS, Artifactory, Nexus, static web servers, internal CDNs. Asset download URLs are constructed from a configurable template; version detection is optional.

Authentication: DIRECT_TOKEN env var or direct.token config key. Sent as Authorization: Bearer <value>.

Params keys:

Key Required Description
url_template Yes Download URL template. See placeholders below.
version_url No URL returning the latest version string. The response body is read under a 1 MiB cap (mirroring the checksum/signature fetches) so a hostile or misconfigured endpoint cannot stream an unbounded body into memory.
version_format No Override format detection: text, json, yaml, or xml.
version_key No Field name to extract from structured responses. Tries tag_name then version by default.
pinned_version No Static version string. Disables all network version checks.
checksum_url_template No Template for the SHA-256 checksums manifest URL. Same placeholders as url_template. Activates checksum verification on Update() — the Direct provider implements release.ChecksumProvider and fetches the manifest from the expanded URL.
signature_url_template No Template for the detached signature (checksums.txt.sig) URL. Same placeholders as url_template. The Direct provider implements release.SignatureProvider and fetches the signature from the expanded URL for Phase 2 signature verification.

URL template placeholders:

Placeholder Example value
{version} v1.2.3
{version_bare} 1.2.3 (no leading v)
{os} Linux, Darwin, Windows
{arch} x86_64, arm64
{tool} value of ReleaseSource.Repo
{ext} tar.gz

Version endpoint formats — auto-detected from Content-Type, overridable via version_format:

# Plain text (text/plain)
v1.2.3

# JSON (application/json)
{"tag_name": "v1.2.3", "prerelease": false}

# YAML (application/yaml)
version: v1.2.3

# XML (application/xml)
<release><version>v1.2.3</version></release>

Example configuration:

props.Tool{
    ReleaseSource: props.ReleaseSource{
        Type: "direct",
        Repo: "mytool",
        Params: map[string]string{
            "url_template": "https://releases.example.com/{tool}/{version}/{tool}_{os}_{arch}.{ext}",
            "version_url":  "https://releases.example.com/latest.json",
            "version_key":  "tag_name",
        },
    },
}


Usage

pkg/setup.NewUpdater handles provider lookup automatically using props.Tool.ReleaseSource.Type. Simply set the type and call NewUpdater — no provider import needed. The context is forwarded through private-repo token resolution, so remote-store credential backends (Vault, AWS SSM) honour the caller's deadline.

updater, err := setup.NewUpdater(cmd.Context(), props, "", false)

Injecting a provider (tests and custom backends)

NewUpdater resolves its release client in precedence order:

  1. an explicit setup.WithReleaseProvider(p) option;
  2. the props.Tool.ReleaseProvider field (runtime-only, json:"-");
  3. the ReleaseSource.Type registry lookup (the default).

An injected provider (1 or 2) is self-contained, so NewUpdater skips both the registry Lookup and the private-repository token gate that precedes it. This is a parallel-safe seam — each call site supplies its own provider, with no global release.Register mutation — and it is what lets a tool's main (or a test binary) drive self-update against an in-memory backend:

// Test or custom-backend wiring — no network, no registry mutation.
p.Tool.ReleaseProvider = myProvider           // field form (flows through the
                                              // auto-wired update command), or:
updater, _ := setup.NewUpdater(ctx, p, "", false, setup.WithReleaseProvider(myProvider))

Production tools leave ReleaseProvider nil and are resolved from the registry by ReleaseSource.Type as before — the seam is purely additive.

Direct provider construction

For use cases outside the update command:

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

factory, err := release.Lookup("gitea")
if err != nil {
    return err
}

src := release.ReleaseSourceConfig{
    Host:  "https://git.example.com",
    Owner: "my-org",
    Repo:  "my-tool",
}

provider, err := factory(src, props.Config)

Getting the latest release

rel, err := provider.GetLatestRelease(ctx, "my-org", "my-repo")
if err != nil {
    return err
}

fmt.Println(rel.GetTagName(), rel.GetName())
for _, asset := range rel.GetAssets() {
    fmt.Println(" -", asset.GetName(), asset.GetBrowserDownloadURL())
}

Downloading an asset

rc, _, err := provider.DownloadReleaseAsset(ctx, "my-org", "my-repo", asset)
if err != nil {
    return err
}
defer rc.Close()

outFile, _ := props.FS.Create("/tmp/mytool.tar.gz")
defer outFile.Close()
io.Copy(outFile, rc)

Testing

Mocks for all three interfaces are generated by mockery:

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

func TestAutoUpdate(t *testing.T) {
    mockRel := mock_release.NewMockRelease(t)
    mockRel.EXPECT().GetTagName().Return("v2.0.0")
    mockRel.EXPECT().GetDraft().Return(false)

    mockProvider := mock_release.NewMockProvider(t)
    mockProvider.EXPECT().
        GetLatestRelease(mock.Anything, "my-org", "my-repo").
        Return(mockRel, nil)

    // Pass mockProvider wherever release.Provider is required
}

For HTTP-based providers (Gitea, Bitbucket, Direct), unit tests use httptest.NewServer to serve mock responses without any network access.

releasetest — in-memory double for hermetic self-update tests

pkg/vcs/release/releasetest is a public, network-free release.Provider double for exercising the whole self-update pipeline (download → checksum → signature → extract → replace) from your own tests. It also implements ChecksumProvider and SignatureProvider, and its builders construct the GoReleaser-shaped artefacts in memory — including the security-relevant corrupt-checksum and bad-signature variants. The builders take no *testing.T, so the double is usable at runtime too (e.g. an env-gated stub in a test binary).

Combine it with the injection seam to point a tool's update flow at a scripted release:

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

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

    const current = "v1.0.0"
    stub := releasetest.New(releasetest.WithRelease(current)) // latest == current

    p := &props.Props{
        Logger:  logger.NewNoop(),
        Config:  config.NewContainerFromViper(logger.NewNoop(), viper.New()),
        Version: version.NewInfo(current, "", ""),
        Tool:    props.Tool{Name: "mytool", ReleaseProvider: stub},
    }

    updater, err := setup.NewUpdater(context.Background(), p, "", false)
    require.NoError(t, err)

    latest, err := updater.GetLatestVersionString(context.Background())
    require.NoError(t, err)
    assert.Equal(t, current, latest) // the injected stub drove the check — no network
}

Key builders:

Builder Purpose
New(opts...) construct a Source from options
WithRelease(tag, assets...) register a release (first one is "latest" by default)
WithLatestTag(tag) / WithMissingTag(tag) override latest / make a tag resolve to ErrReleaseNotFound
TarGzAsset(tool, bin, body) a release-binary asset named for the current OS/arch
ChecksumsAsset(corrupt, assets...) a checksums.txt; corrupt=true hashes a different payload
SignatureAsset(entity, manifest, bad) a checksums.txt.sig; bad=true signs different bytes

Worked references in this repo: pkg/setup/update_e2e_test.go drives the full verified pipeline (checksum/signature happy + abort) over an in-memory filesystem, and features/cli/update.feature exercises the user-visible outcomes end-to-end through the e2e binary (already-latest no-op, version not-found, corrupt checksum, bad signature) — all hermetic.

Why a DI seam rather than release.Register

The global registry is process-wide mutable state; mutating it from tests cannot run under t.Parallel(). Injecting through WithReleaseProvider / Tool.ReleaseProvider keeps each test's provider local and parallel-safe.


  • GitHubgithub.NewReleaseProvider implementation
  • GitLabgitlab.NewReleaseProvider implementation
  • Setup — how NewUpdater selects and constructs providers
  • Auto-Update Lifecycle — how release.Provider drives version checks