Skip to content

Author a provider

A provider teaches this module how to talk to one forge. It ships as your own module — nothing needs to be contributed here — and a consumer enables it with a blank import.

You need ten symbols, all from forge, and no dependency on any framework.

1. Implement the contract

package myforge

import (
    "context"
    "io"

    "gitlab.com/phpboyscout/go/forge"
)

type Provider struct {
    baseURL string
    token   string
}

func (p *Provider) GetLatestRelease(ctx context.Context, owner, repo string) (forge.Release, error)
func (p *Provider) GetReleaseByTag(ctx context.Context, owner, repo, tag string) (forge.Release, error)
func (p *Provider) ListReleases(ctx context.Context, owner, repo string, limit int) ([]forge.Release, error)
func (p *Provider) DownloadReleaseAsset(ctx context.Context, owner, repo string, asset forge.ReleaseAsset) (io.ReadCloser, string, error)

Return your own types satisfying forge.Release and forge.ReleaseAsset. The mapping from your platform's API payload onto those interfaces is the real work; everything else here is boilerplate.

Say "not supported" properly

Where your platform has no equivalent concept, return the sentinel — never a bespoke error:

func (p *Provider) ListReleases(context.Context, string, string, int) ([]forge.Release, error) {
    return nil, forge.ErrNotSupported
}

Callers branch on errors.Is(err, forge.ErrNotSupported) to fall back. Any other error turns a recoverable fallback into a hard failure, and the caller cannot tell the difference.

The second return value of DownloadReleaseAsset

It is a redirect URL, and it is a security boundary rather than a convenience. Some APIs answer an asset request with a redirect to storage that the client is expected to follow itself. If you receive such a redirect and do not follow it, return the location here with a nil reader.

Callers are expected to refuse a non-empty redirect rather than follow it — following would fetch bytes from a host nobody vetted. Return "" whenever the body is served directly, which is the common case.

Never return a readable body and a redirect URL. Return one.

2. Resolve credentials through the shared chain

Do not read the environment yourself. Take a forge.AuthConfig and let ResolveToken walk the chain:

type Settings struct {
    ReleaseSource    forge.ReleaseSourceConfig
    Auth             forge.AuthConfig
    TokenFallbackEnv string
}

token := forge.ResolveToken(settings.Auth, settings.TokenFallbackEnv)

This gets you environment references, OS keychain lookups and literal values for free, in a precedence order consumers already understand. See authenticate.

3. Pin the credential to your host

This is the step most likely to be missed, and the most costly to miss. Asset URLs come from release metadata, which a release author controls:

if p.token != "" && forge.HostTrusted(downloadURL, p.baseURL) {
    req.Header.Set("Authorization", "token "+p.token)
}

Without it, a hostile release author points an asset URL at their own server and your provider posts them the token.

It is strict by default. If your platform legitimately serves assets from a separate domain, widen it explicitly rather than dropping the check:

forge.HostTrusted(downloadURL, p.baseURL,
    forge.WithAdditionalHosts(p.assetHost))

See credential pinning.

4. Register at init

package myforge

import "gitlab.com/phpboyscout/go/forge"

func init() {
    err := forge.Register("mycorp-git", func(src forge.ReleaseSourceConfig, cfg forge.Config) (forge.Provider, error) {
        return New(SettingsFromConfig(src, cfg, "MYCORP_TOKEN"))
    })
    if err != nil {
        panic("myforge: " + err.Error())
    }
}

The source type is any string you like — the registry does not police the set.

Register reports a duplicate; you decide what to do

Register returns ErrAlreadyRegistered rather than overwriting — silent overwriting would let a blank import displace another provider with no diagnostic, and initialisation order would decide the winner.

From init() there is nothing sensible to do with the error, so panic at your own call site, as above: the message then names the module at fault rather than pointing back into forge. Code building a registry programmatically — a plugin host, a tool selecting providers at runtime — handles the error instead, which is why this does not panic on your behalf.

Use forge.Registered(name) to register conditionally, or forge.Unregister(name) to replace one deliberately.

Consumers enable you with a blank import:

import _ "example.com/myforge"

5. Read configuration through the narrow seam

The factory receives a forge.Config — two methods, GetString and Sub. It is deliberately not a config library, so your provider imports no config framework and can be driven by whatever the consumer already uses:

func SettingsFromConfig(src forge.ReleaseSourceConfig, cfg forge.Config, fallbackEnv string) Settings {
    settings := Settings{ReleaseSource: src, TokenFallbackEnv: fallbackEnv}
    if cfg == nil {
        return settings
    }

    if sub := forge.SubConfig(cfg, "mycorp"); sub != nil {
        settings.Auth = forge.AuthConfig{
            Env:      sub.GetString("auth.env"),
            Value:    sub.GetString("auth.value"),
            Keychain: sub.GetString("auth.keychain"),
        }
    }

    return settings
}

cfg may be nil. SubConfig returns nil for a missing subtree rather than panicking.

Prove it with the conformance harness

The compiler checks your method set. It cannot check the protocol — which error means "fall back", that a nil result and nil error may never be returned together, that a size bound you accepted is actually enforced. A provider can satisfy forge.Provider completely and still be wrong in every way a caller cares about.

Run the harness from your own test suite:

import forgetest "gitlab.com/phpboyscout/go/forge/test"

func TestConformance(t *testing.T) {
    forgetest.RunProviderConformance(t, forgetest.ConformanceConfig{
        NewProvider: func() forge.Provider { return newTestProvider(t) },
        Capabilities: forgetest.Capabilities{
            GetReleaseByTag: true,
            ListReleases:    false, // platform has no listing
        },
        Owner: "acme", Repo: "tool", Tag: "v1.0.0",
    })
}

Declare capabilities honestly. The harness checks both directions: an undeclared capability must return ErrNotSupported, and a declared one must not. Over-declaring is caught precisely because it would otherwise skip every check for that method while implying they had run.

It catches:

Defect Why it matters
(nil, nil) returns The caller cannot tell success from failure
A bespoke error where ErrNotSupported belongs Turns a fallback into a hard failure
An over- or under-declared capability Silently weakens every other check
An accepted-but-ignored maxBytes A hostile server can stream indefinitely
An empty download with no error Nothing to read, no reason why
A body and a redirect The caller must guess which to use

Optional capabilities

Implement ChecksumProvider or SignatureProvider only if your platform serves those by some route other than a normal release asset. Most providers should not — the default asset-by-name lookup already handles the common case.

If you do implement one, enforce the caller-supplied maxBytes. You are the only code that sees the response before it is buffered.