Backend agnosticism¶
The design goal this module exists to serve, stated as plainly as it was originally written:
- Backend Agnosticism
- Consuming code depends on
release.Provider, not*github.GHClientor*gitlab.GitLabReleaseProvider. Switching from GitHub to GitLab releases is a one-line constructor change.
That is the whole idea. Everything below is consequence.
What it costs to get wrong¶
The obvious way to add release support is to reach for the platform's SDK and call it directly:
// the design this module rejects
client := github.NewClient(httpClient)
rel, _, err := client.Repositories.GetLatestRelease(ctx, owner, repo)
It works, and it quietly makes three commitments. Your update path now imports go-github, so every consumer of your tool compiles it whether they use GitHub or not. Your business logic now knows the shape of GitHub's types, so supporting GitLab means a second code path rather than a second constructor. And a self-hosted forge nobody has heard of becomes a change to your source rather than a change to your configuration.
The interface removes all three. Provider is four methods over types this
module defines, so consuming code is written once against the contract, and
which implementation satisfies it becomes a runtime decision.
Why a registry rather than a switch¶
A switch on the source type would achieve backend agnosticism for this
module's providers, and no others:
// works, and closes the set
switch sourceType {
case "github":
return newGitHub(cfg)
case "gitlab":
return newGitLab(cfg)
}
Every new forge is then an edit to the core, which means only the core's authors can add one. A registry inverts that:
The core never learns the name. A provider ships as its own module, a consumer blank-imports it, and it works exactly as well as a first-party one. That is why the first-party providers are themselves separate modules, not because their weight demanded it, but because a boundary nobody dogfoods is a boundary that rots. If the built-in providers used a private path, the public one would break without anyone noticing.
It also means you pay only for what you use. The core imports no forge SDK at
all (a guard test enforces it) so accepting a Provider costs you nothing
but this module.
Why the source type is a plain string¶
Endpoint.Type is a string, not an enum. An enum would be more
type-safe and would defeat the point: the set of forges is not closed, and a
constant this module has not declared could never be named by a consumer.
Constants are provided for the known set as a convenience, not a constraint.
The registry rejects an unknown type at lookup with a hint listing what is registered, which is usually the real problem: a missing blank import.
Parity is a promise, and its limit is the forge¶
A capability built for one first-party forge is built for all of them. That is a commitment rather than an aspiration, and it is why a capability arriving on one adapter first is described as not yet on the others rather than left to be inferred.
The limit is the forge, never the effort. Some capabilities cannot exist on some platforms, and pretending otherwise would be the leaky abstraction this whole module is written to avoid:
- Bitbucket Cloud has no static site hosting, so
Sitesis never there. - Bitbucket withdrew snippets; its API answers
410 Gone. - Bitbucket Issues reached Atlassian's announced removal date in August
2026, so
forge-bitbucketimplements neither issue capability.
None of those is a gap we chose. Each is recorded as never in the
provider matrix, with the reason attached, so the
distinction is visible to a reader rather than held in someone's head.
So the promise reads precisely as: where a forge can support a capability, it gets it. What you should not find is a capability withheld from a forge that could have had it, and if you do, that is a bug in this family's delivery rather than a design decision.
The practical consequence for a consumer is that a not yet is worth asking
about and a never is not. The
provider matrix is where that is written down.
What this does not give you¶
Honesty about the limits, since a leaky abstraction is worse than an explicit one:
- Platforms differ, and the contract admits it. Not every forge has an
addressable tag, or a listable set of releases. Rather than pretend, the
contract has
ErrNotSupportedand callers branch on it. - A 404 does not name what was missing. Every provider returns a not-found
sentinel, and the conformance harness holds them to it. Which one depends
on what the provider addressed, because a bare 404 cannot say.
forge-gitlabreports a 404 from its release listing asErrNotFound, since a missing project is what that status means there; Bitbucket Downloads has no release concept to miss. Branch on both where you only care that something was absent. See the errors reference. - The credential model assumes a token.
CredentialSourceyields one string. Bitbucket needs a username and an app password, soforge-bitbucketcomposes two of them side by side rather than one. That works, but is a shape the contract does not name. - Some fields are inert on some backends.
GetDraft()is alwaysfalseon GitLab, and the private-repository guidance isforge-bitbucket's alone, applied at the operation rather than at construction. See what forge does not do.
Related¶
- What forge does not do: the limits in full
- Providers: what is registered today
- Author a provider
- Optional capabilities