use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value as JsonValue;
use crate::runner::RunnerError;
use crate::types::ProducedArtifact;
pub mod appcast;
pub mod apple;
pub mod authenticode;
pub mod edsign;
pub mod event_log;
pub mod github_release;
pub mod notarize;
pub mod play;
pub mod sparkle;
pub mod testflight;
pub mod winsparkle;
pub use authenticode::AuthenticodeProvider;
pub use event_log::{EventLogConfig, EventLogProvider, EVENT_LOG_PROVIDER};
pub use github_release::GithubReleaseProvider;
pub use notarize::NotarizeProvider;
pub use play::PlayProvider;
pub use sparkle::SparkleProvider;
pub use testflight::TestFlightProvider;
pub use winsparkle::WinSparkleProvider;
pub trait SecretSource: Send + Sync {
fn resolve(&self, name: &str) -> Option<String>;
}
impl SecretSource for crate::secrets_bridge::SecretsConfig {
fn resolve(&self, name: &str) -> Option<String> {
self.resolve_one(name)
}
}
#[derive(Debug, Clone, Default)]
pub struct MapSecrets(pub BTreeMap<String, String>);
impl SecretSource for MapSecrets {
fn resolve(&self, name: &str) -> Option<String> {
self.0.get(name).cloned()
}
}
pub struct ProviderContext<'a> {
pub version: &'a str,
pub artifacts: &'a [ProducedArtifact],
pub base_url: Option<&'a str>,
pub config: &'a JsonValue,
pub work_dir: &'a Path,
pub secrets: &'a dyn SecretSource,
pub dry_run: bool,
}
impl ProviderContext<'_> {
pub fn require_secret(&self, slot: &str) -> Result<String, RunnerError> {
self.secrets
.resolve(slot)
.filter(|v| !v.is_empty())
.ok_or_else(|| {
RunnerError::Outcome(format!(
"release provider: credential slot `{slot}` is unset — declare it in \
~/.yah/qed/secrets.toml (e.g. `{slot} = \"vault:<slot>\"`)"
))
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProviderReport {
pub actions: Vec<String>,
pub produced: Vec<ProducedArtifact>,
pub published: Vec<String>,
}
impl ProviderReport {
pub fn action(line: impl Into<String>) -> Self {
Self {
actions: vec![line.into()],
..Default::default()
}
}
}
#[async_trait]
pub trait ReleaseProvider: Send + Sync {
fn name(&self) -> &str;
fn required_slots(&self) -> Vec<&str> {
Vec::new()
}
async fn dispatch(&self, ctx: &ProviderContext<'_>) -> Result<ProviderReport, RunnerError>;
}
#[derive(Default, Clone)]
pub struct ProviderRegistry {
providers: BTreeMap<String, Arc<dyn ReleaseProvider>>,
}
impl ProviderRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn production() -> Self {
Self::new()
.with(Arc::new(NotarizeProvider::default()))
.with(Arc::new(AuthenticodeProvider::default()))
.with(Arc::new(SparkleProvider))
.with(Arc::new(WinSparkleProvider))
.with(Arc::new(TestFlightProvider::default()))
.with(Arc::new(PlayProvider::default()))
.with(Arc::new(GithubReleaseProvider::default()))
.with(Arc::new(EventLogProvider::default()))
}
pub fn with(mut self, provider: Arc<dyn ReleaseProvider>) -> Self {
self.providers.insert(provider.name().to_string(), provider);
self
}
pub fn register(&mut self, provider: Arc<dyn ReleaseProvider>) {
self.providers.insert(provider.name().to_string(), provider);
}
pub fn get(&self, name: &str) -> Option<&Arc<dyn ReleaseProvider>> {
self.providers.get(name)
}
pub fn names(&self) -> Vec<&str> {
self.providers.keys().map(String::as_str).collect()
}
pub fn missing_slots(&self, name: &str, secrets: &dyn SecretSource) -> Option<Vec<String>> {
let provider = self.get(name)?;
Some(
provider
.required_slots()
.into_iter()
.filter(|slot| secrets.resolve(slot).filter(|v| !v.is_empty()).is_none())
.map(str::to_string)
.collect(),
)
}
pub async fn dispatch(
&self,
name: &str,
ctx: &ProviderContext<'_>,
) -> Result<ProviderReport, RunnerError> {
let provider = self.get(name).ok_or_else(|| {
RunnerError::Outcome(format!(
"release provider `{name}` is not registered (known: {})",
self.names().join(", ")
))
})?;
provider.dispatch(ctx).await
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeProvider;
#[async_trait]
impl ReleaseProvider for FakeProvider {
fn name(&self) -> &str {
"fake"
}
fn required_slots(&self) -> Vec<&str> {
vec!["FAKE_TOKEN"]
}
async fn dispatch(&self, ctx: &ProviderContext<'_>) -> Result<ProviderReport, RunnerError> {
let _token = ctx.require_secret("FAKE_TOKEN")?;
if ctx.dry_run {
return Ok(ProviderReport::action(format!(
"would publish {} artifact(s) at v{}",
ctx.artifacts.len(),
ctx.version
)));
}
Ok(ProviderReport {
actions: vec!["published".into()],
produced: vec![],
published: vec![format!("https://fake/{}", ctx.version)],
})
}
}
fn ctx<'a>(
secrets: &'a dyn SecretSource,
work: &'a Path,
cfg: &'a JsonValue,
dry_run: bool,
) -> ProviderContext<'a> {
ProviderContext {
version: "1.2.3",
artifacts: &[],
base_url: None,
config: cfg,
work_dir: work,
secrets,
dry_run,
}
}
#[test]
fn registry_get_and_names() {
let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
assert_eq!(reg.names(), vec!["fake"]);
assert!(reg.get("fake").is_some());
assert!(reg.get("nope").is_none());
}
#[test]
fn missing_slots_reports_unresolved_only() {
let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
let empty = MapSecrets::default();
assert_eq!(
reg.missing_slots("fake", &empty).unwrap(),
vec!["FAKE_TOKEN".to_string()]
);
let mut m = BTreeMap::new();
m.insert("FAKE_TOKEN".to_string(), "abc".to_string());
let present = MapSecrets(m);
assert!(reg.missing_slots("fake", &present).unwrap().is_empty());
assert!(reg.missing_slots("nope", &empty).is_none());
}
#[test]
fn empty_slot_value_counts_as_missing() {
let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
let mut m = BTreeMap::new();
m.insert("FAKE_TOKEN".to_string(), String::new());
assert_eq!(
reg.missing_slots("fake", &MapSecrets(m)).unwrap(),
vec!["FAKE_TOKEN".to_string()]
);
}
#[tokio::test]
async fn dispatch_dry_run_plans_without_publishing() {
let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
let work = tempfile::tempdir().unwrap();
let cfg = JsonValue::Null;
let mut m = BTreeMap::new();
m.insert("FAKE_TOKEN".to_string(), "abc".to_string());
let secrets = MapSecrets(m);
let report = reg
.dispatch("fake", &ctx(&secrets, work.path(), &cfg, true))
.await
.unwrap();
assert_eq!(report.actions.len(), 1);
assert!(report.actions[0].contains("would publish"));
assert!(report.published.is_empty(), "dry run ships nothing");
}
#[tokio::test]
async fn dispatch_live_reports_published_url() {
let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
let work = tempfile::tempdir().unwrap();
let cfg = JsonValue::Null;
let mut m = BTreeMap::new();
m.insert("FAKE_TOKEN".to_string(), "abc".to_string());
let secrets = MapSecrets(m);
let report = reg
.dispatch("fake", &ctx(&secrets, work.path(), &cfg, false))
.await
.unwrap();
assert_eq!(report.published, vec!["https://fake/1.2.3".to_string()]);
}
#[tokio::test]
async fn dispatch_missing_credential_is_typed_error() {
let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
let work = tempfile::tempdir().unwrap();
let cfg = JsonValue::Null;
let secrets = MapSecrets::default();
let err = reg
.dispatch("fake", &ctx(&secrets, work.path(), &cfg, true))
.await
.unwrap_err();
assert!(
format!("{err}").contains("FAKE_TOKEN"),
"error names the slot: {err}"
);
}
#[tokio::test]
async fn dispatch_unknown_provider_lists_known() {
let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
let work = tempfile::tempdir().unwrap();
let cfg = JsonValue::Null;
let secrets = MapSecrets::default();
let err = reg
.dispatch("ghost", &ctx(&secrets, work.path(), &cfg, true))
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("ghost") && msg.contains("fake"),
"names unknown + known: {msg}"
);
}
}