1use super::{
2 PublicationReceipt, PublicationState, Publisher, VerificationReport, receipt, validate_manifest,
3};
4use crate::command::{CommandRequest, CommandRunner};
5use crate::config::PublisherConfig;
6use crate::doctor::Secret;
7use crate::domain::{ArtifactIdentity, ArtifactManifest, PreparedArtifact, TargetPlan};
8use anyhow::{Context, Result, bail};
9use reqwest::StatusCode;
10use sha2::{Digest, Sha256};
11use std::collections::BTreeMap;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15pub trait MavenRemote: Send + Sync {
16 fn get(&self, url: &str, actor: &str, token: &Secret) -> Result<Option<Vec<u8>>>;
17}
18
19struct ReqwestMavenRemote {
20 client: reqwest::blocking::Client,
21}
22
23impl ReqwestMavenRemote {
24 fn new() -> Result<Self> {
25 Ok(Self {
26 client: reqwest::blocking::Client::builder()
27 .user_agent(concat!("release-tool/", env!("CARGO_PKG_VERSION")))
28 .build()
29 .context("failed to construct Maven HTTP client")?,
30 })
31 }
32}
33
34impl MavenRemote for ReqwestMavenRemote {
35 fn get(&self, url: &str, actor: &str, token: &Secret) -> Result<Option<Vec<u8>>> {
36 let response = self
37 .client
38 .get(url)
39 .basic_auth(actor, Some(token.expose()))
40 .send()
41 .with_context(|| format!("failed to read Maven artifact {url}"))?;
42 if response.status() == StatusCode::NOT_FOUND {
43 return Ok(None);
44 }
45 let response = response
46 .error_for_status()
47 .with_context(|| format!("failed to read Maven artifact {url}"))?;
48 Ok(Some(
49 response
50 .bytes()
51 .context("failed to read Maven artifact body")?
52 .to_vec(),
53 ))
54 }
55}
56
57pub struct GithubMavenPublisher {
58 root: PathBuf,
59 repository: String,
60 name: String,
61 settings: PathBuf,
62 server_id: String,
63 wrapper: PathBuf,
64 actor: String,
65 token: Secret,
66 runner: Arc<dyn CommandRunner>,
67 remote: Arc<dyn MavenRemote>,
68}
69
70impl GithubMavenPublisher {
71 #[allow(clippy::too_many_arguments)]
72 pub fn new(
73 root: impl Into<PathBuf>,
74 repository: &str,
75 name: &str,
76 config: &PublisherConfig,
77 wrapper: impl Into<PathBuf>,
78 actor: &str,
79 token: Secret,
80 runner: Arc<dyn CommandRunner>,
81 ) -> Result<Self> {
82 Self::new_with_remote(
83 root,
84 repository,
85 name,
86 config,
87 wrapper,
88 actor,
89 token,
90 runner,
91 Arc::new(ReqwestMavenRemote::new()?),
92 )
93 }
94
95 #[allow(clippy::too_many_arguments)]
96 pub fn new_with_remote(
97 root: impl Into<PathBuf>,
98 repository: &str,
99 name: &str,
100 config: &PublisherConfig,
101 wrapper: impl Into<PathBuf>,
102 actor: &str,
103 token: Secret,
104 runner: Arc<dyn CommandRunner>,
105 remote: Arc<dyn MavenRemote>,
106 ) -> Result<Self> {
107 let PublisherConfig::GithubMaven {
108 settings,
109 server_id,
110 } = config
111 else {
112 bail!("publisher `{name}` is not a github_maven publisher");
113 };
114 Ok(Self {
115 root: root.into(),
116 repository: repository.to_owned(),
117 name: name.to_owned(),
118 settings: settings.clone(),
119 server_id: server_id.clone(),
120 wrapper: wrapper.into(),
121 actor: actor.to_owned(),
122 token,
123 runner,
124 remote,
125 })
126 }
127
128 fn base_url(&self) -> String {
129 format!("https://maven.pkg.github.com/{}", self.repository)
130 }
131
132 fn url(&self, identity: &ArtifactIdentity) -> Result<String> {
133 let ArtifactIdentity::MavenPackage {
134 group_id,
135 artifact_id,
136 version,
137 ..
138 } = identity
139 else {
140 bail!("GitHub Maven publisher received a non-Maven artifact identity");
141 };
142 Ok(format!(
143 "{}/{}/{artifact_id}/{version}/{}",
144 self.base_url(),
145 group_id.replace('.', "/"),
146 identity.maven_file_name()?
147 ))
148 }
149
150 fn state_for(&self, identities: &[ArtifactIdentity]) -> Result<PublicationState> {
151 let mut present = Vec::new();
152 let mut missing = Vec::new();
153 for identity in identities {
154 let url = self.url(identity)?;
155 if self.remote.get(&url, &self.actor, &self.token)?.is_some() {
156 present.push(url);
157 } else {
158 missing.push(url);
159 }
160 }
161 if present.is_empty() {
162 Ok(PublicationState::Absent)
163 } else if missing.is_empty() {
164 Ok(PublicationState::Complete)
165 } else {
166 Ok(PublicationState::Partial { present, missing })
167 }
168 }
169
170 fn deploy(&self, manifest: &ArtifactManifest) -> Result<()> {
171 let launcher = tempfile::tempdir().context("failed to create Maven deployment launcher")?;
174 let launcher_pom = launcher.path().join("pom.xml");
175 std::fs::write(
176 &launcher_pom,
177 "<project><modelVersion>4.0.0</modelVersion><groupId>release.tool</groupId><artifactId>deployment</artifactId><version>1</version><packaging>pom</packaging></project>\n",
178 )?;
179 type Gav = (String, String, String);
180 let mut packages: BTreeMap<Gav, Vec<&PreparedArtifact>> = BTreeMap::new();
181 for artifact in &manifest.artifacts {
182 let ArtifactIdentity::MavenPackage {
183 group_id,
184 artifact_id,
185 version,
186 ..
187 } = &artifact.identity
188 else {
189 bail!("Maven artifact manifest contains a non-Maven identity");
190 };
191 packages
192 .entry((group_id.clone(), artifact_id.clone(), version.clone()))
193 .or_default()
194 .push(artifact);
195 }
196 let mut deployments = Vec::new();
197 for ((group_id, artifact_id, version), artifacts) in &packages {
198 let pom = artifacts
199 .iter()
200 .find(|artifact| {
201 classifier(&artifact.identity).is_none()
202 && extension(&artifact.identity) == Some("pom")
203 })
204 .with_context(|| format!("Maven package {group_id}:{artifact_id} has no POM"))?;
205 let primary_artifacts = artifacts
206 .iter()
207 .filter(|artifact| {
208 classifier(&artifact.identity).is_none()
209 && extension(&artifact.identity) != Some("pom")
210 })
211 .copied()
212 .collect::<Vec<_>>();
213 if primary_artifacts.len() > 1 {
214 bail!(
215 "Maven package {group_id}:{artifact_id}:{version} has more than one primary artifact"
216 );
217 }
218 let primary = primary_artifacts.first().copied().unwrap_or(*pom);
219 let mut arguments = vec![
220 "--settings".to_owned(),
221 self.settings.display().to_string(),
222 "--batch-mode".to_owned(),
223 "--no-transfer-progress".to_owned(),
224 "--non-recursive".to_owned(),
225 "-f".to_owned(),
226 launcher_pom.display().to_string(),
227 "org.apache.maven.plugins:maven-deploy-plugin:3.1.4:deploy-file".to_owned(),
228 format!("-Dfile={}", primary.path.display()),
229 ];
230 if extension(&primary.identity) == Some("pom") {
231 arguments.extend([
232 "-Dpackaging=pom".to_owned(),
233 "-DgeneratePom=false".to_owned(),
234 ]);
235 } else {
236 arguments.push(format!("-DpomFile={}", pom.path.display()));
237 }
238 let attached = artifacts
239 .iter()
240 .filter(|artifact| classifier(&artifact.identity).is_some())
241 .collect::<Vec<_>>();
242 if !attached.is_empty() {
243 let mut files = Vec::new();
244 let mut classifiers = Vec::new();
245 let mut types = Vec::new();
246 for artifact in attached {
247 let path = artifact
248 .path
249 .to_str()
250 .context("Maven attachment path must be UTF-8")?;
251 if path.contains(',') {
252 bail!("Maven attachment path must not contain a comma: {path}");
253 }
254 files.push(path);
255 classifiers.push(classifier(&artifact.identity).expect("filtered attachment"));
256 types.push(extension(&artifact.identity).expect("Maven identity"));
257 }
258 arguments.extend([
259 format!("-Dfiles={}", files.join(",")),
260 format!("-Dclassifiers={}", classifiers.join(",")),
261 format!("-Dtypes={}", types.join(",")),
262 ]);
263 }
264 arguments.extend([
265 format!("-DrepositoryId={}", self.server_id),
266 format!("-Durl={}", self.base_url()),
267 format!("-DgroupId={group_id}"),
268 format!("-DartifactId={artifact_id}"),
269 format!("-Dversion={version}"),
270 ]);
271 let mut request =
272 CommandRequest::new(self.wrapper.display().to_string(), arguments, &self.root);
273 request
274 .environment
275 .insert("GITHUB_ACTOR".to_owned(), self.actor.clone());
276 request
277 .environment
278 .insert("GITHUB_TOKEN".to_owned(), self.token.expose().to_owned());
279 deployments.push((
280 format!("deploy Maven package {group_id}:{artifact_id}:{version}"),
281 request,
282 ));
283 }
284 for (description, request) in deployments {
286 self.runner
287 .execute(&request)?
288 .redact([self.token.expose()])
289 .require_success(&description)?;
290 }
291 Ok(())
292 }
293}
294
295impl Publisher for GithubMavenPublisher {
296 fn inspect(&self, plan: &TargetPlan) -> Result<PublicationState> {
297 self.state_for(&plan.artifacts)
298 }
299
300 fn publish(&self, manifest: &ArtifactManifest) -> Result<PublicationReceipt> {
301 if !manifest.release.tag_already_sealed {
302 bail!(
303 "release {} is not sealed on the remote",
304 manifest.release.tag
305 );
306 }
307 if manifest.publisher != self.name {
308 bail!(
309 "artifact manifest belongs to publisher `{}`",
310 manifest.publisher
311 );
312 }
313 validate_manifest(manifest)?;
314 let identities = manifest
315 .artifacts
316 .iter()
317 .map(|artifact| artifact.identity.clone())
318 .collect::<Vec<_>>();
319 match self.state_for(&identities)? {
320 PublicationState::Complete => {
321 self.verify(manifest)?;
322 return Ok(receipt(manifest, &self.name, true));
323 }
324 PublicationState::Partial { .. } => {
325 bail!("partial Maven publication is not recoverable automatically")
326 }
327 PublicationState::Invalid { reason } => bail!("invalid Maven publication: {reason}"),
328 PublicationState::Absent => {}
329 }
330 let deploy_result = self.deploy(manifest);
331 let state_after_deploy = self.state_for(&identities);
332 match (deploy_result, state_after_deploy) {
333 (_, Ok(PublicationState::Complete)) => {}
334 (Err(deploy_error), Ok(state)) => {
335 return Err(deploy_error
336 .context(format!("Maven deploy failed and remote state is {state:?}")));
337 }
338 (Err(deploy_error), Err(inspect_error)) => {
339 return Err(deploy_error.context(format!(
340 "Maven deploy failed; remote reconciliation also failed: {inspect_error:#}"
341 )));
342 }
343 (Ok(()), Ok(state)) => {
344 bail!("Maven publication is not complete after deploy: {state:?}");
345 }
346 (Ok(()), Err(inspect_error)) => return Err(inspect_error),
347 }
348 self.verify(manifest)?;
349 Ok(receipt(manifest, &self.name, false))
350 }
351
352 fn verify(&self, manifest: &ArtifactManifest) -> Result<VerificationReport> {
353 let mut verified = Vec::new();
354 for artifact in &manifest.artifacts {
355 let url = self.url(&artifact.identity)?;
356 let bytes = self
357 .remote
358 .get(&url, &self.actor, &self.token)?
359 .with_context(|| format!("published Maven artifact is missing: {url}"))?;
360 let actual = hex::encode(Sha256::digest(&bytes));
361 if actual != artifact.sha256 {
362 bail!(
363 "published Maven artifact digest mismatch for {url}: expected {}, found {actual}",
364 artifact.sha256
365 );
366 }
367 verified.push(url);
368 }
369 Ok(VerificationReport {
370 target: manifest.target.clone(),
371 verified: true,
372 artifacts: verified,
373 })
374 }
375
376 fn verify_existing(&self, plan: &TargetPlan) -> Result<VerificationReport> {
377 match self.inspect(plan)? {
378 PublicationState::Complete => {}
379 state => bail!("cannot verify incomplete Maven publication: {state:?}"),
380 }
381 let mut verified = Vec::new();
382 for identity in &plan.artifacts {
383 let url = self.url(identity)?;
384 let bytes = self
385 .remote
386 .get(&url, &self.actor, &self.token)?
387 .with_context(|| format!("published Maven artifact is missing: {url}"))?;
388 if bytes.is_empty() {
389 bail!("published Maven artifact is empty: {url}");
390 }
391 verified.push(url);
392 }
393 Ok(VerificationReport {
394 target: plan.name.clone(),
395 verified: true,
396 artifacts: verified,
397 })
398 }
399}
400
401fn extension(identity: &ArtifactIdentity) -> Option<&str> {
402 match identity {
403 ArtifactIdentity::MavenPackage { extension, .. } => Some(extension),
404 _ => None,
405 }
406}
407
408fn classifier(identity: &ArtifactIdentity) -> Option<&str> {
409 match identity {
410 ArtifactIdentity::MavenPackage { classifier, .. } => classifier.as_deref(),
411 _ => None,
412 }
413}