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.Isanswers "which refusal"; the platform's own error must stay reachable witherrors.As. notFoundis the caller's choice, not the status'. Passforge.ErrReleaseNotFoundon a release path andforge.ErrNotFoundelsewhere A bare 404 cannot tell you which was addressed, but your call site knows.- Silence is not "retry now". Pass
0toforge.RateLimitedwhen the forge gave no reset, andforge.RetryAfterreports it as unknown rather than as a zero to sleep on. Useforge.ParseRetryAfterrather than parsing headers yourself: it is bounded, and that bound is what stands between a caller and an eleven-day sleep on a hostileRetry-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:
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:
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:
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.
Related¶
- Backend agnosticism: why the registry
- Credential pinning: the threat model
- File and watch issues: the consumer side
- Test against a forge: doubles and mocks