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
Providerimplementations 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:
- The provider does not implement the interface, so the type assertion fails.
- 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:
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.
DefaultMaxChecksumsSizeandDefaultMaxSignatureSizeare 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.Siteis aSiteStatusread from the enumeration payload. It costs nothing, needs no elevated token, and is mostly useful in the negative:SiteNoneeliminates a candidate without a request.Sites.GetSitereturns 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:
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.