1use 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#[derive(Debug)]
19pub struct PublishRequest<'a, G, R, N, F> {
20 pub state: &'a ReleaseState,
22 pub repository: &'a RepositoryRoot,
24 pub commit: &'a str,
26 pub authority: &'a AuthorityBranch,
28 pub git: &'a G,
30 pub registry: R,
32 pub npm_registry: Option<N>,
34 pub forge: &'a F,
36 pub artifacts: Option<ArtifactInput<'a>>,
38 pub credentials: PublishCredentials,
40 pub account: &'a Account<String>,
42}
43
44#[derive(Clone, Debug)]
46pub struct PublishCredentials {
47 pub cargo_authorization: Token,
49 pub token: Token,
51}
52
53#[derive(Clone, Copy, Debug)]
55pub struct ArtifactInput<'a> {
56 pub manifest: &'a Path,
58 pub directory: &'a Path,
60}
61
62pub 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 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 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}