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.ListCommentsreads them; nothing posts one. - No editing, closing, labelling or assigning an issue. An issue is filed and then observed.
- No merging.
PullRequestsopens, 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
GetReleaseByTagandListReleasesreturnErrNotSupported. - Bitbucket Cloud Issues reached Atlassian's announced removal date in
August 2026, so
forge-bitbucketimplements neither issue capability rather than shipping something with an expiry date. directcannot enumerate. A URL template has no listing to walk, soListReleasesreturnsErrNotSupported, andGetReleaseByTagsynthesises 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
Sitesrefuses 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.Textis 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
sinceparameter 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 whatSinceexists 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.Stringis 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 ongo/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/httpclientorgo/transit. Importforgeto accept or author aProviderand you pay for no HTTP stack at all. Importforge/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.
Related¶
- Providers: the current capability matrix
- Errors: the sentinels behind several of these limits
- Optional capabilities: why an absent capability is a fallback rather than a fault
- Backend agnosticism: what the contract buys, and what it flattens