Skip to content

Add your own release backend

When your artefacts live somewhere none of the six built-in backends understands — an internal Artifactory, a package registry, a signed manifest service — you implement ReleaseProvider in your own crate. No fork, no patch to rtb-forge.

The snippets below accumulate into one module and compile as written against rtb-forge 0.7.

Implement the trait

Four methods, all read-only:

use async_trait::async_trait;
use rtb_forge::{ProviderError, Release, ReleaseAsset, ReleaseProvider};
use tokio::io::AsyncRead;

pub struct MirrorProvider {
    endpoint: String,
    channel: String,
}

#[async_trait]
impl ReleaseProvider for MirrorProvider {
    async fn latest_release(&self) -> Result<Release, ProviderError> {
        // … fetch, then build the value type:
        let mut release = Release::new("1.4.2", "v1.4.2", time::OffsetDateTime::now_utc());
        release.assets = vec![ReleaseAsset::new(
            "tool-1.4.2.tar.gz",
            "tool-1.4.2.tar.gz",
            format!("{}/{}/tool-1.4.2.tar.gz", self.endpoint, self.channel),
        )];
        Ok(release)
    }

    async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError> {
        Err(ProviderError::NotFound { what: tag.to_string() })
    }

    async fn list_releases(&self, _limit: usize) -> Result<Vec<Release>, ProviderError> {
        Err(ProviderError::Unsupported)
    }

    async fn download_asset(
        &self,
        _asset: &ReleaseAsset,
    ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError> {
        todo!()
    }
}

Release and ReleaseAsset are #[non_exhaustive], so build them with Release::new(name, tag, created_at) and ReleaseAsset::new(id, name, download_url) and assign the optional fields afterwards. Struct literals will not compile from outside the crate.

Returning ProviderError::Unsupported for a capability you genuinely do not have is correct and expected — the Bitbucket backend does exactly that for list_releases.

Write a factory

A ProviderFactory is a plain fn pointer that turns config plus an optional token into an Arc<dyn ReleaseProvider>:

use std::sync::Arc;
use rtb_forge::{ProviderFactory, ReleaseSourceConfig};
use secrecy::SecretString;

pub fn factory(
    cfg: &ReleaseSourceConfig,
    _token: Option<SecretString>,
) -> Result<Arc<dyn ReleaseProvider>, ProviderError> {
    let ReleaseSourceConfig::Custom { params, .. } = cfg else {
        return Err(ProviderError::InvalidConfig(format!(
            "internal-mirror factory called with source_type={}",
            cfg.source_type()
        )));
    };

    let endpoint = params.get("endpoint").ok_or_else(|| {
        ProviderError::InvalidConfig("internal-mirror requires `endpoint`".into())
    })?;
    let channel = params.get("channel").map_or("stable", String::as_str);

    Ok(Arc::new(MirrorProvider {
        endpoint: endpoint.clone(),
        channel: channel.to_string(),
    }))
}

Validate everything here. The factory is the only place a configuration error can be raised before the network is touched, and InvalidConfig is the variant for it.

Write a small type implementing ProviderRegistration and a function returning it, annotated with linkme:

use linkme::distributed_slice;
use rtb_forge::{ProviderRegistration, RELEASE_PROVIDERS};

struct MirrorRegistration;

impl ProviderRegistration for MirrorRegistration {
    fn source_type(&self) -> &'static str {
        "internal-mirror"
    }
    fn factory(&self) -> ProviderFactory {
        factory as ProviderFactory
    }
}

#[allow(unsafe_code)] // linkme emits #[link_section]; no hand-written unsafe here.
#[distributed_slice(RELEASE_PROVIDERS)]
fn register_mirror() -> Box<dyn ProviderRegistration> {
    Box::new(MirrorRegistration)
}

Add linkme to your own Cargo.toml at a version compatible with the one rtb-forge uses:

[dependencies]
linkme = "0.3"

The #[allow(unsafe_code)] is only needed if your crate denies or forbids unsafe_code. linkme expands to a #[link_section] attribute, which Rust 1.95 and later attribute to that lint; there is no actual unsafe block.

Make sure the registration is linked in

This is the one failure mode worth planning for. If your backend lives in a library crate that the final binary never otherwise references, the linker may drop the object file and your registration disappears — lookup returns None with no error anywhere.

The fix is to make the binary reference the crate:

// In the binary's main.rs.
use my_mirror_backend as _;

Then assert it at startup, so a regression is loud:

debug_assert!(
    rtb_forge::registered_types().contains(&"internal-mirror"),
    "mirror backend was not linked in"
);

Configure it

The Custom variant carries your discriminator in a nested type key, and everything else in a string-to-string map:

release:
  source_type: custom
  type: internal-mirror
  params:
    endpoint: https://artifacts.corp.internal/deploy-tool
    channel: stable

ReleaseSourceConfig::source_type() returns internal-mirror for that config — not custom — so the ordinary resolution path finds your factory with no special case:

let factory = rtb_forge::lookup(cfg.source_type()).ok_or(/* … */)?;
let provider = factory(&cfg, token)?;

Two constraints on params: values are all strings, so numbers and booleans must be quoted in YAML, and there is no nesting. If you need structured configuration, parse it out of a string value yourself.

Do not collide with a built-in

Your source_type must not be github, gitlab, gitea, codeberg, bitbucket or direct. lookup returns the first match in slice order, which is link order — not something you should rely on. Pick a name that cannot collide, and check registered_types() in a test.