Skip to content

Release-provider methods and backend behaviour

ReleaseProvider has four methods and no others. All are async, all are read-only, and the trait is object-safe via async-trait — consumers hold Arc<dyn ReleaseProvider>.

#[async_trait::async_trait]
pub trait ReleaseProvider: Send + Sync + 'static {
    async fn latest_release(&self) -> Result<Release, ProviderError>;
    async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError>;
    async fn list_releases(&self, limit: usize) -> Result<Vec<Release>, ProviderError>;
    async fn download_asset(&self, asset: &ReleaseAsset)
        -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError>;
}

What is in a Release?

Field Type Meaning when unfilled
name String Falls back to the tag when the host has no name
tag String Always populated; preserved verbatim, never parsed as semver
body String Empty string
draft bool false
prerelease bool false
created_at OffsetDateTime Unix epoch when the host's timestamp will not parse as RFC 3339
published_at Option<OffsetDateTime> None
assets Vec<ReleaseAsset> Empty vec

tag is not validated as semver. GitHub, GitLab and Gitea repositories routinely mix v1.2.3 with dated tags like 2026-04-23, so callers that need a semver::Version parse it themselves and decide what to do with the ones that fail.

Release is #[non_exhaustive]. Construct one with Release::new(name, tag, created_at) and assign the optional fields afterwards — struct-literal construction from outside the crate will not compile.

What is in a ReleaseAsset?

Field Type Notes
id String Provider-native. Numeric on GitHub/GitLab/Gitea; the filename on Bitbucket and Direct.
name String Filename including extension, not a URL.
size u64 0 when the host does not report a size.
content_type Option<String> None when unreported.
download_url String Fully-qualified URL, passed straight back to download_asset.

Also #[non_exhaustive]; use ReleaseAsset::new(id, name, download_url).

latest_release

Backend Request Selection rule
GitHub GET /repos/{owner}/{repo}/releases/latest Whatever GitHub calls latest — excludes drafts and prereleases server-side
GitLab GET /api/v4/projects/{owner}%2F{repo}/releases?per_page=1 First entry with upcoming_release == false
Gitea / Codeberg GET /api/v1/repos/{owner}/{repo}/releases/latest Whatever Gitea calls latest
Bitbucket GET /repositories/{ws}/{slug}/refs/tags?sort=-target.date&pagelen=100 Newest tag by target date
Direct GET <version_url> (skipped entirely when pinned_version is set) The single discovered version

GitLab's latest_release can report "not found" on a repository that has releases

GitLab has no dedicated latest-release endpoint, so the backend asks for one release and filters it. If that single newest release is an upcoming (scheduled) release, the filtered list is empty and the call returns ProviderError::NotFound { what: "latest release" } even though older published releases exist. Fall back to list_releases and filter yourself if your project schedules releases ahead of time.

Bitbucket's latest_release is the newest tag, not the newest release

Bitbucket Cloud has no releases concept. The backend synthesises one: the newest git tag by target.date becomes the release, its tag message becomes body, and published_at is set equal to created_at. Any tag counts — including tags that were never intended as a release.

release_by_tag

Backend Request
GitHub GET /repos/{owner}/{repo}/releases/tags/{tag}
GitLab GET /api/v4/projects/{owner}%2F{repo}/releases/{tag}
Gitea / Codeberg GET /api/v1/repos/{owner}/{repo}/releases/tags/{tag}
Bitbucket GET /repositories/{ws}/{slug}/refs/tags/{tag}
Direct no request

The tag is percent-encoded before being placed in the path, so tags containing /, + or # are safe. Encoding is aggressive: everything outside A-Z a-z 0-9 - _ . ~ is escaped.

The Direct backend does not verify the tag you ask for

With pinned_version set, release_by_tag returns that release for the pinned string and ProviderError::NotFound for anything else. Without pinned_version it performs no request at all and synthesises a Release for whatever string you passed. Asking for v99.0.0 from a direct source returns a Release tagged v99.0.0 with an asset URL that will 404 on download. The direct backend has no way to enumerate history, so it trusts the caller.

list_releases

Backend Request Caps
GitHub GET .../releases?per_page={n} limit clamped to 1–100; single page only
GitLab GET .../releases?per_page={n} limit clamped to 1–100; single page only
Gitea / Codeberg GET .../releases?limit={n} limit clamped to 1–50; single page only
Bitbucket none always ProviderError::Unsupported
Direct none returns a one-element vec containing latest_release()

Nothing paginates. A limit above the backend's cap fetches one page at the cap and returns at most that many releases; it does not follow Link headers or next cursors. A limit of 0 is clamped up to 1 by the request, and then the final .take(limit) truncates the result back to an empty vector — asking for zero releases on GitHub, GitLab or Gitea spends a round-trip and returns [].

Drafts are excluded for unauthenticated callers by the host, not by this crate. Prereleases are included; filter on Release::prerelease yourself.

The Direct backend ignores limit entirely and always returns exactly one element.

download_asset

Takes a &ReleaseAsset — normally one you got from a Release — and performs a GET against its download_url. Returns the body as Box<dyn AsyncRead + Send + Unpin> plus the Content-Length the host reported, or 0 when the header is absent.

Do not size an allocation from the returned length. Chunked responses report 0, and the value is the server's claim, not a guarantee. Stream into the destination with tokio::io::copy and enforce your own limits.

Backend Auth header sent Extra headers
GitHub Authorization: Bearer <token> Accept: application/octet-stream, X-GitHub-Api-Version: 2022-11-28
GitLab PRIVATE-TOKEN: <token>
Gitea / Codeberg Authorization: token <token>
Bitbucket HTTP Basic (username + token)
Direct Authorization: Bearer <token>

The Accept: application/octet-stream header on GitHub is what makes the API serve asset bytes rather than the asset's JSON metadata.

Which fields each backend can actually fill

Field GitHub GitLab Gitea / Codeberg Bitbucket Direct
name host name, else tag host name, else tag host name, else tag tag name version string
body release body description release body tag message always empty
draft host value always false host value always false always false
prerelease host value inferred: tag contains - host value always false always false
created_at created_at created_at created_at tag target date always Unix epoch
published_at published_at released_at published_at same as created_at always None
assets[].size host value always 0 host value download size always 0
assets[].content_type host value GitLab link_type always None always None always None

Two of these bite in practice:

  • GitLab prerelease is a guess. GitLab's release API has no prerelease flag, so the backend sets prerelease = true when the tag string contains a hyphen. That is correct for 1.2.3-rc.1 and wrong for a dated tag like 2026-04-23, which is marked as a prerelease.
  • GitLab assets have no size. GitLab release assets are links, not uploaded blobs, so size is always 0 and content_type carries GitLab's link_type (package, image, runbook, other) rather than a MIME type.

How Bitbucket decides which files belong to a tag

Bitbucket Downloads are repository-level, not per-tag, so the backend pairs them by filename. For tag T it fetches GET /repositories/{ws}/{slug}/downloads?pagelen=100 and keeps every download whose lowercased filename contains either the lowercased tag, or the lowercased tag with a leading v stripped.

Consequences worth knowing before you rely on it:

  • Tag v1.2 matches tool-1.2.10-linux.tar.gz, because 1.2 is a substring of 1.2.10. Substring matching is not version-aware.
  • Only the first 100 downloads are considered. A repository with more will silently miss assets.
  • A download with no links.self.href gets an empty download_url, and download_asset on it fails as a transport error rather than a validation error.
  • Asset id is the filename, so it is only unique within the repository's download list.

Discovering backends at runtime

Backends register themselves at link time through a linkme distributed slice. There is no init() to call.

use rtb_forge::{lookup, registered_types, ReleaseSourceConfig};

let cfg: ReleaseSourceConfig = serde_yaml::from_str(yaml)?;
let factory = lookup(cfg.source_type()).ok_or_else(|| {
    format!("unknown source_type; available: {:?}", registered_types())
})?;
let provider = factory(&cfg, token)?;
  • lookup(source_type) returns Option<ProviderFactory>. None means no backend registered under that discriminator — usually a Cargo feature that was turned off, or a typo.
  • registered_types() returns the registered discriminators sorted and deduplicated. It reflects the features this binary was built with, which makes it the right thing to print in an error message.

Both walk the slice linearly and construct a boxed registration per entry on every call. The slice holds one entry per compiled backend, so the cost is negligible next to the network round-trip that follows — but do not call lookup in a hot loop.