Skip to main content

ephemeris_release/
publisher.rs

1//! Publication of selected units in dependency order.
2
3use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::path::{Path, PathBuf};
5use std::vec;
6
7use crate::Author;
8use crate::artifacts::{ArtifactError, StagedRelease};
9use crate::error::{ForgeFailure, GitFailure, PackageFailure};
10use crate::traits::{ForgeRelease, ForgeTags, GitCommands, GitRefs, PackagePublisher};
11use crate::unit::Unit;
12
13/// The immutable identity checked before publication starts.
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub(crate) struct Authority {
16    /// Branch that must still point at `commit`.
17    pub(crate) branch: String,
18    /// Merged commit to publish.
19    pub(crate) commit: String,
20}
21
22/// A unit whose package, tag, and forge release are complete.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct Outcome {
25    /// Release graph ID.
26    pub unit: String,
27    /// Published version.
28    pub version: String,
29}
30
31/// Where a tag that points at another commit was found.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub(crate) enum TagLocation {
34    /// The repository publication runs in.
35    Local,
36    /// The forge.
37    Remote,
38}
39
40/// A publish failure. Rerunning resumes at the failed step.
41#[derive(Debug, thiserror::Error)]
42pub(crate) enum PublishError {
43    /// The authoritative branch moved away from the commit to publish.
44    #[error("authoritative branch {branch:?} is at {actual}, expected {expected}")]
45    WrongAuthority {
46        /// The authoritative branch.
47        branch: String,
48        /// The commit to publish.
49        expected: String,
50        /// Where the branch points now.
51        actual: String,
52    },
53    /// The batch has no dependency order.
54    #[error("release graph contains a dependency cycle among {}", units.join(", "))]
55    Cycle {
56        /// The units that wait on each other, directly or indirectly.
57        units: Vec<String>,
58    },
59    /// Two units of the batch share a name.
60    #[error("unit {0:?} appears twice in the release batch")]
61    DuplicateUnit(String),
62    /// The authoritative branch head could not be read.
63    #[error("cannot read the authoritative branch")]
64    AuthorityCheck(#[source] GitFailure),
65    /// An unpublished package has no staged file to publish.
66    #[error("no staged package file for {0}")]
67    MissingPackageFile(String),
68    /// Looking up a unit's package failed.
69    #[error("cannot look up the package of {unit}")]
70    PackageLookup {
71        /// The unit being processed.
72        unit: String,
73        /// The package failure.
74        #[source]
75        source: PackageFailure,
76    },
77    /// Publishing a unit's package failed.
78    #[error("cannot publish the package of {unit}")]
79    PackagePublish {
80        /// The unit being processed.
81        unit: String,
82        /// The package failure.
83        #[source]
84        source: PackageFailure,
85    },
86    /// Reading, creating, or pushing a unit's tag failed.
87    #[error("cannot read, create, or push the tag of {unit}")]
88    Tag {
89        /// The unit being processed.
90        unit: String,
91        /// The Git failure.
92        #[source]
93        source: GitFailure,
94    },
95    /// The unit's tag exists at another commit. A tag never moves.
96    #[error("tag {tag:?} of {unit} is at {actual} ({location:?}), expected {expected}")]
97    TagMoved {
98        /// The unit being processed.
99        unit: String,
100        /// The tag.
101        tag: String,
102        /// Where the tag was found.
103        location: TagLocation,
104        /// The commit to publish.
105        expected: String,
106        /// The commit the tag points at.
107        actual: String,
108    },
109    /// Reading the forge tag failed.
110    #[error("cannot read the forge tag of {unit}")]
111    Remote {
112        /// The unit being processed.
113        unit: String,
114        /// The forge failure.
115        #[source]
116        source: ForgeFailure,
117    },
118    /// The forge release or its assets could not be completed.
119    #[error("release of {unit} failed")]
120    Release {
121        /// The unit being processed.
122        unit: String,
123        /// The release failure.
124        #[source]
125        source: ArtifactError,
126    },
127}
128
129impl DependencyOrder {
130    /// Sort `units` so that every unit follows its dependencies, rejecting
131    /// cycles.
132    fn new(units: &[&Unit]) -> Result<Self, PublishError> {
133        let names: BTreeSet<&str> = units.iter().map(|unit| unit.name.as_str()).collect();
134        if names.len() != units.len() {
135            let mut seen = BTreeSet::new();
136            let duplicate = units
137                .iter()
138                .find(|unit| !seen.insert(unit.name.as_str()))
139                .map_or_else(String::new, |unit| unit.name.clone());
140            return Err(PublishError::DuplicateUnit(duplicate));
141        }
142        let mut waiting: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
143        for unit in units {
144            waiting.insert(
145                unit.name.as_str(),
146                unit.dependencies
147                    .iter()
148                    .map(String::as_str)
149                    .filter(|dependency| names.contains(dependency))
150                    .collect(),
151            );
152        }
153        let mut queue: VecDeque<_> = waiting
154            .iter()
155            .filter(|(_, dependencies)| dependencies.is_empty())
156            .map(|(name, _)| *name)
157            .collect();
158        let mut result = Vec::with_capacity(units.len());
159        while let Some(name) = queue.pop_front() {
160            result.extend(units.iter().position(|unit| unit.name == name));
161            for unit in units {
162                if let Some(dependencies) = waiting.get_mut(unit.name.as_str())
163                    && dependencies.remove(name)
164                    && dependencies.is_empty()
165                {
166                    queue.push_back(&unit.name);
167                }
168            }
169        }
170        if result.len() == units.len() {
171            Ok(Self(result))
172        } else {
173            Err(PublishError::Cycle {
174                units: waiting
175                    .into_iter()
176                    .filter(|(_, dependencies)| !dependencies.is_empty())
177                    .map(|(name, _)| name.to_owned())
178                    .collect(),
179            })
180        }
181    }
182}
183
184impl IntoIterator for DependencyOrder {
185    type Item = usize;
186    type IntoIter = vec::IntoIter<usize>;
187
188    fn into_iter(self) -> Self::IntoIter {
189        self.0.into_iter()
190    }
191}
192
193/// Indices of units sorted so that every unit follows its dependencies; the
194/// only constructor establishes the order.
195struct DependencyOrder(Vec<usize>);
196
197/// A selected unit and the forge release staged for it.
198#[derive(Debug)]
199pub(crate) struct UnitRelease<'a> {
200    pub(crate) unit: Unit,
201    pub(crate) release: StagedRelease<'a>,
202    /// The staged package file, for a unit that publishes to a registry.
203    pub(crate) package: Option<PathBuf>,
204}
205
206/// Executes the publication contract of [`crate::publication`] in dependency
207/// order.
208#[derive(Debug)]
209pub(crate) struct Publisher<'a, G, P, F> {
210    git: &'a G,
211    packages: &'a P,
212    forge: &'a F,
213    author: Author<'a>,
214}
215
216impl<'a, G, P, F> Publisher<'a, G, P, F>
217where
218    G: GitCommands + GitRefs,
219    P: PackagePublisher,
220    F: ForgeRelease + ForgeTags,
221{
222    /// Bind the adapters a publication run uses.
223    pub(crate) const fn new(git: &'a G, packages: &'a P, forge: &'a F, author: Author<'a>) -> Self {
224        Self {
225            git,
226            packages,
227            forge,
228            author,
229        }
230    }
231
232    /// Publish from an exact merged commit. Each unit gets its package, then
233    /// its release artifacts, then its tag, then its published release.
234    /// Every step reads the remote first, so rerunning after a partial
235    /// failure finishes the job.
236    ///
237    /// # Errors
238    ///
239    /// Returns a graph, authority, command, or remote error at the first
240    /// incomplete step.
241    pub(crate) async fn publish(
242        &self,
243        repository: &Path,
244        authority: &Authority,
245        releases: &[UnitRelease<'_>],
246    ) -> Result<Vec<Outcome>, PublishError> {
247        let actual = self
248            .git
249            .authoritative_head(repository, &authority.branch)
250            .map_err(|error| PublishError::AuthorityCheck(GitFailure::new(error)))?;
251        if actual != authority.commit {
252            return Err(PublishError::WrongAuthority {
253                branch: authority.branch.clone(),
254                expected: authority.commit.clone(),
255                actual,
256            });
257        }
258        let units: Vec<&Unit> = releases.iter().map(|release| &release.unit).collect();
259        let order = DependencyOrder::new(&units)?;
260        let mut outcomes = Vec::with_capacity(releases.len());
261        for UnitRelease {
262            unit,
263            release,
264            package,
265        } in order.into_iter().filter_map(|index| releases.get(index))
266        {
267            let published = self.packages.is_published(unit).await.map_err(|error| {
268                PublishError::PackageLookup {
269                    unit: unit.name.clone(),
270                    source: PackageFailure::new(error),
271                }
272            })?;
273            if !published {
274                let file = package
275                    .as_deref()
276                    .ok_or_else(|| PublishError::MissingPackageFile(unit.name.clone()))?;
277                self.packages.publish(unit, file).await.map_err(|error| {
278                    PublishError::PackagePublish {
279                        unit: unit.name.clone(),
280                        source: PackageFailure::new(error),
281                    }
282                })?;
283            }
284            let release_error = |source| PublishError::Release {
285                unit: unit.name.clone(),
286                source,
287            };
288            let prepared = release.prepare(self.forge).await.map_err(release_error)?;
289            self.tag(repository, &authority.commit, unit).await?;
290            prepared.publish(self.forge).await.map_err(release_error)?;
291            outcomes.push(Outcome {
292                unit: unit.name.clone(),
293                version: unit.version.to_string(),
294            });
295        }
296        Ok(outcomes)
297    }
298
299    /// Converge the unit's tag on `commit`, locally and on the forge.
300    async fn tag(&self, repository: &Path, commit: &str, unit: &Unit) -> Result<(), PublishError> {
301        let tag = unit.tag();
302        let moved = |location, actual| PublishError::TagMoved {
303            unit: unit.name.clone(),
304            tag: tag.clone(),
305            location,
306            expected: commit.to_owned(),
307            actual,
308        };
309        match self
310            .forge
311            .tag_commit(&tag)
312            .await
313            .map_err(|error| PublishError::Remote {
314                unit: unit.name.clone(),
315                source: ForgeFailure::new(error),
316            })? {
317            Some(actual) if actual == commit => return Ok(()),
318            Some(actual) => return Err(moved(TagLocation::Remote, actual)),
319            None => {}
320        }
321        let tag_error = |source| PublishError::Tag {
322            unit: unit.name.clone(),
323            source,
324        };
325        match self
326            .git
327            .reference(repository, &format!("refs/tags/{tag}"))
328            .map_err(GitFailure::new)
329            .map_err(tag_error)?
330        {
331            None => self
332                .git
333                .create_tag(repository, &tag, commit, self.author)
334                .map_err(GitFailure::new)
335                .map_err(tag_error)?,
336            Some(actual) if actual == commit => {}
337            Some(actual) => return Err(moved(TagLocation::Local, actual)),
338        }
339        self.git
340            .push_tag(repository, &tag)
341            .map_err(GitFailure::new)
342            .map_err(tag_error)
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use std::cell::RefCell;
349    use std::sync::LazyLock;
350    use std::{fs, io};
351
352    use ephemeris_token::Token;
353
354    use super::*;
355    use crate::adapters::Adapters;
356    use crate::artifacts::{OciLogin, Publication, Stage};
357    use crate::remote::{RemoteAsset, RemoteRelease};
358    use crate::state::{OciSpec, ReleaseUnitState};
359    use crate::traits::PackageRegistry;
360
361    const AUTHOR: Author<'static> = Author {
362        name: "Release Bot",
363        email: "release@example.invalid",
364    };
365
366    static IMAGE: LazyLock<OciSpec> = LazyLock::new(|| OciSpec {
367        package: "image".into(),
368        registry: "registry.example/team".into(),
369        repository: "app".into(),
370        latest: false,
371    });
372
373    /// The package registry, local repository, and forge that keep what
374    /// earlier runs did. A step named in `failing` fails once.
375    #[derive(Default)]
376    struct World {
377        packages: RefCell<BTreeSet<String>>,
378        local_tags: RefCell<BTreeMap<String, String>>,
379        remote_tags: RefCell<BTreeMap<String, String>>,
380        releases: RefCell<Vec<(String, bool)>>,
381        failing: RefCell<BTreeSet<String>>,
382        events: RefCell<Vec<String>>,
383    }
384
385    impl World {
386        fn failing(event: &str) -> Self {
387            Self {
388                failing: RefCell::new(BTreeSet::from([event.to_owned()])),
389                ..Self::default()
390            }
391        }
392
393        fn step(&self, event: String) -> Result<(), io::Error> {
394            if self.failing.borrow_mut().remove(&event) {
395                return Err(io::Error::other(event));
396            }
397            self.events.borrow_mut().push(event);
398            Ok(())
399        }
400
401        async fn run(&self, units: &[Unit]) -> Result<Vec<Outcome>, PublishError> {
402            self.run_at("merged", units).await
403        }
404
405        async fn run_at(&self, commit: &str, units: &[Unit]) -> Result<Vec<Outcome>, PublishError> {
406            self.run_releases(commit, &releases(units)).await
407        }
408
409        async fn run_releases(
410            &self,
411            commit: &str,
412            releases: &[UnitRelease<'_>],
413        ) -> Result<Vec<Outcome>, PublishError> {
414            Publisher::new(self, self, self, AUTHOR)
415                .publish(
416                    Path::new("."),
417                    &Authority {
418                        branch: "main".into(),
419                        commit: commit.into(),
420                    },
421                    releases,
422                )
423                .await
424        }
425    }
426
427    impl GitCommands for World {
428        type Error = io::Error;
429
430        fn authoritative_head(&self, _: &Path, _: &str) -> Result<String, Self::Error> {
431            Ok("merged".into())
432        }
433
434        fn create_tag(
435            &self,
436            _: &Path,
437            tag: &str,
438            commit: &str,
439            _: Author<'_>,
440        ) -> Result<(), Self::Error> {
441            self.step(format!("tag:{tag}"))?;
442            self.local_tags
443                .borrow_mut()
444                .insert(tag.into(), commit.into());
445            Ok(())
446        }
447
448        fn push_tag(&self, _: &Path, tag: &str) -> Result<(), Self::Error> {
449            self.step(format!("push:{tag}"))?;
450            let commit = self.local_tags.borrow()[tag].clone();
451            self.remote_tags.borrow_mut().insert(tag.into(), commit);
452            Ok(())
453        }
454    }
455
456    impl GitRefs for World {
457        type Error = io::Error;
458
459        fn reference(&self, _: &Path, name: &str) -> Result<Option<String>, Self::Error> {
460            let tag = name.strip_prefix("refs/tags/").unwrap_or(name);
461            Ok(self.local_tags.borrow().get(tag).cloned())
462        }
463    }
464
465    impl PackagePublisher for World {
466        type Error = io::Error;
467
468        async fn is_published(&self, unit: &Unit) -> Result<bool, Self::Error> {
469            Ok(self.packages.borrow().contains(&unit.name))
470        }
471
472        async fn publish(&self, unit: &Unit, _: &Path) -> Result<(), Self::Error> {
473            self.step(format!("package:{}", unit.name))?;
474            self.packages.borrow_mut().insert(unit.name.clone());
475            Ok(())
476        }
477    }
478
479    impl ForgeTags for World {
480        type Error = io::Error;
481
482        async fn tag_commit(&self, tag: &str) -> Result<Option<String>, Self::Error> {
483            Ok(self.remote_tags.borrow().get(tag).cloned())
484        }
485    }
486
487    impl ForgeRelease for World {
488        type Error = io::Error;
489
490        async fn release_by_tag(&self, tag: &str) -> Result<Option<RemoteRelease>, Self::Error> {
491            Ok(self
492                .releases
493                .borrow()
494                .iter()
495                .zip(1..)
496                .find(|((candidate, _), _)| candidate == tag)
497                .map(|((_, draft), id)| RemoteRelease { id, draft: *draft }))
498        }
499
500        async fn create_draft(
501            &self,
502            tag: &str,
503            _: &str,
504            _: &str,
505            _: &str,
506        ) -> Result<u64, Self::Error> {
507            self.step(format!("release:{tag}"))?;
508            let mut releases = self.releases.borrow_mut();
509            releases.push((tag.into(), true));
510            u64::try_from(releases.len()).map_err(io::Error::other)
511        }
512
513        async fn assets(&self, _: u64) -> Result<Vec<RemoteAsset>, Self::Error> {
514            Ok(Vec::new())
515        }
516
517        async fn publish(&self, release_id: u64) -> Result<(), Self::Error> {
518            let index = usize::try_from(release_id.saturating_sub(1)).map_err(io::Error::other)?;
519            let tag = self.releases.borrow()[index].0.clone();
520            self.step(format!("publish:{tag}"))?;
521            self.releases.borrow_mut()[index].1 = false;
522            Ok(())
523        }
524
525        async fn asset_bytes(&self, _: &RemoteAsset) -> Result<Vec<u8>, Self::Error> {
526            Err(io::Error::other(
527                "a release without artifacts reads no asset",
528            ))
529        }
530
531        async fn upload(&self, _: u64, _: &str, _: &[u8]) -> Result<(), Self::Error> {
532            Err(io::Error::other(
533                "a release without artifacts uploads nothing",
534            ))
535        }
536
537        async fn delete_asset(&self, _: u64, _: u64) -> Result<(), Self::Error> {
538            Err(io::Error::other(
539                "a release without artifacts deletes nothing",
540            ))
541        }
542    }
543
544    fn unit_json(name: &str, dependencies: &[&str]) -> String {
545        let dependencies = dependencies
546            .iter()
547            .map(|dependency| format!(r#""{dependency}""#))
548            .collect::<Vec<_>>()
549            .join(",");
550        format!(
551            r#"{{"target":{{"ecosystem":"cargo","manifest":"{name}/Cargo.toml","publish":true}},"source":["{name}"],"changelog":"{name}/CHANGELOG.md","dependencies":[{dependencies}],"pins":{{}},"tag_prefix":"{name}-v","kind":"library","application_bump":"none","revision_file":null}}"#
552        )
553    }
554
555    fn unit_from(name: &str, state: &str) -> Unit {
556        Unit::configured(
557            name,
558            Some(name.into()),
559            "1.1.0".parse().unwrap(),
560            &facet_json::from_str::<ReleaseUnitState>(state).unwrap(),
561        )
562    }
563
564    fn unit(name: &str, dependencies: &[&str]) -> Unit {
565        unit_from(name, &unit_json(name, dependencies))
566    }
567
568    /// Stage a forge release for `unit` whose OCI push runs `script`.
569    fn oci_release<'a>(
570        unit: &Unit,
571        directory: &Path,
572        script: &str,
573        spec: &'a OciSpec,
574    ) -> UnitRelease<'a> {
575        use std::os::unix::fs::PermissionsExt as _;
576
577        let program = directory.join("push");
578        fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap();
579        fs::set_permissions(&program, fs::Permissions::from_mode(0o755)).unwrap();
580        let manifest = directory.join("manifest.json");
581        fs::write(
582            &manifest,
583            format!(
584                r#"[{{"kind":"oci","unit":"{}","name":"image","package":"image","registry":"registry.example/team","repository":"app","latest":false,"program":{program:?}}}]"#,
585                unit.name
586            ),
587        )
588        .unwrap();
589        UnitRelease {
590            unit: unit.clone(),
591            release: Stage::read(&manifest, directory)
592                .unwrap()
593                .release(
594                    Publication {
595                        unit: unit.name.clone(),
596                        tag: unit.tag(),
597                        target_commit: "merged".into(),
598                        title: unit.title(),
599                        notes: String::new(),
600                        expected: Vec::new(),
601                        oci: vec![("image", spec)],
602                        version: unit.version.to_string(),
603                        working_directory: directory.to_owned(),
604                    },
605                    Some(&OciLogin::new("bot".into(), Token::new("secret"))),
606                )
607                .unwrap(),
608            package: Some(PathBuf::from(format!("artifacts/{}.crate", unit.name))),
609        }
610    }
611
612    /// Stage a forge release without artifacts for each unit.
613    fn releases(units: &[Unit]) -> Vec<UnitRelease<'static>> {
614        units
615            .iter()
616            .map(|unit| UnitRelease {
617                unit: unit.clone(),
618                release: Stage::empty()
619                    .release(
620                        Publication {
621                            unit: unit.name.clone(),
622                            tag: unit.tag(),
623                            target_commit: "merged".into(),
624                            title: unit.title(),
625                            notes: String::new(),
626                            expected: Vec::new(),
627                            oci: Vec::new(),
628                            version: unit.version.to_string(),
629                            working_directory: PathBuf::from("."),
630                        },
631                        None,
632                    )
633                    .unwrap(),
634                package: Some(PathBuf::from(format!("artifacts/{}.crate", unit.name))),
635            })
636            .collect()
637    }
638
639    #[tokio::test]
640    async fn publishes_dependency_graph_in_order() {
641        let world = World::default();
642        world
643            .run(&[unit("oe", &[]), unit("fota", &["oe"])])
644            .await
645            .unwrap();
646        let events = world.events.borrow();
647        assert!(
648            events.iter().position(|e| e == "package:oe")
649                < events.iter().position(|e| e == "package:fota")
650        );
651    }
652
653    #[tokio::test]
654    async fn publisher_uses_the_configured_unit_tag_prefix() {
655        let world = World::default();
656        let unit = unit_from(
657            "application",
658            &unit_json("application", &[]).replace("application-v", "dpdhl-llm-"),
659        );
660        world.run(&[unit]).await.unwrap();
661        let events = world.events.borrow();
662        assert!(events.contains(&"tag:dpdhl-llm-1.1.0".into()));
663        assert!(events.contains(&"push:dpdhl-llm-1.1.0".into()));
664        assert!(events.contains(&"release:dpdhl-llm-1.1.0".into()));
665    }
666
667    #[tokio::test]
668    async fn publisher_tags_a_revision_with_the_suffixed_tag() {
669        let world = World::default();
670        let mut unit = unit("app", &[]);
671        unit.revision = Some(2);
672        world.run(&[unit]).await.unwrap();
673        assert_eq!(
674            &*world.events.borrow(),
675            &[
676                "package:app",
677                "release:app-v1.1.0-2",
678                "tag:app-v1.1.0-2",
679                "push:app-v1.1.0-2",
680                "publish:app-v1.1.0-2"
681            ]
682        );
683    }
684
685    #[tokio::test]
686    async fn retry_skips_effects_that_already_exist() {
687        let world = World::default();
688        world.packages.borrow_mut().insert("oe".into());
689        world
690            .remote_tags
691            .borrow_mut()
692            .insert("oe-v1.1.0".into(), "merged".into());
693        world
694            .releases
695            .borrow_mut()
696            .push(("oe-v1.1.0".into(), false));
697        world.run(&[unit("oe", &[])]).await.unwrap();
698        assert!(world.events.borrow().is_empty());
699    }
700
701    #[tokio::test]
702    async fn cargo_registry_publication_disabled_still_creates_tags_and_forge_releases() {
703        struct NeverRegistry;
704        impl PackageRegistry for NeverRegistry {
705            type Error = io::Error;
706            async fn package_exists(&self, _: &str, _: &str) -> Result<bool, Self::Error> {
707                panic!("publish=false must not query a package registry")
708            }
709        }
710
711        let world = World::default();
712        let packages = Adapters::new(NeverRegistry, Some(NeverRegistry));
713        let unit = unit_from(
714            "private-tool",
715            &unit_json("private-tool", &[])
716                .replace("private-tool/Cargo.toml", "tools/private/Cargo.toml")
717                .replace(r#""publish":true"#, r#""publish":false"#),
718        );
719        Publisher::new(&world, &packages, &world, AUTHOR)
720            .publish(
721                Path::new("."),
722                &Authority {
723                    branch: "main".into(),
724                    commit: "merged".into(),
725                },
726                &releases(&[unit]),
727            )
728            .await
729            .unwrap();
730        assert_eq!(
731            &*world.events.borrow(),
732            &[
733                "release:private-tool-v1.1.0",
734                "tag:private-tool-v1.1.0",
735                "push:private-tool-v1.1.0",
736                "publish:private-tool-v1.1.0"
737            ]
738        );
739    }
740
741    #[test]
742    fn order_rejects_a_dependency_cycle() {
743        let units = [unit("a", &["b"]), unit("b", &["a"])];
744        assert!(matches!(
745            DependencyOrder::new(&[&units[0], &units[1]]),
746            Err(PublishError::Cycle { units }) if units == ["a", "b"]
747        ));
748    }
749
750    #[test]
751    fn order_ignores_dependencies_outside_the_batch() {
752        let fota = unit("fota", &["oe"]);
753        let order = DependencyOrder::new(&[&fota]).unwrap();
754        assert_eq!(order.into_iter().collect::<Vec<_>>(), [0]);
755    }
756
757    #[test]
758    fn order_rejects_a_duplicate_unit_name() {
759        let first = unit("app", &[]);
760        let second = unit("app", &[]);
761        assert!(matches!(
762            DependencyOrder::new(&[&first, &second]),
763            Err(PublishError::DuplicateUnit(name)) if name == "app"
764        ));
765    }
766
767    #[tokio::test]
768    async fn refuses_a_non_authoritative_commit_before_side_effects() {
769        let world = World::default();
770        let error = world.run_at("other", &[unit("oe", &[])]).await.unwrap_err();
771        assert!(matches!(
772            error,
773            PublishError::WrongAuthority {
774                branch: _,
775                expected: _,
776                actual: _
777            }
778        ));
779        assert!(world.events.borrow().is_empty());
780    }
781
782    #[tokio::test]
783    async fn retry_after_a_mid_batch_failure_resumes_at_the_failed_unit() {
784        let world = World::failing("package:b");
785        let units = [unit("a", &[]), unit("b", &["a"])];
786        assert!(matches!(
787            world.run(&units).await,
788            Err(PublishError::PackagePublish { unit, source: _ }) if unit == "b"
789        ));
790        assert_eq!(
791            world.events.take(),
792            [
793                "package:a",
794                "release:a-v1.1.0",
795                "tag:a-v1.1.0",
796                "push:a-v1.1.0",
797                "publish:a-v1.1.0"
798            ]
799        );
800        assert_eq!(world.run(&units).await.unwrap().len(), 2);
801        assert_eq!(
802            world.events.take(),
803            [
804                "package:b",
805                "release:b-v1.1.0",
806                "tag:b-v1.1.0",
807                "push:b-v1.1.0",
808                "publish:b-v1.1.0"
809            ]
810        );
811    }
812
813    #[tokio::test]
814    async fn a_rerun_after_a_failed_tag_push_pushes_the_existing_local_tag() {
815        let world = World::failing("push:oe-v1.1.0");
816        let units = [unit("oe", &[])];
817        assert!(matches!(
818            world.run(&units).await,
819            Err(PublishError::Tag { unit, source: _ }) if unit == "oe"
820        ));
821        assert_eq!(
822            world.events.take(),
823            ["package:oe", "release:oe-v1.1.0", "tag:oe-v1.1.0"]
824        );
825        world.run(&units).await.unwrap();
826        assert_eq!(world.events.take(), ["push:oe-v1.1.0", "publish:oe-v1.1.0"]);
827    }
828
829    #[tokio::test]
830    async fn a_rerun_after_a_failed_release_creation_creates_the_release() {
831        let world = World::failing("release:oe-v1.1.0");
832        let units = [unit("oe", &[])];
833        assert!(matches!(
834            world.run(&units).await,
835            Err(PublishError::Release { unit, source: _ }) if unit == "oe"
836        ));
837        assert_eq!(world.events.take(), ["package:oe"]);
838        world.run(&units).await.unwrap();
839        assert_eq!(
840            world.events.take(),
841            [
842                "release:oe-v1.1.0",
843                "tag:oe-v1.1.0",
844                "push:oe-v1.1.0",
845                "publish:oe-v1.1.0"
846            ]
847        );
848    }
849
850    #[tokio::test]
851    async fn a_rerun_after_a_failed_publish_publishes_the_draft_and_then_leaves_it_untouched() {
852        let world = World::failing("publish:oe-v1.1.0");
853        let units = [unit("oe", &[])];
854        assert!(world.run(&units).await.is_err());
855        assert_eq!(
856            world.events.take(),
857            [
858                "package:oe",
859                "release:oe-v1.1.0",
860                "tag:oe-v1.1.0",
861                "push:oe-v1.1.0"
862            ]
863        );
864        world.run(&units).await.unwrap();
865        assert_eq!(world.events.take(), ["publish:oe-v1.1.0"]);
866        world.run(&units).await.unwrap();
867        assert_eq!(world.events.take(), Vec::<String>::new());
868    }
869
870    #[tokio::test]
871    async fn a_tag_at_another_commit_is_never_moved() {
872        let world = World::default();
873        world
874            .remote_tags
875            .borrow_mut()
876            .insert("oe-v1.1.0".into(), "other".into());
877        let units = [unit("oe", &[])];
878        assert!(matches!(
879            world.run(&units).await,
880            Err(PublishError::TagMoved {
881                location: TagLocation::Remote,
882                actual,
883                unit: _,
884                tag: _,
885                expected: _,
886            }) if actual == "other"
887        ));
888        world.remote_tags.borrow_mut().clear();
889        world
890            .local_tags
891            .borrow_mut()
892            .insert("oe-v1.1.0".into(), "other".into());
893        assert!(matches!(
894            world.run(&units).await,
895            Err(PublishError::TagMoved {
896                location: TagLocation::Local,
897                actual,
898                unit: _,
899                tag: _,
900                expected: _,
901            }) if actual == "other"
902        ));
903        assert_eq!(world.events.take(), ["package:oe", "release:oe-v1.1.0"]);
904        assert_eq!(*world.releases.borrow(), [("oe-v1.1.0".to_owned(), true)]);
905    }
906
907    #[cfg(unix)]
908    #[tokio::test]
909    async fn the_tag_is_pushed_after_the_artifacts_and_before_the_release_is_published() {
910        let directory = tempfile::tempdir().unwrap();
911        let world = World::default();
912        let units = [unit("oe", &[])];
913        let release = oci_release(
914            &units[0],
915            directory.path(),
916            "touch \"$(dirname \"$0\")/pushed\"",
917            &IMAGE,
918        );
919        world.run_releases("merged", &[release]).await.unwrap();
920        assert_eq!(
921            world.events.take(),
922            [
923                "package:oe",
924                "release:oe-v1.1.0",
925                "tag:oe-v1.1.0",
926                "push:oe-v1.1.0",
927                "publish:oe-v1.1.0"
928            ]
929        );
930        assert!(directory.path().join("pushed").exists());
931    }
932
933    #[cfg(unix)]
934    #[tokio::test]
935    async fn a_failed_oci_push_leaves_no_tag_and_a_draft_release() {
936        let directory = tempfile::tempdir().unwrap();
937        let world = World::default();
938        let units = [unit("oe", &[])];
939        let release = oci_release(&units[0], directory.path(), "exit 3", &IMAGE);
940        assert!(matches!(
941            world.run_releases("merged", &[release]).await,
942            Err(PublishError::Release { unit, source: ArtifactError::OciPush { name: _, status: _ } })
943                if unit == "oe"
944        ));
945        assert_eq!(world.events.take(), ["package:oe", "release:oe-v1.1.0"]);
946        assert!(world.local_tags.borrow().is_empty());
947        assert!(world.remote_tags.borrow().is_empty());
948        assert_eq!(*world.releases.borrow(), [("oe-v1.1.0".to_owned(), true)]);
949    }
950}