Skip to main content

yah_qed/
publish.rs

1//! Release-channel publishing — the producer leg of the almanac releases feed.
2//!
3//! When a QED pipeline succeeds with an [`Outcome::Publish`](crate::types::Outcome::Publish),
4//! the runner collects every [`ProducedArtifact`] declared by the successful
5//! steps, lays them out into a release channel tree, writes a per-binary
6//! `release-manifest.json` pointer, uploads the tree to the channel bucket, and
7//! fires the almanac revalidate hook.
8//!
9//! ## Layout
10//!
11//! ```text
12//! [<prefix>/]<binary>/<version>/<triple>/<filename>      ← the built artifacts
13//! [<prefix>/]<binary>/release-manifest.json              ← shared pointer (this-stage view)
14//! [<prefix>/]<binary>/release-manifest-<triple>.json     ← per-triple stable record
15//! ```
16//!
17//! `release-manifest.json` is the file almanac's `R2Channel` reads
18//! (`crates/yah/almanac/src/r2.rs`). Its wire shape is a forward-compatible
19//! subset of `updater::ReleaseManifest` (self-updating-binaries.md): the fields
20//! almanac needs (`version`, `pub_date`, `notes`, `host.bundle.<triple>`), so
21//! the channel doubles as the almanac source AND the self-update pointer root.
22//!
23//! ## Multi-triple merge (R330-B8)
24//!
25//! In a multi-platform release, each `yah qed run release-build` invocation runs
26//! on its own host (darwin-aarch64, linux-x86_64, …) and only knows about its
27//! own triple's artifacts. The shared `<binary>/release-manifest.json` written
28//! here therefore contains only this stage's triples — a sequential publish of
29//! linux-x86_64 *after* darwin-aarch64 would overwrite the darwin view.
30//!
31//! To make cross-stage merge possible without R2 read-modify-write (which races
32//! between concurrent publishes), `stage_release` ALSO writes a per-triple
33//! manifest at `<binary>/release-manifest-<triple>.json` containing just that
34//! triple's bundle entry. These keys are stable and idempotent: a re-run of the
35//! same triple writes the same key, never clobbering a sibling triple's record.
36//! The GHA assembly job (which already owns macOS code signing — yubaba can't
37//! sign macOS) reads every `release-manifest-<triple>.json` and writes the
38//! authoritative signed shared `release-manifest.json` once all triples land.
39//!
40//! ## What this module owns vs. delegates
41//!
42//! This module owns the *layout + manifest assembly* (pure, filesystem-only,
43//! unit-tested with a tempdir). The actual bucket upload + hook POST are I/O
44//! that vary by host, so they're delegated to a [`ReleasePublisher`] adapter —
45//! the CLI supplies a Cloudflare-R2-backed impl that reuses the cloud crate's
46//! `publish_to_r2`; tests supply a recording fake.
47//!
48//! Part of R330-F3 — canonical ticket annotation lives in `builtins.rs`.
49//!
50//! @yah:ticket(R488-F3, "ProducedArtifact aggregation across children into parent's Outcome::Publish (single revalidate)")
51//! @yah:assignee(agent:claude)
52//! @yah:at(2026-06-08T02:54:15Z)
53//! @yah:status(review)
54//! @yah:phase(P3)
55//! @yah:parent(R488)
56//! @arch:see(.yah/docs/working/W201-qed-pipeline-composition.md)
57//! @yah:depends_on(R488-F2)
58//! @yah:tier(Cleric)
59//! @yah:handoff("F3 shipped. (a) End-to-end multi-child publish fan-in test: 3 SubPipeline children producing yah/desktop/mesofact binaries roll up into a single parent Outcome::Publish that fires StageRecorder.sync ONCE (6 staged objects: 3 binaries + 3 per-binary manifests) and StageRecorder.revalidate ONCE. Uses real PublishingOutcomeDispatcher with an Arc-wrapped ReleasePublisher fake — exercises stage_release end-to-end across composite runs. (b) Continue-on-error semantics pinned: SubPipeline step with on_fail=Continue marks itself failed but parent loop proceeds; sibling steps after run. Overall RunStatus stays Failed. Child produces dropped on failure — documented current behaviour. (c) load_and_validate_graph wired into both entry points: app/yah/cli/src/qed.rs (after placement gate, before proxy probe — pre-flight cycle/depth check on every yah qed run) AND app/yah/cli/src/camp.rs qed_run_handler (LoaderSubPipelineResolver attached to PipelineRunner so daemon resolves SubPipelines identically). New top-level re-exports in qed lib.rs: LoaderSubPipelineResolver, validate_sub_pipeline_graph, SubPipelineConfig/Ref/Collect/Resolver/Error, MAX_SUB_PIPELINE_DEPTH. 2 new runner tests (190 pass total, +2 from F2). cargo check --workspace clean.")
60//! @yah:next("F4 (named output exposure): QedStep grows outputs: Vec<OutputDecl> and step results carry output values. SubPipelineCollect.outputs already exists from F1 — F4 wires propagation through the child run into parents expression context. Needs W200-F2 (expression engine) for parent-side substitution, OR a minimal qed-side substitution syntax that the W200 engine subsumes later.")
61//! @yah:verify("cargo test -p qed --lib runner::tests::sub_pipeline (9 tests)")
62//! @yah:verify("cargo test -p qed --lib")
63//! @yah:verify("cargo check --workspace")
64
65use 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/// Everything the dispatcher needs to publish one release: the destination,
76/// the resolved version, and the artifacts collected from successful steps.
77#[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
87/// Resolve the release version: `YAH_RELEASE_VERSION` env override (set by the
88/// release tag / GHA), falling back to the version this binary was built at
89/// (`CARGO_PKG_VERSION`, which is the workspace version — `version.workspace`).
90pub 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// ── Channel manifest wire types ─────────────────────────────────────────────
98//
99// A forward-compatible subset of `updater::ReleaseManifest`. We deliberately do
100// not depend on the updater crate here: the producer only fills the fields it
101// can know (version, pub_date, notes, per-triple url+size). The signing-only
102// fields (`signature`, `ipc_contract`) are layered on by the GHA signing leg
103// (yubaba can't sign macOS — see the builtin's gotcha). almanac's `R2Channel`
104// reader ignores the signing fields, so the chain works with this subset.
105
106/// `release-manifest.json` as emitted by the producer.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ChannelManifest {
109    /// Release version, without a leading `v`.
110    pub version: String,
111    /// ISO-8601 UTC publish timestamp.
112    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    /// Per-triple bundle pointers, keyed by triple shorthand.
121    pub bundle: BTreeMap<String, ChannelBundle>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct ChannelBundle {
126    /// Absolute download URL (when `base_url` is set) or a bucket-relative key.
127    pub url: String,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub size: Option<u64>,
130}
131
132/// Bucket key for the per-binary mutable pointer almanac re-fetches on push.
133const MANIFEST_FILENAME: &str = "release-manifest.json";
134
135/// Per-triple stable manifest filename (R330-B8). One per (binary, triple),
136/// containing only that triple's bundle. Cross-stage merge fan-in feeds on these.
137fn per_triple_manifest_filename(triple: &str) -> String {
138    format!("release-manifest-{triple}.json")
139}
140
141/// Result of staging a release tree into a directory: the object keys written
142/// (relative to the staging root) and the per-binary manifests.
143#[derive(Debug, Clone, Default)]
144pub struct StageReport {
145    /// Artifact object keys, e.g. `yah/0.8.6/darwin-aarch64/yah`.
146    pub object_keys: Vec<String>,
147    /// Manifest object keys, e.g. `yah/release-manifest.json`.
148    pub manifest_keys: Vec<String>,
149    /// The emitted manifests, keyed by binary name.
150    pub manifests: BTreeMap<String, ChannelManifest>,
151}
152
153/// Resolve a [`ProducedArtifact::triple`], defaulting to the build host's
154/// triple in the `<os>-<arch>` shorthand the channel + updater use.
155pub 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
175/// Lay the artifacts out into `staging_dir` as the release channel tree and
176/// write each binary's `release-manifest.json`. Pure filesystem work — no
177/// network. The caller hands `staging_dir` to a [`ReleasePublisher`] to upload.
178pub 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    // binary -> (triple -> ChannelBundle), preserving deterministic order.
188    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        // Per-triple stable manifests: one file per (binary, triple) containing
224        // only that triple's bundle entry. Idempotent under repeated publishes
225        // of the same triple; safe under concurrent publishes of different
226        // triples (different keys). The GHA assembler reads these to build the
227        // signed shared manifest. (R330-B8)
228        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        // Shared manifest: this-stage's view of all triples it built. For a
251        // single-stage publish this is the authoritative manifest; for a
252        // multi-stage publish it is "best-effort latest" until the GHA
253        // assembler overwrites it with the merged signed version.
254        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// ── The publish adapter seam ────────────────────────────────────────────────
278
279/// Performs the I/O the [`PublishingOutcomeDispatcher`] can't do itself:
280/// uploading a staged channel tree to a bucket and firing the revalidate hook.
281///
282/// The qed crate stays dependency-light by keeping this abstract — the CLI
283/// supplies a Cloudflare-R2-backed impl (reusing `cloud::publish_to_r2` + a
284/// reqwest POST), and tests supply a recording fake.
285#[async_trait]
286pub trait ReleasePublisher: Send + Sync {
287    /// Upload every file under `staging_dir` (already laid out as the channel
288    /// tree) to `bucket` on `provider`, under the optional key `prefix`.
289    async fn sync(
290        &self,
291        staging_dir: &Path,
292        provider: &str,
293        bucket: &str,
294        prefix: Option<&str>,
295    ) -> Result<(), RunnerError>;
296
297    /// Fire the almanac revalidate hook so the feed re-fetches from this
298    /// channel. A no-configured-receiver impl returns `Ok(())`.
299    async fn revalidate(&self) -> Result<(), RunnerError>;
300}
301
302/// The real outcome dispatcher (R330-F3): stages produced artifacts into the
303/// release channel layout, uploads them via a [`ReleasePublisher`], then fires
304/// the revalidate hook. `warden_deploy` / `almanac_run` stay logging stubs
305/// (those backends are still pending — R040-F4 / the almanac scheduler).
306pub 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
375/// A [`ReleasePublisher`] that does nothing but log — the default when no real
376/// bucket/receiver is wired (e.g. a local `yah qed run` with no credentials).
377pub 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        // Empty string falls back to host too.
423        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        // Artifact landed at <binary>/<version>/<triple>/<filename> (v stripped).
448        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        // Manifests: both the shared key and a per-triple stable key (R330-B8).
453        // Order: per-triple entries land first (inner loop), shared last.
454        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        // The on-disk manifest round-trips through the same wire type.
471        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        // Prefix is applied to both the object and the manifest keys.
494        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        // No base_url → manifest url is the bucket-relative key.
506        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        // One shared manifest per binary, plus one per-triple stable manifest
538        // per (binary, triple).
539        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    /// R330-B8: simulate two sequential single-triple publishes (darwin then
555    /// linux) and assert that the per-triple stable keys provide a non-
556    /// clobbering record of both triples. The shared release-manifest.json
557    /// would be overwritten by each stage (single-stage view), but the
558    /// per-triple `release-manifest-<triple>.json` files coexist — the input
559    /// the downstream merger reads.
560    #[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        // Simulate two sequential single-triple publishes into the SAME R2
567        // bucket layout by staging both into a shared staging root.
568        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        // Each stage emits its own per-triple key (idempotent, non-colliding).
596        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        // Both per-triple manifests survive on disk after the second stage
606        // (the bug was: shared key clobbered, no record of the first triple).
607        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        // The shared release-manifest.json reflects the LAST stage (best-effort
640        // latest single-stage view) — the GHA assembler is what unifies it.
641        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        // SAFETY: single-threaded test; we set + clear the override locally.
653        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        // Falls back to the compiled crate version (non-empty).
657        assert!(!resolve_release_version().is_empty());
658    }
659
660    // ── PublishingOutcomeDispatcher with a recording publisher ──────────────
661
662    #[derive(Default)]
663    struct RecordingPublisher {
664        synced: Mutex<Vec<String>>,
665        revalidated: Mutex<u32>,
666        /// Manifest contents captured from the staging dir at sync time.
667        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            // Confirm the staged tree actually exists at sync time (the
680            // tempdir must outlive this call).
681            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        // Build a dispatcher around a publisher we can inspect. The dispatcher
702        // owns the publisher, so use an Arc clone for assertions.
703        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        // Move a probe out before constructing the dispatcher: read counters
755        // after via a shared Arc instead.
756        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}