1use std::collections::{BTreeMap, BTreeSet};
15use std::path::PathBuf;
16
17use ephemeris_files::RepositoryRoot;
18
19use crate::adapters::{PinError, ResolveError, read_digest};
20use crate::artifacts::{ArtifactError, OciLogin, PackageKind, Publication, Stage};
21use crate::error::GitFailure;
22use crate::git::GitHistory;
23use crate::publisher::{PublishError, UnitRelease};
24use crate::state::{ReleaseArtifactState, ReleaseState, ReleaseTarget};
25use crate::traits::GitRefs;
26use crate::unit::{Unit, UnitStatus, UnparsableTag};
27
28#[derive(Debug, thiserror::Error)]
30pub(crate) enum PublicationError {
31 #[error(transparent)]
33 Discovery(#[from] ResolveError),
34 #[error("release publication git operation failed")]
36 Git(#[from] GitFailure),
37 #[error(transparent)]
39 Publish(#[from] PublishError),
40 #[error("publication commit {commit} is not the authoritative branch tip {tip}")]
42 NotTip {
43 commit: String,
45 tip: String,
47 },
48 #[error("artifact unit {0:?} is not configured")]
50 UnconfiguredArtifactUnit(String),
51 #[error("unit {0:?} has artifacts, and no artifact manifest was given")]
53 MissingArtifacts(String),
54 #[error("unit {unit:?} publishes to {kind} and declares {actual} {kind} artifacts, not one")]
56 PackageArtifactCount {
57 unit: String,
59 kind: &'static str,
61 actual: usize,
63 },
64 #[error("artifact {name:?} of unit {unit:?} does not match the unit's publication target")]
66 UnexpectedPackageArtifact {
67 unit: String,
69 name: String,
71 },
72 #[error("unit {0:?} revision must be positive")]
74 ZeroRevision(String),
75 #[error(transparent)]
77 UnparsableTag(#[from] UnparsableTag),
78 #[error(transparent)]
80 Artifact(#[from] ArtifactError),
81 #[error(transparent)]
83 Pin(#[from] PinError),
84 #[error(
86 "{unit:?} pins {pinned} for the asset {artifact:?} of {dependency:?}, but the build has \
87 {built}"
88 )]
89 DigestMismatch {
90 unit: String,
92 dependency: String,
94 artifact: String,
96 pinned: String,
98 built: String,
100 },
101}
102
103impl ReleaseState {
104 pub(crate) fn selected_units<G: GitHistory + GitRefs>(
110 &self,
111 repository: &RepositoryRoot,
112 commit: &str,
113 git: &G,
114 ) -> Result<Vec<Unit>, PublicationError> {
115 let discovered = self.discover_units(repository)?;
116 let mut selected = BTreeSet::new();
117 for unit in &discovered {
118 let tags = git
119 .tags(repository.as_path(), &unit.tag_prefix)
120 .map_err(GitFailure::new)
121 .map_err(PublicationError::Git)?;
122 let select = match unit.status(&tags)? {
123 UnitStatus::Pending => true,
124 UnitStatus::Released {
125 latest: _,
126 tagged: true,
127 } => git
128 .reference(repository.as_path(), &format!("refs/tags/{}", unit.tag()))
129 .map_err(GitFailure::new)
130 .map_err(PublicationError::Git)?
131 .is_some_and(|target| target == commit),
132 UnitStatus::Unversioned
133 | UnitStatus::Released {
134 latest: _,
135 tagged: false,
136 } => false,
137 };
138 if select {
139 selected.insert(unit.name.clone());
140 }
141 }
142 Ok(discovered
143 .into_iter()
144 .filter(|unit| selected.contains(&unit.name))
145 .map(|mut unit| {
146 unit.dependencies
147 .retain(|dependency| selected.contains(dependency.as_str()));
148 unit
149 })
150 .collect())
151 }
152
153 pub(crate) fn stage_releases<'a>(
161 &'a self,
162 repository: &RepositoryRoot,
163 commit: &str,
164 units: Vec<Unit>,
165 artifacts: Option<&Stage>,
166 login: Option<&OciLogin>,
167 ) -> Result<Vec<UnitRelease<'a>>, PublicationError> {
168 if let Some(key) = self
169 .artifacts
170 .keys()
171 .find(|key| !self.units.contains_key(*key))
172 {
173 return Err(PublicationError::UnconfiguredArtifactUnit(key.clone()));
174 }
175 let empty = Stage::empty();
176 let selected: BTreeSet<String> = units.iter().map(|unit| unit.name.clone()).collect();
177 units
178 .into_iter()
179 .map(|unit| {
180 if unit.revision == Some(0) {
181 return Err(PublicationError::ZeroRevision(unit.name));
182 }
183 let configured = self.artifacts.get(&unit.name);
184 let staged = match (artifacts, configured) {
185 (Some(staged), _) => staged,
186 (None, Some(artifacts)) if !artifacts.is_empty() => {
187 return Err(PublicationError::MissingArtifacts(unit.name.clone()));
188 }
189 (None, _) => &empty,
190 };
191 let mut expected = Vec::new();
192 let mut oci = Vec::new();
193 for (name, artifact) in configured.into_iter().flatten() {
194 match artifact {
195 ReleaseArtifactState::Asset(spec) => expected.push((name.as_str(), spec)),
196 ReleaseArtifactState::Oci(spec) => oci.push((name.as_str(), spec)),
197 ReleaseArtifactState::Cargo { package: _ }
198 | ReleaseArtifactState::Npm { package: _ } => {}
199 }
200 }
201 unit.check_digest_pins(repository, staged, &selected)?;
202 let package = unit.package_file(configured, staged)?;
203 let publication = Publication {
204 unit: unit.name.clone(),
205 tag: unit.tag(),
206 target_commit: commit.to_owned(),
207 title: unit.title(),
208 notes: unit.release_notes(repository)?,
209 expected,
210 oci,
211 version: unit.version.to_string(),
212 working_directory: repository.as_path().to_owned(),
213 };
214 let release = staged.release(publication, login)?;
215 Ok(UnitRelease {
216 unit,
217 release,
218 package,
219 })
220 })
221 .collect()
222 }
223}
224
225impl Unit {
226 fn check_digest_pins(
234 &self,
235 repository: &RepositoryRoot,
236 staged: &Stage,
237 selected: &BTreeSet<String>,
238 ) -> Result<(), PublicationError> {
239 for (dependency, pin) in &self.pins {
240 if !selected.contains(dependency) {
241 continue;
242 }
243 for (artifact, digest) in &pin.digests {
244 let Some(bytes) = staged.asset_bytes(dependency, artifact)? else {
245 continue;
246 };
247 let built = digest.algorithm.hex(&bytes);
248 let pinned = read_digest(repository, self, dependency, digest)?;
249 if pinned != built {
250 return Err(PublicationError::DigestMismatch {
251 unit: self.name.clone(),
252 dependency: dependency.clone(),
253 artifact: artifact.clone(),
254 pinned,
255 built,
256 });
257 }
258 }
259 }
260 Ok(())
261 }
262
263 pub(crate) fn package_file(
267 &self,
268 configured: Option<&BTreeMap<String, ReleaseArtifactState>>,
269 staged: &Stage,
270 ) -> Result<Option<PathBuf>, PublicationError> {
271 let wanted = match &self.target {
272 ReleaseTarget::Cargo {
273 publish: true,
274 manifest: _,
275 } => Some(PackageKind::Cargo),
276 ReleaseTarget::Npm {
277 publish: true,
278 manifest: _,
279 } => Some(PackageKind::Npm),
280 ReleaseTarget::Cargo {
281 publish: false,
282 manifest: _,
283 }
284 | ReleaseTarget::Npm {
285 publish: false,
286 manifest: _,
287 }
288 | ReleaseTarget::VersionFile { version_file: _ }
289 | ReleaseTarget::Document { path: _, key: _ } => None,
290 };
291 let mut declared = Vec::new();
292 for (name, artifact) in configured.into_iter().flatten() {
293 let (kind, package) = match artifact {
294 ReleaseArtifactState::Cargo { package } => (PackageKind::Cargo, package),
295 ReleaseArtifactState::Npm { package } => (PackageKind::Npm, package),
296 ReleaseArtifactState::Asset(_) | ReleaseArtifactState::Oci(_) => continue,
297 };
298 if wanted != Some(kind) {
299 return Err(PublicationError::UnexpectedPackageArtifact {
300 unit: self.name.clone(),
301 name: name.clone(),
302 });
303 }
304 declared.push((name, package));
305 }
306 let Some(kind) = wanted else {
307 return Ok(None);
308 };
309 let [(name, package)] = declared[..] else {
310 return Err(PublicationError::PackageArtifactCount {
311 unit: self.name.clone(),
312 kind: kind.label(),
313 actual: declared.len(),
314 });
315 };
316 Ok(Some(staged.package(&self.name, kind, name, package)?))
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use std::cell::RefCell;
323 use std::collections::BTreeMap;
324 use std::convert::Infallible;
325 use std::fs;
326 #[cfg(unix)]
327 use std::os::unix::fs::symlink;
328 use std::path::{Path, PathBuf};
329
330 use ephemeris_files::RepositoryRoot;
331 use ephemeris_token::Token;
332
333 use super::PublicationError;
334 use crate::artifacts::{ArtifactError, OciLogin, Stage};
335 use crate::digest::DigestAlgorithm;
336 use crate::git::{GitHistory, UnitScope};
337 use crate::publisher::{Authority, Publisher};
338 use crate::remote::{RemoteAsset, RemoteRelease};
339 use crate::state::{AssetSpec, OciSpec, ReleaseArtifactState, ReleaseUnitState};
340 use crate::traits::PackagePublisher;
341 use crate::unit::{Unit, UnparsableTag};
342 use crate::{Author, ForgeRelease, ForgeTags, GitCommands, GitRefs, ReleaseState};
343
344 const AUTHOR: Author<'static> = Author {
345 name: "Release Bot",
346 email: "release@example.invalid",
347 };
348
349 const EMPTY_STATE: &str = r#"{"cargo":null,"npm":null,"authoritative_branch":"main","proposal_branch":"ephemeris/release","commit_subject":"release: {unit} {version}","commit_format":null,"units":{},"artifacts":{},"account":{"login":null,"name":{"literal":"Release Bot"},"email":{"literal":"release@example.invalid"}},"packages_credential":"forge"}"#;
350
351 const CARGO_TARGET: &str =
352 r#"{"ecosystem":"cargo","manifest":"core/Cargo.toml","publish":true}"#;
353
354 struct DigestFixture {
356 _directories: (tempfile::TempDir, tempfile::TempDir),
357 state: ReleaseState,
358 root: RepositoryRoot,
359 staged: Stage,
360 }
361
362 impl DigestFixture {
363 fn stage(&self) -> Result<usize, PublicationError> {
364 self.stage_without("")
365 }
366
367 fn stage_without(&self, unselected: &str) -> Result<usize, PublicationError> {
368 let mut units = self.state.discover_units(&self.root)?;
369 units.retain(|unit| unit.name != unselected);
370 self.state
371 .stage_releases(&self.root, "merged", units, Some(&self.staged), None)
372 .map(|releases| releases.len())
373 }
374 }
375
376 #[derive(Debug, thiserror::Error)]
377 #[error("unexpected git operation: {0}")]
378 struct Unexpected(&'static str);
379
380 struct TagsOnly(&'static [&'static str]);
383
384 impl GitRefs for TagsOnly {
385 type Error = Unexpected;
386
387 fn reference(&self, _: &Path, name: &str) -> Result<Option<String>, Self::Error> {
388 let tag = name.strip_prefix("refs/tags/").unwrap_or(name);
389 Ok(self.0.contains(&tag).then(|| "released".to_owned()))
390 }
391 }
392
393 impl GitHistory for TagsOnly {
394 type Error = Unexpected;
395
396 fn tags(&self, _: &Path, prefix: &str) -> Result<Vec<String>, Self::Error> {
397 Ok(self
398 .0
399 .iter()
400 .filter(|tag| tag.starts_with(prefix))
401 .map(|tag| (*tag).to_owned())
402 .collect())
403 }
404
405 fn resolve(&self, _: &Path, _: &str) -> Result<String, Self::Error> {
406 Err(Unexpected("selection does not resolve revisions"))
407 }
408
409 fn has_changes(
410 &self,
411 _: &Path,
412 _: Option<&str>,
413 _: &str,
414 _: UnitScope<'_>,
415 ) -> Result<bool, Self::Error> {
416 Err(Unexpected("selection does not diff"))
417 }
418
419 fn commit_messages(
420 &self,
421 _: &Path,
422 _: Option<&str>,
423 _: &str,
424 _: UnitScope<'_>,
425 ) -> Result<Vec<String>, Self::Error> {
426 Err(Unexpected("selection does not read commits"))
427 }
428
429 fn equivalent(&self, _: &Path, _: &str, _: &str, _: &str) -> Result<bool, Self::Error> {
430 Err(Unexpected("selection does not compare commits"))
431 }
432
433 fn is_ancestor(&self, _: &Path, _: &str, _: &str) -> Result<bool, Self::Error> {
434 Err(Unexpected("selection does not compare ancestry"))
435 }
436 }
437
438 #[derive(Default)]
439 struct FakeRemote {
440 release: RefCell<bool>,
441 assets: RefCell<Vec<(RemoteAsset, Vec<u8>)>>,
442 published: RefCell<bool>,
443 drafts: RefCell<Vec<(String, String, String)>>,
444 }
445
446 impl ForgeTags for FakeRemote {
447 type Error = Infallible;
448
449 async fn tag_commit(&self, _: &str) -> Result<Option<String>, Self::Error> {
450 Ok(None)
451 }
452 }
453
454 impl ForgeRelease for FakeRemote {
455 type Error = Infallible;
456
457 async fn release_by_tag(&self, _: &str) -> Result<Option<RemoteRelease>, Self::Error> {
458 Ok(self.release.borrow().then_some(RemoteRelease {
459 id: 1,
460 draft: !*self.published.borrow(),
461 }))
462 }
463 async fn create_draft(
464 &self,
465 tag: &str,
466 _: &str,
467 title: &str,
468 notes: &str,
469 ) -> Result<u64, Self::Error> {
470 self.drafts
471 .borrow_mut()
472 .push((tag.to_owned(), title.to_owned(), notes.to_owned()));
473 *self.release.borrow_mut() = true;
474 Ok(1)
475 }
476 async fn assets(&self, _: u64) -> Result<Vec<RemoteAsset>, Self::Error> {
477 Ok(self
478 .assets
479 .borrow()
480 .iter()
481 .map(|(asset, _)| asset.clone())
482 .collect())
483 }
484 async fn upload(&self, _: u64, filename: &str, bytes: &[u8]) -> Result<(), Self::Error> {
485 let id = u64::try_from(self.assets.borrow().len())
486 .map_or(u64::MAX, |count| count.saturating_add(1));
487 self.assets.borrow_mut().push((
488 RemoteAsset {
489 id,
490 name: filename.to_owned(),
491 size: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
492 sha256: None,
493 download_url: String::new(),
494 },
495 bytes.to_vec(),
496 ));
497 Ok(())
498 }
499 async fn delete_asset(&self, _: u64, asset_id: u64) -> Result<(), Self::Error> {
500 self.assets
501 .borrow_mut()
502 .retain(|(asset, _)| asset.id != asset_id);
503 Ok(())
504 }
505 async fn asset_bytes(&self, asset: &RemoteAsset) -> Result<Vec<u8>, Self::Error> {
506 Ok(self
507 .assets
508 .borrow()
509 .iter()
510 .find(|(candidate, _)| candidate.id == asset.id)
511 .unwrap()
512 .1
513 .clone())
514 }
515 async fn publish(&self, _: u64) -> Result<(), Self::Error> {
516 *self.published.borrow_mut() = true;
517 Ok(())
518 }
519 }
520
521 async fn publish_configured_artifacts(
523 state: &ReleaseState,
524 repository: &RepositoryRoot,
525 commit: &str,
526 manifest: &Path,
527 directory: &Path,
528 remote: &FakeRemote,
529 ) -> Result<(), PublicationError> {
530 let login = OciLogin::new("bot".into(), Token::new("secret"));
531 publish_with_login(
532 state,
533 repository,
534 commit,
535 manifest,
536 directory,
537 remote,
538 Some(&login),
539 )
540 .await
541 }
542
543 async fn publish_with_login(
544 state: &ReleaseState,
545 repository: &RepositoryRoot,
546 commit: &str,
547 manifest: &Path,
548 directory: &Path,
549 remote: &FakeRemote,
550 login: Option<&OciLogin>,
551 ) -> Result<(), PublicationError> {
552 let units = state.discover_units(repository)?;
553 let staged = Stage::read(manifest, directory)?;
554 for release in state.stage_releases(repository, commit, units, Some(&staged), login)? {
555 release
556 .release
557 .prepare(remote)
558 .await?
559 .publish(remote)
560 .await?;
561 }
562 Ok(())
563 }
564
565 fn unit(name: &str, target: &str) -> String {
566 format!(
567 r#"{{"target":{target},"source":["{name}"],"changelog":"{name}/CHANGELOG.md","dependencies":[],"pins":{{}},"tag_prefix":"{name}-v","kind":"application","application_bump":"patch","revision_file":null}}"#
568 )
569 }
570
571 fn version_file(name: &str) -> String {
572 format!(r#"{{"ecosystem":"version-file","version_file":"{name}/VERSION"}}"#)
573 }
574
575 fn state(units: &[(&str, String)]) -> ReleaseState {
576 let units = units
577 .iter()
578 .map(|(name, unit)| format!(r#""{name}":{unit}"#))
579 .collect::<Vec<_>>()
580 .join(",");
581 facet_json::from_str(
582 &EMPTY_STATE.replace(r#""units":{}"#, &format!(r#""units":{{{units}}}"#)),
583 )
584 .unwrap()
585 }
586
587 fn artifact_fixture() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, ReleaseState) {
588 let repository = tempfile::tempdir().unwrap();
589 fs::create_dir_all(repository.path().join("app")).unwrap();
590 fs::write(repository.path().join("app/VERSION"), "2.4.0\n").unwrap();
591 fs::write(repository.path().join("app/revision"), "3\n").unwrap();
592 let stage = tempfile::tempdir().unwrap();
593 fs::create_dir_all(stage.path().join("artifacts")).unwrap();
594 fs::write(stage.path().join("artifacts/web.tar"), b"web asset").unwrap();
595 let manifest = stage.path().join("manifest.json");
596 fs::write(
597 &manifest,
598 r#"[{"kind":"asset","unit":"web","name":"web","package":"web-package","output":"web","filename":"web.tar","format":"tar","variant":"default","required_names":[],"path":"artifacts/web.tar"}]"#,
599 )
600 .unwrap();
601 let web = unit("app", &version_file("app"))
602 .replace("app-v", "web-v")
603 .replace(
604 r#""revision_file":null"#,
605 r#""revision_file":"app/revision""#,
606 );
607 let release_state = ReleaseState {
608 artifacts: BTreeMap::from([(
609 "web".into(),
610 BTreeMap::from([(
611 "web".into(),
612 ReleaseArtifactState::Asset(AssetSpec {
613 package: "web-package".into(),
614 output: "web".into(),
615 filename: "web.tar".into(),
616 format: "tar".into(),
617 variant: "default".into(),
618 required_names: Vec::new(),
619 }),
620 )]),
621 )]),
622 ..state(&[("web", web)])
623 };
624 (repository, stage, manifest, release_state)
625 }
626
627 fn package_unit(target: &str) -> Unit {
628 let state: ReleaseUnitState = facet_json::from_str(&unit("core", target)).unwrap();
629 Unit::configured(
630 "core",
631 Some("core".into()),
632 "1.0.0".parse().unwrap(),
633 &state,
634 )
635 }
636
637 fn package_stage(entry: &str, file: Option<&str>) -> (tempfile::TempDir, Stage) {
638 let directory = tempfile::tempdir().unwrap();
639 fs::create_dir_all(directory.path().join("artifacts")).unwrap();
640 if let Some(file) = file {
641 fs::write(directory.path().join(file), b"package").unwrap();
642 }
643 let manifest = directory.path().join("manifest.json");
644 fs::write(&manifest, format!("[{entry}]")).unwrap();
645 let stage = Stage::read(&manifest, directory.path()).unwrap();
646 (directory, stage)
647 }
648
649 fn declared(artifact: ReleaseArtifactState) -> BTreeMap<String, ReleaseArtifactState> {
650 BTreeMap::from([("crate".to_owned(), artifact)])
651 }
652
653 fn track_all(root: &Path) {
655 let repository = git2::Repository::init(root).unwrap();
656 let mut index = repository.index().unwrap();
657 index
658 .add_all(["*"], git2::IndexAddOption::DEFAULT, None)
659 .unwrap();
660 index.write().unwrap();
661 }
662
663 fn publication_units<G: GitHistory + GitRefs>(
664 state: &ReleaseState,
665 repository: &RepositoryRoot,
666 commit: &str,
667 git: &G,
668 ) -> Result<Vec<Unit>, PublicationError> {
669 state.selected_units(repository, commit, git)
670 }
671
672 #[test]
673 fn a_publishing_cargo_unit_gets_its_staged_crate() {
674 let (directory, stage) = package_stage(
675 r#"{"kind":"cargo","unit":"core","name":"crate","package":"crate-core","path":"artifacts/core.crate"}"#,
676 Some("artifacts/core.crate"),
677 );
678 let configured = declared(ReleaseArtifactState::Cargo {
679 package: "crate-core".into(),
680 });
681 assert_eq!(
682 package_unit(CARGO_TARGET)
683 .package_file(Some(&configured), &stage)
684 .unwrap(),
685 Some(directory.path().join("artifacts/core.crate"))
686 );
687 }
688
689 #[test]
690 fn a_publishing_npm_unit_gets_its_staged_tarball() {
691 let (directory, stage) = package_stage(
692 r#"{"kind":"npm","unit":"core","name":"crate","package":"npm-pack-core","path":"artifacts/core.tgz"}"#,
693 Some("artifacts/core.tgz"),
694 );
695 let configured = declared(ReleaseArtifactState::Npm {
696 package: "npm-pack-core".into(),
697 });
698 let target = r#"{"ecosystem":"npm","manifest":"core/package.json","publish":true}"#;
699 assert_eq!(
700 package_unit(target)
701 .package_file(Some(&configured), &stage)
702 .unwrap(),
703 Some(directory.path().join("artifacts/core.tgz"))
704 );
705 }
706
707 #[test]
708 fn a_staged_package_file_that_is_absent_is_an_error() {
709 let (_directory, stage) = package_stage(
710 r#"{"kind":"cargo","unit":"core","name":"crate","package":"crate-core","path":"artifacts/core.crate"}"#,
711 None,
712 );
713 let configured = declared(ReleaseArtifactState::Cargo {
714 package: "crate-core".into(),
715 });
716 assert!(matches!(
717 package_unit(CARGO_TARGET).package_file(Some(&configured), &stage),
718 Err(PublicationError::Artifact(ArtifactError::Io {
719 path: _,
720 source: _
721 }))
722 ));
723 }
724
725 #[test]
726 fn a_staged_package_of_another_name_is_a_mismatch() {
727 let (_directory, stage) = package_stage(
728 r#"{"kind":"cargo","unit":"core","name":"crate","package":"other","path":"artifacts/core.crate"}"#,
729 Some("artifacts/core.crate"),
730 );
731 let configured = declared(ReleaseArtifactState::Cargo {
732 package: "crate-core".into(),
733 });
734 assert!(matches!(
735 package_unit(CARGO_TARGET).package_file(Some(&configured), &stage),
736 Err(PublicationError::Artifact(
737 ArtifactError::ArtifactMismatch {
738 name: _,
739 field: "package"
740 }
741 ))
742 ));
743 }
744
745 #[test]
746 fn a_staged_package_at_another_path_is_rejected() {
747 let (_directory, stage) = package_stage(
748 r#"{"kind":"cargo","unit":"core","name":"crate","package":"crate-core","path":"artifacts/other.crate"}"#,
749 Some("artifacts/other.crate"),
750 );
751 let configured = declared(ReleaseArtifactState::Cargo {
752 package: "crate-core".into(),
753 });
754 assert!(matches!(
755 package_unit(CARGO_TARGET).package_file(Some(&configured), &stage),
756 Err(PublicationError::Artifact(ArtifactError::UnexpectedPath {
757 name: _,
758 path: _
759 }))
760 ));
761 }
762
763 #[test]
764 fn a_publishing_unit_needs_exactly_one_package_artifact() {
765 let (_directory, stage) = package_stage("", None);
766 assert!(matches!(
767 package_unit(CARGO_TARGET).package_file(None, &stage),
768 Err(PublicationError::PackageArtifactCount {
769 unit: _,
770 kind: "cargo",
771 actual: 0
772 })
773 ));
774 let configured = BTreeMap::from([
775 (
776 "a".to_owned(),
777 ReleaseArtifactState::Cargo {
778 package: "a".into(),
779 },
780 ),
781 (
782 "b".to_owned(),
783 ReleaseArtifactState::Cargo {
784 package: "b".into(),
785 },
786 ),
787 ]);
788 assert!(matches!(
789 package_unit(CARGO_TARGET).package_file(Some(&configured), &stage),
790 Err(PublicationError::PackageArtifactCount {
791 unit: _,
792 kind: "cargo",
793 actual: 2
794 })
795 ));
796 }
797
798 #[test]
799 fn a_package_artifact_of_the_wrong_kind_is_rejected() {
800 let (_directory, stage) = package_stage("", None);
801 let configured = declared(ReleaseArtifactState::Npm {
802 package: "npm-pack-core".into(),
803 });
804 assert!(matches!(
805 package_unit(CARGO_TARGET).package_file(Some(&configured), &stage),
806 Err(PublicationError::UnexpectedPackageArtifact { unit: _, name })
807 if name == "crate"
808 ));
809 let private = r#"{"ecosystem":"cargo","manifest":"core/Cargo.toml","publish":false}"#;
810 assert!(matches!(
811 package_unit(private).package_file(Some(&configured), &stage),
812 Err(PublicationError::UnexpectedPackageArtifact { unit: _, name: _ })
813 ));
814 }
815
816 #[test]
817 fn a_unit_that_does_not_publish_needs_no_package_file() {
818 let (_directory, stage) = package_stage("", None);
819 let private = r#"{"ecosystem":"cargo","manifest":"core/Cargo.toml","publish":false}"#;
820 assert_eq!(
821 package_unit(private).package_file(None, &stage).unwrap(),
822 None
823 );
824 }
825
826 #[tokio::test]
827 async fn version_file_artifact_publication_does_not_need_a_cargo_workspace() {
828 let (repository, stage, manifest, release_state) = artifact_fixture();
829 let remote = FakeRemote::default();
830 publish_configured_artifacts(
831 &release_state,
832 &RepositoryRoot::new(repository.path()).unwrap(),
833 "0123456789abcdef0123456789abcdef01234567",
834 &manifest,
835 stage.path(),
836 &remote,
837 )
838 .await
839 .unwrap();
840 assert!(*remote.published.borrow());
841 }
842
843 #[tokio::test]
844 async fn revision_tracked_artifact_unit_gets_exactly_one_tag_and_one_release() {
845 struct FakeGit {
846 events: RefCell<Vec<String>>,
847 }
848 impl GitCommands for FakeGit {
849 type Error = Infallible;
850 fn authoritative_head(&self, _: &Path, _: &str) -> Result<String, Self::Error> {
851 Ok("0123456789abcdef0123456789abcdef01234567".into())
852 }
853 fn create_tag(
854 &self,
855 _: &Path,
856 tag: &str,
857 _: &str,
858 _: Author<'_>,
859 ) -> Result<(), Self::Error> {
860 self.events.borrow_mut().push(format!("tag:{tag}"));
861 Ok(())
862 }
863 fn push_tag(&self, _: &Path, tag: &str) -> Result<(), Self::Error> {
864 self.events.borrow_mut().push(format!("push:{tag}"));
865 Ok(())
866 }
867 }
868 impl GitRefs for FakeGit {
869 type Error = Infallible;
870 fn reference(&self, _: &Path, _: &str) -> Result<Option<String>, Self::Error> {
871 Ok(None)
872 }
873 }
874 struct NoPackages;
875 impl PackagePublisher for NoPackages {
876 type Error = Infallible;
877 async fn is_published(&self, _: &Unit) -> Result<bool, Self::Error> {
878 Ok(true)
879 }
880 async fn publish(&self, _: &Unit, _: &Path) -> Result<(), Self::Error> {
881 Ok(())
882 }
883 }
884
885 let (repository, stage, manifest, release_state) = artifact_fixture();
886 fs::write(
887 repository.path().join("app/CHANGELOG.md"),
888 "# Changelog\n\n## [Unreleased]\n\n## [2.4.0] - 2026-10-21\n\n### Changed\n\n- Faster \
889 uploads.\n\n## [2.3.0] - 2026-09-01\n\n- Older.\n",
890 )
891 .unwrap();
892 let root = RepositoryRoot::new(repository.path()).unwrap();
893 let units = release_state.discover_units(&root).unwrap();
894 assert_eq!(units.len(), 1);
895 assert_eq!(units[0].revision, Some(3));
896 assert_eq!(units[0].tag(), "web-v2.4.0-3");
897 let releases = release_state
898 .stage_releases(
899 &root,
900 "0123456789abcdef0123456789abcdef01234567",
901 units,
902 Some(&Stage::read(&manifest, stage.path()).unwrap()),
903 None,
904 )
905 .unwrap();
906
907 let git = FakeGit {
908 events: RefCell::new(Vec::new()),
909 };
910 let remote = FakeRemote::default();
911 Publisher::new(&git, &NoPackages, &remote, AUTHOR)
912 .publish(
913 repository.path(),
914 &Authority {
915 branch: "origin/main".into(),
916 commit: "0123456789abcdef0123456789abcdef01234567".into(),
917 },
918 &releases,
919 )
920 .await
921 .unwrap();
922 assert_eq!(
923 &*git.events.borrow(),
924 &["tag:web-v2.4.0-3", "push:web-v2.4.0-3"]
925 );
926 assert!(*remote.published.borrow());
927 assert_eq!(remote.assets.borrow().len(), 1);
928 assert_eq!(
929 &*remote.drafts.borrow(),
930 &[(
931 "web-v2.4.0-3".to_owned(),
932 "web 2.4.0-3".to_owned(),
933 "### Changed\n\n- Faster uploads.".to_owned()
934 )]
935 );
936 }
937
938 #[tokio::test]
939 async fn artifact_unit_without_revision_file_publishes_under_the_plain_version() {
940 let (repository, stage, manifest, mut release_state) = artifact_fixture();
941 release_state.units.get_mut("web").unwrap().revision_file = None;
942 let root = RepositoryRoot::new(repository.path()).unwrap();
943 let units = release_state.discover_units(&root).unwrap();
944 assert_eq!(units[0].revision, None);
945 assert_eq!(units[0].tag(), "web-v2.4.0");
946 let remote = FakeRemote::default();
947 publish_configured_artifacts(
948 &release_state,
949 &root,
950 "0123456789abcdef0123456789abcdef01234567",
951 &manifest,
952 stage.path(),
953 &remote,
954 )
955 .await
956 .unwrap();
957 assert!(*remote.published.borrow());
958 assert_eq!(
959 &*remote.drafts.borrow(),
960 &[(
961 "web-v2.4.0".to_owned(),
962 "web 2.4.0".to_owned(),
963 String::new()
964 )]
965 );
966 }
967
968 #[cfg(unix)]
969 fn oci_fixture(
970 script: &str,
971 latest: bool,
972 ) -> (
973 tempfile::TempDir,
974 tempfile::TempDir,
975 PathBuf,
976 ReleaseState,
977 PathBuf,
978 ) {
979 use std::os::unix::fs::PermissionsExt as _;
980
981 let (repository, stage, manifest, mut release_state) = artifact_fixture();
982 let log = stage.path().join("pushed");
983 let program = stage.path().join("push");
984 fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap();
985 fs::set_permissions(&program, fs::Permissions::from_mode(0o755)).unwrap();
986 fs::write(
987 &manifest,
988 format!(
989 r#"[{{"kind":"asset","unit":"web","name":"web","package":"web-package","output":"web","filename":"web.tar","format":"tar","variant":"default","required_names":[],"path":"artifacts/web.tar"}},{{"kind":"oci","unit":"web","name":"image","package":"web-image","registry":"registry.example/team","repository":"web","latest":{latest},"program":{program:?}}}]"#
990 ),
991 )
992 .unwrap();
993 release_state.artifacts.get_mut("web").unwrap().insert(
994 "image".into(),
995 ReleaseArtifactState::Oci(OciSpec {
996 package: "web-image".into(),
997 registry: "registry.example/team".into(),
998 repository: "web".into(),
999 latest,
1000 }),
1001 );
1002 (repository, stage, manifest, release_state, log)
1003 }
1004
1005 #[cfg(unix)]
1006 #[tokio::test]
1007 async fn oci_artifact_is_pushed_for_the_version_and_latest() {
1008 let (repository, stage, manifest, release_state, log) = oci_fixture(
1009 "printf '%s\\n' \"$1\" >> \"$(dirname \"$0\")/pushed\"\npwd -P > \"$(dirname \
1010 \"$0\")/cwd\"\nprintf '%s:%s' \"$EPHEMERIS_OCI_USERNAME\" \
1011 \"$EPHEMERIS_OCI_PASSWORD\" > \"$(dirname \"$0\")/login\"",
1012 true,
1013 );
1014 let remote = FakeRemote::default();
1015 publish_configured_artifacts(
1016 &release_state,
1017 &RepositoryRoot::new(repository.path()).unwrap(),
1018 "0123456789abcdef0123456789abcdef01234567",
1019 &manifest,
1020 stage.path(),
1021 &remote,
1022 )
1023 .await
1024 .unwrap();
1025 assert!(*remote.published.borrow());
1026 assert_eq!(
1027 fs::read_to_string(log).unwrap(),
1028 "registry.example/team/web:2.4.0\nregistry.example/team/web:latest\n"
1029 );
1030 assert_eq!(
1031 fs::read_to_string(stage.path().join("cwd")).unwrap().trim(),
1032 repository.path().canonicalize().unwrap().to_str().unwrap()
1033 );
1034 assert_eq!(
1035 fs::read_to_string(stage.path().join("login")).unwrap(),
1036 "bot:secret"
1037 );
1038 }
1039
1040 #[cfg(unix)]
1041 #[tokio::test]
1042 async fn oci_artifact_without_a_registry_login_is_an_error_before_any_push() {
1043 let (repository, stage, manifest, release_state, log) =
1044 oci_fixture("touch \"$(dirname \"$0\")/pushed\"", true);
1045 let remote = FakeRemote::default();
1046 let error = publish_with_login(
1047 &release_state,
1048 &RepositoryRoot::new(repository.path()).unwrap(),
1049 "0123456789abcdef0123456789abcdef01234567",
1050 &manifest,
1051 stage.path(),
1052 &remote,
1053 None,
1054 )
1055 .await
1056 .unwrap_err();
1057 assert!(matches!(
1058 error,
1059 PublicationError::Artifact(ArtifactError::MissingRegistryLogin(name))
1060 if name == "image"
1061 ));
1062 assert!(!log.exists());
1063 assert!(remote.drafts.borrow().is_empty());
1064 }
1065
1066 #[cfg(unix)]
1067 #[tokio::test]
1068 async fn oci_artifact_without_latest_is_pushed_once() {
1069 let (repository, stage, manifest, release_state, log) = oci_fixture(
1070 "printf '%s\\n' \"$1\" >> \"$(dirname \"$0\")/pushed\"",
1071 false,
1072 );
1073 publish_configured_artifacts(
1074 &release_state,
1075 &RepositoryRoot::new(repository.path()).unwrap(),
1076 "0123456789abcdef0123456789abcdef01234567",
1077 &manifest,
1078 stage.path(),
1079 &FakeRemote::default(),
1080 )
1081 .await
1082 .unwrap();
1083 assert_eq!(
1084 fs::read_to_string(log).unwrap(),
1085 "registry.example/team/web:2.4.0\n"
1086 );
1087 }
1088
1089 #[cfg(unix)]
1090 #[tokio::test]
1091 async fn failing_oci_push_names_the_artifact_and_status() {
1092 let (repository, stage, manifest, release_state, log) = oci_fixture("exit 3", true);
1093 let error = publish_configured_artifacts(
1094 &release_state,
1095 &RepositoryRoot::new(repository.path()).unwrap(),
1096 "0123456789abcdef0123456789abcdef01234567",
1097 &manifest,
1098 stage.path(),
1099 &FakeRemote::default(),
1100 )
1101 .await
1102 .unwrap_err();
1103 assert!(matches!(
1104 error,
1105 PublicationError::Artifact(ArtifactError::OciPush { name, status })
1106 if name == "image" && status.code() == Some(3)
1107 ));
1108 assert!(!log.exists());
1109 }
1110
1111 #[tokio::test]
1112 async fn artifact_unit_with_zero_revision_is_rejected() {
1113 let (repository, stage, manifest, release_state) = artifact_fixture();
1114 fs::write(repository.path().join("app/revision"), "0\n").unwrap();
1115 let remote = FakeRemote::default();
1116 let error = publish_configured_artifacts(
1117 &release_state,
1118 &RepositoryRoot::new(repository.path()).unwrap(),
1119 "0123456789abcdef0123456789abcdef01234567",
1120 &manifest,
1121 stage.path(),
1122 &remote,
1123 )
1124 .await
1125 .unwrap_err();
1126 assert!(matches!(error, PublicationError::ZeroRevision(key) if key == "web"));
1127 assert!(remote.drafts.borrow().is_empty());
1128 }
1129
1130 #[test]
1131 fn selection_uses_configured_tag_prefix() {
1132 let root = tempfile::tempdir().unwrap();
1133 fs::write(
1134 root.path().join("Cargo.toml"),
1135 "[workspace]\nmembers = [\"core\"]\n",
1136 )
1137 .unwrap();
1138 fs::create_dir_all(root.path().join("core/src")).unwrap();
1139 fs::write(root.path().join("core/src/lib.rs"), "pub fn fixture() {}\n").unwrap();
1140 fs::write(
1141 root.path().join("core/Cargo.toml"),
1142 "[package]\nname = \"core\"\nversion = \"1.1.0\"\n",
1143 )
1144 .unwrap();
1145 fs::write(root.path().join("README.md"), "fixture\n").unwrap();
1146 track_all(root.path());
1147
1148 let core = unit(
1149 "core",
1150 r#"{"ecosystem":"cargo","manifest":"core/Cargo.toml","publish":true}"#,
1151 )
1152 .replace(r#""kind":"application""#, r#""kind":"library""#)
1153 .replace("core-v", "custom-");
1154 let state = state(&[("core", core)]);
1155 #[cfg(unix)]
1156 let repository_alias = {
1157 let alias = root.path().with_extension("repository-alias");
1158 symlink(root.path(), &alias).unwrap();
1159 alias
1160 };
1161 #[cfg(not(unix))]
1162 let repository_alias = root.path().to_path_buf();
1163 let selected = publication_units(
1164 &state,
1165 &RepositoryRoot::new(&repository_alias).unwrap(),
1166 "commit",
1167 &TagsOnly(&["custom-1.0.0"]),
1168 )
1169 .unwrap();
1170 assert_eq!(selected.len(), 1);
1171 assert_eq!(selected[0].tag_prefix, "custom-");
1172 }
1173
1174 #[test]
1175 fn selection_preserves_an_explicit_empty_tag_prefix() {
1176 let root = tempfile::tempdir().unwrap();
1177 fs::write(
1178 root.path().join("Cargo.toml"),
1179 "[workspace]\nmembers = [\"core\"]\n",
1180 )
1181 .unwrap();
1182 fs::create_dir_all(root.path().join("core/src")).unwrap();
1183 fs::write(root.path().join("core/src/lib.rs"), "pub fn fixture() {}\n").unwrap();
1184 fs::write(
1185 root.path().join("core/Cargo.toml"),
1186 "[package]\nname = \"core\"\nversion = \"1.1.0\"\n",
1187 )
1188 .unwrap();
1189 fs::write(root.path().join("README.md"), "fixture\n").unwrap();
1190 track_all(root.path());
1191
1192 let core = unit(
1193 "core",
1194 r#"{"ecosystem":"cargo","manifest":"core/Cargo.toml","publish":true}"#,
1195 )
1196 .replace(r#""kind":"application""#, r#""kind":"library""#)
1197 .replace("core-v", "");
1198 let state = state(&[("core", core)]);
1199 let selected = publication_units(
1200 &state,
1201 &RepositoryRoot::new(root.path()).unwrap(),
1202 "commit",
1203 &TagsOnly(&["1.0.0"]),
1204 )
1205 .unwrap();
1206 assert_eq!(selected.len(), 1);
1207 assert_eq!(selected[0].tag_prefix, "");
1208 }
1209
1210 #[test]
1211 fn existing_numeric_revision_tag_covers_its_base_version() {
1212 let root = tempfile::tempdir().unwrap();
1213 fs::create_dir_all(root.path().join("app")).unwrap();
1214 fs::write(root.path().join("app/VERSION"), "1.2.3\n").unwrap();
1215 track_all(root.path());
1216 let state = state(&[("app", unit("app", &version_file("app")))]);
1217 let selected = publication_units(
1218 &state,
1219 &RepositoryRoot::new(root.path()).unwrap(),
1220 "commit",
1221 &TagsOnly(&["app-v1.2.3", "app-v1.2.3-2"]),
1222 )
1223 .unwrap();
1224 assert_eq!(selected, Vec::<Unit>::new());
1225
1226 let reselected = publication_units(
1227 &state,
1228 &RepositoryRoot::new(root.path()).unwrap(),
1229 "commit",
1230 &TagsOnly(&["app-v1.2.3-2"]),
1231 )
1232 .unwrap();
1233 assert_eq!(
1234 reselected,
1235 Vec::<Unit>::new(),
1236 "a revision tag of 1.2.3 already covers 1.2.3, so it is not reselected"
1237 );
1238 }
1239
1240 #[test]
1241 fn revision_only_release_is_selected() {
1242 let root = tempfile::tempdir().unwrap();
1243 fs::create_dir_all(root.path().join("app")).unwrap();
1244 fs::write(root.path().join("app/VERSION"), "1.2.3\n").unwrap();
1245 fs::write(root.path().join("app/revision"), "1\n").unwrap();
1246 let app = unit("app", &version_file("app")).replace(
1247 r#""revision_file":null"#,
1248 r#""revision_file":"app/revision""#,
1249 );
1250 let state = state(&[("app", app)]);
1251 let select = || {
1252 publication_units(
1253 &state,
1254 &RepositoryRoot::new(root.path()).unwrap(),
1255 "commit",
1256 &TagsOnly(&["app-v1.2.3-1"]),
1257 )
1258 .unwrap()
1259 };
1260 assert_eq!(select(), Vec::<Unit>::new());
1261
1262 fs::write(root.path().join("app/revision"), "2\n").unwrap();
1263 let selected = select();
1264 assert_eq!(selected.len(), 1);
1265 assert_eq!(selected[0].tag(), "app-v1.2.3-2");
1266 }
1267
1268 fn two_app_state(root: &Path) -> ReleaseState {
1269 for name in ["app", "new"] {
1270 fs::create_dir_all(root.join(name)).unwrap();
1271 fs::write(root.join(name).join("VERSION"), "1.2.3\n").unwrap();
1272 }
1273 state(&[
1274 ("app", unit("app", &version_file("app"))),
1275 ("new", unit("new", &version_file("new"))),
1276 ])
1277 }
1278
1279 #[test]
1280 fn an_untagged_unit_is_selected_as_a_first_release_beside_a_released_one() {
1281 let root = tempfile::tempdir().unwrap();
1282 let state = two_app_state(root.path());
1283 let selected = publication_units(
1284 &state,
1285 &RepositoryRoot::new(root.path()).unwrap(),
1286 "merged",
1287 &TagsOnly(&["app-v1.2.3"]),
1288 )
1289 .unwrap();
1290 assert_eq!(
1291 selected.iter().map(Unit::tag).collect::<Vec<_>>(),
1292 ["new-v1.2.3"]
1293 );
1294 }
1295
1296 #[test]
1297 fn tags_under_the_prefix_that_name_no_version_are_an_error() {
1298 let root = tempfile::tempdir().unwrap();
1299 let state = two_app_state(root.path());
1300 let result = publication_units(
1301 &state,
1302 &RepositoryRoot::new(root.path()).unwrap(),
1303 "merged",
1304 &TagsOnly(&["app-vlatest"]),
1305 );
1306 assert!(matches!(
1307 result,
1308 Err(PublicationError::UnparsableTag(UnparsableTag { unit, tag }))
1309 if unit == "app" && tag == "app-vlatest"
1310 ));
1311 }
1312
1313 #[test]
1314 fn a_unit_tagged_at_the_commit_is_selected_again_to_resume_it() {
1315 let root = tempfile::tempdir().unwrap();
1316 let state = two_app_state(root.path());
1317 let selected = publication_units(
1318 &state,
1319 &RepositoryRoot::new(root.path()).unwrap(),
1320 "released",
1321 &TagsOnly(&["app-v1.2.3", "new-v1.2.3"]),
1322 )
1323 .unwrap();
1324 assert_eq!(selected.len(), 2);
1325 }
1326
1327 #[test]
1328 fn a_selected_unit_with_artifacts_needs_the_artifact_input() {
1329 let (repository, _stage, _manifest, release_state) = artifact_fixture();
1330 let root = RepositoryRoot::new(repository.path()).unwrap();
1331 let units = release_state.discover_units(&root).unwrap();
1332 assert!(matches!(
1333 release_state.stage_releases(&root, "merged", units, None, None),
1334 Err(PublicationError::MissingArtifacts(unit)) if unit == "web"
1335 ));
1336 }
1337
1338 fn digest_pin_fixture(pinned: &str, artifact: &str) -> DigestFixture {
1339 let (repository, stage, _, mut release_state) = artifact_fixture();
1340 fs::create_dir_all(repository.path().join("action")).unwrap();
1341 fs::write(
1342 repository.path().join("action/action.yml"),
1343 format!("version: 1.0.0\ndigest: {pinned}\n"),
1344 )
1345 .unwrap();
1346 let action = unit(
1347 "action",
1348 r#"{"ecosystem":"document","path":"action/action.yml","key":["version"]}"#,
1349 )
1350 .replace(r#""dependencies":[]"#, r#""dependencies":["web"]"#)
1351 .replace(
1352 r#""pins":{}"#,
1353 &format!(
1354 r#""pins":{{"web":{{"path":"action/action.yml","key":["version"],"digests":{{"{artifact}":{{"path":"action/action.yml","key":["digest"],"algorithm":"sha256","package":"web-package","output":"web"}}}}}}}}"#
1355 ),
1356 );
1357 release_state.units.extend(
1358 state(&[("action", action)])
1359 .units
1360 .into_iter()
1361 .filter(|(name, _)| name == "action"),
1362 );
1363 let root = RepositoryRoot::new(repository.path()).unwrap();
1364 let staged = Stage::read(&stage.path().join("manifest.json"), stage.path()).unwrap();
1365 DigestFixture {
1366 _directories: (repository, stage),
1367 state: release_state,
1368 root,
1369 staged,
1370 }
1371 }
1372
1373 #[test]
1374 fn a_digest_pin_that_matches_the_built_asset_is_staged() {
1375 let pinned = DigestAlgorithm::Sha256.hex(b"web asset");
1376 assert_eq!(digest_pin_fixture(&pinned, "web").stage().unwrap(), 2);
1377 }
1378
1379 #[test]
1380 fn a_digest_pin_that_differs_from_the_built_asset_fails_before_any_upload() {
1381 let built = DigestAlgorithm::Sha256.hex(b"web asset");
1382 assert!(matches!(
1383 digest_pin_fixture("stale", "web").stage(),
1384 Err(PublicationError::DigestMismatch { unit, dependency, artifact, pinned, built: actual })
1385 if unit == "action"
1386 && dependency == "web"
1387 && artifact == "web"
1388 && pinned == "stale"
1389 && actual == built
1390 ));
1391 }
1392
1393 #[test]
1394 fn a_digest_pin_is_not_checked_without_the_asset_in_the_manifest() {
1395 assert_eq!(digest_pin_fixture("stale", "absent").stage().unwrap(), 2);
1396 }
1397
1398 #[test]
1399 fn a_digest_pin_of_an_unselected_dependency_is_not_checked() {
1400 assert_eq!(
1401 digest_pin_fixture("", "web").stage_without("web").unwrap(),
1402 1
1403 );
1404 }
1405
1406 #[test]
1407 fn an_untagged_unit_at_0_0_0_is_not_selected() {
1408 let root = tempfile::tempdir().unwrap();
1409 let state = two_app_state(root.path());
1410 fs::write(root.path().join("new/VERSION"), "0.0.0\n").unwrap();
1411 let selected = publication_units(
1412 &state,
1413 &RepositoryRoot::new(root.path()).unwrap(),
1414 "merged",
1415 &TagsOnly(&["app-v1.2.3"]),
1416 )
1417 .unwrap();
1418 assert_eq!(selected, Vec::<Unit>::new());
1419 }
1420}