Skip to content

What forge does not do

A list of things this module deliberately does not do, and things it does not do yet. Both are worth knowing before you design around it: an absence you can see is a decision, and an absence you cannot see is a bug waiting to be filed.

Where a limit exists for a reason, the reason is here too.

It reads releases; it does not create them

forge.Provider is four methods, and all four read:

GetLatestRelease(ctx, owner, repo)
GetReleaseByTag(ctx, owner, repo, tag)
ListReleases(ctx, owner, repo, limit)
DownloadReleaseAsset(ctx, owner, repo, asset)

Nothing here creates a release, uploads an asset, edits release notes or deletes anything. Publishing is a job for your release pipeline; this module is what consumes the result.

forge-github does carry a github.url.upload setting, which looks like an exception and is not: go-github's client takes an upload endpoint at construction, so the value has to exist for the client to be built. No method in this module uploads through it.

What this module writes, and what it refuses to

Most of the contract reads. Three optional capabilities write, and they are separate interfaces precisely so a reviewer can grep for the write path:

Capability Writes Undoable
IssueFiler.CreateIssue an issue on somebody's tracker No. Nothing here deletes or edits one
Snippets a snippet, account- or project-scoped Yes, the contract carries a delete
PullRequests opens, retitles and closes a pull request Yes, Close

IssueFiler is the one to watch. It publishes to a tracker other people read and it has no way to take anything back, which is why it sanitises by default.

One more writes somewhere else entirely: KeyManager.UploadKey registers an SSH public key on the authenticated account, not on a project. If your review rule is "nothing in this process may write", that method counts too.

What is not there, and is not planned:

  • No commenting. Issues.ListComments reads them; nothing posts one.
  • No editing, closing, labelling or assigning an issue. An issue is filed and then observed.
  • No merging. PullRequests opens, updates and closes; it does not merge, and it will not. Merging a release pull request is a maintainer action in this estate, and a verb in the interface is an invitation to automate it.
  • No repository creation, no forking, no branch or tag manipulation.
  • No release creation — yet. Reading releases is all this contract does today, and that is how far the code has got rather than a decision anyone took. It is wanted, and it is being specified.

It does not retry, back off, or cache

forge never retries on your behalf. A provider surfaces the platform's rate-limit response (HTTP 429, or GitHub's 403 with X-RateLimit-Remaining: 0) as a wrapped error so you can read a Retry-After and decide. Pagination loops do not sleep and retry inside the provider.

There is no cache. Two calls for the same release are two requests. Deciding what is worth holding on to needs to know your refresh policy, which is not something a release-reading library can guess.

It does not verify anything it downloads

This module supplies the retrieval half of release integrity: it gets you the checksums manifest and the detached signature. Comparing a hash, verifying a signature against a key you trust, and deciding what to do when either fails are all the consumer's.

That split is on purpose, because verification policy belongs to the tool that is about to replace its own binary. It does mean a provider returning a manifest is not a provider that has checked anything. See verify a release before you trust it.

It is not a configuration library

forge.Config is two methods, GetString and Sub. It reads no files, no environment variables and no flags, resolves no precedence, and cannot be watched for changes.

That is the point: precedence lives once, in your configuration stack, where it is inspectable, rather than a second time inside here, where two precedence systems in one path would make the result unpredictable. Bridging go/config takes six lines; see authenticate.

It draws no user interface

A provider that needs an interactive step (an OAuth device flow's "open this URL and type this code") surfaces it through a forge.Prompter you implement. No terminal or TUI dependency enters this module, and none will: the CLI owns presentation.

Not every provider answers every capability

The contract is a superset, and platforms differ. The capability matrix is the current state; the shape of the limits is stable:

  • Bitbucket Cloud is served over the Downloads API, which is a file bucket rather than a release system. There are no tags and no release list, so GetReleaseByTag and ListReleases return ErrNotSupported.
  • Bitbucket Cloud Issues reached Atlassian's announced removal date in August 2026, so forge-bitbucket implements neither issue capability rather than shipping something with an expiry date.
  • direct cannot enumerate. A URL template has no listing to walk, so ListReleases returns ErrNotSupported, and GetReleaseByTag synthesises a release for any tag you name, so a wrong tag surfaces as a 404 at download time rather than at lookup.
  • Plain Gitea has no Pages feature, so Sites refuses there while the same implementation answers for Codeberg.

A provider implementing none of the optional capabilities is fully conformant. Absence is a fallback rather than a fault. See optional capabilities.

A 404 does not say what was missing

errors.Is(err, forge.ErrReleaseNotFound) is reliable across the first-party set, and the conformance harness holds every provider to it: a tag that does not resolve must carry that sentinel rather than the platform's own error text.

What a bare 404 carries is a provider decision, because the status cannot say what was addressed. forge-gitlab reports a 404 from its release listing as ErrNotFound, since a missing project is what that status means there, and keeps ErrReleaseNotFound for a project that exists with no releases. Bitbucket Downloads has no release concept to miss. Branch on both where you only care that something was absent.

See the errors reference.

GetDraft() is always false on GitLab

GitLab has no draft-release concept, so there is nothing to report and the accessor returns the zero value. GitHub and Gitea return the real thing.

This is what a single interface over several platforms costs. The alternative, omitting the accessor, would make every caller branch on which forge it happens to be talking to, which is the coupling the contract exists to remove.

There is no fast failure for a private repository

A provider does not know, at construction, whether the repository you will ask it about is private, because one connection serves public and private repositories alike. So none of them refuses to build for want of a credential.

forge-bitbucket comes closest, and does it at the operation rather than at construction: when a request is refused and no credential was resolved, it attaches guidance naming the variables to set, because that is what a private repository looks like from the outside. It is a better error, not an earlier one.

If you need to fail early, check for a credential yourself at the layer that actually knows whether the repository is private.

This used to be a construction-time flag

ReleaseSourceConfig.Private was read by forge-bitbucket alone and ignored everywhere else. It was removed with the rest of that type (spec 0008 D3), and the check it fed moved to the operation (D8). Migrate from ReleaseSourceConfig covers the move.

A published site is never a reachable site

Neither SiteStatus nor Site.State promises a browser gets a 200. SitePublished means the forge reports a site as configured; SiteStateBuilt means the last build succeeded. A deploy can land on a domain that has since lapsed.

Site.URL can also be derived, composed from a template rather than reported by the forge, and a custom domain can live entirely in DNS where the API cannot see it. That is what Site.URLSource is for: a failed probe of a SiteURLDerived address means unknown, not absent.

A related limit: forge reports whether a forge enforces HTTPS, in Site.TLS, but never upgrades an http:// address on its own where it does not. GitHub in particular reports https_enforced: false for sites that hold a valid certificate and serve HTTPS perfectly well, so "not enforced" is not "no TLS", and only the caller can decide what to do about it.

Probing is an HTTP request only you can make. A provider that tried would be answering from wherever forge happens to be running.

At-most-once issue filing is not atomic

IssueDraft.IdempotencyKey defends against a retry whose first response was lost. Two concurrent creates with the same key can both search, both find nothing, and both file.

No forge offers an idempotency mechanism for issue creation. GitLab's iid parameter would serve but needs administrator or owner rights, which defeats a least-privilege token, so the guarantee is emulated by writing the key into the issue and searching for it first. Two further consequences: the key is published in a readable issue body, and every create costs one extra search.

No ordering is promised

  • IssueQuery.Text is a filter, not a relevance ranking. Providers use server-side search where the forge offers it and filter client-side where it does not, so match quality differs between providers.
  • Comments come back in no promised order. Forges that support a since parameter filter server-side and return oldest-first; GitLab has no such parameter and emulates it by reading newest-first and stopping early. Forcing agreement would mean buffering the whole set, which is what Since exists to avoid.

Sort what you collect if order matters to you.

Nothing sanitises what you read

CreateIssue sanitises what it sends, with secrets redacted and @-mentions rendered inert, unless the draft opts out. Nothing sanitises the read path. An issue body that already contains a secret is a fact about that tracker, and hiding it from a caller reading the issue would conceal an incident rather than prevent one.

Two limits on the sanitiser itself, both inherited deliberately:

  • redact.String is calibrated for log lines, not prose. Its broadest pattern rewrites any run of 41 or more token characters, so a SHA-256 digest in a bug report becomes a redaction marker while a 40-character git SHA-1 survives. A mangled digest is annoying; a published credential is an incident.
  • Fullwidth () and small () commercial-at are neutralised as a precaution. The four first-party forges match an ASCII @. Do not cite the non-ASCII forms as a known hazard, and do not remove the handling believing it defends one.

It ships no telemetry, and the contract carries no HTTP stack

The core takes log/slog from the standard library and nothing else for observability. Two guard tests keep it that way, and they are worth knowing about because they tell you what importing this module actually costs:

  • TestDependencyFootprint: no package in this module may depend on a forge SDK (go-github, the GitLab client, the Gitea SDK, go-bitbucket), on a provider module, on go-tool-base, on cobra/pflag/viper or charm, on a cloud SDK, or on go/credentials. That last one is the point of the credential seam: this module reaches no keychain of its own.
  • TestContractIsTransportFree: the root package also may not pull in OpenTelemetry, go/httpclient or go/transit. Import forge to accept or author a Provider and you pay for no HTTP stack at all. Import forge/direct, which performs real network I/O, and you do.

net/http itself is deliberately not forbidden, and the root package now imports it directly: transport.go for the two connection rungs, refusal.go for ParseRetryAfter, logging.go for the option struct. Those are standard-library types in the contract's own signatures rather than a transport choice.

The enforceable claim is therefore the narrower one: no provider SDK and no instrumented client stack. Not "no HTTP types at all", which stopped being true when the rungs landed.

None of this is a position against tracing. It composes with observability through the slog.Handler you supply. It is a position about who pays for the dependency.

Provider modules version independently, and everything is pre-1.0

forge and each forge-* module are separate Go modules with their own tags. A capability defined in the contract lands in the providers afterwards, so the contract version and the provider version are not the same number and are not meant to be.

Everything is below 1.0, so a minor bump can carry a breaking change. Pin your versions.