1use 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#[derive(Clone, Debug, Eq, PartialEq)]
15pub(crate) struct Authority {
16 pub(crate) branch: String,
18 pub(crate) commit: String,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct Outcome {
25 pub unit: String,
27 pub version: String,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub(crate) enum TagLocation {
34 Local,
36 Remote,
38}
39
40#[derive(Debug, thiserror::Error)]
42pub(crate) enum PublishError {
43 #[error("authoritative branch {branch:?} is at {actual}, expected {expected}")]
45 WrongAuthority {
46 branch: String,
48 expected: String,
50 actual: String,
52 },
53 #[error("release graph contains a dependency cycle among {}", units.join(", "))]
55 Cycle {
56 units: Vec<String>,
58 },
59 #[error("unit {0:?} appears twice in the release batch")]
61 DuplicateUnit(String),
62 #[error("cannot read the authoritative branch")]
64 AuthorityCheck(#[source] GitFailure),
65 #[error("no staged package file for {0}")]
67 MissingPackageFile(String),
68 #[error("cannot look up the package of {unit}")]
70 PackageLookup {
71 unit: String,
73 #[source]
75 source: PackageFailure,
76 },
77 #[error("cannot publish the package of {unit}")]
79 PackagePublish {
80 unit: String,
82 #[source]
84 source: PackageFailure,
85 },
86 #[error("cannot read, create, or push the tag of {unit}")]
88 Tag {
89 unit: String,
91 #[source]
93 source: GitFailure,
94 },
95 #[error("tag {tag:?} of {unit} is at {actual} ({location:?}), expected {expected}")]
97 TagMoved {
98 unit: String,
100 tag: String,
102 location: TagLocation,
104 expected: String,
106 actual: String,
108 },
109 #[error("cannot read the forge tag of {unit}")]
111 Remote {
112 unit: String,
114 #[source]
116 source: ForgeFailure,
117 },
118 #[error("release of {unit} failed")]
120 Release {
121 unit: String,
123 #[source]
125 source: ArtifactError,
126 },
127}
128
129impl DependencyOrder {
130 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
193struct DependencyOrder(Vec<usize>);
196
197#[derive(Debug)]
199pub(crate) struct UnitRelease<'a> {
200 pub(crate) unit: Unit,
201 pub(crate) release: StagedRelease<'a>,
202 pub(crate) package: Option<PathBuf>,
204}
205
206#[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 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 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 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 #[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 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 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}