Skip to content

Optional capabilities

Several of this module's interfaces are not part of Provider, and are discovered by runtime type assertion instead:

if cp, ok := provider.(forge.ChecksumProvider); ok {
    manifest, err := cp.DownloadChecksumManifest(ctx, rel, maxBytes)
    // ...
}

Type assertion is usually a smell, a sign the interface is wrong. Here it is deliberate, and the reason is compatibility.

Why not just add the method

If checksum retrieval were a fifth method on Provider, adding it would break every existing implementation, including ones written outside this repository that we cannot see or fix. The contract's own doc comment states the goal:

This keeps third-party Provider implementations source-compatible: they gain the feature by opting in, not by implementing a new required method.

An optional interface adds capability without invalidating anything. A provider that ignores it keeps compiling and keeps working, falling back to the default behaviour of locating checksums.txt by name in the release's asset list.

That fallback is what makes the pattern honest. The capability is not lost when a provider opts out; it is obtained a different way. The interface exists for the cases the default cannot serve. The direct provider composes a checksum URL from a template that may point outside the asset listing entirely, so there is no asset to find by name.

Two ways to say "no", and they mean the same thing

A caller must treat these identically:

  1. The provider does not implement the interface, so the type assertion fails.
  2. The provider implements it but returns ErrNotSupported, being configured in a way that disables the capability, such as an empty URL template.

Both mean fall back. Only the second is easy to get wrong: returning a bespoke error instead of the sentinel turns a recoverable fallback into a hard failure, and the caller has no way to tell it was meant to be recoverable.

// wrong — the caller cannot recover
return nil, errors.New("checksums not configured")

// right — the caller falls back
return nil, forge.ErrNotSupported

This is exactly the kind of rule that is easy to state and easy to breach, which is why it is mechanically checked rather than left to prose.

Discovering a capability through a decorator

A plain type assertion only sees the concrete value in front of it. Wrap a provider in a decorator that forwards the four required methods (a logging or metrics shim, say) and the assertion silently reports every optional capability absent, because the wrapper does not carry those interfaces. Since a caller treats "not implemented" as a graceful fallback, verification would quietly downgrade rather than fail.

forge.As closes that seam. It mirrors errors.As: it walks the provider's Unwrap() chain and finds the first value in it that satisfies the capability.

var km forge.KeyManager
if forge.As(provider, &km) {
    // provider — or something it wraps — can upload keys
}

A decorator makes itself transparent by implementing forge.ProviderUnwrapper:

func (d loggingProvider) Unwrap() forge.Provider { return d.inner }

Prefer forge.As over a bare assertion at every capability call site, so a future decorator cannot strip a capability without anyone noticing. The first-party providers are not decorators and need no Unwrap; a bare assertion against them and forge.As behave identically, because As matches a provider that implements the capability directly before it unwraps anything.

Size bounds: the caller decides, the provider enforces

Both optional methods take a maxBytes bound, and the split of responsibility is deliberate:

  • The caller owns the policy. It passes the value, so a tool shipping unusually large artefacts can raise the ceiling without every provider needing a configuration knob. DefaultMaxChecksumsSize and DefaultMaxSignatureSize are defaults, not mandates.
  • The provider owns the enforcement. It is the only code that sees the response before it is buffered, so it is the only place a cap can prevent a hostile server streaming indefinitely rather than merely truncate afterwards.

A provider that accepts the parameter and ignores it looks correct and is not. Nothing at compile time notices, so the conformance harness does.

The discovery capabilities

Three more follow the same pattern, for callers whose corpus is defined by a predicate rather than a list: "every public repository in this namespace carrying a docsite marker".

Interface Answers
Repositories what is in this namespace?
Contents does this repository carry this file?
Sites does this repository publish a documentation site?

Discover them with forge.As, as with every other optional capability. A decorator that forwards only the required methods would defeat a bare assertion.

They are separate because a provider may be able to enumerate a namespace and not read files, or the reverse. A provider implementing none of them is fully conformant. Two of the four first-party forges have no site feature at all, so Sites is unimplementable there rather than merely unimplemented.

That variance is the pattern working. What makes it safe is that a caller cannot tell "not implemented" from "configured off", and must not try.

When a wrong answer is a disclosure

The release capabilities fail toward an inconvenience: a missing checksum means a download is refused. Discovery fails differently. A caller asking "is this repository public?" acts on the answer, so a loose answer publishes something.

Two rules follow, and they are the reason Visibility is a named type with an explicit unknown rather than a bool.

Providers fail closed. A visibility the provider cannot determine is VisibilityUnknown, never the nearest-looking constant. This is easier to get wrong than it sounds, because it is a decoding problem before it is a logic problem:

// wrong — an absent field decodes to false, which reads as public
var payload struct{ Private bool `json:"private"` }

// right — absent is distinguishable, and maps to VisibilityUnknown
var payload struct{ Private *bool `json:"private"` }

GitHub's SDK models it as a pointer and is safe by construction; Gitea's models it as a plain bool, and a hand-rolled provider decodes whatever it is told to.

Callers allowlist, never denylist.

if repo.Visibility == forge.VisibilityPublic {  }  // right
if repo.Visibility != forge.VisibilityPrivate {  } // admits unknown AND internal

Sites are two-tier, because the useful part is gated

Every forge that publishes sites puts the interesting details (the URL, the build state) behind a permission most read-only tokens do not have. GitLab requires Maintainer or Owner; GitHub requires the repo scope.

Putting a URL on Repository would therefore mean a field that is empty when your token is too weak, and empty is also what "no site" looks like. The answer would change with the token rather than with the repository, and no caller could tell which it got. So the concept is split:

  • Repository.Site is a SiteStatus read from the enumeration payload. It costs nothing, needs no elevated token, and is mostly useful in the negative: SiteNone eliminates a candidate without a request.
  • Sites.GetSite returns the URL and build state, for the caller that opts into paying for them.

SiteUnknown is the zero value and does not mean "no site". Codeberg reports it and has sites; GitLab reports it whenever its Pages access level is merely non-disabled, since that setting says nothing about whether a site was ever deployed.

Neither tier promises the site is reachable. A deploy can succeed onto a domain that has since lapsed, and a custom domain can be configured entirely in DNS where the API cannot see it, which is why Site.URLSource exists, so a caller can tell how much a failed probe proves. Checking liveness is an HTTP request only the caller can make; a provider that tried would be answering from wherever forge happens to run.

Two more sentinels, and why they are distinct

ErrNotSupported means fall back. ErrNotFound means the thing is not there, which for discovery is an ordinary answer rather than a failure:

data, err := contents.GetFile(ctx, owner, repo, "zensical.toml", ref, forge.DefaultMaxFileSize)
switch {
case errors.Is(err, forge.ErrNotFound):
    return false, nil // no marker: this repository does not qualify
case err != nil:
    return false, err // could not check: retry, do not exclude
}

Collapsing the two is how a corpus rots quietly. Treat a transient failure as "absent" and a repository drops out looking exactly like one that never qualified; treat "absent" as a failure and discovery breaks on every candidate that legitimately lacks the file, which is most of them.

The issue capabilities, and the write that cannot be undone

Two more, for tools that raise issues on someone's behalf and follow what happens to them:

Interface Answers
Issues what is already filed, what state is it in, what was said?
IssueFiler file this

They are split for a reason the others are not: the credential requirement differs. Reading is satisfied by a read-only API scope; filing is not. A deployment trusted to watch and search but not to write can decline IssueFiler and find out at startup rather than on its first attempt to file.

The split also makes the write path something a reviewer can grep for. CreateIssue is the only method that writes to somebody's project.

Two other capabilities write, and to different places. KeyManager.UploadKey registers an SSH key on the authenticated account, and Snippets stores files on either an account or a project. If your rule is "this process writes nothing anywhere", both count too.

Snippets, and the visibility that is not what it sounds like

Snippets parks one or more files somewhere durable (a spike artefact, a generated report, anything too big for an issue body) and returns an address worth citing.

Only two of the five providers implement it, permanently. Bitbucket Cloud withdrew snippets (the API answers 410 Gone) and Gitea has no such feature, so neither implements the interface and each pins that absence with a test. This is the capability pattern working, not a gap someone forgot to fill.

The part worth reading carefully is SnippetVisibility, which is deliberately not Visibility:

Value Who can read it
SnippetVisibilityPublic anyone; listed and discoverable
SnippetVisibilityUnlisted anyone holding the URL, not listed and not protected
SnippetVisibilityInternal authenticated users of the instance (GitLab only)
SnippetVisibilityPrivate only those granted access

Unlisted exists because a GitHub "secret" gist is exactly that and nothing more. Calling it secret invites a caller to treat it as access control, which it is not. A provider that cannot deliver the visibility you asked for returns ErrNotSupported rather than substituting something weaker. GitHub does this for Private, because a gist has no protected mode at all.

The returned Visibility is the effective one and may be more restrictive than requested: on GitLab a project's own snippet setting can cap it, and the forge does not reconcile the two, so a capped snippet still reports itself public, so the provider computes the real answer from both. It is never less restrictive than you asked for.

ReleasePublisher, and the commit no forge will accept

Creating a release looks like the simplest write in this contract. It is not, and the reason is a field that every forge accepts and none of them honours.

The method names carry the noun, and PullRequests does not

CreateRelease, UpdateRelease, AddReleaseAsset — beside PullRequests' bare Find, Create and Close. That looks inconsistent and is not.

PullRequests strips the noun for exactly one reason: MergeRequests is a type alias, an alias can rename a type but never a method, so a single CreatePullRequest would hand the GitLab-flavoured word back at every call site of a project that deliberately spells it the other way.

Releases have no such split. Every forge calls them releases, there is no alias, and so there is nothing for a stripped noun to buy — while the cost is immediate, because a provider serves both capabilities on one type and Go allows one method of a given name per type.

forge v0.19.0 shipped this capability as Create, Update and AddAsset, and every first-party adapter already had Create and Update from PullRequests. The capability was therefore unimplementable by all four of them, and nobody found out until an adapter was written. v0.20.0 renamed the methods, and a test in the contract package now implements every optional capability on a single type so the compiler catches the next collision.

The commit is checked, never sent

A release should be cut from a specific commit — the one a consumer confirmed had landed, not whatever a branch points at by the time the call is made. Every forge offers a parameter that looks like it does this: GitHub's target_commitish, GitLab's ref.

Neither uses it once the tag exists. GitHub documents the field as "unused if the Git tag already exists". GitLab behaves identically and does not say so — creating a tag at one commit and then a release naming another returns 201 Created, with no error and no warning, and the release sits on the tag's commit.

So ReleaseDraft.Commit is a precondition the provider verifies, not an instruction it forwards. The provider resolves the tag, compares, and returns ErrNotFound on a mismatch. An instruction a forge may silently discard becomes a check it cannot fake, which is the same move ResolveMergedCommit makes one step earlier in the same workflow.

Refusing an absent tag is not pedantry

Given a tag that does not exist, every first-party forge will happily create it from whatever ref it was handed and report success. A provider that simply forwards the call therefore passes every happy-path test while tagging the repository — with the caller's credential, which may be exactly the credential whose writes do not fire the pipeline that was meant to follow.

Creating is also not atomic. A failed create can leave the tag behind: a rejected asset link returns 400 with no release created, and the tag it was asked to create remains. A caller retrying then finds a tag this contract wrote, and checks Commit against it — a check against your own output proves nothing.

CreateRelease refuses an absent tag, which removes the class rather than handling it.

Attaching a file is four different operations

forge attaching a file
GitHub uploads a binary to the release
Gitea / Codeberg uploads an attachment
GitLab links to a URL hosted elsewhere
Bitbucket no release object at all

GitLab has no endpoint that accepts a binary into a release. AddReleaseAsset is still one verb everywhere, because forge-gitlab publishes the bytes to the project's generic package registry and links the result — which is what goreleaser does for the same reason, and what the estate already relies on.

That is a visible side effect: a package appears in the project's registry. It is stated rather than hidden, and the package name derives from the tag so it is predictable.

A success that returns an error

GitLab models neither draft nor prerelease. Asking for a draft there leaves three options, and two are bad: refusing denies a caller a release over a field the platform lacks, and publishing silently hands them the opposite of what they asked for, on the one operation with no delete to undo it.

So the provider creates the release and says what it could not apply, returning ErrNotHonoured alongside the created release.

This is Go's (n > 0, err != nil) shape, and it has a sharp edge worth stating plainly: if err != nil { return err } treats a successful creation as a failure, and a caller who retries gets ErrAlreadyExists for a release that was already correct. Two guarantees make it safe — the result is populated whenever the release exists, and ErrNotHonoured never travels alone — so the check that works is:

rel, err := pub.CreateRelease(ctx, owner, repo, draft)
if err != nil && !errors.Is(err, forge.ErrNotHonoured) {
    return err // a real failure; nothing was created
}
// rel exists either way. Log err if non-nil: something was dropped.

PullRequests, and the field that is missing on purpose

PullRequests opens a proposed change against a target branch, finds it again on a later run, keeps it current, and establishes which commit it produced. The last of those is why the capability exists.

Two names, one type

Three of the four first-party forges say pull request; GitLab says merge request. There is no common term to pick, so the contract carries both:

type MergeRequests = PullRequests

That is a Go type alias, not a wrapper. They are the same type, so a provider satisfies both by implementing either, and a type assertion against one succeeds on a value declared against the other. forge-gitlab can write var _ forge.MergeRequests = (*Provider)(nil) and forge-github can write var _ forge.PullRequests = (*Provider)(nil); both compile, and both are the same assertion.

No method name carries the noun — Find, Create, Update, Close, ResolveMergedCommit. That is deliberate and it is what makes the alias worth having. An alias renames a type and cannot rename a method, so a single CreatePullRequest would hand the GitLab-flavoured name back with the word it exists to avoid still sitting at every call site.

PullRequests is canonical, which decides what godoc leads with and what this documentation says. It decides nothing else.

There is no head SHA, and there will not be one

A forge records a pull request's head commit and will hand it to you. It is wrong exactly when it matters most.

GitLab 19.2 rebases automatically before a fast-forward merge and does not write the result back, so the recorded head stays at a commit the rebase orphaned. A release tool that trusts it tags a commit which is not on the target branch. Measured across one group, 9 of 231 tags were not on their default branch, consistently dropping docs, chore, ci, style and test commits.

So the field is absent rather than documented as unreliable. A field that exists gets read; a warning in a doc comment is not present at the call site; and the failure is a permanent tag. Ask ResolveMergedCommit instead, which returns a commit it has confirmed is on the target branch and returns ErrNotFound rather than a best guess when nothing confirms.

A caller that genuinely wants the raw record can still reach the platform's own type through ProviderUnwrapper. That is deliberately the awkward path.

Confirmation means containment, not existence

The trap underneath the trap. An orphaned commit still exists — fetching it succeeds and returns its message intact — so a provider that confirms a candidate by fetching it passes its own check and is still wrong. Only asking which branches contain the commit separates the two.

Two finders, because open and merged differ in cardinality

Find searches open pull requests and returns at most one, because a forge refuses a second open request for the same source and target pair. FindLastMerged searches merged ones, and there may be many: a release branch is reused, and one such branch carried 0 open and 53 merged merge requests.

That difference is why it is two methods rather than one taking a state. A single method would return the one in one mode and an arbitrary one of fifty-three in the other, with a signature unable to say which.

FindLastMerged orders by merge time, not last-updated. The two diverge: activity after a merge moves one and leaves the other alone, so ordering by the wrong timestamp is a claim about recency rather than evidence of it.

Update sends the whole title and body

There is no partial update and no way to clear a field. A forge distinguishes "field omitted, leave it" from "field sent empty, clear it", and a plain string cannot express both: read as leave and a caller can never clear, read as clear and a caller changing only the title silently wipes the body.

Supplying both costs nothing, since Find already returned them.

The lost-update race against a human editing the body is real and is not solved here — no first-party forge offers a compare-and-swap on this operation. Which regions of a body are machine-owned is the caller's policy, stated once in its own composition, the same way credential precedence is.

There is no Merge, and that is not an oversight

Merging a release pull request is a maintainer action in this estate. A verb in the interface is an invitation to automate it.

Why writing changes the calculus

A mistake in a read is an inconvenience for the caller. A mistake here is a public artefact in a tracker the caller may not control, and its two worst failures land on someone who is not the caller:

  • A credential pasted into a support question and republished belongs to whoever pasted it.
  • An activated @-handle belongs to whoever owns that name, who on a forge is very often a stranger, notified on every reference to an issue they have nothing to do with.

So CreateIssue sanitises what it sends, by default:

// what CreateIssue applies unless draft.Unsanitised is set
body = forge.Sanitise(body) // redact.String + InertMentions

That is a library rewriting a caller's payload, which normally deserves suspicion. It is defensible here for one specific reason: both halves are idempotent. A caller that already sanitised is not damaged by it happening again, which is not true of transformations in general, and is why this is a default rather than a policy.

Nothing sanitises the read path. An issue body that already contains a secret is a fact about the tracker, and hiding it from a caller reading the issue would conceal an incident rather than prevent one.

Removing the @ is the defence; the backticks are presentation

InertMentions rewrites @name to `name`. It is tempting to assume the backticks do the work, and that a forge will not linkify inside inline code. GitLab documents that @username and @groupname notify, and that @all is rendered as plain text, but says nothing about code spans or fenced blocks.

A neutralisation resting on that would rest on undocumented behaviour, on the one path where being wrong pages an uninvolved person. So the @ is removed, which needs no assumption about any renderer.

Filing twice is the failure the contract designs against

No forge offers an idempotency mechanism for issue creation. GitLab's iid parameter would serve but requires administrator or owner rights, which defeats the point of a least-privilege token. So IdempotencyKey is emulated: the provider writes the key into the issue as a visible trailer and searches for it before creating.

It defends against a retry whose response was lost, not against concurrent callers, and the contract says so rather than implying more.