Skip to content

Providers

A provider teaches forge to talk to one release backend. Each ships as its own module (except direct, which is not a forge), so a tool links only the clients it actually uses.

Enable one with a blank import; the registry does the rest:

import (
    "gitlab.com/phpboyscout/go/forge"

    _ "gitlab.com/phpboyscout/go/forge-gitlab"
)

factory, err := forge.Lookup("gitlab")

Your code never names a provider type. See backend agnosticism.

Capability matrix

Parity is the commitment; the forge is the limit

A capability built for one first-party forge is built for all of them. never in the tables below means the platform cannot support it, and the reason is always given. not yet means it has not been written, which is a statement about delivery rather than a decision about scope — and it is worth asking about.

See backend agnosticism.

Provider Source types Module Vendor SDK
GitHub github go/forge-github go-github/v90
GitLab gitlab go/forge-gitlab gitlab-org/api/client-go/v2
Gitea / Codeberg gitea, codeberg go/forge-gitea gitea.dev/sdk
Bitbucket bitbucket go/forge-bitbucket none
Direct direct ships in go/forge none
Provider GetReleaseByTag ListReleases ChecksumProvider SignatureProvider Can return a redirect
GitHub yes (CDN)
GitLab no
Gitea / Codeberg no
Bitbucket ErrNotSupported ErrNotSupported no
Direct (synthesised) ErrNotSupported (opt-in) (opt-in) no

The redirect column matters because callers are told to refuse a non-empty redirect from DownloadReleaseAsset rather than follow it. Only GitHub ever populates it, when its API redirects an asset request to a CDN.

Discovery capabilities

Shipped in forge-github, forge-gitlab and forge-gitea v0.5.1, and forge-bitbucket v0.4.1.

Provider Repositories Contents Sites
GitHub
GitLab
Gitea ErrNotSupported, no Pages feature
Codeberg
Bitbucket never, no static hosting
Direct never, not a forge never never

"Never" is not a gap. Bitbucket Cloud has no static hosting, so Sites is unimplementable there rather than merely unimplemented. That is the optional-capability pattern doing its job.

forge-gitea is the row worth a second look. It registers two source types from one implementation, and only Codeberg has sites, so the same Go type implements Sites and returns ErrNotSupported when constructed for plain gitea.

Callers treat both refusals identically. See discover repositories.

Issue capabilities

Shipped in forge-github, forge-gitlab and forge-gitea v0.5.1.

Provider Issues IssueFiler
GitHub
GitLab
Gitea / Codeberg
Bitbucket never (see below) never
Direct never, not a forge never

Bitbucket Cloud Issues: the announced removal date has passed

Atlassian's sunset announcement stopped Issues being enabled on repositories not already using them from April 2026, and removed them entirely in mid-August 2026 — a date now behind us. Whether Atlassian shipped to that timetable is not verified here, and it changes nothing for this module either way: forge-bitbucket implements neither interface, so a caller's type assertion fails and it falls back.

Bitbucket's other capabilities are unaffected. The sunset covers Issues and Wikis only, not Downloads, repositories or contents.

They are split because the credential requirement differs, not for tidiness:

Operation GitLab role Token scope
SearchIssues, GetIssue, ListComments Guest read_api
CreateIssue Guest api

A read-only token satisfies Issues entirely and fails IssueFiler at the point of creation. A deployment that is not trusted to write should therefore decline the capability rather than discover the limit on its first escalation.

IssueFiler is the only capability that writes to a project. See file and watch issues, and note that KeyManager below writes to the authenticated account.

Labels on an issue

IssueDraft.Labels follows the same rule as PullRequestDraft.Labels, and that is deliberate: a caller learning it once should not find it means something else on the neighbouring type. Specified by spec 0017.

Setting a label never creates it, and matching is exact

Every name must already exist on the repository. A name that matches no label is not sent and comes back in the ErrNotHonoured hint. Matching is exact, so Support does not resolve to a support label.

The never-create rule is stronger here than on a pull request: IssueFiler is the capability that files a report into a project the caller may not own, so creating a label as a side effect turns a typo into a permanent artefact on somebody else's tracker.

CreateIssue can return an issue and an error. When a label could not be applied it returns the filed issue together with an error wrapping ErrNotHonoured, so the reflexive if err != nil { return err } treats a filed report as a failure. A caller that sets no labels can never see this.

An unresolvable name is reported, not refused. A report exists to be filed; losing it over a marker is the worse outcome, and the marker is the decoration.

Getting the check wrong costs less here than elsewhere, and only because of the at-most-once guarantee: a caller that retries finds the existing issue rather than filing a duplicate — provided it set an IdempotencyKey. Without one, the retry files a second report.

Pull-request capabilities

The contract landed in forge v0.16.0 and every forge adapter implements it: forge-gitlab v0.13.0, forge-github v0.14.0, forge-gitea v0.15.0 and forge-bitbucket v0.10.0.

Provider PullRequests
GitLab
GitHub
Gitea / Codeberg
Bitbucket
Direct never, no repository to propose a change against

Direct is a genuine never rather than a gap. It fetches from URL templates and has no repository to propose a change against, so no implementation would make it able to; that is what separates a never from work still outstanding.

MergeRequests is a Go type alias for PullRequests, so forge-gitlab declares itself in GitLab's vocabulary and it is the same assertion:

var _ forge.MergeRequests = (*GitLabReleaseProvider)(nil)

Why the method names carry no noun, and why that is what makes the alias work, is in optional capabilities.

The credential each operation needs, in GitLab's vocabulary:

Operation GitLab role Token scope
Find, FindLastMerged, ResolveMergedCommit Reporter read_api
Create, Update, Close Developer api

The other forges split the same way — the three reads need a token that can read the repository, and the three writes need one that can write to it.

ResolveMergedCommit is a read, but it is the one worth understanding before using this capability. It never returns the pull request's recorded head without first confirming that commit is on the target branch. GitLab is where this bites hardest, because it rebases before a fast-forward merge without writing the result back, but the guarantee is the contract's rather than any one forge's and each adapter asks its own platform the same question a different way. See optional capabilities for the whole story, and note the practical consequence: on a rebased merge request it costs a bounded walk of the target branch (at most 100 commits) rather than a single field read.

There is no Merge. Merging a release pull request is a maintainer action.

Labels

PullRequestDraft.Labels attaches labels when opening a pull request, and PullRequest.Labels reports the ones it carries. Specified by spec 0014.

Provider Labels on a pull request
GitLab
GitHub
Gitea / Codeberg
Bitbucket never, Bitbucket Cloud has no label concept
Direct never, no repository to propose a change against

Bitbucket's never is the platform. It has no labels anywhere — not on pull requests and not on issues, which is why it implements neither Issues nor IssueFiler. A draft carrying labels still creates the pull request there, and reports every requested label as not honoured.

Setting a label never creates it

Every name in Labels must already exist on the repository. A name that matches no label is not sent, and comes back in the ErrNotHonoured hint. Matching is exact, so Release::Pending does not resolve to a release::pending label.

GitLab and GitHub would both create an unknown name on the spot; Gitea cannot, because its API takes label IDs. The contract declines rather than guaranteeing what one adapter cannot honour — and because creating a label as a side effect of opening a pull request turns a typo into a permanent artefact on someone's project.

On GitLab a group label is inherited by every subproject and needs no per-project setup, so seeding one label covers a whole group. GitHub has no organisation-level inheritance, so seeding there is per-repository. Gitea has organisation labels, but keeps them on a separate endpoint that the repository listing does not include, so a name can resolve there and not in the repository's own set.

Create can now return a pull request and an error. When a label could not be applied it returns the created pull request together with an error wrapping ErrNotHonoured, so the reflexive if err != nil { return err } treats a success as a failure — and because Create makes no at-most-once claim, the retry opens a second pull request. See migrating to labelled pull requests. A caller that sets no labels can never see this.

There is no way to change a pull request's labels afterwards. Mutating a label is how tools encode state on a pull request, and reading a forge record as evidence of what landed is the failure ResolveMergedCommit exists to prevent.

Release-publishing capabilities

ReleasePublisher creates a release against a tag, amends its notes and attaches files. The contract landed in forge v0.20.0 — v0.19.0 shipped it unimplementable, see the method migration — and every forge that has releases implements it. See each adapter's CHANGELOG for the release that carries it.

Provider ReleasePublisher
GitLab
GitHub
Gitea / Codeberg
Bitbucket never, Bitbucket Cloud has no release object
Direct never, no repository to publish against

Bitbucket's never is the platform, not the effort. It has no release resource at all — this module already synthesises one from Downloads filenames on the read side, and GetReleaseByTag and ListReleases return ErrNotSupported. Writing would mean accepting a tag, a name and a body, storing none of them, and returning a release the caller could never find again.

Operation GitLab role Token scope
CreateRelease, UpdateRelease, AddReleaseAsset Maintainer api

It does not create the tag. A tag is a git object and needs no forge API, so that lives in go/repo. CreateRelease requires the tag to exist and returns ErrNotFound when it does not — see optional capabilities for why refusing matters more than it sounds.

The commit is checked, not sent, and each adapter asks its own forge a different way. GitLab's tag endpoint reports the commit directly. Gitea's does too, annotated or not. GitHub's ref reports a tag object for an annotated tag, so it needs a second dereference — miss it and every annotated tag is refused, which is what release tooling produces.

There is no DeleteRelease. Unpublishing a release is a maintainer action, the same line PullRequests draws at Merge.

Publishing a release complete, and hosted assets

ReleaseAssetPublisher publishes a release that already carries its assets, and attaches an asset the caller has already hosted somewhere rather than holds the bytes of. Specified by spec 0015.

It exists because CreateRelease followed by AddReleaseAsset necessarily passes through a state where the release is visible and empty, and that window fails badly rather than untidily: the release exists, so a consumer fetching an asset gets a 404 that looks like a missing file rather than a race.

The support for the two asset shapes is inverted across the family. This is the fact to read before designing against it.

Provider Bytes the caller holds A location already hosted
GitLab wrapper over the link native, assets:links
GitHub native, upload_url never, no external-asset concept
Gitea / Codeberg native, attachment upload never, no external-asset concept
Bitbucket never, no release object never, no release object
Direct never, no repository never, no repository

Neither shape is universally supported, and the platform weakest on one is strongest on the other. GitLab's release assets are links, so its byte upload is a wrapper that publishes to the project's generic package registry and links the result. GitHub and Gitea take only bytes.

A location a forge cannot hold becomes a footer, and is still reported

On GitHub and Gitea a location-shaped asset is rendered as a link in an ### Assets footer appended to the release notes, and reported with ErrNotHonoured.

Both halves matter. The footer keeps the location reachable by a human reading the release; the error is owed to a machine, because Release.GetAssets() will not contain it and a caller counting assets would otherwise be told nothing is missing when something is.

Nothing is fetched. A provider never downloads the URL to convert it into bytes — it is caller-supplied, and an outbound request carrying a credential to an address this module does not control is the hazard release-asset handling already avoids.

The footer is written once, at create. UpdateRelease replaces the body wholesale, so an update that does not carry the footer forward removes it. Which regions of a body are machine-owned is the caller's policy, as it is everywhere else in this contract. AddReleaseAssetLocation therefore has no footer fallback — appending to an existing body would mean a read-modify-write on every call, and that carries the lost-update race UpdateRelease already documents as unsolved.

A location is never checked for reachability

No provider fetches, HEADs or resolves the URL. The address is supplied by whoever is publishing, and a request this module makes on their behalf to a host it does not control is a capability nobody asked for.

So a forge will record a location that 404s. The guarantee is that no observer sees the release before its assets are attached; that the bytes are there is the caller's guarantee, and it is worth checking before publishing rather than after.

What is checked is shape: absolute, http or https, with a host — which catches a relative path resolving against the forge's own host, and costs no request.

A source carrying Content is a caller error on AddReleaseAssetLocation. That method attaches a location; handed bytes it refuses before any request, rather than quietly becoming AddReleaseAsset and hiding which path ran. The refusal carries no sentinel, exactly as ReleaseAssetSource.Validate reports a malformed source — in particular it is not ErrNotFound, which on that method means no release exists for the tag. Specified by spec 0016.

How each forge reaches the guarantee, which is stated as an observable property rather than a request count, because only one platform can do it in a single request:

Provider Route Visible states before completion
GitLab upload to the package registry, then create with every link none
GitHub create as a draft, upload, publish none, a draft needs push access
Gitea / Codeberg create as a draft, attach, publish none, enforced server-side

A draft the caller asked for is honoured. ReleaseDraft.Draft is intent, not merely the mechanism above: where a platform models it, the same route runs and the final publish step is not taken. GitLab models neither draft nor prerelease and continues to report ErrNotHonoured for them.

If an asset fails to attach, nothing is left behind. On the draft-route platforms the provider deletes the unpublished draft it created, so a retry behaves like a first attempt rather than hitting ErrAlreadyExists against a half-built release with no verb to finish or remove it. That is not a delete capability: nothing lets a caller ask for a deletion, and the tag is untouched.

Two errors are specific to this capability. ErrAlreadyExists says the tag already carries a release. ErrNotHonoured says the release was created and some field could not be applied — it is returned with the release, so the usual if err != nil check treats a success as a failure. Read its godoc before using this capability.

The address an attached asset is fetched from

An asset attached through either release-publishing capability is fetchable at the address that platform conventionally serves a release asset from, not only at the location it was hosted at. The address is derived from the asset's Name. Specified by spec 0018.

Provider Conventional address How it is obtained
GitLab <host>/<owner>/<repo>/-/releases/<tag>/downloads/<name> set per link as direct_asset_path
GitHub the asset's browser_download_url issued by the platform on upload
Gitea / Codeberg the attachment's browser_download_url issued by the platform on upload
Bitbucket never, no release object
Direct never, no repository

Its absence is invisible to everything except a fetch

A release whose assets carry no conventional address still exists, still reports the right asset count, and still hands back every location it was given. Release.GetAssets() says nothing is wrong. A consumer following the conventional address gets a 404 that reads as a missing file rather than as a missing property of the release.

It is the same shape as the empty-release window this capability was built to close, one layer along. Counting assets does not catch it; fetching does, which is why the check lives in each adapter's own tests rather than in the conformance harness — the harness drives a Provider, so it can neither see a request nor construct a platform-specific address.

On GitLab it reached a consumer's build first, in go/forge#19.

Nothing on ReleaseAssetSource switches it on. A field would make the address opt-in, and every caller who did not set it would keep the same silent failure.

Where a platform attaches an asset and cannot give it a conventional address, the provider reports that with ErrNotHonoured beside the created release, and the hint names the asset. GitLab is in that position for some names, which is narrower than it sounds and worth knowing before you name an artefact:

Accepted in the address letters, digits, _, -, ., and / between segments
Rejected a space, +, #, ?, %, @, ~, (, ), ,, :, any non-ASCII character

Measured against gitlab.com on 2026-09-01. A rejected character makes GitLab refuse the whole asset link, not just the address, so the provider omits the address rather than sending a request that fails — tool_1.2.3+build.5.tar.gz reaches this through semver build metadata alone. The asset is always attached. A name this contract accepts never costs a caller the asset over an address.

Comment capabilities

Two capabilities post a comment: IssueCommenter on an issue, and PullRequestCommenter on a pull or merge request. Specified by spec 0021.

Provider IssueCommenter PullRequestCommenter Available from
GitLab forge-gitlab v0.20.0
GitHub forge-github v0.20.0
Gitea / Codeberg forge-gitea v0.20.0
Bitbucket never, the issue tracker was withdrawn forge-bitbucket v0.12.0
Direct never, no tracker never

Bitbucket's row is the one to check before relying on it: the code is on main and its release is proposed but not yet tagged, in forge-bitbucket !105. The other three are cut.

Every forge that can carry a comment now does. The contract shipped one release ahead of its adapters, and this table published not yet against all four during that gap, which is the direction the matrix is allowed to be wrong in.

Bitbucket's never is the platform, and it is measured rather than inferred. Atlassian withdrew Bitbucket Cloud's issue tracker along with wikis and snippets; its own published API description declares zero paths containing /issues, checked on 2026-09-07. So the adapter does not implement IssueCommenter at all rather than stubbing it — a stub would make As assert successfully and tell a caller the capability exists. Pull-request comments are unaffected.

One endpoint or two, and the caller never has to know. GitLab is the only one of the four where an issue comment and a pull-request comment are genuinely different calls. On GitHub and Gitea a pull request is an issue, sharing a number space and a comment endpoint, so both capabilities route to the same place with a different number. That is the platform's model rather than the ambiguity the two-capability split refuses: the split keeps a caller from confusing an issue number with a pull request number, and by the time an adapter runs, the caller has already said which it meant by choosing the method.

Two capabilities, not one — and the alternative fails silently

Every first-party provider implements Issues and PullRequests on the same concrete type. A single shared Comment(…, number int, …) would be satisfied for both by one implementation, and an issue number and a pull request number are both int — so the provider could not tell which resource was meant.

It would compile. That makes it worse than the Create/Update collision v0.19.0 met on ReleasePublisher, where the compiler objected. Here the type system has nothing to say and the failure is a comment on the wrong object.

The method names differ deliberately. CreateIssueComment matches IssueFiler.CreateIssue; the pull-request side is bare Comment because MergeRequests is a type alias and an alias renames a type but never a method — CreatePullRequestComment would hand the GitLab noun back at every MergeRequestCommenter call site.

Two forges make it easy to post the wrong kind of comment

A conversation comment and a review comment against a line of a diff are different things, and two adapters can reach the wrong one without complaint.

On GitHub they are different endpoints with confusingly similar names. IssuesService.CreateComment is what this capability means, because a pull request is an issue there; PullRequestsService.CreateComment posts the review comment. Both compile, both succeed, and nothing but the request path tells them apart.

On Bitbucket it is mirrored: one endpoint serves both, and a single optional inline field decides which you get. Posting is safe by omission, but the listing returns both — so that adapter filters inline comments out, and filters them out of the idempotency scan too. A diff comment quoting the marker is not this comment having been posted, and matching it would silently drop the one the caller asked for.

On Gitea the review-comment calls need a review id the caller does not have, so the compiler objects rather than the reviewer.

At-most-once, and what it costs. CommentDraft.IdempotencyKey makes a post at-most-once across a retry: the provider looks for a comment carrying the key and returns that one rather than posting a second. The key is written with AppendIdempotencyKey, the same visible [forge-idempotency: …] marker IssueFiler uses — an HTML comment would be hidden on three forges and render as literal text on Bitbucket.

It is not atomic, the key is published, and it costs a full listing of the target's comments rather than one filtered request: no forge offers a server-side search over comments, checked at each pinned client and, for Bitbucket, against a published API description that declares no query parameters on the listing at all. PullRequestCommenter.ListRequestComments exists so that guarantee can be checked rather than merely asserted, and it returns the complete set — a partial view read as "no duplicate" is what posts the duplicate.

A short page is not the end of the listing

Every adapter pages on what the response says rather than on how many records came back, and on Gitea that is load-bearing rather than stylistic: the server clamps an oversized page size to its own MAX_RESPONSE_ITEMS, so a page shorter than the one requested is the ordinary answer and not evidence the listing ended.

Bitbucket adds a second edge. It paginates with an absolute next URL the server chooses, which is author-controlled input like any other in a response, so the adapter pins it to the API host before following it — otherwise a link elsewhere would carry the caller's credentials to a host they never configured.

Wiki capabilities

Wikis reads and writes a project's wiki pages. Specified by spec 0020.

Two mechanisms sit under one contract, and a caller never sees which ran:

Provider Mechanism Available from Notes
GitLab ✅ native page API forge-gitlab v0.19.0 nested slugs are first-class
Gitea / Codeberg ✅ native page API forge-gitea v0.20.0 writes by title, reads and edits by sub_url
GitHub git forge-github v0.19.0 no wiki API exists; the wiki is <repo>.wiki.git
Bitbucket never Atlassian removed wikis on 2026-08-20
Direct never no project to carry a wiki

Bitbucket's never is the platform. Atlassian sunset Bitbucket Cloud's wikis alongside its issue tracker: from April 2026 they could no longer be enabled on repositories not already using them, and existing wikis were removed in mid-August 2026, with no interaction possible through the UI or the API.

Confirmed against the live API: a repository created on 2026-09-06 carries no has_wiki field and no wiki link — the feature is gone from the data model, not merely from the interface.

links: avatar, branches, clone, commits, downloads, forks, hooks, html,
       pullrequests, self, source, tags, watchers

An adapter was written against the git route before this was known and is closed unmerged. The mistake is recorded in spec 0020: Atlassian's "Clone a wiki" support page still describes the feature, and a support document describing how to use something is not evidence it exists.

Bitbucket is narrowing, and that predicts future answers

Wikis are the third feature Atlassian has withdrawn from Bitbucket Cloud, after snippets and the issue tracker. The stated direction is to focus Bitbucket on source control and CI/CD and move everything else onto Jira and Confluence.

So a never on this provider is often the platform shedding a feature rather than never having had one, and a capability that is not source-control-shaped should expect one here. Check the changelog before assuming a Bitbucket feature still exists, including where this module's own documentation says it does — three of these rows were written while the feature was alive.

GitHub's absence of an API is the platform, not its client: the REST wiki routes 404 against a repository that has a wiki, and its GraphQL schema carries one boolean about wikis and no page type.

The wiki must already exist, and that is ErrNotSupported

On GitHub a wiki does not exist until a human creates its first page in a browser. Pushing to a wiki remote that does not exist is refused even for the repository's owner, and has_wiki: true is a default setting rather than evidence — of 30 repositories reporting it, none had a wiki behind it. Bitbucket behaved the same way while it had wikis at all.

So every method returns ErrNotSupported when the project has no wiki, never ErrNotFound. ErrNotFound means the page is absent, which a caller fixes by calling CreateWikiPage; the other is not fixable by any call this contract offers. GitLab and Gitea create the wiki on first write, so the refusal never fires there.

The page's identity is its path, specs/0079-pipeline-churn, and a path written is a path read back. Each platform stores and shows it differently, and those differences are the provider's problem:

Platform How it stores the path What a human sees
GitLab a nested slug the nested page
Gitea one escaped filename, specs%2F0079-pipeline-churn.- the title
GitHub specs/0079-pipeline-churn.md in git 0079-pipeline-churn — the directory is dropped

Two traps this capability closes, both measured

A GitLab edit that omits the title MOVES the page. The slug is re-derived from the content, so a page at specs/0079-… silently becomes a root-level page and every index link to it breaks. The adapter always sends the path as the title.

Gitea's write and read paths are not inverses. escapeSegToWeb appends a .- marker to any title containing a hyphen; the read path adds none. So reading back a page you just wrote returns 404 for every NNNN-slug name — measured on Gitea 1.24.7, where specs/0079-pipeline-churn reads 404 by title and 200 by sub_url. The trigger is the hyphen, not the slash.

A losing write is ErrConflict. On the git-backed forges a page update is clone, edit, push, and a second writer in that window makes the push a non-fast-forward. It surfaces rather than being retried or forced: last-write-wins on a wiki holding a project's decision records is the failure this capability exists to prevent. A provider re-reads immediately before writing to narrow the window; it cannot close it.

Publishing a stranded draft

DraftPublisher finishes a release left unpublished by an interrupted run. Specified by spec 0022.

Provider DraftPublisher Available from
GitHub forge-github v0.22.0
Gitea / Codeberg forge-gitea v0.22.0
GitLab never, no draft concept
Bitbucket never, no release object
Direct never, nothing to publish

The two adapters find the release through opposite endpoints, and each asserts which:

Forge How the draft is found Why
Gitea the tag its GetRelease hides a draft only from callers who cannot write
GitHub the release listing GetReleaseByTag answers 404 for a draft even with push access

A provider reaching for the wrong one reports ErrNotFound for exactly the state it was asked to recover — and the contract warns that a caller told that goes and creates a release.

Why it exists at all. CreateReleaseWithAssets guarantees no observer without write access ever sees a release without its assets. Only GitLab manages that in one request; GitHub and Gitea reach it by creating a draft, attaching, then publishing. A process killed in that window leaves the draft, and the provider's own discard-on-failure cannot help — it runs on an in-process failure, and a killed process runs no Go code.

Publishing an already-published release SUCCEEDS

It returns the release with a nil error, not ErrAlreadyExists. The caller asked for a published release on that tag and there is one.

That is the guarantee a re-running release tool depends on: a verb for recovering from an interrupted run has to be safe to call when the run was not interrupted. Both platforms make it free — measured 2026-09-07, a publish of an already-published release answers 200 on GitHub and Gitea 1.27.3 with published_at unchanged, so a repeat does not re-date it.

A tag carrying more than one release is refused, not guessed

GitHub permits several releases on one tag — a spike created three. The provider returns a plain error naming every candidate and publishes nothing.

Deliberately not a sentinel: there is nothing a caller can do programmatically, because choosing which of two drafts is the real release is a judgement about intent and this contract has no verb to remove the other. Guessing would publish the wrong release under a tag people trust, with no delete to undo it.

A conformant provider can no longer create that state, but repositories carry it today — and those are exactly the ones this verb is for.

Finding a stranded draft needs the listing, not the tag. GetReleaseByTag returns 404 for a draft on GitHub even to a writer; Gitea returns it. On both forges the release listing shows drafts to anyone with push access, and Release.GetDraft() reports the flag — so ListReleases is how a caller detects one. That asymmetry is a documented cost rather than a defect: making GetReleaseByTag uniform would put a listing scan on every call to serve a rare recovery.

Unmeasured: does publishing a draft fire the same automation?

Creating a release published fires a forge's release automation — webhooks, Actions, pipelines. Whether the draft to published transition fires the same thing has not been measured on either platform.

It matters because the failure is silent in the worst way: a recovered release is real, and the build that was supposed to follow never runs. That is the same shape ReleasePublisher already warns about for a credential whose writes do not trigger downstream automation.

It changes no signature, so it is recorded here rather than in the contract, and a caller relying on the automation should verify it on its own forge before depending on this verb to trigger it.

There is no discard. Destroying published things is a maintainer act in this estate rather than a library call, which is why ReleasePublisher carries no delete, PullRequests no Merge and RepositoryCreator no delete. The recovery this capability offers is finishing the release.

Repository creation

RepositoryCreator creates a repository. Specified by spec 0019.

Provider RepositoryCreator Available from
GitLab forge-gitlab v0.21.0
GitHub forge-github v0.21.0
Gitea / Codeberg forge-gitea v0.21.0
Bitbucket forge-bitbucket v0.13.0
Direct never, no forge to create anything on

Implemented everywhere, and no two adapters do it the same way. That is the case for the capability existing rather than a caller writing four code paths:

Forge What it costs the adapter
GitLab Name sent as path too, and a namespace-path-to-id lookup — one extra request
Gitea three create routes, and an identity call to avoid one of them
GitHub two routes, a refusal, and two fields reported as unhonoured
Bitbucket no resolution at all, three fields reported, and a 200 on success

It is a capability of its own rather than a method on Repositories, which is read-only by design. A tool that enumerates a namespace has said nothing about wanting to create in one, and should not acquire a write verb by upgrading.

Name is the path segment too. GitHub, Gitea and Bitbucket have one field that is both. GitLab has two — a display name and a URL path — and derives the second by slugifying the first, so that provider sends the value as both. Otherwise "My Tool" creates a repository at my-tool and the returned Path is something the caller never wrote. The returned Repository.Path is always owner + "/" + Name.

owner is required and is a path. An empty one does not mean "my own namespace", because Bitbucket has no personal namespace for such a value to name. What each provider does with the path is its own problem: GitLab resolves it to a numeric id, GitHub and Gitea choose between a user and an organisation endpoint, and Bitbucket uses it verbatim as a workspace slug.

What the draft carries, and what only some forges honour

Name, Description and Visibility are accepted everywhere. The other two are not, and this table is measured rather than read off documentation:

Field Honoured on Reported with ErrNotHonoured on
Initialise GitLab, GitHub, Gitea Bitbucket — no such field exists
DefaultBranch GitLab, Gitea GitHub, Bitbucket — both accept it and discard it

Two forges take a default branch, ignore it, and say nothing

POST /user/repos with "default_branch":"trunk" returns 201 and main on GitHub. Bitbucket returns 200 and master. Neither errors, neither warns.

On GitHub the client is not even the proximate cause: go-github's createRepoRequest carries no such field, so a caller setting Repository.DefaultBranch has it dropped before the request is built. Both layers discard it in silence.

Bitbucket's adapter does not send mainbranch at all, having measured that it would be discarded. A request that looks like it asked for something is worse than one that did not: the next reader has to measure the API to find out which.

That is why ErrNotHonoured here is a requirement rather than a nicety, and it is the same trap ReleaseDraft.Commit exists to close: a field a forge takes, discards, and stays quiet about.

On Bitbucket it is worse than unimplemented. mainbranch is validated against branches that actually exist — setting it after creation answers 400 "trunk is not a valid branch" — and since Bitbucket cannot initialise, a repository it creates has no branches at all. No ordering of calls this contract can make will set a default branch there.

VisibilityInternal exists on GitLab and GitHub Enterprise only. A provider that cannot honour it creates the repository private, never public, and reports it. The failure has to be safe, because there is no delete here to undo it.

One create route on two forges ignores the namespace

This is the sharpest thing the implementation turned up, and it is not visible from any documentation.

On Gitea and GitHub, the endpoint used for a personal namespace — POST /user/repos — takes no namespace parameter at all. It creates in the authenticated user's namespace whatever the caller asked for. Measured on Gitea 1.27.3: authenticated as spike, intending the namespace other, it answered 201 and spike/ns-probe.

So the obvious mapping of "the namespace is a user, use the user endpoint" creates the repository in the wrong place and reports success, and this contract has no delete to take it back. Neither adapter does that:

Namespace Gitea GitHub
an organisation CreateOrgRepo orgs/<org>/repos
your own the user endpoint, correct because "no namespace" is then right same
another user's the admin route, the only one naming a user; ErrForbidden without site-admin rights ErrNotSupported — github.com has no such route

Each costs one identity request on the user path. Spec 0019 D4 rejected "who am I" as a way of deciding organisation-versus-user, and this is not that: it asks as well rather than instead, because one endpoint's answer depends on it.

A collision is four forges and three status codes

Forge Status Body
GitLab 400 has already been taken
GitHub 422 name already exists on this account
Gitea 409 The repository with the same name already exists.
Bitbucket 400 Repository with this Slug and Owner already exists.

All four map onto ErrAlreadyExists. Only one of them is 409, and the two answering 400 share that status with every malformed request — so a provider maps on the body rather than the status. A creation path reusing an existing 409 mapper is correct on Gitea and turns "this name is taken" into ErrNotFound on two of the others.

A created repository may not be readable immediately

go-github documents that its create returns "without actually waiting for GitHub to finish creating the repository". This contract never retries, so the returned value is what the create answered with and an immediate read-back may miss it.

Three immediate reads did not reproduce it, and three on Gitea and Bitbucket did not either — so the caveat is GitHub's alone. It is the vendor's own warning, though, and one non-reproduction is not grounds for building a read-after-create assumption on it.

There is no delete, archive, rename or transfer, and no post-creation configuration. See spec 0019 for where those lines sit.

Account capabilities

Two more optional interfaces exist for a tool's first-run setup: signing a user in, and registering their SSH key. They follow the same contract as every other capability: discover with forge.As, and treat a failed assertion and ErrNotSupported identically.

Provider Authenticator KeyManager
GitHub ✅ OAuth device flow
GitLab ✅ OAuth device flow
Gitea / Codeberg ❌ (pre-issued token only)
Bitbucket ❌ (app password only)
Direct never, not a forge never
var auth forge.Authenticator
if forge.As(provider, &auth) {
    token, err := auth.Login(ctx, myPrompter) // blocks until the user finishes
}

Login needs a forge.Prompter (one method, ShowDeviceCode(ctx, DeviceCode)) which you implement. That is how forge offers a device flow without taking a terminal or TUI dependency: the provider hands you the code, the URL and its expiry, and your CLI decides how to show them. Fall back to manual token entry when the assertion fails or ErrNotSupported comes back.

The device flow needs an OAuth client ID as well as the eventual token:

Provider Config key Environment fallback
GitHub github.auth.client_id GITHUB_CLIENT_ID
GitLab gitlab.auth.client_id GITLAB_CLIENT_ID

KeyManager.UploadKey(ctx, name, publicKey) registers an OpenSSH-format public key under a human-readable label on the authenticated account. It is a write, and it is the one write in this module that is not to a project.

What the contract flattens

A single interface over five platforms cannot preserve every distinction. Two worth knowing, because code branching on them misbehaves silently rather than failing:

Accessor Caveat
GetDraft() Always false on GitLab. The platform has no draft-release concept, so there is nothing to report. GitHub and Gitea return the real value.
GetLatestRelease() GitHub, GitLab and Gitea have a dedicated endpoint. Bitbucket and Direct synthesise a release, so "latest" means newest by the rule in each section below.

See also the honest limits in backend agnosticism.

A dash means the provider does not implement the optional interface, so callers fall back to locating the file by name among the release assets, which is how most releases publish checksums anyway. That fallback is the normal path, not a degraded one. See optional capabilities.


GitLab

import _ "gitlab.com/phpboyscout/go/forge-gitlab"

Registers the source type gitlab. Works against gitlab.com and any self-hosted instance.

Configuration

Key Purpose
gitlab.auth.value Default credential key. Compose a layer rather than committing a literal
gitlab.auth.client_id OAuth client ID for the device flow (Authenticator)
gitlab.url.api Override the API endpoint outright
GITLAB_TOKEN Well-known fallback, consulted last
GITLAB_CLIENT_ID Well-known fallback for the client ID

The endpoint is derived from Endpoint.Host:

Host API base
(empty) https://gitlab.com/api/v4
git.example.com https://git.example.com/api/v4

gitlab.url.api overrides both. See authenticate for the resolution order.

Credentials are optional

A public project resolves unauthenticated. A token raises rate limits and is required for private projects.

Security

The token travels as a PRIVATE-TOKEN header, and is attached only to asset downloads on the configured instance, where host, port and scheme must all match. Asset URLs come from release metadata that a release author controls, so an unpinned credential hands the token to whatever host that author names. See credential pinning.


GitHub

import _ "gitlab.com/phpboyscout/go/forge-github"

Registers the source type github. Works against github.com and GitHub Enterprise.

Configuration

Key Purpose
github.auth.value Default credential key. Compose a layer rather than committing a literal
github.auth.client_id OAuth client ID for the device flow (Authenticator)
github.url.api Override the API endpoint
github.url.upload Override the endpoint go-github is constructed with. Nothing in this module uploads: see what forge does not do
GITHUB_TOKEN Well-known fallback
GITHUB_CLIENT_ID Well-known fallback for the client ID

For Enterprise, set Endpoint.Host and both endpoints are derived (/api/v3/ and /api/uploads/). Setting url.api alone still derives a matching upload endpoint, so assets never target the wrong host.

Credentials are optional; public repositories resolve unauthenticated.

What is deliberately absent

Pull requests and repository creation exist in go-tool-base and were not extracted. Shipping either for one forge would give GitHub capabilities the others lack, which is the disparity this module's design exists to prevent. Adding them across every provider in lockstep is specified separately.

SSH keys, file contents and device login are not in that category. All three shipped, for more than one provider, and appear in the tables above.


Bitbucket

import _ "gitlab.com/phpboyscout/go/forge-bitbucket"

Registers the source type bitbucket, over the Downloads API, which is a file bucket rather than a release system. It is a deliberately partial provider: there are no tags and no release list, so GetReleaseByTag and ListReleases return ErrNotSupported and callers fall back.

It is the only provider implementing both optional interfaces, locating checksums.txt and checksums.txt.sig by exact filename. Without a release object there is nothing else to hang a manifest on.

Configuration

Bitbucket needs two credentials, not a single token:

Key Purpose
bitbucket.keychain OS keychain entry holding a JSON blob with both fields. Consulted first
bitbucket.username / bitbucket.app_password Literal values
BITBUCKET_USERNAME / BITBUCKET_APP_PASSWORD Well-known fallbacks, consulted last

One keychain entry rather than two, because a username and app password are useless apart. Version detection reads the version from asset filenames; override with the filename_pattern param.

bitbucket.username.env and bitbucket.app_password.env are no longer read

They were this provider's equivalent of the removed auth.env rung (a config value naming an environment variable), and layer composition replaces them. Configuration still carrying one is reported rather than ignored, for the same reason as ErrStaleAuthKeys: a silent absence surfaces later as an unexplained 401.

Credentials travel as basic auth, which makes the scheme check in credential pinning matter more here than elsewhere.


Gitea and Codeberg

import _ "gitlab.com/phpboyscout/go/forge-gitea"

Registers two source types from one implementation. Codeberg is a Gitea instance, so they differ only in a pre-seeded host and which variable supplies the token:

Source type Host Token fallback
gitea from Endpoint.Host GITEA_TOKEN
codeberg pre-seeded https://codeberg.org CODEBERG_TOKEN

Splitting them into separate modules would mean duplicating the provider to change two constants.

Configuration

Key Purpose
gitea.auth.value Default credential key. Compose a layer rather than committing a literal
gitea.url.api Override the API endpoint
GITEA_TOKEN / CODEBERG_TOKEN Well-known fallbacks

Endpoint.Host selects the instance. Credentials are optional; public repositories resolve unauthenticated.

api_version is inert

An earlier hand-rolled client built the API path itself, so the api_version param could pin an instance to a non-v1 API. The provider now uses the official SDK, which targets v1 (the only version Gitea has ever shipped), so the param is ignored rather than rejected.

Security

The token travels as an Authorization header, attached only to asset downloads on the configured instance. See credential pinning.


Direct

import _ "gitlab.com/phpboyscout/go/forge/direct"

Registers the source type direct. Not a forge, but a plain download source (a web server, an artefact store, a CDN) with releases and no API to ask about them. It ships inside the core module because it will never need a vendor SDK, and doubles as the contract's reference implementation.

Because there is no API, the URLs are composed from templates and the latest version has to be told to it. Full setup is in use the direct provider; the keys are:

url_template is the only required one, and tool_name, pinned_version, version_url, version_format, version_key, checksum_url_template and signature_url_template are optional. The configuration reference carries the table with each key's default and behaviour, so there is one copy of it rather than two.

Config key Purpose
direct.auth.value Default credential key
direct.token Legacy literal. Still honoured, below auth.value
DIRECT_TOKEN Well-known fallback

Neither pinned_version nor version_url set means a version check returns ErrVersionUnknown. Assuming a version silently would be worse. The token travels as a Bearer header.

The synthetic asset is named after the tag, not the URL

direct composes a release with exactly one asset, and that asset's GetName() is <tag>.tar.gz, so v1.2.3.tar.gz, regardless of the filename url_template produces. Code that locates an asset by matching a filename convention will not find it. Use the single asset the release exposes.


No provider refuses to build for want of a credential

A provider with no resolvable token proceeds anonymously, which is correct for a public repository. Whether the repository you go on to ask about is private is not knowable at construction, because one connection serves both, so there is no construction-time check to make.

forge-bitbucket handles the private case at the operation: a refusal with no credential resolved carries guidance naming the variables to set, since that is what a private repository looks like from outside. The other providers return the refusal sentinels without that hint.

The removed ReleaseSourceConfig.Private flag used to sit here; it was read by Bitbucket alone and ignored elsewhere, and went with the rest of that type in spec 0008 (D3, D8). If you are moving off that type, Migrate from ReleaseSourceConfig is the how rather than the why.

Registry API

forge.RegisteredTypes()      // sorted []string of every registered source type
forge.Registered("gitlab")   // is this type registered?
forge.Unregister("gitlab")   // deliberately replace a built-in

RegisteredTypes is what Lookup's error message lists when a type is unknown, which usually diagnoses a missing blank import. Constants exist for the first-party set (forge.SourceTypeGitHub and friends), but the registry accepts any string, so they are a convenience rather than a constraint.

Adding your own

All first-party providers ship: GitHub, GitLab, Gitea, Codeberg, Bitbucket and plain download sources.

Your own is a first-class peer rather than a workaround. The registry takes any string key, and nothing needs contributing here. See author a provider.