Skip to content

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.GHClient or *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:

// in a provider module's init()
func init() {
    forge.Register("mycorp-git", newMyCorpProvider)
}

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

ReleaseSourceConfig.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 — usually the real problem, which is a missing blank import.

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 ErrNotSupported and callers branch on it.
  • ErrReleaseNotFound is not yet uniform. It is the sentinel to implement in a new provider, but the first-party set still surfaces platform-native not-found errors. Don't rely on errors.Is across all of them today.
  • The credential model assumes a token. Bitbucket needs a username and an app password, which is why it currently carries its own chain.