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:
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:
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.
Related¶
- Backend agnosticism — why the registry
- Credential pinning — the threat model
- Test against a forge — doubles and mocks