1use std::collections::BTreeMap;
66use std::path::Path;
67
68use async_trait::async_trait;
69use chrono::Utc;
70use serde::{Deserialize, Serialize};
71
72use crate::runner::{OutcomeDispatcher, RunnerError};
73use crate::types::ProducedArtifact;
74
75#[derive(Debug, Clone)]
78pub struct PublishRequest {
79 pub provider: String,
80 pub bucket: String,
81 pub prefix: Option<String>,
82 pub base_url: Option<String>,
83 pub version: String,
84 pub artifacts: Vec<ProducedArtifact>,
85}
86
87pub fn resolve_release_version() -> String {
91 std::env::var("YAH_RELEASE_VERSION")
92 .ok()
93 .filter(|v| !v.trim().is_empty())
94 .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string())
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ChannelManifest {
109 pub version: String,
111 pub pub_date: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub notes: Option<String>,
115 pub host: ChannelHost,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct ChannelHost {
120 pub bundle: BTreeMap<String, ChannelBundle>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct ChannelBundle {
126 pub url: String,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub size: Option<u64>,
130}
131
132const MANIFEST_FILENAME: &str = "release-manifest.json";
134
135fn per_triple_manifest_filename(triple: &str) -> String {
138 format!("release-manifest-{triple}.json")
139}
140
141#[derive(Debug, Clone, Default)]
144pub struct StageReport {
145 pub object_keys: Vec<String>,
147 pub manifest_keys: Vec<String>,
149 pub manifests: BTreeMap<String, ChannelManifest>,
151}
152
153pub fn resolve_triple(triple: Option<&str>) -> String {
156 if let Some(t) = triple.filter(|t| !t.is_empty()) {
157 return t.to_string();
158 }
159 let os = match std::env::consts::OS {
160 "macos" => "darwin",
161 other => other,
162 };
163 format!("{os}-{}", std::env::consts::ARCH)
164}
165
166fn join_key(prefix: Option<&str>, parts: &[&str]) -> String {
167 let mut segs: Vec<&str> = Vec::new();
168 if let Some(p) = prefix.map(str::trim).filter(|p| !p.is_empty()) {
169 segs.push(p.trim_matches('/'));
170 }
171 segs.extend_from_slice(parts);
172 segs.join("/")
173}
174
175pub fn stage_release(
179 staging_dir: &Path,
180 artifacts: &[ProducedArtifact],
181 version: &str,
182 prefix: Option<&str>,
183 base_url: Option<&str>,
184) -> std::io::Result<StageReport> {
185 let version = version.trim_start_matches('v').to_string();
186 let mut report = StageReport::default();
187 let mut bundles: BTreeMap<String, BTreeMap<String, ChannelBundle>> = BTreeMap::new();
189
190 for artifact in artifacts {
191 let triple = resolve_triple(artifact.triple.as_deref());
192 let src = Path::new(&artifact.path);
193 let filename = src.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
194 std::io::Error::new(
195 std::io::ErrorKind::InvalidInput,
196 format!("artifact path has no filename: {}", artifact.path),
197 )
198 })?;
199
200 let key = join_key(prefix, &[&artifact.binary, &version, &triple, filename]);
201 let dest = staging_dir.join(&key);
202 if let Some(parent) = dest.parent() {
203 std::fs::create_dir_all(parent)?;
204 }
205 let bytes = std::fs::copy(src, &dest)?;
206
207 let url = match base_url.map(str::trim).filter(|b| !b.is_empty()) {
208 Some(base) => format!("{}/{}", base.trim_end_matches('/'), key),
209 None => key.clone(),
210 };
211 bundles.entry(artifact.binary.clone()).or_default().insert(
212 triple,
213 ChannelBundle {
214 url,
215 size: Some(bytes),
216 },
217 );
218 report.object_keys.push(key);
219 }
220
221 let pub_date = Utc::now().to_rfc3339();
222 for (binary, bundle) in bundles {
223 for (triple, single_bundle) in &bundle {
229 let mut per_triple_bundle: BTreeMap<String, ChannelBundle> = BTreeMap::new();
230 per_triple_bundle.insert(triple.clone(), single_bundle.clone());
231 let manifest = ChannelManifest {
232 version: version.clone(),
233 pub_date: pub_date.clone(),
234 notes: None,
235 host: ChannelHost {
236 bundle: per_triple_bundle,
237 },
238 };
239 let manifest_key = join_key(prefix, &[&binary, &per_triple_manifest_filename(triple)]);
240 let dest = staging_dir.join(&manifest_key);
241 if let Some(parent) = dest.parent() {
242 std::fs::create_dir_all(parent)?;
243 }
244 let json = serde_json::to_vec_pretty(&manifest)
245 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
246 std::fs::write(&dest, json)?;
247 report.manifest_keys.push(manifest_key);
248 }
249
250 let manifest = ChannelManifest {
255 version: version.clone(),
256 pub_date: pub_date.clone(),
257 notes: None,
258 host: ChannelHost { bundle },
259 };
260 let manifest_key = join_key(prefix, &[&binary, MANIFEST_FILENAME]);
261 let dest = staging_dir.join(&manifest_key);
262 if let Some(parent) = dest.parent() {
263 std::fs::create_dir_all(parent)?;
264 }
265 let json = serde_json::to_vec_pretty(&manifest)
266 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
267 std::fs::write(&dest, json)?;
268 report.manifest_keys.push(manifest_key);
269 report.manifests.insert(binary, manifest);
270 }
271
272 report.object_keys.sort();
273 report.manifest_keys.sort();
274 Ok(report)
275}
276
277#[async_trait]
286pub trait ReleasePublisher: Send + Sync {
287 async fn sync(
290 &self,
291 staging_dir: &Path,
292 provider: &str,
293 bucket: &str,
294 prefix: Option<&str>,
295 ) -> Result<(), RunnerError>;
296
297 async fn revalidate(&self) -> Result<(), RunnerError>;
300}
301
302pub struct PublishingOutcomeDispatcher<P: ReleasePublisher> {
307 publisher: P,
308}
309
310impl<P: ReleasePublisher> PublishingOutcomeDispatcher<P> {
311 pub fn new(publisher: P) -> Self {
312 Self { publisher }
313 }
314}
315
316#[async_trait]
317impl<P: ReleasePublisher> OutcomeDispatcher for PublishingOutcomeDispatcher<P> {
318 async fn warden_deploy(&self, service: &str, env: &str) -> Result<(), RunnerError> {
319 tracing::info!(
320 service,
321 env,
322 "qed outcome: yubaba-deploy skipped (yubaba deploy RPC not yet stable, R040-F4)"
323 );
324 Ok(())
325 }
326
327 async fn almanac_run(&self, pipeline: &str) -> Result<(), RunnerError> {
328 tracing::info!(
329 pipeline,
330 "qed outcome: almanac-run skipped (cadence scheduler pending)"
331 );
332 Ok(())
333 }
334
335 async fn publish(&self, req: &PublishRequest) -> Result<(), RunnerError> {
336 if req.artifacts.is_empty() {
337 tracing::warn!(
338 bucket = %req.bucket,
339 "qed outcome: publish has no produced artifacts — \
340 declare `produces` on the build steps; skipping"
341 );
342 return Ok(());
343 }
344
345 let staging = tempfile::tempdir()?;
346 let report = stage_release(
347 staging.path(),
348 &req.artifacts,
349 &req.version,
350 req.prefix.as_deref(),
351 req.base_url.as_deref(),
352 )?;
353 tracing::info!(
354 provider = %req.provider,
355 bucket = %req.bucket,
356 version = %req.version,
357 objects = report.object_keys.len(),
358 manifests = report.manifest_keys.len(),
359 "qed outcome: staged release channel"
360 );
361
362 self.publisher
363 .sync(
364 staging.path(),
365 &req.provider,
366 &req.bucket,
367 req.prefix.as_deref(),
368 )
369 .await?;
370 self.publisher.revalidate().await?;
371 Ok(())
372 }
373}
374
375pub struct LoggingReleasePublisher;
378
379#[async_trait]
380impl ReleasePublisher for LoggingReleasePublisher {
381 async fn sync(
382 &self,
383 staging_dir: &Path,
384 provider: &str,
385 bucket: &str,
386 prefix: Option<&str>,
387 ) -> Result<(), RunnerError> {
388 tracing::info!(
389 provider,
390 bucket,
391 prefix,
392 staging = %staging_dir.display(),
393 "qed publish: sync skipped (no real publisher wired)"
394 );
395 Ok(())
396 }
397
398 async fn revalidate(&self) -> Result<(), RunnerError> {
399 tracing::info!("qed publish: revalidate hook skipped (no receiver configured)");
400 Ok(())
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use std::sync::Mutex;
408 use tempfile::TempDir;
409
410 fn write_dummy(dir: &Path, rel: &str, contents: &[u8]) -> String {
411 let p = dir.join(rel);
412 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
413 std::fs::write(&p, contents).unwrap();
414 p.to_string_lossy().into_owned()
415 }
416
417 #[test]
418 fn resolve_triple_uses_host_when_none() {
419 let t = resolve_triple(None);
420 assert!(t.contains('-'), "host triple shorthand has os-arch: {t}");
421 assert_eq!(resolve_triple(Some("linux-x86_64")), "linux-x86_64");
422 assert_eq!(resolve_triple(Some("")), resolve_triple(None));
424 }
425
426 #[test]
427 fn stage_release_lays_out_channel_and_manifest() {
428 let src = TempDir::new().unwrap();
429 let yah_bin = write_dummy(src.path(), "target/release/yah", b"YAH-BINARY");
430
431 let staging = TempDir::new().unwrap();
432 let artifacts = vec![ProducedArtifact {
433 binary: "yah".into(),
434 path: yah_bin,
435 triple: Some("darwin-aarch64".into()),
436 }];
437
438 let report = stage_release(
439 staging.path(),
440 &artifacts,
441 "v0.8.6",
442 None,
443 Some("https://releases.yah.dev"),
444 )
445 .unwrap();
446
447 assert_eq!(report.object_keys, vec!["yah/0.8.6/darwin-aarch64/yah"]);
449 let copied = staging.path().join("yah/0.8.6/darwin-aarch64/yah");
450 assert_eq!(std::fs::read(&copied).unwrap(), b"YAH-BINARY");
451
452 assert_eq!(
455 report.manifest_keys,
456 vec![
457 "yah/release-manifest-darwin-aarch64.json",
458 "yah/release-manifest.json",
459 ]
460 );
461 let manifest = &report.manifests["yah"];
462 assert_eq!(manifest.version, "0.8.6");
463 let bundle = &manifest.host.bundle["darwin-aarch64"];
464 assert_eq!(
465 bundle.url,
466 "https://releases.yah.dev/yah/0.8.6/darwin-aarch64/yah"
467 );
468 assert_eq!(bundle.size, Some("YAH-BINARY".len() as u64));
469
470 let bytes = std::fs::read(staging.path().join("yah/release-manifest.json")).unwrap();
472 let parsed: ChannelManifest = serde_json::from_slice(&bytes).unwrap();
473 assert_eq!(&parsed, manifest);
474 }
475
476 #[test]
477 fn stage_release_relative_urls_without_base() {
478 let src = TempDir::new().unwrap();
479 let bin = write_dummy(src.path(), "out/desktop", b"x");
480 let staging = TempDir::new().unwrap();
481 let report = stage_release(
482 staging.path(),
483 &[ProducedArtifact {
484 binary: "desktop".into(),
485 path: bin,
486 triple: Some("linux-x86_64".into()),
487 }],
488 "0.9.0",
489 Some("channels"),
490 None,
491 )
492 .unwrap();
493 assert_eq!(
495 report.object_keys,
496 vec!["channels/desktop/0.9.0/linux-x86_64/desktop"]
497 );
498 assert_eq!(
499 report.manifest_keys,
500 vec![
501 "channels/desktop/release-manifest-linux-x86_64.json",
502 "channels/desktop/release-manifest.json",
503 ]
504 );
505 assert_eq!(
507 report.manifests["desktop"].host.bundle["linux-x86_64"].url,
508 "channels/desktop/0.9.0/linux-x86_64/desktop"
509 );
510 }
511
512 #[test]
513 fn stage_release_groups_multiple_binaries() {
514 let src = TempDir::new().unwrap();
515 let yah = write_dummy(src.path(), "target/release/yah", b"a");
516 let desktop = write_dummy(src.path(), "target/release/desktop", b"bb");
517 let staging = TempDir::new().unwrap();
518 let report = stage_release(
519 staging.path(),
520 &[
521 ProducedArtifact {
522 binary: "yah".into(),
523 path: yah,
524 triple: Some("darwin-aarch64".into()),
525 },
526 ProducedArtifact {
527 binary: "desktop".into(),
528 path: desktop,
529 triple: Some("darwin-aarch64".into()),
530 },
531 ],
532 "1.0.0",
533 None,
534 None,
535 )
536 .unwrap();
537 assert_eq!(report.manifests.len(), 2);
540 assert!(report
541 .manifest_keys
542 .contains(&"yah/release-manifest.json".to_string()));
543 assert!(report
544 .manifest_keys
545 .contains(&"desktop/release-manifest.json".to_string()));
546 assert!(report
547 .manifest_keys
548 .contains(&"yah/release-manifest-darwin-aarch64.json".to_string()));
549 assert!(report
550 .manifest_keys
551 .contains(&"desktop/release-manifest-darwin-aarch64.json".to_string()));
552 }
553
554 #[test]
561 fn stage_release_per_triple_keys_survive_sequential_publishes() {
562 let src = TempDir::new().unwrap();
563 let yah_darwin = write_dummy(src.path(), "target/release/yah-darwin", b"D");
564 let yah_linux = write_dummy(src.path(), "target/release/yah-linux", b"LL");
565
566 let staging = TempDir::new().unwrap();
569
570 let report1 = stage_release(
571 staging.path(),
572 &[ProducedArtifact {
573 binary: "yah".into(),
574 path: yah_darwin,
575 triple: Some("darwin-aarch64".into()),
576 }],
577 "0.8.6",
578 None,
579 Some("https://releases.yah.dev"),
580 )
581 .unwrap();
582 let report2 = stage_release(
583 staging.path(),
584 &[ProducedArtifact {
585 binary: "yah".into(),
586 path: yah_linux,
587 triple: Some("linux-x86_64".into()),
588 }],
589 "0.8.6",
590 None,
591 Some("https://releases.yah.dev"),
592 )
593 .unwrap();
594
595 assert!(report1
597 .manifest_keys
598 .iter()
599 .any(|k| k == "yah/release-manifest-darwin-aarch64.json"));
600 assert!(report2
601 .manifest_keys
602 .iter()
603 .any(|k| k == "yah/release-manifest-linux-x86_64.json"));
604
605 let darwin_path = staging
608 .path()
609 .join("yah/release-manifest-darwin-aarch64.json");
610 let linux_path = staging
611 .path()
612 .join("yah/release-manifest-linux-x86_64.json");
613 assert!(
614 darwin_path.exists(),
615 "darwin per-triple manifest must persist"
616 );
617 assert!(
618 linux_path.exists(),
619 "linux per-triple manifest must persist"
620 );
621
622 let darwin: ChannelManifest =
623 serde_json::from_slice(&std::fs::read(&darwin_path).unwrap()).unwrap();
624 let linux: ChannelManifest =
625 serde_json::from_slice(&std::fs::read(&linux_path).unwrap()).unwrap();
626 assert!(darwin.host.bundle.contains_key("darwin-aarch64"));
627 assert_eq!(
628 darwin.host.bundle.len(),
629 1,
630 "per-triple manifest is single-triple"
631 );
632 assert!(linux.host.bundle.contains_key("linux-x86_64"));
633 assert_eq!(
634 linux.host.bundle.len(),
635 1,
636 "per-triple manifest is single-triple"
637 );
638
639 let shared_path = staging.path().join("yah/release-manifest.json");
642 let shared: ChannelManifest =
643 serde_json::from_slice(&std::fs::read(&shared_path).unwrap()).unwrap();
644 assert!(
645 shared.host.bundle.contains_key("linux-x86_64"),
646 "shared manifest reflects the most recent stage"
647 );
648 }
649
650 #[test]
651 fn resolve_release_version_prefers_env() {
652 std::env::set_var("YAH_RELEASE_VERSION", "9.9.9");
654 assert_eq!(resolve_release_version(), "9.9.9");
655 std::env::remove_var("YAH_RELEASE_VERSION");
656 assert!(!resolve_release_version().is_empty());
658 }
659
660 #[derive(Default)]
663 struct RecordingPublisher {
664 synced: Mutex<Vec<String>>,
665 revalidated: Mutex<u32>,
666 captured_manifests: Mutex<Vec<String>>,
668 }
669
670 #[async_trait]
671 impl ReleasePublisher for RecordingPublisher {
672 async fn sync(
673 &self,
674 staging_dir: &Path,
675 _provider: &str,
676 bucket: &str,
677 _prefix: Option<&str>,
678 ) -> Result<(), RunnerError> {
679 let manifest = staging_dir.join("yah/release-manifest.json");
682 let body = std::fs::read_to_string(&manifest).unwrap();
683 self.captured_manifests.lock().unwrap().push(body);
684 self.synced.lock().unwrap().push(bucket.to_string());
685 Ok(())
686 }
687
688 async fn revalidate(&self) -> Result<(), RunnerError> {
689 *self.revalidated.lock().unwrap() += 1;
690 Ok(())
691 }
692 }
693
694 #[tokio::test]
695 async fn dispatcher_stages_uploads_and_revalidates() {
696 use std::sync::Arc;
697 let src = TempDir::new().unwrap();
698 let bin = write_dummy(src.path(), "target/release/yah", b"BIN");
699 let publisher = Arc::new(RecordingPublisher::default());
700
701 struct ArcPublisher(Arc<RecordingPublisher>);
704 #[async_trait]
705 impl ReleasePublisher for ArcPublisher {
706 async fn sync(
707 &self,
708 d: &Path,
709 p: &str,
710 b: &str,
711 pre: Option<&str>,
712 ) -> Result<(), RunnerError> {
713 self.0.sync(d, p, b, pre).await
714 }
715 async fn revalidate(&self) -> Result<(), RunnerError> {
716 self.0.revalidate().await
717 }
718 }
719
720 let dispatcher = PublishingOutcomeDispatcher::new(ArcPublisher(publisher.clone()));
721 let req = PublishRequest {
722 provider: "r2".into(),
723 bucket: "yah-releases".into(),
724 prefix: None,
725 base_url: Some("https://releases.yah.dev".into()),
726 version: "0.8.6".into(),
727 artifacts: vec![ProducedArtifact {
728 binary: "yah".into(),
729 path: bin,
730 triple: Some("darwin-aarch64".into()),
731 }],
732 };
733 dispatcher.publish(&req).await.unwrap();
734
735 assert_eq!(
736 publisher.synced.lock().unwrap().as_slice(),
737 ["yah-releases"]
738 );
739 assert_eq!(*publisher.revalidated.lock().unwrap(), 1);
740 let manifest = &publisher.captured_manifests.lock().unwrap()[0];
741 assert!(
742 manifest.contains("0.8.6"),
743 "manifest carries version: {manifest}"
744 );
745 assert!(
746 manifest.contains("darwin-aarch64"),
747 "manifest carries triple"
748 );
749 }
750
751 #[tokio::test]
752 async fn dispatcher_skips_when_no_artifacts() {
753 let publisher = RecordingPublisher::default();
754 use std::sync::Arc;
757 let probe = Arc::new(publisher);
758 struct ArcPublisher(Arc<RecordingPublisher>);
759 #[async_trait]
760 impl ReleasePublisher for ArcPublisher {
761 async fn sync(
762 &self,
763 d: &Path,
764 p: &str,
765 b: &str,
766 pre: Option<&str>,
767 ) -> Result<(), RunnerError> {
768 self.0.sync(d, p, b, pre).await
769 }
770 async fn revalidate(&self) -> Result<(), RunnerError> {
771 self.0.revalidate().await
772 }
773 }
774 let dispatcher = PublishingOutcomeDispatcher::new(ArcPublisher(probe.clone()));
775 let req = PublishRequest {
776 provider: "r2".into(),
777 bucket: "yah-releases".into(),
778 prefix: None,
779 base_url: None,
780 version: "0.8.6".into(),
781 artifacts: vec![],
782 };
783 dispatcher.publish(&req).await.unwrap();
784 assert!(
785 probe.synced.lock().unwrap().is_empty(),
786 "no artifacts → no sync"
787 );
788 assert_eq!(*probe.revalidated.lock().unwrap(), 0);
789 }
790}