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:
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¶
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¶
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¶
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¶
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¶
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.
Related¶
- Configuration: every key, param and environment variable, with defaults and failure modes
- Errors: the sentinels these providers return
- What forge does not do: the limits behind the "never" entries above