Skip to main content

yah_qed/provider/
mod.rs

1//! Vendor release-provider adapter seam (R509).
2//!
3//! [`crate::publish`] owns the *almanac channel* producer — laying artifacts
4//! into an R2 tree and firing the revalidate hook. That [`ReleasePublisher`]
5//! shape (`sync` + `revalidate`) is specific to the content-addressed channel
6//! and does **not** fit the heterogeneous vendor publishers tier-3 releases
7//! need: Apple notarization blocks on a remote ticket, Authenticode mutates a
8//! `.exe` in place, Sparkle emits an appcast + signed delta, TestFlight and
9//! Play upload to a vendor API. None of those is "upload a staged tree to a
10//! bucket".
11//!
12//! This module is the shared seam those adapters plug into. Each vendor
13//! adapter (Sparkle, winsparkle, notarize, Authenticode, TestFlight, Play,
14//! GitHub Release) implements [`ReleaseProvider`] in its own child ticket
15//! under R509 and is registered into a [`ProviderRegistry`] by name. The
16//! adapters are independent — they share only this contract — so they fan out
17//! and merge in any order.
18//!
19//! ## Contract
20//!
21//! - A pipeline references an adapter by **name** (`provider = "sparkle"`),
22//!   alongside a vendor-specific `with` config blob and the credential
23//!   **slot names** the adapter reads. The slot names resolve through the
24//!   existing [`crate::secrets_bridge`] (`~/.yah/qed/secrets.toml`) — no new
25//!   credential mechanism. Each adapter declares its slots via
26//!   [`ReleaseProvider::required_slots`] so the runner can do a plan-time
27//!   presence check and a dry-run can report what it *would* read.
28//! - [`ReleaseProvider::dispatch`] performs the sign / notarize / upload. It
29//!   honors [`ProviderContext::dry_run`]: a dry run validates config + checks
30//!   credential presence and returns the actions it *would* take, performing
31//!   no network I/O and no artifact mutation.
32//! - Adapters that *transform* an artifact (sign, staple, notarize) return the
33//!   transformed artifacts in [`ProviderReport::produced`]; adapters that
34//!   *ship* an artifact return the destination URLs in
35//!   [`ProviderReport::published`]. An adapter may do both (Sparkle emits an
36//!   appcast artifact *and* uploads it).
37//!
38//! ## Wiring (deferred to the first landing adapter)
39//!
40//! This module lands the contract + registry only. Threading a named provider
41//! through `Outcome` dispatch in [`crate::runner`] is intentionally left to the
42//! first vendor F-ticket that needs a live run path, so the dispatch shape is
43//! designed against a *real* adapter rather than speculatively. Until then the
44//! registry is exercised by unit tests and by `qed validate`'s plan-time slot
45//! check. See `.yah/docs/working/W208-qed-tier3-release-gap-closure.md` §5.
46
47use std::collections::BTreeMap;
48use std::path::Path;
49use std::sync::Arc;
50
51use async_trait::async_trait;
52use serde_json::Value as JsonValue;
53
54use crate::runner::RunnerError;
55use crate::types::ProducedArtifact;
56
57pub mod appcast;
58pub mod apple;
59pub mod authenticode;
60pub mod edsign;
61pub mod event_log;
62pub mod github_release;
63pub mod notarize;
64pub mod play;
65pub mod sparkle;
66pub mod testflight;
67pub mod winsparkle;
68
69pub use authenticode::AuthenticodeProvider;
70pub use event_log::{EventLogConfig, EventLogProvider, EVENT_LOG_PROVIDER};
71pub use github_release::GithubReleaseProvider;
72pub use notarize::NotarizeProvider;
73pub use play::PlayProvider;
74pub use sparkle::SparkleProvider;
75pub use testflight::TestFlightProvider;
76pub use winsparkle::WinSparkleProvider;
77
78/// Resolves a credential slot name to its current secret value. Implemented by
79/// [`crate::secrets_bridge::SecretsConfig`] over the real vault; tests supply a
80/// map-backed fake. Kept abstract so the adapter seam doesn't pull the vault /
81/// `keys` crate into adapter unit tests.
82pub trait SecretSource: Send + Sync {
83    /// Resolve `name` (a bridged slot name, e.g. `"APPLE_API_KEY"`) to its
84    /// value, or `None` when it isn't declared / doesn't resolve.
85    fn resolve(&self, name: &str) -> Option<String>;
86}
87
88impl SecretSource for crate::secrets_bridge::SecretsConfig {
89    fn resolve(&self, name: &str) -> Option<String> {
90        self.resolve_one(name)
91    }
92}
93
94/// A [`SecretSource`] backed by an in-memory map. Test fixture + the escape
95/// hatch for callers that already hold resolved secrets.
96#[derive(Debug, Clone, Default)]
97pub struct MapSecrets(pub BTreeMap<String, String>);
98
99impl SecretSource for MapSecrets {
100    fn resolve(&self, name: &str) -> Option<String> {
101        self.0.get(name).cloned()
102    }
103}
104
105/// Everything an adapter needs to run one publish: the release version, the
106/// artifacts produced by the run's successful steps, the vendor-specific
107/// config blob, a scratch dir, a secret resolver, and the dry-run flag.
108pub struct ProviderContext<'a> {
109    /// Resolved release version (no leading `v`).
110    pub version: &'a str,
111    /// Artifacts collected from the run's successful `produces` declarations.
112    pub artifacts: &'a [ProducedArtifact],
113    /// Public-facing root for absolute URLs (e.g. `https://releases.yah.dev`).
114    pub base_url: Option<&'a str>,
115    /// Vendor-specific config (the pipeline's `with = { ... }` table), opaque
116    /// to the seam. Each adapter deserializes its own typed config from this.
117    pub config: &'a JsonValue,
118    /// Scratch dir the adapter may write into (appcast XML, deltas, signed
119    /// bundles). The caller owns its lifetime (a tempdir) and cleans it up.
120    pub work_dir: &'a Path,
121    /// Credential resolver over the secrets bridge.
122    pub secrets: &'a dyn SecretSource,
123    /// When `true`, validate + report intended actions only; perform no
124    /// network I/O and mutate no artifacts.
125    pub dry_run: bool,
126}
127
128impl ProviderContext<'_> {
129    /// Resolve a required slot, mapping a miss to a typed [`RunnerError`] so an
130    /// adapter can `?`-propagate a missing-credential failure with a message
131    /// that names the slot and points at the secrets bridge.
132    pub fn require_secret(&self, slot: &str) -> Result<String, RunnerError> {
133        self.secrets
134            .resolve(slot)
135            .filter(|v| !v.is_empty())
136            .ok_or_else(|| {
137                RunnerError::Outcome(format!(
138                    "release provider: credential slot `{slot}` is unset — declare it in \
139                 ~/.yah/qed/secrets.toml (e.g. `{slot} = \"vault:<slot>\"`)"
140                ))
141            })
142    }
143}
144
145/// What an adapter did (or, under `dry_run`, would do).
146#[derive(Debug, Clone, Default, PartialEq, Eq)]
147pub struct ProviderReport {
148    /// Human-readable log of actions taken (or planned, under dry-run). One
149    /// line per discrete action; surfaced into the run's event stream.
150    pub actions: Vec<String>,
151    /// Artifacts the adapter produced or transformed in place — signed
152    /// bundles, stapled `.app`s, generated appcast XML / deltas. These replace
153    /// or augment the inputs for any downstream provider.
154    pub produced: Vec<ProducedArtifact>,
155    /// Destination URLs / vendor record locators the adapter shipped to
156    /// (appcast URL, TestFlight build link, GitHub Release URL). For the
157    /// dashboard + the run summary.
158    pub published: Vec<String>,
159}
160
161impl ProviderReport {
162    /// A report carrying a single action line and nothing else — the common
163    /// shape for a notarize/sign step + the canonical dry-run skeleton.
164    pub fn action(line: impl Into<String>) -> Self {
165        Self {
166            actions: vec![line.into()],
167            ..Default::default()
168        }
169    }
170}
171
172/// A named vendor release adapter. One impl per vendor (Sparkle, notarize,
173/// Authenticode, TestFlight, Play, GitHub Release, winsparkle), each landing in
174/// its own R509 child ticket.
175#[async_trait]
176pub trait ReleaseProvider: Send + Sync {
177    /// Stable name referenced in pipeline TOML (`provider = "<name>"`). Must be
178    /// unique within a [`ProviderRegistry`].
179    fn name(&self) -> &str;
180
181    /// Credential slot names this adapter reads from the secrets bridge. Used
182    /// for the plan-time presence check ([`ProviderRegistry::missing_slots`])
183    /// and dry-run reporting. Empty for adapters that need no credentials.
184    fn required_slots(&self) -> Vec<&str> {
185        Vec::new()
186    }
187
188    /// Perform the sign / notarize / upload. Must honor
189    /// [`ProviderContext::dry_run`] — a dry run does config validation +
190    /// credential-presence checks and returns the intended actions only.
191    async fn dispatch(&self, ctx: &ProviderContext<'_>) -> Result<ProviderReport, RunnerError>;
192}
193
194/// Registry of release-provider adapters, keyed by [`ReleaseProvider::name`].
195/// The runner builds one default registry (with every wired adapter) and looks
196/// up the named provider when dispatching a vendor publish outcome.
197#[derive(Default, Clone)]
198pub struct ProviderRegistry {
199    providers: BTreeMap<String, Arc<dyn ReleaseProvider>>,
200}
201
202impl ProviderRegistry {
203    pub fn new() -> Self {
204        Self::default()
205    }
206
207    /// The built-in vendor adapter set (R509). The CLI + daemon construction
208    /// sites pass this into [`crate::runner::PipelineRunner::with_release_providers`]
209    /// so an `Outcome::Provider { provider = "notarize", … }` resolves to a real
210    /// adapter. **This is the single registration point**: every R509 child
211    /// adapter (authenticode, sparkle, winsparkle, testflight, play,
212    /// github-release) lands one `.with(Arc::new(...))` line here as it merges.
213    pub fn production() -> Self {
214        Self::new()
215            .with(Arc::new(NotarizeProvider::default()))
216            .with(Arc::new(AuthenticodeProvider::default()))
217            .with(Arc::new(SparkleProvider))
218            .with(Arc::new(WinSparkleProvider))
219            .with(Arc::new(TestFlightProvider::default()))
220            .with(Arc::new(PlayProvider::default()))
221            .with(Arc::new(GithubReleaseProvider::default()))
222            // R508: declares the `event-log` outcome so a pipeline opting into
223            // a persistent-log spool doesn't fail at dispatch. The byte upload
224            // is performed by the host daemon that owns the JSONL — see
225            // [`event_log`].
226            .with(Arc::new(EventLogProvider::default()))
227    }
228
229    /// Register an adapter. Later registrations with the same name win (so a
230    /// host can override a built-in adapter). Returns `self` for chaining.
231    pub fn with(mut self, provider: Arc<dyn ReleaseProvider>) -> Self {
232        self.providers.insert(provider.name().to_string(), provider);
233        self
234    }
235
236    /// Register an adapter in place.
237    pub fn register(&mut self, provider: Arc<dyn ReleaseProvider>) {
238        self.providers.insert(provider.name().to_string(), provider);
239    }
240
241    /// Look up an adapter by name.
242    pub fn get(&self, name: &str) -> Option<&Arc<dyn ReleaseProvider>> {
243        self.providers.get(name)
244    }
245
246    /// Registered provider names, sorted.
247    pub fn names(&self) -> Vec<&str> {
248        self.providers.keys().map(String::as_str).collect()
249    }
250
251    /// Plan-time credential check: the slots the named provider declares that
252    /// do **not** currently resolve to a non-empty value. An empty vec means
253    /// every required slot is present. `None` when the provider isn't
254    /// registered (an unknown-provider error the caller reports separately).
255    pub fn missing_slots(&self, name: &str, secrets: &dyn SecretSource) -> Option<Vec<String>> {
256        let provider = self.get(name)?;
257        Some(
258            provider
259                .required_slots()
260                .into_iter()
261                .filter(|slot| secrets.resolve(slot).filter(|v| !v.is_empty()).is_none())
262                .map(str::to_string)
263                .collect(),
264        )
265    }
266
267    /// Dispatch the named provider, or a typed unknown-provider error listing
268    /// the registered names.
269    pub async fn dispatch(
270        &self,
271        name: &str,
272        ctx: &ProviderContext<'_>,
273    ) -> Result<ProviderReport, RunnerError> {
274        let provider = self.get(name).ok_or_else(|| {
275            RunnerError::Outcome(format!(
276                "release provider `{name}` is not registered (known: {})",
277                self.names().join(", ")
278            ))
279        })?;
280        provider.dispatch(ctx).await
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    /// A trivial adapter: requires one slot, echoes a planned action, and (when
289    /// live) reports a published URL.
290    struct FakeProvider;
291
292    #[async_trait]
293    impl ReleaseProvider for FakeProvider {
294        fn name(&self) -> &str {
295            "fake"
296        }
297        fn required_slots(&self) -> Vec<&str> {
298            vec!["FAKE_TOKEN"]
299        }
300        async fn dispatch(&self, ctx: &ProviderContext<'_>) -> Result<ProviderReport, RunnerError> {
301            // Credential is required even for the action plan.
302            let _token = ctx.require_secret("FAKE_TOKEN")?;
303            if ctx.dry_run {
304                return Ok(ProviderReport::action(format!(
305                    "would publish {} artifact(s) at v{}",
306                    ctx.artifacts.len(),
307                    ctx.version
308                )));
309            }
310            Ok(ProviderReport {
311                actions: vec!["published".into()],
312                produced: vec![],
313                published: vec![format!("https://fake/{}", ctx.version)],
314            })
315        }
316    }
317
318    fn ctx<'a>(
319        secrets: &'a dyn SecretSource,
320        work: &'a Path,
321        cfg: &'a JsonValue,
322        dry_run: bool,
323    ) -> ProviderContext<'a> {
324        ProviderContext {
325            version: "1.2.3",
326            artifacts: &[],
327            base_url: None,
328            config: cfg,
329            work_dir: work,
330            secrets,
331            dry_run,
332        }
333    }
334
335    #[test]
336    fn registry_get_and_names() {
337        let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
338        assert_eq!(reg.names(), vec!["fake"]);
339        assert!(reg.get("fake").is_some());
340        assert!(reg.get("nope").is_none());
341    }
342
343    #[test]
344    fn missing_slots_reports_unresolved_only() {
345        let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
346        let empty = MapSecrets::default();
347        assert_eq!(
348            reg.missing_slots("fake", &empty).unwrap(),
349            vec!["FAKE_TOKEN".to_string()]
350        );
351        let mut m = BTreeMap::new();
352        m.insert("FAKE_TOKEN".to_string(), "abc".to_string());
353        let present = MapSecrets(m);
354        assert!(reg.missing_slots("fake", &present).unwrap().is_empty());
355        // Unknown provider → None (not an empty vec).
356        assert!(reg.missing_slots("nope", &empty).is_none());
357    }
358
359    #[test]
360    fn empty_slot_value_counts_as_missing() {
361        let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
362        let mut m = BTreeMap::new();
363        m.insert("FAKE_TOKEN".to_string(), String::new());
364        assert_eq!(
365            reg.missing_slots("fake", &MapSecrets(m)).unwrap(),
366            vec!["FAKE_TOKEN".to_string()]
367        );
368    }
369
370    #[tokio::test]
371    async fn dispatch_dry_run_plans_without_publishing() {
372        let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
373        let work = tempfile::tempdir().unwrap();
374        let cfg = JsonValue::Null;
375        let mut m = BTreeMap::new();
376        m.insert("FAKE_TOKEN".to_string(), "abc".to_string());
377        let secrets = MapSecrets(m);
378        let report = reg
379            .dispatch("fake", &ctx(&secrets, work.path(), &cfg, true))
380            .await
381            .unwrap();
382        assert_eq!(report.actions.len(), 1);
383        assert!(report.actions[0].contains("would publish"));
384        assert!(report.published.is_empty(), "dry run ships nothing");
385    }
386
387    #[tokio::test]
388    async fn dispatch_live_reports_published_url() {
389        let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
390        let work = tempfile::tempdir().unwrap();
391        let cfg = JsonValue::Null;
392        let mut m = BTreeMap::new();
393        m.insert("FAKE_TOKEN".to_string(), "abc".to_string());
394        let secrets = MapSecrets(m);
395        let report = reg
396            .dispatch("fake", &ctx(&secrets, work.path(), &cfg, false))
397            .await
398            .unwrap();
399        assert_eq!(report.published, vec!["https://fake/1.2.3".to_string()]);
400    }
401
402    #[tokio::test]
403    async fn dispatch_missing_credential_is_typed_error() {
404        let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
405        let work = tempfile::tempdir().unwrap();
406        let cfg = JsonValue::Null;
407        let secrets = MapSecrets::default();
408        let err = reg
409            .dispatch("fake", &ctx(&secrets, work.path(), &cfg, true))
410            .await
411            .unwrap_err();
412        assert!(
413            format!("{err}").contains("FAKE_TOKEN"),
414            "error names the slot: {err}"
415        );
416    }
417
418    #[tokio::test]
419    async fn dispatch_unknown_provider_lists_known() {
420        let reg = ProviderRegistry::new().with(Arc::new(FakeProvider));
421        let work = tempfile::tempdir().unwrap();
422        let cfg = JsonValue::Null;
423        let secrets = MapSecrets::default();
424        let err = reg
425            .dispatch("ghost", &ctx(&secrets, work.path(), &cfg, true))
426            .await
427            .unwrap_err();
428        let msg = format!("{err}");
429        assert!(
430            msg.contains("ghost") && msg.contains("fake"),
431            "names unknown + known: {msg}"
432        );
433    }
434}