Skip to main content

ephemeris_release/
publication.rs

1//! The publication contract. Every step reads the remote first and
2//! converges, so a rerun after any failure finishes the job.
3//!
4//! 1. Check that the commit is the merged release proposal.
5//! 2. Select the units whose tag points at the commit, or whose version is
6//!    newer than their latest tag.
7//! 3. Check the staged artifact files of the selected units.
8//! 4. Publish each unit's package unless its registry has it.
9//! 5. Tag the commit locally and on the forge. A tag at another commit is an
10//!    error.
11//! 6. Create the draft release, converge its assets, push the OCI artifacts,
12//!    and publish the draft.
13
14use 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/// Unit selection or artifact publication failure.
29#[derive(Debug, thiserror::Error)]
30pub(crate) enum PublicationError {
31    /// The configured units cannot be resolved.
32    #[error(transparent)]
33    Discovery(#[from] ResolveError),
34    /// Listing or reading tags failed.
35    #[error("release publication git operation failed")]
36    Git(#[from] GitFailure),
37    /// Publishing the selected units failed.
38    #[error(transparent)]
39    Publish(#[from] PublishError),
40    /// Publication needs the branch tip, and the commit is not it.
41    #[error("publication commit {commit} is not the authoritative branch tip {tip}")]
42    NotTip {
43        /// The commit given to publish.
44        commit: String,
45        /// The tip of the authoritative branch.
46        tip: String,
47    },
48    /// Artifacts are configured for a unit that does not exist.
49    #[error("artifact unit {0:?} is not configured")]
50    UnconfiguredArtifactUnit(String),
51    /// A selected unit has artifacts, and the run was given none.
52    #[error("unit {0:?} has artifacts, and no artifact manifest was given")]
53    MissingArtifacts(String),
54    /// A publishing unit does not declare exactly one package artifact.
55    #[error("unit {unit:?} publishes to {kind} and declares {actual} {kind} artifacts, not one")]
56    PackageArtifactCount {
57        /// The release unit.
58        unit: String,
59        /// The artifact kind its target needs.
60        kind: &'static str,
61        /// The declared count.
62        actual: usize,
63    },
64    /// A unit declares a package artifact its target does not publish.
65    #[error("artifact {name:?} of unit {unit:?} does not match the unit's publication target")]
66    UnexpectedPackageArtifact {
67        /// The release unit.
68        unit: String,
69        /// The artifact.
70        name: String,
71    },
72    /// A unit's revision file holds 0.
73    #[error("unit {0:?} revision must be positive")]
74    ZeroRevision(String),
75    /// Tags exist under a unit's prefix and none of them names a version.
76    #[error(transparent)]
77    UnparsableTag(#[from] UnparsableTag),
78    /// Staging validation failed.
79    #[error(transparent)]
80    Artifact(#[from] ArtifactError),
81    /// A digest pin file cannot be read.
82    #[error(transparent)]
83    Pin(#[from] PinError),
84    /// A digest pin differs from the digest of the built dependency asset.
85    #[error(
86        "{unit:?} pins {pinned} for the asset {artifact:?} of {dependency:?}, but the build has \
87         {built}"
88    )]
89    DigestMismatch {
90        /// The unit holding the pin.
91        unit: String,
92        /// The dependency that owns the asset.
93        dependency: String,
94        /// The asset artifact.
95        artifact: String,
96        /// The digest in the pin file.
97        pinned: String,
98        /// The digest of the built asset.
99        built: String,
100    },
101}
102
103impl ReleaseState {
104    /// Select the configured units this run publishes or resumes.
105    ///
106    /// # Errors
107    /// Returns an error for invalid release state, git failures, or malformed
108    /// versions.
109    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    /// Stage the forge release of every selected unit, checking every local
154    /// file before anything is created.
155    ///
156    /// # Errors
157    /// Returns an error for invalid artifact configuration, a zero revision, a
158    /// unit with artifacts but no `artifacts`, or staged files that do not
159    /// match, or an OCI artifact has no `login`.
160    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    /// Check that every digest pin of a `selected` dependency holds the
227    /// digest of the staged asset it names.
228    ///
229    /// A digest pin changes only in the release commit of its dependency, and
230    /// that release selects the dependency, so the check sees every changed
231    /// pin. The staged asset of an unselected dependency is a fresh build, not
232    /// the published asset.
233    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    /// The staged package file of a unit that publishes to a registry.
264    ///
265    /// A unit that does not publish needs none and may not declare one.
266    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    /// A Git operation the scenario does not expect.
355    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    /// Lists fixed tags, all on an earlier commit; every other Git operation
381    /// is unexpected.
382    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    /// Stage and publish the forge release of every configured unit.
522    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    /// Initialize a repository at `root` that tracks every file in it.
654    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}