Extract the forge/release layer into go/forge and per-provider modules¶
- Authors
- Matt Cockayne, Claude Opus 4.8 (AI drafting assistant)
- Date
- 2026-07-19
- Status
- IMPLEMENTED (2026-07-19). Phases 3–5 shipped:
go/forgev0.1.1, the four provider modules at v0.1.0, and the GTB cut-over. Q1–Q6 resolved with user; see Resolutions. - Related
- VCS stack extraction plan (IN PROGRESS — the parent plan; R1–R8), module-extraction playbook (§5 procedure, §10.1 provider-authoring API, §10.10 compatibility matrix), repo + aferobilly extraction (phases 1–2, COMPLETE), chat extraction (the provider-registry precedent), extraction report
Summary¶
pkg/vcs/release defines what a forge release provider is: a Provider contract, a
process-wide registry keyed by source-type string, the release/asset types, and two
optional capability interfaces discovered by runtime type assertion. pkg/vcs (root)
holds the token-resolution chain every real provider uses. Four provider packages
implement the contract for GitHub, GitLab, Gitea/Codeberg and Bitbucket.
This spec extracts the contract and the auth helpers into a single module,
gitlab.com/phpboyscout/go/forge, then splits each provider into its own module.
The headline evidence is good: pkg/vcs/release imports zero go-tool-base packages,
and exactly one file in the whole core carries a foreign dependency. But two findings
reshape the work, and both come from auditing rather than from the original plan:
- Auth cannot be left behind.
ResolveToken/TokenConfig/AuthConfiglive inpkg/vcsroot, the contract lives inpkg/vcs/release, and three of four providers need both. Extracting only the contract would force out-of-tree provider authors to import go-tool-base for auth — the exact lockstep trappkg/chatfell into (playbook §10.1). They move together. githubis not a release provider. ItsGitHubClientinterface has thirteen methods, of which four are release-related; the rest (pull requests, repo creation, SSH key upload, file contents) are consumed bypkg/setup/github, outside the VCS tree. Settling what belongs inforge-githubis deferred to phase 4 (R2), once the contract has proven itself against three simpler providers.
A third finding contradicts an already-approved decision: direct was kept in the core
partly to serve as the contract's reference implementation (plan R2), but the code does not
support that role. It is rewritten, and a conformance harness ships alongside it (R1).
Motivation¶
Release management is the capability tool-builders most often want standalone — the extraction report ranks the VCS/release stack as the highest aggregate value remaining. Beyond reuse, three forces specific to this layer:
- The contract is already a plugin registry.
Provider+Register(sourceType, factory)atinit()time, plus optional interfaces by type-assert, is structurally identical togo/chat. A module boundary formalises what is already a plugin architecture rather than inventing one. - A module boundary is expensive to add later and cheap to add now. Gitea gained an official SDK in May 2026; Bitbucket may gain one. Adopting a vendor SDK inside an existing provider module is a dependency change; doing it after the fact to an in-tree package is a re-extraction.
- Third-party providers are currently impossible to author cleanly. Everything a
provider needs is exported, but split across two packages, one of which drags the
framework. Nine further gaps (below) would silently mislead an out-of-tree author; four are fixed
before
v0.1.0(R6) and two more are closed by the conformance harness (R1).
Target modules¶
| Module | Contents | Depends on |
|---|---|---|
gitlab.com/phpboyscout/go/forge |
Provider, Release, ReleaseAsset, registry, ReleaseSourceConfig, Config, SubConfig, sentinels, optional capability interfaces, token resolution (ResolveToken, TokenConfig, AuthConfig), the direct provider, forgetest |
go/credentials, go/httpclient, cockroachdb/errors |
go/forge-gitlab |
GitLab release provider | forge, gitlab-org/api/client-go/v2, go/httpclient |
go/forge-gitea |
Gitea and Codeberg providers | forge, go/httpclient (no SDK today — see D7) |
go/forge-bitbucket |
Bitbucket provider | forge, go/httpclient, go/credentials, go/regexutil |
go/forge-github |
GitHub provider — scope deferred to phase 4 (R2) | forge, go-github/v88, go/httpclient, possibly cli/oauth + go/browser + afero |
Docs live on the forge microsite (forge.go.phpboyscout.uk); provider repos get a
strong README with signposts, per plan R4 and the chat precedent.
Current structure & coupling¶
The core is already clean¶
pkg/vcs/release (provider.go, registry.go, source_config.go, constants.go)
imports only context, io, sort, sync and cockroachdb/errors. ReleaseSourceConfig
was deliberately decoupled from props.ReleaseSource to break an import cycle
(source_config.go:3-6), and the ProviderFactory signature already takes a narrow local
Config interface (registry.go:12-15) rather than config.Containable. That earlier
decoupling is what makes this extraction cheap.
pkg/vcs root: auth.go imports only go/credentials; config.go imports nothing.
config_adapter.go is the sole coupling point — it needs config.Containable from
go/config to implement ConfigFromContainable. It is a 20-line bridge and stays in GTB.
The provider-authoring surface (measured, not assumed)¶
All four providers consume exactly these:
release.Provider, release.Release, release.ReleaseAsset, release.Config,
release.ReleaseSourceConfig, release.Register, release.SubConfig
Three of four additionally consume vcs.TokenConfig, vcs.AuthConfig,
vcs.ResolveToken. Bitbucket is the exception: it imports no pkg/vcs at all and
reimplements the whole credential chain locally; it converges on the shared chain during
phase 4 (R5).
Provider inventory¶
| Provider | Prod / Test LOC | SDK | Registers | Notes |
|---|---|---|---|---|
| gitlab | 275 / 404 | client-go/v2 v2.46.0 |
gitlab |
Smallest; cleanest |
| gitea | 324 / 659 | none (raw HTTP) | gitea + codeberg |
Two registry keys, one impl |
| bitbucket | 672 / 1166 | none (raw HTTP) | bitbucket |
Only provider implementing the optional interfaces; bespoke auth |
| github | 582 / 1260 | go-github/v88, cli/oauth |
github |
13-method client, 4 release-related; OAuth device flow |
Extraction difficulty, easiest first: gitlab → gitea → bitbucket → github. This matches the parent plan's sequencing, so P4 ordering is unchanged.
Nine provider-authoring gaps¶
Found by audit; each would silently mislead an out-of-tree author.
| # | Gap | Evidence |
|---|---|---|
| G1 | DownloadReleaseAsset's second return value is undocumented (it is a redirect URL, by go-github convention) |
provider.go:53; both impls return "" |
| G2 | maxBytes capping is asymmetric — caller's job for checksums, provider's job for signatures — and the bound values live outside the extracted set |
direct/provider.go:175-177 vs :188-189; setup.MaxChecksumsSize |
| G3 | Duplicate registration silently overwrites, no error or warning | registry.go:44 |
| G4 | ErrReleaseNotFound is documented but honoured by no production provider |
provider.go:24-29 |
| G5 | ErrVersionUnknown is direct-specific leakage into the shared contract |
provider.go:19-22 |
| G6 | No conformance harness — releasetest is a fake for consumers, and no provider test imports it |
releasetest.go; grep |
| G7 | The ErrNotSupported fall-through protocol is documented in prose only, enforced nowhere |
provider.go:56-84 |
| G8 | parseKeychainRef is unexported |
auth.go:134 |
| G9 | mocks/pkg/vcs/release/* live in a root-level in-tree package, not with the interfaces |
mocks/pkg/vcs/release/ |
DirectReleaseProvider.SetToolName is a tenth item of a different kind: a dead seam whose
doc comment claims "called by the setup package" when it is never called anywhere
(direct/provider.go:107). Either wire it or delete it.
Decisions¶
Carried from the parent plan (R1–R8) unless marked new.
D1 — Split every provider into its own module, including the SDK-free ones. Consistency over special-casing; adopting an SDK later becomes a dependency change inside a module rather than a re-extraction. (plan R1)
D2 — direct stays in the core, and is rewritten to use ResolveToken and the
standard AuthConfig layout so it is a faithful example of the path every real provider
takes. A forgetest conformance harness ships alongside it. (plan R2, amended by R1)
D3 — Auth moves with the contract. ResolveToken, ResolveTokenContext, TokenConfig
and AuthConfig ship in go/forge. (new — the audit's central finding) Without this,
out-of-tree providers import go-tool-base for auth and we reproduce the chat lockstep
trap.
D4 — ConfigFromContainable stays in GTB. It is the go/config bridge; the narrow
Config interface travels as the seam. (new)
D5 — Gitea owns two registry keys. gitea and codeberg ship from one module; the
Codeberg factory pre-seeds Host and uses CODEBERG_TOKEN. Splitting would mean
duplicating the provider. (new)
D6 — Docs on the forge microsite; provider repos get READMEs only. (plan R4)
D7 — Adopt the Gitea SDK during extraction, as a separate commit in the same MR so the move stays reviewable and behaviour-preserving up to that point. (plan R7)
D8 — Each module publishes its own mocks subpackage, closing G9. (convention)
D9 — Restore the recovered "Backend Agnosticism" intent on the forge microsite, in its original wording rather than the weaker surviving paraphrase. (plan; see Documentation)
D10 — Lockstep versioning with a published compatibility matrix. Core and providers move
in matching minor versions, as chat does. (R3)
D11 — Host pinning becomes a shared core primitive. One reviewed implementation replaces three; third parties get a security primitive they would otherwise reinvent less carefully. (R4)
D12 — Bitbucket converges on the shared credential chain, which widens to carry a dual credential (username + app password) rather than a single token. Because this is a behaviour change rather than a move, it lands as a separate commit inside the same MR, so every commit before it is a provable pure move. (R5, mitigation per D7's precedent)
D13 — Four contract gaps are fixed before v0.1.0 freezes the surface across five
modules: G1, G2, G3, G5. (R6)
D14 — maxBytes is enforced provider-side, with the bounds shipped as forge
constants — PROVISIONAL. The limit belongs at the trust boundary, a third party cannot
forget it, and the conformance harness can verify it mechanically. This moves
setup.MaxChecksumsSize and verify.MaxSignatureSize into the module.
This is a starting position, deliberately held loosely (agreed with user 2026-07-19): G2 requires symmetry, and either side can supply it. Implementation decides. Pivot to caller-side enforcement — or to an injectable bound — if any of these surface:
- The bound needs to vary by consumer. A
forgeconstant serves one policy. If GTB and another consumer legitimately want different caps, provider-side hardcoding is the wrong home and the bound should be a constructor argument. - Providers cannot know the bound without a config knob. If enforcing it pushes a configuration surface into every provider, the cure is worse than the disease — that is complexity multiplied across five modules.
- The harness cannot verify it without network or fixtures so large they slow the suite. Mechanical verifiability is half the justification; if it evaporates, so does the argument for this side.
Whichever way it lands, record the outcome here with a dated note rather than a silent edit. (new — the sub-decision G2 required)
D15 — Remove the suppressed mutable globals at cut-over; do not repoint them. (new, 2026-07-19)
pkg/setup/checksum.go declares its size bounds and checksum policy as exported mutable
package variables, each with a //nolint:gochecknoglobals suppression:
//nolint:gochecknoglobals // size bounds are tool-author tunables
var (
MaxChecksumsSize int64 = 1 << 20
MaxBinaryDownloadSize int64 = 512 << 20
)
//nolint:gochecknoglobals // tool-author compile-time override
var DefaultRequireChecksum = false
This is the pattern AGENTS.md bans — "this pattern races under t.Parallel()" — and the
suppression is what let it survive. The cost is already visible and already documented, in
update_checksum_test.go:
The requirement behind it — tool authors tuning the bounds — is real, but never needed a
mutable global. forge expresses it as a const default plus a caller-supplied
parameter, which is strictly more flexible (per-call rather than per-process), needs no
suppression, and leaves tests parallel-safe. See D14.
At cut-over therefore: delete the three variables, take the bounds as SelfUpdater fields
or explicit arguments defaulting to forge.DefaultMaxChecksumsSize /
forge.DefaultMaxSignatureSize, and drop the two suppressions. The oversized-response test
regains t.Parallel() by passing its own bound.
Scope, honestly. Only these two suppressions are removed. The four
//nolint:gochecknoglobals // sentinel error annotations in the same file are not a
smell and stay: Go has no const errors, so var ErrX = errors.New(...) is the only way to
declare a sentinel, and AGENTS.md explicitly permits a narrowly-scoped suppression where
one is genuinely unavoidable. The compiled-regex global is idiomatic MustCompile and is
also out of scope. The point is to stop a banned pattern crossing into the new boundary
unexamined — not to run a suppression count to zero.
Seams and decoupling¶
Little decoupling work is needed — the hard part was done by the earlier
ReleaseSourceConfig and ProviderFactory changes. What remains:
props.Tool.ReleaseProvider(props/tool.go:203) is typedrelease.Provider, sopkg/propsgains a dependency ongo/forge. Acceptable and unavoidable: it is the documented direct-injection seam that takes precedence over registry lookup.- Host pinning is duplicated three ways —
assetHostTrustedin gitea and gitlab,setBasicAuthIfHostMatchesin bitbucket — with no shared helper. Candidate for the core — now resolved: it moves into the core (R4, D11). ghClientIDis package-level mutable state seeded from env in a secondinit()(github/login.go:17-21), likely also ldflags-injected. Same class as thegitProgressOutputglobal removed during therepoextraction; it should become an explicit constructor argument if the OAuth flow moves.
Migration procedure¶
Per playbook §5, one phase per module, each landing green before the next.
Phase 3 — go/forge.
Bootstrap the repo to §3; move pkg/vcs/release/* + auth.go + config.go + the direct
provider + releasetest (renamed forgetest); apply D13's gap fixes (G1, G2, G3, G5) and
D14's bound relocation; rewrite direct per R1 and add the conformance harness; add the
shared host-pinning primitive (D11); add the depfootprint guard
(forbid go-tool-base, every forge SDK, viper/pflag/cobra/charm/OTel/cloud); publish mocks;
author the microsite; cut v0.1.0.
Phase 4 — providers, in ascending difficulty. forge-gitlab → forge-gitea (+ SDK
swap as a separate commit, D7) → forge-bitbucket (+ credential convergence as a separate
commit, D12). Each: move package + its own tests, repoint to forge, README with signposts,
v0.1.0.
Then stop and reassess (R2). With three providers proven against the contract, settle
GitHub's scope before extracting it. Only then forge-github.
Phase 5 — GTB cut-over (single MR). Delete the in-tree packages; remove the suppressed
mutable globals rather than repointing them (D15); repoint pkg/setup,
pkg/setup/github, pkg/props, pkg/vcs/repo/config_adapter.go; keep
ConfigFromContainable and the GTB config-key schemas; blank-import the provider modules
in pkg/setup/providers.go; migration note; docs sweep.
Test redistribution¶
Classify before moving (playbook §11.2). Favourable: no provider test imports
mocks/pkg/vcs/*, and all internal tests are same-package, so they travel with their
module. Specifics:
gitlab— all internal, same-package. Moves verbatim.gitea,bitbucket— mixed internal/external; the external ones drive theProviderinterface and are the more portable.github— all seven test files internal, reaching intoenterpriseURLs,deriveUploadURLand client internals. Travels, but only with the whole package.releasetestself-test moves with it.- GTB keeps the tests for
ConfigFromContainableand thepkg/setupupdate flow, which should switch to the module's published mocks.
Documentation: nothing left behind¶
An intent audit is an enforced per-phase deliverable (plan R4). The sweep for this phase is already done and found the following.
Intent to carry¶
The Backend Agnosticism design goal, recovered from
docs/explanation/concepts/vcs-repositories.md (deleted in c567e94), in its original
wording:
- Backend Agnosticism
- Consuming code depends on
release.Provider, not*github.GHClientor*gitlab.GitLabReleaseProvider. Switching from GitHub to GitLab releases is a one-line constructor change.
The surviving paraphrase in components/vcs/index.md lost both the concrete "one-line
constructor change" claim and the named anti-pattern types. Restore the original.
Two further pieces of intent that must survive:
- Why opt-in interfaces rather than required methods (
provider.go:63-68): providers that do not implement a capability fall back to locatingchecksums.txtby filename, which "keeps third-party Provider implementations source-compatible: they gain the feature by opting in, not by implementing a new required method." - Why a DI seam rather than
release.Registerin tests (components/vcs/release.md): "The global registry is process-wide mutable state; mutating it from tests cannot run undert.Parallel()." This is the same parallel-safety principle that drove thereposettings contract.
Eight stale doc claims to fix at cut-over¶
| # | Claim | Reality |
|---|---|---|
| S1 | direct returns ErrNotSupported from GetReleaseByTag |
It returns a synthetic release, no error (direct/provider.go:127-129) |
| S2 | ProviderFactory takes config.Containable |
Takes the narrow Config (registry.go:29). The docs predate the decoupling that makes this extraction possible. |
| S3 | ResolveTokenContext(ctx, cfg config.Containable, …) |
Takes TokenConfig (auth.go:54) |
| S4 | Root pkg/vcs "contains only auth.go" |
Also config.go, config_adapter.go, doc.go; and ResolveToken is used by gitea and repo too |
| S5 | Bitbucket workspace Params key |
Does not exist; workspace := owner is hardcoded (bitbucket/release.go:170) |
| S6 | Bitbucket credentials are a 2-step chain | It is 4 steps; the entire keychain-blob tier is undocumented (release.go:521-556) |
| S7 | GitLab usage example passes a config container | NewReleaseProvider takes a typed Settings; the example will not compile |
| S8 | ErrReleaseNotFound follow-up note |
Drops bitbucket's exclusion and the spec cross-reference |
Also: provider.go:28 cites docs/development/specs/2026-06-19-injectable-release-source.md,
which does not exist in the specs directory — recover it from history or fix the
reference, since it is the decision record for the injection seam.
Migrate vs stay¶
Migrate: components/vcs/release.md (the bulk), the five provider pages collapsing into
a microsite provider index, direct's URL-template and version-endpoint tables, the token
precedence chain, GitLab's platform-difference table.
Stay: props.Tool.* wiring, pkg/setup.NewUpdater and its three-tier precedence,
update.require_checksum/require_signature policy, all GTB config-key schemas
(github.url.*, gitlab.url.api, bitbucket.*, direct.token), providers.go
blank-import wiring, the update CLI reference and its feature file.
Deleted content to audit¶
concepts/auto-update.md (73 lines) and reference/cli/update.md (100 lines) were deleted
in the same c567e94 restructure. Recover both and confirm they were genuinely re-homed
rather than dropped. components/version-control.md is a 17-line redirect stub with an
already-incomplete link list; it will dangle entirely post-extraction — resolve it rather
than repoint it.
Acceptance criteria¶
go/forgebuilds with zero go-tool-base imports;depfootprintguard proves it and forbids every forge SDK.- Each provider module builds against
forgealone, with no go-tool-base import. - A third party can author a provider without importing go-tool-base, demonstrated by
directas a faithful worked example (R1) and validated byforgetest.RunProviderConformance. The harness must fail a provider that mishandles theErrNotSupportedfall-through. - All four in-tree providers register and resolve exactly as today;
giteastill owns bothgiteaandcodeberg. - Every module ≥90% coverage;
just cigreen in GTB after cut-over. - Scaffold verification: generated projects still self-update.
- The eight stale claims are corrected and the Backend Agnosticism wording restored.
- G1, G2, G3 and G5 are closed.
maxBytesis symmetric — one side owns capping for both optional interfaces, and the docs say which. D14 starts provider-side; if implementation trips one of its pivot triggers, the outcome is recorded there with a dated note. - Bitbucket's credential convergence is a separate, isolable commit; every commit before it in that MR is behaviour-preserving.
pkg/setupno longer declares mutable exported size/policy globals, the twogochecknoglobalssuppressions covering them are gone, andTestDownloadChecksumManifest_RejectsOversizedResponseruns undert.Parallel()(D15).
Resolutions (confirmed with user 2026-07-19)¶
R1 — direct is improved and a conformance harness ships. The audit showed direct
could not serve as the reference implementation plan R2 intended: it bypasses ResolveToken
for os.Getenv("DIRECT_TOKEN"), uses a config idiom no other provider uses, stubs
ListReleases, and fabricates synthetic release types with GetID() hardcoded to 0 — so
it never demonstrates mapping a real API payload, which is the hard part of authoring a
provider. Resolution: rewrite direct onto ResolveToken/AuthConfig, and ship
forgetest.RunProviderConformance(t, factory, caps). The harness is the load-bearing half —
it closes G6 and G7 by mechanically enforcing the ErrNotSupported fall-through protocol
that is currently prose-only and enforced nowhere, which no amount of example-copying does.
R2 — GitHub's scope is deferred to phase 4. Only 4 of GitHubClient's 13 methods are
release-related; the rest serve pkg/setup/github. Rather than decide what forge means up
front, extract the core and the three straightforward providers first, then settle GitHub's
boundary once the contract has proven itself against three real implementations. Phase 4
stops and reassesses before GitHub. The options surveyed — move the whole client, move
only the release provider, or split into two modules — are recorded here so the question is
not re-litigated from scratch.
R3 — Lockstep versioning plus a published compatibility matrix, matching chat.
Consistency with the established toolkit convention beat the lower coordination cost of
letting providers float on a caret range, which would leave downstreams on untested
core/provider pairings with no matrix to consult.
R4 — Host pinning moves into the forge core. It is a security primitive — it stops credentials being sent to a redirected host — and three separate implementations mean three chances to get it subtly wrong. The three current call sites differ enough that the shared signature needs care during implementation.
R5 — Bitbucket converges on the shared credential chain (chosen against the drafting recommendation to defer). Bitbucket needs username + app password, so the shared chain widens to carry a dual credential rather than a single token. Because this is a genuine behaviour change occurring during a move — the combination that makes regressions hardest to attribute — it lands as a separate commit inside the same MR, exactly as D7 handles the Gitea SDK swap. Every commit before it is then a provable pure move.
R6 — Fix G1, G2, G3 and G5 before v0.1.0. These freeze into a published surface across
five modules; fixing them afterwards is a breaking change to all five, whereas fixing them
now is nearly free while the surface is still ours. G6 and G7 are closed by R1's harness.
G4 (ErrReleaseNotFound) needs changes inside each provider and is deferred; G8
(parseKeychainRef) is minor and deferred; G9 is handled by D8.
Open questions¶
All resolved — see Resolutions above.
Status¶
IMPLEMENTED (2026-07-19).
go/forge v0.1.1 and forge-github/-gitlab/-gitea/-bitbucket v0.1.0 are published;
GTB consumes them and the in-tree packages are deleted.
R2 resolved with context that changed the answer: GitHub's wider API predates the framework
being opensourced, still has value, and is retained in GTB rather than moved or deleted
— because shipping it for one forge alone would create exactly the capability disparity
backend agnosticism exists to prevent. Restoring it across every provider in lockstep is
specified separately at go/forge
docs/development/specs/2026-07-19-forge-operations-beyond-releases.md.
Findings worth carrying forward: all four providers had the same credential-downgrade hole
(host compared, scheme not) — now closed by the shared HostTrusted; the conformance
harness had a hole in its own size-bound check, found only by breaking a provider known to
be correct; and the Gitea SDK swap fixed a latent bug where the old code claimed
/releases/latest did not exist.