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, and not something you should rely on. Pick a name that cannot collide, and check registered_types() in a test.