rtb-forge 0.8.1

Release-provider abstractions and backends (GitHub, GitLab, Bitbucket, Gitea, Codeberg, Direct) plus an async git-operations Repo built on gix. Part of the phpboyscout Rust toolkit.
Documentation
---
title: Release-provider methods and backend behaviour
description: What each ReleaseProvider method requests from each backend, how the response is mapped onto Release, and which fields each forge cannot fill.
tags: [reference, releases, http]
---

# 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`, so consumers hold
`Arc<dyn ReleaseProvider>`.

```rust
#[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, because 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, which 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.

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

let cfg: ReleaseSourceConfig = serde_saphyr::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. That is 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.