rtb-vcs v0.1 — Release-provider slice¶
Status: APPROVED — ready for TDD / BDD work after rtb-redact lands.
Scope: release operations only. Git-operations slice (Repo, gix
/ git2 adapters, commit/diff/blame/clone) is deferred to rtb-vcs
v0.2 at the v0.5 roadmap milestone. See
2026-04-23-v0.2-scope.md.
Target crate: rtb-vcs (currently a 19-line stub).
Parent contract:
§15 Version control (VCS) of
the framework spec.
Consumer (v0.2): rtb-update. Future (v0.3+): rtb-ai for release-note
summarisation; scaffolder for repository metadata.
GTB reference: pkg/vcs/release/.
1. Motivation¶
rtb-update needs to fetch release metadata and asset bytes from the
tool author's distribution source. Tool authors pick one of six
backends: GitHub (cloud or Enterprise), GitLab (cloud or self-hosted),
Bitbucket (Cloud or Data Center), Gitea (self-hosted), Codeberg (the
hosted Gitea instance at codeberg.org), or a "direct" provider that
reads a version indicator and derives asset URLs from a template. The
set matches go-tool-base's release package, which is the product-
surface parity target for RTB.
The abstraction is narrow by design: read-only, four methods, no
coupling to the self-update flow itself. rtb-update sits on top. The
same trait will power future release-note features in rtb-ai and
generated-repo metadata in the scaffolder without those crates needing
to re-learn six API shapes.
2. Public API¶
2.1 Crate root¶
//! Release-provider abstractions for the Rust Tool Base.
pub mod release;
pub use release::{
Release, ReleaseAsset, ReleaseProvider,
ProviderFactory, ProviderError, ReleaseSourceConfig,
};
// Built-in backends are feature-gated so a downstream tool that only
// targets GitHub doesn't pay the compile-cost of the others.
#[cfg(feature = "github")] pub mod github;
#[cfg(feature = "gitlab")] pub mod gitlab;
#[cfg(feature = "bitbucket")] pub mod bitbucket;
#[cfg(feature = "gitea")] pub mod gitea;
#[cfg(feature = "codeberg")] pub mod codeberg;
#[cfg(feature = "direct")] pub mod direct;
Cargo features default to ["github", "gitlab", "bitbucket", "gitea",
"codeberg", "direct"]; tool authors opt out by setting
default-features = false, features = ["github"] etc.
2.2 The ReleaseProvider trait¶
#[async_trait::async_trait]
pub trait ReleaseProvider: Send + Sync + 'static {
/// Fetch metadata for the repository's latest non-draft,
/// non-prerelease release.
async fn latest_release(&self) -> Result<Release, ProviderError>;
/// Fetch metadata for a specific tag. Returns
/// `ProviderError::NotFound` if the tag does not exist or is a
/// draft release the caller lacks permission to see.
async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError>;
/// List up to `limit` most-recent releases, newest first. Includes
/// prereleases (caller filters) but excludes drafts for unauthenticated
/// callers.
async fn list_releases(&self, limit: usize) -> Result<Vec<Release>, ProviderError>;
/// Stream an asset's bytes.
async fn download_asset(
&self,
asset: &ReleaseAsset,
) -> Result<(Box<dyn tokio::io::AsyncRead + Send + Unpin>, u64), ProviderError>;
}
2.3 Value types¶
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[non_exhaustive]
pub struct Release {
pub name: String,
pub tag: String,
pub body: String,
pub draft: bool,
pub prerelease: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
pub assets: Vec<ReleaseAsset>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[non_exhaustive]
pub struct ReleaseAsset {
pub id: String,
pub name: String,
pub size: u64,
pub content_type: Option<String>,
pub download_url: String,
}
Release parses semver-shaped tags via semver::Version::parse; a
non-semver tag is preserved as-is and the tool is responsible for
rejecting it.
2.4 Error type¶
#[derive(Debug, thiserror::Error, miette::Diagnostic, Clone)]
#[non_exhaustive]
pub enum ProviderError {
#[error("release or asset not found: {what}")]
#[diagnostic(code(rtb::vcs::not_found))]
NotFound { what: String },
#[error("authentication failed for {host}")]
#[diagnostic(
code(rtb::vcs::unauthorized),
help("check the credential registered for this release source"),
)]
Unauthorized { host: String },
#[error("rate limited by {host}; retry after {retry_after:?}")]
#[diagnostic(code(rtb::vcs::rate_limited))]
RateLimited { host: String, retry_after: Option<std::time::Duration> },
#[error("network transport error: {0}")]
#[diagnostic(code(rtb::vcs::transport))]
Transport(String),
#[error("response body could not be parsed: {0}")]
#[diagnostic(code(rtb::vcs::malformed_response))]
MalformedResponse(String),
#[error("operation is not supported by this provider")]
#[diagnostic(
code(rtb::vcs::unsupported),
help("Bitbucket Cloud lacks a native list-releases endpoint; use latest_release or release_by_tag"),
)]
Unsupported,
#[error("provider configuration is invalid: {0}")]
#[diagnostic(code(rtb::vcs::invalid_config))]
InvalidConfig(String),
#[error("I/O error: {0}")]
#[diagnostic(code(rtb::vcs::io))]
Io(#[from] std::sync::Arc<std::io::Error>),
}
Io wraps Arc<io::Error> for the same reason rtb-credentials does —
keeps the enum Clone without losing the underlying error.
2.5 Registration + lookup¶
Providers register via linkme::distributed_slice:
pub struct RegisteredProvider {
pub source_type: &'static str,
pub factory: ProviderFactory,
}
pub type ProviderFactory =
fn(&ReleaseSourceConfig, Option<secrecy::SecretString>)
-> Result<std::sync::Arc<dyn ReleaseProvider>, ProviderError>;
#[linkme::distributed_slice]
pub static RELEASE_PROVIDERS: [RegisteredProvider] = [..];
pub fn lookup(source_type: &str) -> Option<ProviderFactory>;
pub fn registered_types() -> Vec<&'static str>;
Built-in backends each export a #[distributed_slice(RELEASE_PROVIDERS)]
entry from their module. Tools that want a custom backend define their
own and register via the same slice.
2.6 Source config — typed per-provider params¶
Each built-in backend defines a typed #[derive(serde::Deserialize)]
params struct. ReleaseSourceConfig is an enum that either carries
one of the typed built-ins or falls back to a freeform map for
downstream-registered custom backends:
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(tag = "source_type", rename_all = "lowercase")]
#[non_exhaustive]
pub enum ReleaseSourceConfig {
Github(GithubParams),
Gitlab(GitlabParams),
Bitbucket(BitbucketParams),
Gitea(GiteaParams),
Codeberg(CodebergParams),
Direct(DirectParams),
/// Escape hatch for custom/third-party providers registered via
/// `RELEASE_PROVIDERS`. Factory authors decide whether to parse
/// the map into their own typed struct inside the factory.
#[serde(rename = "custom")]
Custom {
#[serde(rename = "type")]
source_type: String,
params: std::collections::BTreeMap<String, String>,
},
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct GithubParams {
/// `api.github.com` for Cloud, `<host>/api/v3` for Enterprise.
/// Normalised by the factory — trailing slashes stripped; a bare
/// `<host>` without `/api/...` is promoted to `<host>/api/v3`.
pub host: String,
pub owner: String,
pub repo: String,
#[serde(default)]
pub private: bool,
#[serde(default = "GithubParams::default_timeout_seconds")]
pub timeout_seconds: u64,
}
// … GitlabParams / GiteaParams / CodebergParams / DirectParams /
// BitbucketParams follow the same shape, each with fields the
// backend actually uses. Codeberg's struct carries only
// `owner` + `repo` — its host is hard-coded to `codeberg.org`.
Why typed, not a BTreeMap:
- Typos surface at config-load time via
serde, not at update time as a provider error. - Each backend documents its config via rustdoc on the struct, generating a single source of truth.
- Future typed-struct migrations in downstream tools don't need a
breaking change — custom providers use the
Custom { source_type, params }escape hatch and can evolve their own shape independently.
ReleaseSourceConfig is still reachable from a flat rtb-app::
ReleaseSource via From/TryFrom — tool authors configure in
ToolMetadata and rtb-update calls
lookup(cfg.source_type())?(cfg, token) to obtain a provider.
2.7 Host normalisation¶
Built-in backends normalise user-supplied hosts at factory construction so config files can be relaxed about URL shape:
- Trailing slashes stripped.
https://prefix optional; protocol is enforced to behttps(anyhttp://is rejected viaProviderError::InvalidConfig).- GitHub: a bare
<host>without/api/...is promoted to<host>/api/v3for Enterprise, or toapi.github.comfor the public Cloud host. - GitLab: a host without
/api/v4is promoted; already-correct URLs are left alone. - Gitea: no promotion — Gitea endpoints are always under
/api/v1but the client prepends this internally, sohoststays the user- supplied value verbatim after whitespace/slash trim. - Bitbucket Cloud vs Data Center require different URL shapes and the
backend disambiguates by hostname (
api.bitbucket.orgvs anything else).
Custom providers (registered via RELEASE_PROVIDERS) do not
receive automatic normalisation. Their factory decides. This keeps
the built-in UX friendly while leaving plugin authors in charge of
their own URL parsing.
3. Per-backend notes¶
3.1 github¶
- HTTP client via
octocrab. - Auth: PAT as
Authorization: Bearer <token>. GitHub App JWT deferred (see O1). host— eitherapi.github.com(GitHub Cloud) or<enterprise-host>/api/v3(Enterprise).- Draft releases visible only when authenticated; unauthenticated
callers get 404s, which map to
ProviderError::NotFound. - Rate limits: 60/hour unauth, 5000/hour auth. Surfaced via
RateLimitedwhen hit; the provider does not auto-retry (leaves the policy to the caller).
3.2 gitlab¶
- HTTP client via
reqwest(not thegitlabcrate — too heavy for release-only). - Auth: PAT via
PRIVATE-TOKENheader. host—gitlab.comor self-hosted.- URL shape:
https://<host>/api/v4/projects/<owner>%2F<repo>/releases.
3.3 bitbucket¶
- Bitbucket Cloud and Data Center have no list-releases endpoint;
releases are inferred from tags.
list_releasesreturnsProviderError::Unsupported.latest_releasewalks tags by date;release_by_tagfetches by tag slug. - Auth: App password (username+password) for Cloud; PAT for DC.
host—api.bitbucket.org/2.0(Cloud) or<dc-host>/rest/api/1.0.- Asset download: Bitbucket stores release binaries as repository
"downloads" rather than per-tag attachments. The provider queries the
downloads endpoint, matches by naming convention documented in
docs/components/rtb-vcs.md#bitbucket-asset-naming.
3.4 gitea¶
- HTTP client via
reqwest. - Auth: PAT via
Authorization: token <token>header. host— any self-hosted Gitea instance.- API shape mirrors GitHub closely; one implementation covers both
giteaandcodeberg.
3.5 codeberg¶
- Registered as a distinct
source_type = "codeberg"but delegates to the Gitea implementation withhost = "codeberg.org"hard-coded. - The only functional difference: a
Codebergvariant inrtb_app::ReleaseSourcecarriesowner+repoonly (nohostfield), producing a slightly less noisy config file for the common case.
3.6 direct¶
- No API parsing. Reads a
version_url(plain text or a JSON document with.versionat the root) and constructs asset URLs fromasset_url_template. - Template grammar:
{version}— substituted with the discovered version string.{target}— substituted with the host target triple (x86_64-unknown-linux-gnu, etc).{ext}—.tar.gz,.zip, etc. — picked per-platform.{arch},{os}— short forms.list_releasesreturns a single-element vector;release_by_tagmatches only the configured pinned version.- Useful for air-gapped mirrors, S3-backed private releases, and one-off toys.
4. Acceptance criteria¶
Unit coverage is per-backend. BDD scenarios cover cross-cutting behaviour (registration, error mapping, auth handling).
4.1 Unit tests (T#) — trait layer¶
- T1 — Registry returns factory for each default-feature backend.
lookup("github"),lookup("gitlab"),lookup("bitbucket"),lookup("gitea"),lookup("codeberg"),lookup("direct")all returnSome. - T2 — Unknown source type returns
Nonefromlookup. - T3 —
registered_types()is sorted and deduplicated. - T4 —
ReleaseSourceConfiground-trips throughserde_yaml. - T5 —
ProviderErrorderivesClonewithout losingio::Errorpayload. - T6 —
Releaseparses a semver-shaped tag viasemver::Versionwhen callers opt in. - T7 —
ReleaseProvidertrait object isSend + Sync + 'static.
4.2 Unit tests (T#) — per backend (via wiremock)¶
- T10–T15: GitHub. Latest, by-tag, list, download, unauthorized
(401 →
Unauthorized), rate-limited (403 +X-RateLimit-Remaining: 0→RateLimitedwithretry_after). - T20–T25: GitLab. Same shape, with
PRIVATE-TOKENhandling. - T30–T35: Bitbucket. Latest (tag-walk), by-tag, list returns
Unsupported, download via downloads endpoint, unauthorized, malformed JSON. - T40–T43: Gitea. Latest, by-tag, list, download.
- T50 — Codeberg.
lookup("codeberg")yields a provider whose effective host iscodeberg.org. - T60–T65: Direct. Plain-text version URL, JSON version URL,
template substitution with
{version}/{target}/{ext}, missing pinned version yieldsInvalidConfig.
4.3 Gherkin acceptance (S#)¶
Scenarios live in crates/rtb-vcs/tests/features/:
- S1 — Tool-author flow (GitHub public repo). Given a tool
configured with
ReleaseSource::Github { host, owner, repo }, When the updater asks for the latest release, Then aReleasevalue is returned whosetagparses as semver and whoseassetsis non-empty. - S2 — Self-hosted GitLab with PAT. Given a
PRIVATE-TOKENcredential is resolved fromrtb-credentialsand the provider config points at a self-hosted GitLab, Then list_releases succeeds and returns the latest 10 by creation date. - S3 — Codeberg via the Codeberg alias. Given
ReleaseSource:: Codeberg { owner, repo }, Then the underlying provider is the Gitea backend pinned tocodeberg.org. - S4 — Bitbucket cannot list releases. Given a Bitbucket provider,
When list_releases is called, Then
ProviderError::Unsupportedsurfaces with the documented help text. - S5 — Direct provider with a JSON version URL. Happy-path asset download via template substitution.
- S6 — Rate-limited response. Given the backend returns 429 or
provider-specific rate-limit signal, Then
ProviderError::RateLimitedcarries theretry_afterduration. - S7 — Draft-release visibility. Given an authenticated GitHub
caller and a draft release, Then
release_by_tag(draft_tag)returns the draft; unauthenticated caller getsNotFound.
5. Security & operational requirements¶
#![forbid(unsafe_code)]at the crate root.- All HTTPS —
reqwestconfigured withhttps_only(true); HTTP URLs inReleaseSourceConfig::hostare rejected at factory construction withProviderError::InvalidConfig. - Credentials flow end-to-end as
SecretString. The provider never logs the token; per-request headers are redacted at thertb-cli::httpDEBUG layer viartb-redact::SENSITIVE_HEADERS. download_assetis a streaming reader (AsyncRead). No in-memory buffering of multi-megabyte asset payloads.- Connection + request timeouts default to 30 s / 300 s; callers
override via
ReleaseSourceConfig::params["timeout_seconds"]. - Retries: no default retry policy — the caller (e.g.
rtb-update) decides. Rate-limit errors preserveretry_afterso callers can back off explicitly. - User-supplied strings from
ReleaseSourceConfigthat flow into URL construction are percent-encoded at the provider boundary (e.g. GitLab's%2F-joined project paths).
6. Non-goals (explicit)¶
- Writeable release APIs. Creating releases, uploading assets, editing tags — out of scope for v0.1. The release slice is read-only. Writing is a v0.3+ concern if a real user need emerges.
- Git repository operations.
Repo, clone, fetch, commit — all deferred tortb-vcsv0.2 (roadmap v0.5). - Continuous release monitoring (polling loops, webhooks) — a tool concern, not a library concern.
- Pre-release channel semantics.
Release::prereleaseis exposed; the crate does not apply "stable vs beta" channel logic. That belongs inrtb-update. - Caching. Every call hits the wire. Downstream tools add HTTP
caching via a
reqwest::ClientBuilder::middlewareif they need it.
7. Rollout plan¶
- Land this spec + Gherkin + failing unit tests with trait + error skeleton.
- Implement the GitHub backend first — it's the richest API surface and unblocks T10–T15 plus S1 / S7.
- Implement GitLab, Gitea, Codeberg (Gitea delegation), Direct in parallel PRs.
- Implement Bitbucket last — it has the most awkward shape (no list endpoint, tag-walk for latest).
- Integrate with
rtb-update(separate spec, separate PR sequence). - Document each backend's
paramskeys indocs/components/rtb-vcs.md.
8. Open questions¶
- O1 — GitHub App JWT auth. Mentioned in CLAUDE.md (§ Version Control). Not in v0.1 — PAT only. Track as 0.2.x follow-up; most Enterprise installations accept PATs.
-
O2 —
async-traitmacro vs nativeasync fn in trait. Resolved:async-traitat v0.1, with a standing review flag for v0.3. Reasoning (user asked for a cost/effort estimate for switching later): -
Native
async fn in trait(stable since Rust 1.75) is ergonomic withimpl Trait, but not object-safe on its own.dyn ReleaseProvideris load-bearing:rtb-updatestoresArc<dyn ReleaseProvider>and the plugin registry returnsArc<dyn ReleaseProvider>. Withoutdyn-safety, every consumer becomes generic (fn update<P: ReleaseProvider>(p: P, ...)), which propagates type parameters throughrtb-update's entire flow and defeats the linkme-based factory registry (factory functions returnArc<dyn _>). - The
trait_variantcrate (a Rust-project-maintained macro) can generate both the native anddyn-safe forms, but adds a dep and a learning curve for users implementing custom providers. - Switching cost today: very low. Three places: the trait definition, five built-in impls, one mock impl in tests. Roughly 30 minutes of mechanical work.
- Switching cost at v0.3: still low for core, but any
downstream tool-author who has written a custom
ReleaseProviderimpl needs to migrate. That's a breaking API change for them, which we'd book into a minor bump. - Why not switch now anyway:
async-traitis the pragmatic production answer as of early 2026. Tokio, tower, axum, and most widely-used libraries that needdyn-safe async traits are still onasync-trait— not because the ecosystem is lazy, but because nativedyn-safety support requires either manual boxing at each call site or thetrait_variantcrate, neither of which is a clean ergonomic win yet.
Decision criterion for v0.3: if, by the time we're specifying
v0.3, either (a) trait_variant ships in std, or (b) native
async fn in trait grows dyn-safety without adapters, we migrate
in a 0.3.0 minor bump with a migration note. Until then, hold.
- O3 — ReleaseAsset::id: String or u64? GitHub uses numeric
IDs; GitLab uses numeric IDs; Bitbucket and Direct use path-shaped
identifiers. String accommodates all. Cost: one allocation per
asset. Proposed resolution: String.
- O4 — list_releases(limit) default. GTB's default limit is
implicit (caller passes). Should ReleaseProvider offer a
default_limit() const? Proposed resolution: no — the caller always
has context the provider doesn't.
- O5 — Per-provider config param grammar. Resolved: typed
per-provider structs (§ 2.6). Typos surface at serde
deserialize time; each backend's config is self-documenting via
rustdoc; downstream custom providers use the Custom { source_type,
params } escape hatch on the ReleaseSourceConfig enum. This
future-proofs the crate for pluggable third-party backends without
forcing them into a freeform map.
- O6 — Host normalisation. Resolved: hybrid (§ 2.7). Built-in
backends normalise aggressively at factory construction (strip
trailing slashes, enforce https, promote bare hosts to the
provider's canonical API path). Custom providers registered via
RELEASE_PROVIDERS do their own URL parsing — plugin authors know
their own endpoint shapes and shouldn't inherit built-in biases.