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 rather than 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.

Name which refusal you got

A forge refuses in three distinguishable ways, and a caller reading them wrongly does real damage. Map them, and map rate limiting before permission:

func refuse(err error, resp *http.Response, notFound error) error {
    switch resp.StatusCode {
    case http.StatusNotFound:
        return errors.Wrap(notFound, err.Error())

    case http.StatusUnauthorized:
        return errors.Wrap(forge.ErrUnauthorized, err.Error())

    case http.StatusTooManyRequests:
        delay, _ := forge.ParseRetryAfter(resp.Header, time.Now())

        return forge.RateLimited(err, delay)

    case http.StatusForbidden:
        // BEFORE concluding permission: an exhausted budget advertised on a 403
        // is a throttle, and telling a throttled caller it lacks permission
        // sends it looking for a grant it already has.
        if resp.Header.Get("X-RateLimit-Remaining") == "0" {
            delay, _ := forge.ParseRetryAfter(resp.Header, time.Now())

            return forge.RateLimited(err, delay)
        }

        return errors.Wrap(forge.ErrForbidden, err.Error())

    default:
        return errors.WithStack(err)
    }
}

The ordering is not stylistic. GitHub answers a rate limit with 403, not 429, so a status-ordered mapping reports a throttle as a permission failure. A caller that treats "forbidden" as a credential problem then re-resolves, aiming a burst of resolutions at an API that is already refusing. If your SDK parses rate limits into a typed error, check that type first; it is more reliable than reading headers and impossible to get the order wrong on.

Three rules the harness enforces:

  • Add the sentinel, never substitute it. errors.Is answers "which refusal"; the platform's own error must stay reachable with errors.As.
  • notFound is the caller's choice, not the status'. Pass forge.ErrReleaseNotFound on a release path and forge.ErrNotFound elsewhere A bare 404 cannot tell you which was addressed, but your call site knows.
  • Silence is not "retry now". Pass 0 to forge.RateLimited when the forge gave no reset, and forge.RetryAfter reports it as unknown rather than as a zero to sleep on. Use forge.ParseRetryAfter rather than parsing headers yourself: it is bounded, and that bound is what stands between a caller and an eleven-day sleep on a hostile Retry-After.

See the errors reference for what each sentinel authorises a caller to do.

ListReleases returns a total, not a page

limit is the number of releases the caller wants, not a page size. Platforms cap a single page well below what a caller may ask for (GitHub and GitLab at 100, Gitea instances commonly at 50) so a single request drops older releases and hands back a truncated changelog. Paginate until you have gathered limit releases or run out of history, whichever comes first. A limit <= 0 means "no explicit bound": return your natural first page rather than walking every release ever cut.

forge never retries on your behalf. Surface the platform's rate-limit response (HTTP 429, or GitHub's 403 with X-RateLimit-Remaining: 0) as a wrapped error so the caller can read a Retry-After and decide. Your pagination loop does not sleep-and-retry.

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, because following would fetch bytes from a host nobody vetted. Return "" whenever the body is served directly, which is the common case.

You may instead follow the redirect yourself, but only with a credential-free HTTP client, returning the fetched body and "". This is the GitHub adapter's stance: go-github follows the storage redirect with a token-free client, so no credential reaches the unvetted target and the returned URL is always empty. Never attach your API credential to a followed redirect. The pin exists precisely because the target is author-influenced.

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

Do not trust your SDK to keep the credential on-host

Two of the three first-party SDKs offer a method that leaks a credential when used exactly as its name suggests. go-gitlab's NewRequestToURL refuses an off-instance URL when the request is built, then follows a redirect off-host carrying PRIVATE-TOKEN anyway; go-github's Client() hands back a client that sends Bearer <token> to whatever host you point it at.

Build the download path yourself, on a client that strips sensitive headers across hosts, and gate the credential with forge.HostTrusted. The two are independent defences and you want both. See Credential pinning.

2. Take a credential source, do not resolve one

Do not read the environment or a keychain yourself. Take a forge.CredentialSource and call it:

type Settings struct {
    Endpoint         forge.Endpoint
    Credential       forge.CredentialSource
    TokenFallbackEnv string
    Logger           *slog.Logger

    // The connection rungs, carried here so the direct constructor reaches
    // them too and not only the registry path. See "Honour the injected
    // connection" below.
    HTTPTransport http.RoundTripper
    HTTPClient    *http.Client
}

token, err := settings.Credential(ctx)
if err != nil {
    return nil, err
}

Compose the default in your config adapter, where a consumer can read the precedence and replace it, rather than burying it in a resolution chain:

forge.FirstCredential(
    forge.ConfigCredential(sub, forge.DefaultAuthKey),
    forge.EnvCredential(fallbackEnv),
)

A nil Settings.Credential should fall back to your well-known variable, so construction from configuration alone keeps working. 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.

p.baseURL must be scheme-qualified, or the pin fails closed and the credential is never attached. If your config accepts a bare host (git.example.com), run it through forge.NormalizeHostURL once at construction. It canonicalises a bare host or a full URL into an https://host[:port] origin suitable both for the API client and for HostTrusted:

base, err := forge.NormalizeHostURL(settings.Host) // "git.example.com" -> "https://git.example.com"

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 (
    "context"

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

func init() {
    factory := func(
        ctx context.Context,
        ep forge.Endpoint,
        cfg forge.Config,
        opts ...forge.Option,
    ) (forge.Provider, error) {
        ep.Type = "mycorp-git" // the registry is authoritative; see below

        settings := SettingsFromConfig(ep, cfg, "MYCORP_TOKEN")

        options := forge.NewOptions(opts...)
        settings.Logger = options.Logger
        settings.HTTPTransport = options.HTTPTransport
        settings.HTTPClient = options.HTTPClient

        return New(ctx, settings)
    }

    if err := forge.Register("mycorp-git", factory); err != nil {
        panic("myforge: " + err.Error())
    }
}

The context bounds construction, which is where a credential source runs and may reach a keychain or a remote secret store. opts carries settings with no home in a config subtree; forge.NewOptions normalises them, so a caller who passed no logger still gets a usable one that discards.

Set ep.Type from your registration rather than trusting what arrives. The type selects the configuration subtree your provider reads, and the caller reached your factory by that source type, so the registry already knows the answer. A caller passing another provider's type would otherwise make your provider read that provider's section, credential included.

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

Honour the injected connection

forge.Options carries two connection rungs, and a provider should honour both:

Option What arrives What you do
WithHTTPTransport an http.RoundTripper build your client on it, keeping your own redirect and sensitive-header policy
WithHTTPClient a whole *http.Client use it as-is

Honour them everywhere, but not for an asset download

A partial adoption is worse than none: a provider honouring the injected transport on its API client but not on its asset download hands a caller a connection pool that quietly does not cover every request. forge-github builds a client in three places, and all three take the injected transport.

The exception is a supplied client, which must not fetch a release asset. That URL comes from author-controlled metadata, and a caller's client carries their policy and possibly their credentials, in headers the standard library does not strip across a redirect. Use the injected transport there and build the client yourself. All four first-party adapters split it exactly this way, with an apiClient/pooledClient pair.

Prefer the transport when both are set, unless a caller supplying a whole client should always win for your provider. Say which you do, either way.

func newHTTPClient(o *forge.Options) *http.Client {
    if o.HTTPClient != nil {
        return o.HTTPClient
    }

    return httpclient.NewClient(
        httpclient.WithTransport(o.HTTPTransport), // nil is fine: the default applies
        httpclient.WithSensitiveHeaders("X-MYCORP-TOKEN"),
    )
}

The transport rung shares a pool; the client rung moves an obligation

Both let a consumer avoid a fresh connection pool per provider. They are not equivalent.

With a transport, you still build the client, so your redirect policy survives, including stripping any header you attach by hand before it follows a redirect off the host you pinned. That matters if you set a credential header yourself rather than letting an SDK do it.

With a client, that policy is now the caller's. Document that you honour it, so a consumer supplying one knows the guarantee moved with it.

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, which has 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(ep forge.Endpoint, cfg forge.Config, fallbackEnv string) Settings {
    sub := forge.SubConfig(cfg, "mycorp")

    return Settings{
        Endpoint:         ep,
        TokenFallbackEnv: fallbackEnv,
        Credential: forge.FirstCredential(
            forge.ConfigCredential(sub, forge.DefaultAuthKey),
            forge.EnvCredential(fallbackEnv),
        ),
    }
}

cfg may be nil. SubConfig returns nil for a missing subtree rather than panicking, and ConfigCredential treats a nil config as contributing nothing, so a config-free public lookup still reaches the environment fallback without a branch.

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
A yielded path outside the namespace A caller's containment check answers a different question
A duplicate or empty Path Results a caller cannot key on or deduplicate
yield called again after it returned false Breaks the rule every Go iterator obeys
An absent privacy field decoded as public A disclosure rather than a default
A bespoke error where ErrNotFound belongs "Absent" and "could not check" become the same thing
A rate limit reported as ErrForbidden The caller re-resolves instead of backing off, and a throttle becomes a lockout
An unsentinelled refusal Every failure looks fatal, or callers match on message text
A draft published without sanitisation A credential or a live mention reaches a tracker
An ignored IdempotencyKey A retry files a duplicate that cannot be undone
A repeated key answered with a conflict The retry it exists to serve cannot tell that from failure
A client panic reaching the caller A malformed response ends the process instead of the request

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, because 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.

Discovery capabilities

Repositories, Contents and Sites let a caller define its sources by a rule instead of a list. Implement what your forge can actually answer. Implementing none of them is fully conformant, and Sites is unimplementable on a forge with no site feature.

These are held to a higher standard than the release methods, because a caller acts on the answers. A loose answer here is a disclosure rather than an inconvenience.

Enumerate only what is IN the namespace

Every Repository.Path you yield must equal the namespace or be prefixed by namespace + "/". Check your platform's defaults rather than assuming:

// GitLab: with_shared defaults to TRUE, and includes projects merely SHARED
// into the group — carrying their own, foreign paths.
opts := &gitlab.ListGroupProjectsOptions{
    WithShared:       gitlab.Ptr(false),
    IncludeSubGroups: gitlab.Ptr(o.IncludeSubgroups),
}

Report the path the API answered with, never the one you were asked for. Callers key on it, and forges redirect renamed projects.

Resolve the namespace kind; never guess on a 404

Where your forge splits organisations from users, determine which you have before enumerating:

// right: one request, authoritative
u, _, err := client.Users.Get(ctx, namespace)
if u.GetType() == "Organization" {  } else {  }

The tempting shortcut, trying the org endpoint and falling back to the user endpoint on 404, fails open. GitHub answers 404 for an organisation your token cannot see, and the user endpoint then succeeds for that same name returning public repositories only. The caller receives a short list indistinguishable from a complete one.

Return an error for a partial enumeration

Stopping because yield returned false is not an error, so return nil. Any other early exit must return a non-nil error, or the caller cannot tell a complete namespace from a truncated one. Do not call yield again once it has returned false.

Decode security-bearing fields fail-closed

An absent privacy field must become VisibilityUnknown, not VisibilityPublic. If your SDK models it as a plain bool, absent and false are indistinguishable, so decode through a pointer:

var payload struct {
    Private *bool `json:"private"`
}

Map anything you do not recognise to VisibilityUnknown too. A forge with no internal concept reports public or private and nothing else.

Say "not found" with the sentinel

GetFile on a path that does not exist returns forge.ErrNotFound, and so does GetSite for a repository with no site. A permission failure is neither. It is an error, because telling a caller "absent" when the truth is "your token could not ask" silently drops a repository from its set.

Enforce maxBytes, even when your SDK will not

You are the only code that sees the response before it is buffered. Where the SDK exposes a reader, use io.LimitedReader. Where it buffers internally (GitLab's GetRawFile copies the whole body before returning) check the size the API reports first:

meta, _, err := c.RepositoryFiles.GetRawFileMetaData(pid, path, opt)
if err == nil && meta.Size > maxBytes {
    return nil, errTooLarge
}

Then re-check the length after reading, to catch a file that grew in between.

Always say where a site URL came from

Set Site.URLSource whenever you return a Site.URL. The conformance harness fails a provider that returns one without it, because the zero value (SiteURLUnknown) cannot be told apart from a field you simply never set.

Value Use it when
SiteURLCanonical The forge reported this as the project's own configured domain: GitLab's pages_primary_domain, GitHub's cname. This is the address a caller should cite.
SiteURLGenerated The forge reported the address but generated it, so a *.gitlab.io or *.github.io host. Real, but a custom domain may exist that the response did not carry.
SiteURLDerived You composed it from a template rather than reading it from the forge.

Codeberg's is deterministic (https://<owner>.codeberg.page/<repo>/) but a custom domain lives in DNS, so the derived address can be wrong. That is what lets a caller treat a failed probe as unknown rather than absent.

Report TLS enforcement; do not apply it

Set Site.TLS from whatever your forge calls it (force_https on GitLab, https_enforced on GitHub) and leave it SiteTLSUnknown if your forge does not say. Unknown is a correct answer; the harness does not require otherwise.

Where the forge does enforce, normalise an http:// address to https://: you are reporting what the forge does, not guessing. Where it does not, return the scheme as recorded. Upgrading there would guess TLS availability on exactly the sites where the forge declines to promise it, and a wrong guess yields an address that does not resolve. A caller that refuses to cite http:// has Site.TLS to act on.

And do not return a URL for every repository just because the template always composes. That is a guess dressed as an answer. Confirm something first, and return ErrNotFound when you cannot.

Issue capabilities

Issues reads a project's issues; IssueFiler creates one. Implement what your forge can answer. Implementing neither is fully conformant.

IssueFiler is the only capability that writes to a project, and the rules below exist because its failures are not undoable and do not land on the caller. (KeyManager.UploadKey writes too, to the authenticated account.)

Sanitise before sending, and in this order

CreateIssue must pass Title and Body through forge.Sanitise unless the draft sets Unsanitised. Then, and only then, append the idempotency key:

body := draft.Body
if !draft.Unsanitised {
    body = forge.Sanitise(body)
}

body = forge.AppendIdempotencyKey(body, draft.IdempotencyKey)

The order is contractual. Sanitising afterwards feeds the key to the redactor, and an opaque key of 41 or more characters is rewritten to a redaction marker. Your pre-create search then looks for a key the issue does not carry, and the at-most-once guarantee fails silently.

Make filing at-most-once

When draft.IdempotencyKey is set, search for it before creating and return the existing issue if you find one:

if key := draft.IdempotencyKey; key != "" {
    if found, err := p.findByMarker(ctx, owner, repo, forge.IdempotencySearchText(key)); err == nil {
        return found, nil
    }
}

Use forge.IdempotencySearchText rather than composing the marker yourself, so the format lives in one place. A search string that drifts from what AppendIdempotencyKey wrote fails silently rather than loudly.

Do not treat a repeated key as a conflict. It is a defensible instinct and it is wrong: the case the guarantee exists for is a retry whose first response was lost, and such a caller cannot tell a 409 from a real failure. Return the issue that was already filed.

Never ignore IssueQuery.Text

A provider whose forge has weak issue search filters client-side rather than returning everything. The observable result should match a forge that can filter; only the request count differs.

Returning everything is the dangerous option: a caller checking the first results for a duplicate and finding none concludes there are none, and files a duplicate into a public tracker.

Report state, or report that you cannot

Map the forge's state to IssueStateOpen or IssueStateClosed. Leave IssueStateUnknown only when you genuinely cannot tell, never as a default. A caller polling for closure reads it as neither, and waits forever.

Likewise Number and URL: without the first the issue can never be polled again, and without the second there is nothing to give the person it was filed for.

Flag system comments

Set Comment.System for the forge's own notes: label changes, state transitions, assignments. A caller relaying comments outside the tracker uses it to avoid republishing triage mechanics as though they were replies.

Account capabilities: login and SSH keys

Two more optional interfaces exist for a tool's first-run setup. Implement them only if your forge has the feature. Most of the rules above apply unchanged.

// Authenticator: an interactive login yielding an API token.
func (p *Provider) Login(ctx context.Context, prompter forge.Prompter) (string, error)

// KeyManager: register an OpenSSH public key on the authenticated account.
func (p *Provider) UploadKey(ctx context.Context, name string, publicKey []byte) error

Surface every user-facing step through the Prompter, never through your own output. That is what keeps a terminal or TUI dependency out of this module:

err := prompter.ShowDeviceCode(ctx, forge.DeviceCode{
    UserCode:                resp.UserCode,
    VerificationURI:         resp.VerificationURI,
    VerificationURIComplete: resp.VerificationURIComplete, // when your forge supplies one
    ExpiresIn:               resp.ExpiresIn,
})

ShowDeviceCode returns as soon as the prompt has been shown; you then poll for completion. A non-nil error from it aborts the login. Login blocks until the user finishes, ctx is cancelled, or the device code expires.

Return ErrNotSupported when the current configuration cannot perform an interactive login (a missing OAuth client ID, say) rather than a bespoke error. The caller falls back to manual token entry, exactly as it does when the type assertion fails.

UploadKey is a write, and the only one in this module that is not to a project. name is a label the user will see in their account's key list.

Never let a client panic reach your caller

A forge SDK decodes a network response, and more than one of them dereferences a field of that response without checking it is present. Two live examples in the SDKs this module already wraps: an absent id on a GitLab issue, and an absent repository on a Gitea one when its version probe fails.

Real instances send those fields, so the crash is latent. A truncated body, a proxy, or an instance older than the SDK expects produces it, and in a long-running process a panic is not an error path. It is the process ending.

Route every client call through the guard:

var issues []*sdk.Issue

err := forge.GuardPanic(func() error {
    var e error
    issues, _, e = client.Issues.List(ctx, project)

    return e
})

Wrap the dependency, not your own logic. Keep the guarded region to the third-party call itself, so mapping and validation stay outside it. A blanket recover hides real bugs in your code; a narrow one converts somebody else's crash into an error you can return.

The conformance harness reports a panic as a violation, so this is checked rather than merely asked for.