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 source configuration
description: Every source_type and every configuration key rtb-forge accepts, with defaults, validation rules and the exact error text produced when a value is wrong.
tags: [reference, configuration]
---

# Release source configuration

`ReleaseSourceConfig` is the serde-tagged enum a tool embeds in its own config
file. The tag key is `source_type`; each value selects a variant with its own
typed parameter struct.

```yaml
release:
  source_type: github
  owner: phpboyscout
  repo: rust-tool-base
```

Because it is a typed enum, a missing required key fails at deserialise time
with a serde error naming the field (``missing field `owner` ``), not at update
time with a 404. An *unknown* key is not caught: none of the parameter structs
set `#[serde(deny_unknown_fields)]`, so a mistyped optional key (`privaet:` for
`private:`) is silently ignored and the default is used, with no diagnostic.

## Keys shared by the forge backends

`github`, `gitlab`, `gitea` and `codeberg` all accept these. Bitbucket accepts
all but `owner`/`repo` (it uses `workspace`/`repo_slug`); `direct` accepts only
`timeout_seconds`.

| Key | Type | Default | Meaning |
|---|---|---|---|
| `private` | bool | `false` | **Currently inert; see below.** |
| `timeout_seconds` | u64 | `30` | Per-request timeout. `0` disables the timeout entirely. |

### `private` does not do anything

`private` deserialises and round-trips, but no backend reads it. Whether a
request carries credentials depends solely on whether a token was passed to the
factory:

```rust
let provider = factory(&cfg, Some(token))?;   // authenticated
let provider = factory(&cfg, None)?;          // anonymous
```

Setting `private: true` without supplying a token does not make requests fail
early, and setting `private: false` with a token does not suppress the
`Authorization` header. Treat the key as reserved.

### `timeout_seconds: 0` removes the timeout

The value is passed to `reqwest::ClientBuilder::timeout` only when it is
greater than zero. At `0` no timeout is configured at all, so a request against
an unresponsive host can hang for as long as the OS keeps the socket open.
There is no separate connect timeout.

## `source_type: github`

| Key | Required | Default | Notes |
|---|---|---|---|
| `host` | no | `api.github.com` | API root. GitHub Enterprise takes the instance host. |
| `owner` | **yes** | — | User or organisation. |
| `repo` | **yes** | — | Repository name, no `.git` suffix. |
| `private` | no | `false` | Inert. |
| `timeout_seconds` | no | `30` | |

### How `host` is normalised for GitHub

The factory rewrites `host` before use. Any leading `https://` or `http://` and
any trailing `/` are stripped first, then:

| You write | Provider uses |
|---|---|
| `api.github.com` | `api.github.com` |
| `https://api.github.com/` | `api.github.com` |
| `github.example.com` | `github.example.com/api/v3` |
| `api.github.example.com` | `github.example.com/api/v3` |
| `github.example.com/api/v3` | `github.example.com/api/v3` |
| anything containing `/api/` | left alone |

A bare Enterprise hostname is promoted to `/api/v3`; you do not write the API
path yourself. A host that already contains `/api/` is passed through
unchanged, which is the escape hatch for an instance on a non-standard API
path.

### GitHub validation errors

All are `ProviderError::InvalidConfig`:

| Condition | Message |
|---|---|
| `host` empty or whitespace | `github host must not be empty` |
| `host` begins `http://` | `github host must be https; got <host>` |
| `owner` or `repo` empty | `github owner and repo must not be empty` |

The GitHub backend rejects an `http://` host **unconditionally**. Unlike
GitLab, Gitea and Bitbucket, it does not consult the test-only insecure escape
hatch when doing so. Point a GitHub provider at a local mock with a bare
`127.0.0.1:PORT` host, not an `http://`-prefixed one.

## `source_type: gitlab`

| Key | Required | Default | Notes |
|---|---|---|---|
| `host` | no | `gitlab.com` | Bare host. `/api/v4` is appended by the backend. |
| `owner` | **yes** | — | Group or user namespace. |
| `repo` | **yes** | — | Project slug. |
| `private` | no | `false` | Inert. |
| `timeout_seconds` | no | `30` | |

`host` only has its scheme and trailing slash stripped. There is no promotion
step. The project is addressed by URL-encoded path (`owner%2Frepo`), so you
never need to look up a numeric project ID. A nested subgroup goes in `owner`
verbatim (`group/subgroup`); the slash is percent-encoded for you.

### GitLab validation errors

| Condition | Message |
|---|---|
| `host` empty or whitespace | `gitlab host must not be empty` |
| `host` begins `http://` | `gitlab host must be https; got <host>` |
| `owner` or `repo` empty | `gitlab owner and repo must not be empty` |

## `source_type: gitea`

| Key | Required | Default | Notes |
|---|---|---|---|
| `host` | **yes** | — | **No default.** Omitting it is a serde error. |
| `owner` | **yes** | — | |
| `repo` | **yes** | — | |
| `private` | no | `false` | Inert. |
| `timeout_seconds` | no | `30` | |

`host` is the only forge parameter in the crate with no default, because there
is no canonical Gitea instance. `/api/v1` is appended by the backend.

### Gitea validation errors

| Condition | Message |
|---|---|
| `host` empty or whitespace | `gitea host must not be empty` |
| `host` begins `http://` | `gitea host must be https; got <host>` |
| `owner` or `repo` empty | `gitea owner and repo must not be empty` |

## `source_type: codeberg`

| Key | Required | Default | Notes |
|---|---|---|---|
| `owner` | **yes** | — | |
| `repo` | **yes** | — | |
| `private` | no | `false` | Inert. |
| `timeout_seconds` | no | `30` | |

There is deliberately **no `host` key**. The host is the associated constant
`CodebergParams::HOST`, fixed at `codeberg.org`. Adding a `host:` key to a
`codeberg` block is a serde error. To reach a different Gitea instance, use
`source_type: gitea` and set `host` there.

Codeberg surfaces exactly the Gitea backend's behaviour: same endpoints, same
`Authorization: token` header, same 50-item list cap.

## `source_type: bitbucket`

| Key | Required | Default | Notes |
|---|---|---|---|
| `host` | no | `api.bitbucket.org/2.0` | Includes the API version segment. |
| `workspace` | **yes** | — | Workspace slug. |
| `repo_slug` | **yes** | — | Repository slug. |
| `username` | no | `null` | Required for authenticated requests; see below. |
| `private` | no | `false` | Inert. |
| `timeout_seconds` | no | `30` | |

### Bitbucket auth needs `username` *and* a token

Bitbucket Cloud uses HTTP Basic auth with an App Password. The username comes
from config, the app password comes from the `SecretString` handed to the
factory. The provider only sets an `Authorization` header when **both** are
present:

- `username` set, token supplied → authenticated.
- `username` unset, token supplied → **request is sent anonymously**, silently.
- `username` set, no token → request is sent anonymously.

There is no validation error for the mismatched cases. A private repository
configured without `username` fails as a `404` from Bitbucket, surfaced as
`ProviderError::NotFound`, not as an auth error.

### Bitbucket validation errors

| Condition | Message |
|---|---|
| `host` empty or whitespace | `bitbucket host must not be empty` |
| `host` begins `http://` | `bitbucket host must be https; got <host>` |
| `workspace` or `repo_slug` empty | `bitbucket workspace and repo_slug must not be empty` |

## `source_type: direct`

For an S3 bucket, a private mirror, or anything else that serves files over
HTTPS without a forge API.

| Key | Required | Default | Notes |
|---|---|---|---|
| `version_url` | **yes** | — | Fully-qualified URL returning the current version. |
| `asset_url_template` | **yes** | — | URL template. Must contain `{version}`. |
| `pinned_version` | no | `null` | When set, `version_url` is never fetched. |
| `timeout_seconds` | no | `30` | |

### `asset_url_template` placeholders

Substituted by simple string replacement, in this order:

| Placeholder | Substituted with |
|---|---|
| `{version}` | The discovered or pinned version, verbatim |
| `{target}` | Rust host triple for the running binary |
| `{os}` | `std::env::consts::OS`: `linux`, `macos`, `windows`, … |
| `{arch}` | `std::env::consts::ARCH`: `x86_64`, `aarch64`, … |
| `{ext}` | `.zip` on Windows, `.tar.gz` everywhere else |

`{target}` is resolved from a fixed table of six triples:

| OS / arch | `{target}` |
|---|---|
| linux / x86_64 | `x86_64-unknown-linux-gnu` |
| linux / aarch64 | `aarch64-unknown-linux-gnu` |
| macos / x86_64 | `x86_64-apple-darwin` |
| macos / aarch64 | `aarch64-apple-darwin` |
| windows / x86_64 | `x86_64-pc-windows-msvc` |
| windows / aarch64 | `aarch64-pc-windows-msvc` |

**Any other host combination substitutes the empty string.** On, say,
`linux/armv7`, a template of
`https://example.com/{version}/tool-{target}{ext}` renders as
`https://example.com/1.2.3/tool-.tar.gz`, a plausible-looking URL that 404s.
It does not leave `{target}` in place, and it does not raise a configuration
error. If you support platforms outside that table, build the URL from `{os}`
and `{arch}` instead.

Only the five placeholders above are substituted. Any other `{...}` sequence is
left in the URL verbatim.

### What `version_url` may return

Fetched with a `GET`. The response is treated as JSON when the `Content-Type`
contains `application/json` **or** the body's first non-whitespace character is
`{`; otherwise it is treated as plain text.

- JSON: the root object must have a string `version` key. Anything else is
  `ProviderError::MalformedResponse("direct version_url JSON missing `.version` string")`.
- Plain text: the whole body, trimmed, is the version. An empty body is
  `ProviderError::MalformedResponse("direct version_url returned empty body")`.

### Direct validation errors

| Condition | Message |
|---|---|
| `version_url` empty or whitespace | `direct version_url must not be empty` |
| `version_url` has no `http://`/`https://` scheme | `direct version_url must be a fully-qualified URL; got <url>` |
| `version_url` begins `http://` | `direct version_url must be https; got <url>` |
| `asset_url_template` empty or whitespace | `direct asset_url_template must not be empty` |
| `asset_url_template` lacks `{version}` | `direct asset_url_template must contain the {version} placeholder` |

`asset_url_template` is **not** validated for scheme. Only `version_url` is
checked, so a template pointing at `http://` is accepted at construction and
then rejected by the HTTPS-only `reqwest` client at download time as a
transport error.

## `source_type: custom`

The escape hatch for a backend your tool registers itself.

```yaml
release:
  source_type: custom
  type: internal-mirror
  params:
    endpoint: https://releases.corp.internal
    channel: stable
```

| Key | Type | Meaning |
|---|---|---|
| `type` | string | The discriminator your factory registered under. Must not collide with a built-in. |
| `params` | map of string to string | Freeform. Your factory parses and validates it. |

Note the shape: the enum's tag is `source_type: custom`, and the *actual*
discriminator is the nested `type` key. `ReleaseSourceConfig::source_type()`
returns the nested value (`internal-mirror`), which is what
`rtb_forge::lookup` expects.

`params` values are all strings. There is no nested structure and no typed
coercion. A number or boolean in YAML will fail to deserialise unless quoted.

## What happens when the config does not match the factory

Each factory checks that it was handed its own variant and returns
`ProviderError::InvalidConfig` otherwise, with a message of the form:

```
github factory called with non-github config: source_type=gitlab
```

This is reachable if you call a backend's `factory` directly rather than going
through `lookup(cfg.source_type())`.