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}