Skip to main content

ephemeris_release/
publish.rs

1//! The publication sequence at the authoritative branch tip.
2
3use std::path::Path;
4
5use ephemeris_files::RepositoryRoot;
6use ephemeris_token::Token;
7
8use crate::adapters::Adapters;
9use crate::artifacts::{OciLogin, Stage};
10use crate::error::{Error, GitFailure};
11use crate::git::{GitHistory, ProposalGit};
12use crate::publication::PublicationError;
13use crate::publisher::{Authority, Publisher};
14use crate::state::{AuthorityBranch, ReleaseState};
15use crate::{Account, ForgeRelease, ForgeTags, GitCommands, GitRefs, Outcome, PackageRegistry};
16
17/// Everything [`publish`] works from.
18#[derive(Debug)]
19pub struct PublishRequest<'a, G, R, N, F> {
20    /// The evaluated release policy.
21    pub state: &'a ReleaseState,
22    /// Repository checked out at `commit`.
23    pub repository: &'a RepositoryRoot,
24    /// The commit to publish.
25    pub commit: &'a str,
26    /// Authoritative branch that must point at `commit`.
27    pub authority: &'a AuthorityBranch,
28    /// Local Git operations.
29    pub git: &'a G,
30    /// Package registry lookup for Cargo units.
31    pub registry: R,
32    /// Package registry lookup for npm units, when npm is configured.
33    pub npm_registry: Option<N>,
34    /// Forge tags, releases, and proposal.
35    pub forge: &'a F,
36    /// Built artifacts of the selected units, when any has artifacts.
37    pub artifacts: Option<ArtifactInput<'a>>,
38    /// What package uploads and registry logins authenticate with.
39    pub credentials: PublishCredentials,
40    /// The evaluated account that tags and logs in.
41    pub account: &'a Account<String>,
42}
43
44/// The credentials of one publication.
45#[derive(Clone, Debug)]
46pub struct PublishCredentials {
47    /// The whole `Authorization` value of Cargo crate uploads.
48    pub cargo_authorization: Token,
49    /// The token of npm publishes and of the registry login password.
50    pub token: Token,
51}
52
53/// Built release artifacts on disk.
54#[derive(Clone, Copy, Debug)]
55pub struct ArtifactInput<'a> {
56    /// The artifact manifest.
57    pub manifest: &'a Path,
58    /// The directory holding the artifact files.
59    pub directory: &'a Path,
60}
61
62/// Publish the selected release units. Every step reads the remote first, so
63/// rerunning after any failure finishes the job.
64///
65/// # Errors
66/// Returns an error when a precondition, unit selection, staging, or a
67/// publication step fails.
68pub async fn publish<G, R, N, F>(
69    request: PublishRequest<'_, G, R, N, F>,
70) -> Result<Vec<Outcome>, Error>
71where
72    G: GitCommands + GitHistory + GitRefs + ProposalGit,
73    R: PackageRegistry,
74    N: PackageRegistry,
75    F: ForgeRelease + ForgeTags,
76{
77    let PublishRequest {
78        state,
79        repository,
80        commit,
81        authority,
82        git,
83        registry,
84        npm_registry,
85        forge,
86        artifacts,
87        credentials,
88        account,
89    } = request;
90    git.fetch(repository.as_path(), &authority.refspec())
91        .map_err(GitFailure::new)
92        .map_err(PublicationError::from)?;
93    let tip = git
94        .resolve(repository.as_path(), &authority.tracking())
95        .map_err(GitFailure::new)
96        .map_err(PublicationError::from)?;
97    if tip != commit {
98        return Err(PublicationError::NotTip {
99            commit: commit.to_owned(),
100            tip,
101        }
102        .into());
103    }
104    let units = state.selected_units(repository, commit, git)?;
105    let stage = artifacts
106        .map(|input| Stage::read(input.manifest, input.directory))
107        .transpose()
108        .map_err(PublicationError::from)?;
109    let login = account
110        .login
111        .as_ref()
112        .map(|username| OciLogin::new(username.clone(), credentials.token.clone()));
113    let releases =
114        state.stage_releases(repository, commit, units, stage.as_ref(), login.as_ref())?;
115    let packages = Adapters::new(registry, npm_registry)
116        .authenticated(credentials.cargo_authorization, credentials.token);
117    Ok(Publisher::new(git, &packages, forge, account.author())
118        .publish(
119            repository.as_path(),
120            &Authority {
121                branch: authority.tracking(),
122                commit: commit.to_owned(),
123            },
124            &releases,
125        )
126        .await
127        .map_err(PublicationError::from)?)
128}
129
130#[cfg(test)]
131mod tests {
132    use std::io;
133    use std::path::Path;
134
135    use ephemeris_files::RepositoryRoot;
136    use ephemeris_token::Token;
137    use git2::{Repository, RepositoryInitOptions, Signature};
138
139    use super::{PublishCredentials, PublishRequest, publish};
140    use crate::git::SystemGit;
141    use crate::{Account, ForgeRelease, ForgeTags, PackageRegistry, RemoteAsset, RemoteRelease};
142
143    const 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"}"#;
144
145    /// Refuses every call.
146    struct Refusing;
147
148    impl ForgeTags for Refusing {
149        type Error = io::Error;
150        async fn tag_commit(&self, _: &str) -> Result<Option<String>, Self::Error> {
151            refused()
152        }
153    }
154
155    impl ForgeRelease for Refusing {
156        type Error = io::Error;
157        async fn release_by_tag(&self, _: &str) -> Result<Option<RemoteRelease>, Self::Error> {
158            refused()
159        }
160        async fn create_draft(
161            &self,
162            _: &str,
163            _: &str,
164            _: &str,
165            _: &str,
166        ) -> Result<u64, Self::Error> {
167            refused()
168        }
169        async fn assets(&self, _: u64) -> Result<Vec<RemoteAsset>, Self::Error> {
170            refused()
171        }
172        async fn asset_bytes(&self, _: &RemoteAsset) -> Result<Vec<u8>, Self::Error> {
173            refused()
174        }
175        async fn upload(&self, _: u64, _: &str, _: &[u8]) -> Result<(), Self::Error> {
176            refused()
177        }
178        async fn delete_asset(&self, _: u64, _: u64) -> Result<(), Self::Error> {
179            refused()
180        }
181        async fn publish(&self, _: u64) -> Result<(), Self::Error> {
182            refused()
183        }
184    }
185
186    impl PackageRegistry for Refusing {
187        type Error = io::Error;
188        async fn package_exists(&self, _: &str, _: &str) -> Result<bool, Self::Error> {
189            refused()
190        }
191    }
192
193    fn refused<T>() -> Result<T, io::Error> {
194        Err(io::Error::other("nothing may run before the tip check"))
195    }
196
197    /// A clone of an origin whose `main` holds one commit, returning the
198    /// directories and that commit.
199    fn clone_of_origin() -> (tempfile::TempDir, tempfile::TempDir, String) {
200        let origin = tempfile::tempdir().unwrap();
201        Repository::init_bare(origin.path()).unwrap();
202        let work = tempfile::tempdir().unwrap();
203        let repository = Repository::init_opts(
204            work.path(),
205            RepositoryInitOptions::new().initial_head("main"),
206        )
207        .unwrap();
208        let tree = repository
209            .find_tree(repository.index().unwrap().write_tree().unwrap())
210            .unwrap();
211        let signature = Signature::now("Release test", "release-test@example.invalid").unwrap();
212        let tip = repository
213            .commit(Some("HEAD"), &signature, &signature, "Initial", &tree, &[])
214            .unwrap()
215            .to_string();
216        repository
217            .remote("origin", origin.path().to_str().unwrap())
218            .unwrap();
219        repository
220            .find_remote("origin")
221            .unwrap()
222            .push(&["refs/heads/main:refs/heads/main"], None)
223            .unwrap();
224        (origin, work, tip)
225    }
226
227    async fn publish_at(work: &Path, commit: &str) -> Result<Vec<crate::Outcome>, crate::Error> {
228        publish(PublishRequest {
229            state: &facet_json::from_str(STATE).unwrap(),
230            repository: &RepositoryRoot::new(work).unwrap(),
231            commit,
232            authority: &"main".parse().unwrap(),
233            git: &SystemGit::default(),
234            registry: Refusing,
235            npm_registry: Some(Refusing),
236            forge: &Refusing,
237            artifacts: None,
238            credentials: PublishCredentials {
239                cargo_authorization: Token::new("authorization"),
240                token: Token::new("token"),
241            },
242            account: &Account {
243                login: None,
244                name: "Release Bot".to_owned(),
245                email: "release@example.invalid".to_owned(),
246            },
247        })
248        .await
249    }
250
251    #[tokio::test]
252    async fn the_authoritative_tip_is_published() {
253        let (_origin, work, tip) = clone_of_origin();
254        let result = publish_at(work.path(), &tip).await;
255        assert!(matches!(result, Ok(outcomes) if outcomes.is_empty()));
256    }
257
258    #[tokio::test]
259    async fn a_commit_behind_the_authoritative_tip_is_refused() {
260        let (_origin, work, tip) = clone_of_origin();
261        let other = "a000000000000000000000000000000000000000";
262        let error = publish_at(work.path(), other).await.unwrap_err();
263        assert_eq!(
264            error.to_string(),
265            format!("publication commit {other} is not the authoritative branch tip {tip}")
266        );
267    }
268}