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
prereleaseis a guess. GitLab's release API has no prerelease flag, so the backend setsprerelease = truewhen the tag string contains a hyphen. That is correct for1.2.3-rc.1and wrong for a dated tag like2026-04-23, which is marked as a prerelease. - GitLab assets have no size. GitLab release assets are links, not uploaded
blobs, so
sizeis always0andcontent_typecarries GitLab'slink_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.2matchestool-1.2.10-linux.tar.gz, because1.2is a substring of1.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.hrefgets an emptydownload_url, anddownload_asseton it fails as a transport error rather than a validation error. - Asset
idis 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)returnsOption<ProviderFactory>.Nonemeans 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.