Skip to content

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 be https (any http:// is rejected via ProviderError::InvalidConfig).
  • GitHub: a bare <host> without /api/... is promoted to <host>/api/v3 for Enterprise, or to api.github.com for the public Cloud host.
  • GitLab: a host without /api/v4 is promoted; already-correct URLs are left alone.
  • Gitea: no promotion — Gitea endpoints are always under /api/v1 but the client prepends this internally, so host stays 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.org vs 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 — either api.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 RateLimited when hit; the provider does not auto-retry (leaves the policy to the caller).

3.2 gitlab

  • HTTP client via reqwest (not the gitlab crate — too heavy for release-only).
  • Auth: PAT via PRIVATE-TOKEN header.
  • hostgitlab.com or 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_releases returns ProviderError::Unsupported. latest_release walks tags by date; release_by_tag fetches by tag slug.
  • Auth: App password (username+password) for Cloud; PAT for DC.
  • hostapi.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 gitea and codeberg.

3.5 codeberg

  • Registered as a distinct source_type = "codeberg" but delegates to the Gitea implementation with host = "codeberg.org" hard-coded.
  • The only functional difference: a Codeberg variant in rtb_app::ReleaseSource carries owner + repo only (no host field), 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 .version at the root) and constructs asset URLs from asset_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_releases returns a single-element vector; release_by_tag matches 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 return Some.
  • T2 — Unknown source type returns None from lookup.
  • T3 — registered_types() is sorted and deduplicated.
  • T4 — ReleaseSourceConfig round-trips through serde_yaml.
  • T5 — ProviderError derives Clone without losing io::Error payload.
  • T6 — Release parses a semver-shaped tag via semver::Version when callers opt in.
  • T7 — ReleaseProvider trait object is Send + 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: 0RateLimited with retry_after).
  • T20–T25: GitLab. Same shape, with PRIVATE-TOKEN handling.
  • 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 is codeberg.org.
  • T60–T65: Direct. Plain-text version URL, JSON version URL, template substitution with {version} / {target} / {ext}, missing pinned version yields InvalidConfig.

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 a Release value is returned whose tag parses as semver and whose assets is non-empty.
  • S2 — Self-hosted GitLab with PAT. Given a PRIVATE-TOKEN credential is resolved from rtb-credentials and 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 to codeberg.org.
  • S4 — Bitbucket cannot list releases. Given a Bitbucket provider, When list_releases is called, Then ProviderError::Unsupported surfaces 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::RateLimited carries the retry_after duration.
  • S7 — Draft-release visibility. Given an authenticated GitHub caller and a draft release, Then release_by_tag(draft_tag) returns the draft; unauthenticated caller gets NotFound.

5. Security & operational requirements

  • #![forbid(unsafe_code)] at the crate root.
  • All HTTPS — reqwest configured with https_only(true); HTTP URLs in ReleaseSourceConfig::host are rejected at factory construction with ProviderError::InvalidConfig.
  • Credentials flow end-to-end as SecretString. The provider never logs the token; per-request headers are redacted at the rtb-cli::http DEBUG layer via rtb-redact::SENSITIVE_HEADERS.
  • download_asset is 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 preserve retry_after so callers can back off explicitly.
  • User-supplied strings from ReleaseSourceConfig that 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 to rtb-vcs v0.2 (roadmap v0.5).
  • Continuous release monitoring (polling loops, webhooks) — a tool concern, not a library concern.
  • Pre-release channel semantics. Release::prerelease is exposed; the crate does not apply "stable vs beta" channel logic. That belongs in rtb-update.
  • Caching. Every call hits the wire. Downstream tools add HTTP caching via a reqwest::ClientBuilder::middleware if they need it.

7. Rollout plan

  1. Land this spec + Gherkin + failing unit tests with trait + error skeleton.
  2. Implement the GitHub backend first — it's the richest API surface and unblocks T10–T15 plus S1 / S7.
  3. Implement GitLab, Gitea, Codeberg (Gitea delegation), Direct in parallel PRs.
  4. Implement Bitbucket last — it has the most awkward shape (no list endpoint, tag-walk for latest).
  5. Integrate with rtb-update (separate spec, separate PR sequence).
  6. Document each backend's params keys in docs/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-trait macro vs native async fn in trait. Resolved: async-trait at 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 with impl Trait, but not object-safe on its own. dyn ReleaseProvider is load-bearing: rtb-update stores Arc<dyn ReleaseProvider> and the plugin registry returns Arc<dyn ReleaseProvider>. Without dyn-safety, every consumer becomes generic (fn update<P: ReleaseProvider>(p: P, ...)), which propagates type parameters through rtb-update's entire flow and defeats the linkme-based factory registry (factory functions return Arc<dyn _>).

  • The trait_variant crate (a Rust-project-maintained macro) can generate both the native and dyn-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 ReleaseProvider impl 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-trait is the pragmatic production answer as of early 2026. Tokio, tower, axum, and most widely-used libraries that need dyn-safe async traits are still on async-trait — not because the ecosystem is lazy, but because native dyn-safety support requires either manual boxing at each call site or the trait_variant crate, 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.