forge-bitbucket: resolve the contradictory downloads self-link contract (auth host-pin vs URL-shape parsing)¶
- Authors
- Matt Cockayne, Claude Fable 5 (AI drafting assistant)
- Date
- 2026-07-23
- Status
- DRAFT — pending review
- Related
- architectural review (HIGH finding), forge module extraction (Bitbucket downloads-as-releases model), remote update checksum verification (the require_checksum policy this breaks under)
1. Problem¶
Bitbucket has no releases API; the provider synthesises releases from the repository
Downloads list. Assets are built from the API response's dl.Links.Self.Href
(go/forge-bitbucket/release.go:451-454). Two consumers of that same URL hold
mutually incompatible assumptions about its shape:
setBasicAuthIfHostMatches(release.go:149-159) attaches basic-auth credentials only when the URL host matches the pinnedapiBase—https://api.bitbucket.org/2.0(release.go:128). It assumes an API-host URL.parseDownloadURL(release.go:321-333) requires the path shape/{workspace}/{repo}/downloads/{file}(it checksparts[2] == "downloads") — a browser-hostbitbucket.org/…URL. An API-shaped path (/2.0/repositories/{workspace}/{repo}/downloads/{file}) fails this check with a hard error, notforge.ErrNotSupported.
parseDownloadURL exists only because the synthetic release drops provenance:
resolveDownloadURL (release.go:252-265) re-derives (workspace, repo) from the
first asset's URL to look up checksums.txt/checksums.txt.sig for
DownloadChecksumManifest (release.go:225-232) and DownloadSignature
(release.go:238-245) — the code comment at release.go:253-256 admits the re-derivation.
The unit tests stub browser-shaped hrefs throughout, so the contradiction is never
exercised against the real API, whose v2 links.self.href values are
api.bitbucket.org/2.0/…-shaped. Whichever shape the live API returns, exactly one
half breaks:
- API-shaped (the documented live behaviour): basic auth attaches correctly, but
parseDownloadURLerrors →DownloadChecksumManifest/DownloadSignaturereturn a hard error instead ofErrNotSupported→ underrequire_checksum/require_signature,verifyAssetChecksumaborts (pkg/setup/update.go:687-689) — every Bitbucket self-update fails. - Browser-shaped: checksum lookup parses, but the host never matches
apiBase, so basic auth is never attached — private-repo downloads 401.
2. Proposed change¶
Remove the URL re-derivation entirely; make URL parsing a tolerant fallback only.
- Carry provenance on the synthetic structs.
matchAssets/GetLatestReleasealready know(workspace, repo)— they were the function arguments. Store them as fields on the syntheticbitbucketRelease(and/orbitbucketAsset) when the release is built, and haveresolveDownloadURLread them directly. This deletes the fragile asset-URL round-trip and is correct for both URL shapes. - Make
parseDownloadURLshape-tolerant for any remaining or future callers: accept both/{workspace}/{repo}/downloads/{file}(browser) and/2.0/repositories/{workspace}/{repo}/downloads/{file}(API), keyed on locating thedownloadspath segment rather than a fixed index. If step 1 leaves it with no callers, delete it instead. - Preserve the
ErrNotSupportedcontract. With provenance carried on the struct, the "no assets / no matching file" paths keep returningforge.ErrNotSupported(release.go:258-259, 278) and no URL-parse failure can masquerade as a hard verification error. - Audit
DownloadReleaseAsset(release.go:336-347): it fetches the asset'sBrowserDownloadURLwithsetBasicAuthIfHostMatches— with API-shaped hrefs this is consistent; add a test asserting auth is attached for API-shaped asset URLs and not for foreign hosts (the existing host-pin tests cover the latter). - Add a live-API integration test (env-gated per house convention, e.g.
INT_TEST_FORGE_BITBUCKET=1): fetch the downloads list of a real (public) test repository and assert the actuallinks.self.hrefshape matches what the unit fixtures encode — pinning the fixtures to reality so the two can never silently diverge again. Update the unit fixtures to the live shape as part of this spec.
Alternative considered: teaching setBasicAuthIfHostMatches to also trust
bitbucket.org (widening the pin) so browser-shaped URLs authenticate. Rejected as
the primary fix — it widens a deliberately narrow credential pin and still leaves the
provenance round-trip in place; carrying (workspace, repo) is strictly simpler and
shape-independent.
3. Scope & release plan¶
go/forge-bitbucketonly — release.go (+ struct fields, tests, fixtures, integration test). Thego/forgecore contract is unchanged; no other provider is affected.- Ships as
fix(release): …— a patch release via the releaser-pleaser Release MR. Forge providers are kept in lockstep at minor versions; a single-provider patch does not break parity, so no simultaneous republish of the other adapters is required. - go-tool-base consumes via a go.mod bump of forge-bitbucket; no GTB code change.
Downstream tools distributing via Bitbucket Downloads with
require_checksumenabled are currently hard-broken, so the bump should follow promptly.
4. Acceptance criteria¶
- Unit test with API-shaped fixtures (
https://api.bitbucket.org/2.0/repositories/{ws}/{repo}/downloads/{file}):DownloadChecksumManifestandDownloadSignatureresolve and fetch successfully, with basic auth attached to every request (asserted at the fake server). - Unit test with browser-shaped fixtures: same behaviour for resolution; auth attachment matches the host-pin decision made for that shape (documented in the test) — no 401-by-construction path remains for the live shape.
- Regression test: a release whose downloads list lacks
checksums.txtreturnsforge.ErrNotSupported(not a hard error) fromDownloadChecksumManifest, for both URL shapes — so GTB's optional-verification fallback keeps working. resolveDownloadURLno longer derives(workspace, repo)from an asset URL; the synthetic release/asset structs carry them (asserted directly or via a shape-mismatch test that would previously have errored).parseDownloadURL(if retained) has table tests covering both shapes plus malformed inputs; if deleted, no references remain.- Env-gated integration test against the live Bitbucket API validates the
links.self.hrefshape and the end-to-end checksum-manifest resolution on a real repository; documented in the module's integration-test inventory. - Module CI green; coverage on touched code ≥ 90%.
5. Open questions¶
- Which real Bitbucket repository should back the integration test — a dedicated public fixture repo under the project's workspace (preferred, credential-free), or an existing tool's release repo? A private-repo auth-path integration test would additionally need app-password secrets in CI.
- Should the fix also normalise
GetBrowserDownloadURLto always expose the browser-shaped URL (cosmetic, what a human would open) while downloads use the API URL internally, or keep exposing the API self link verbatim? Verbatim is the no-surprises default; normalising touches the asset accessor contract.