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 extension,
138 } = identity
139 else {
140 bail!("GitHub Maven publisher received a non-Maven artifact identity");
141 };
142 Ok(format!(
143 "{}/{}/{artifact_id}/{version}/{artifact_id}-{version}.{extension}",
144 self.base_url(),
145 group_id.replace('.', "/")
146 ))
147 }
148
149 fn state_for(&self, identities: &[ArtifactIdentity]) -> Result<PublicationState> {
150 let mut present = Vec::new();
151 let mut missing = Vec::new();
152 for identity in identities {
153 let url = self.url(identity)?;
154 if self.remote.get(&url, &self.actor, &self.token)?.is_some() {
155 present.push(url);
156 } else {
157 missing.push(url);
158 }
159 }
160 if present.is_empty() {
161 Ok(PublicationState::Absent)
162 } else if missing.is_empty() {
163 Ok(PublicationState::Complete)
164 } else {
165 Ok(PublicationState::Partial { present, missing })
166 }
167 }
168
169 fn deploy(&self, manifest: &ArtifactManifest) -> Result<()> {
170 type Gav = (String, String, String);
171 let mut packages: BTreeMap<Gav, Vec<&PreparedArtifact>> = BTreeMap::new();
172 for artifact in &manifest.artifacts {
173 let ArtifactIdentity::MavenPackage {
174 group_id,
175 artifact_id,
176 version,
177 ..
178 } = &artifact.identity
179 else {
180 bail!("Maven artifact manifest contains a non-Maven identity");
181 };
182 packages
183 .entry((group_id.clone(), artifact_id.clone(), version.clone()))
184 .or_default()
185 .push(artifact);
186 }
187 let mut deployments = Vec::new();
188 for ((group_id, artifact_id, version), artifacts) in &packages {
189 let pom = artifacts
190 .iter()
191 .find(|artifact| extension(&artifact.identity) == Some("pom"))
192 .with_context(|| format!("Maven package {group_id}:{artifact_id} has no POM"))?;
193 let primary_artifacts = artifacts
194 .iter()
195 .filter(|artifact| extension(&artifact.identity) != Some("pom"))
196 .copied()
197 .collect::<Vec<_>>();
198 if primary_artifacts.len() > 1 {
199 bail!(
200 "Maven package {group_id}:{artifact_id}:{version} has more than one primary artifact"
201 );
202 }
203 let primary = primary_artifacts.first().copied().unwrap_or(*pom);
204 deployments.push((
205 (group_id.clone(), artifact_id.clone(), version.clone()),
206 *pom,
207 primary,
208 ));
209 }
210 for ((group_id, artifact_id, version), pom, primary) in deployments {
211 let mut arguments = vec![
212 "--settings".to_owned(),
213 self.settings.display().to_string(),
214 "--batch-mode".to_owned(),
215 "--no-transfer-progress".to_owned(),
216 "org.apache.maven.plugins:maven-deploy-plugin:3.1.4:deploy-file".to_owned(),
217 format!("-Dfile={}", primary.path.display()),
218 ];
219 if extension(&primary.identity) == Some("pom") {
220 arguments.extend([
221 "-Dpackaging=pom".to_owned(),
222 "-DgeneratePom=false".to_owned(),
223 ]);
224 } else {
225 arguments.push(format!("-DpomFile={}", pom.path.display()));
226 }
227 arguments.extend([
228 format!("-DrepositoryId={}", self.server_id),
229 format!("-Durl={}", self.base_url()),
230 format!("-DgroupId={group_id}"),
231 format!("-DartifactId={artifact_id}"),
232 format!("-Dversion={version}"),
233 ]);
234 let mut request =
235 CommandRequest::new(self.wrapper.display().to_string(), arguments, &self.root);
236 request
237 .environment
238 .insert("GITHUB_ACTOR".to_owned(), self.actor.clone());
239 request
240 .environment
241 .insert("GITHUB_TOKEN".to_owned(), self.token.expose().to_owned());
242 self.runner
243 .execute(&request)?
244 .redact([self.token.expose()])
245 .require_success(&format!(
246 "deploy Maven package {group_id}:{artifact_id}:{version}"
247 ))?;
248 }
249 Ok(())
250 }
251}
252
253impl Publisher for GithubMavenPublisher {
254 fn inspect(&self, plan: &TargetPlan) -> Result<PublicationState> {
255 self.state_for(&plan.artifacts)
256 }
257
258 fn publish(&self, manifest: &ArtifactManifest) -> Result<PublicationReceipt> {
259 if !manifest.release.tag_already_sealed {
260 bail!(
261 "release {} is not sealed on the remote",
262 manifest.release.tag
263 );
264 }
265 if manifest.publisher != self.name {
266 bail!(
267 "artifact manifest belongs to publisher `{}`",
268 manifest.publisher
269 );
270 }
271 validate_manifest(manifest)?;
272 let identities = manifest
273 .artifacts
274 .iter()
275 .map(|artifact| artifact.identity.clone())
276 .collect::<Vec<_>>();
277 match self.state_for(&identities)? {
278 PublicationState::Complete => {
279 self.verify(manifest)?;
280 return Ok(receipt(manifest, &self.name, true));
281 }
282 PublicationState::Partial { .. } => {
283 bail!("partial Maven publication is not recoverable automatically")
284 }
285 PublicationState::Invalid { reason } => bail!("invalid Maven publication: {reason}"),
286 PublicationState::Absent => {}
287 }
288 let deploy_result = self.deploy(manifest);
289 let state_after_deploy = self.state_for(&identities);
290 match (deploy_result, state_after_deploy) {
291 (_, Ok(PublicationState::Complete)) => {}
292 (Err(deploy_error), Ok(state)) => {
293 return Err(deploy_error
294 .context(format!("Maven deploy failed and remote state is {state:?}")));
295 }
296 (Err(deploy_error), Err(inspect_error)) => {
297 return Err(deploy_error.context(format!(
298 "Maven deploy failed; remote reconciliation also failed: {inspect_error:#}"
299 )));
300 }
301 (Ok(()), Ok(state)) => {
302 bail!("Maven publication is not complete after deploy: {state:?}");
303 }
304 (Ok(()), Err(inspect_error)) => return Err(inspect_error),
305 }
306 self.verify(manifest)?;
307 Ok(receipt(manifest, &self.name, false))
308 }
309
310 fn verify(&self, manifest: &ArtifactManifest) -> Result<VerificationReport> {
311 let mut verified = Vec::new();
312 for artifact in &manifest.artifacts {
313 let url = self.url(&artifact.identity)?;
314 let bytes = self
315 .remote
316 .get(&url, &self.actor, &self.token)?
317 .with_context(|| format!("published Maven artifact is missing: {url}"))?;
318 let actual = hex::encode(Sha256::digest(&bytes));
319 if actual != artifact.sha256 {
320 bail!(
321 "published Maven artifact digest mismatch for {url}: expected {}, found {actual}",
322 artifact.sha256
323 );
324 }
325 verified.push(url);
326 }
327 Ok(VerificationReport {
328 target: manifest.target.clone(),
329 verified: true,
330 artifacts: verified,
331 })
332 }
333
334 fn verify_existing(&self, plan: &TargetPlan) -> Result<VerificationReport> {
335 match self.inspect(plan)? {
336 PublicationState::Complete => {}
337 state => bail!("cannot verify incomplete Maven publication: {state:?}"),
338 }
339 let mut verified = Vec::new();
340 for identity in &plan.artifacts {
341 let url = self.url(identity)?;
342 let bytes = self
343 .remote
344 .get(&url, &self.actor, &self.token)?
345 .with_context(|| format!("published Maven artifact is missing: {url}"))?;
346 if bytes.is_empty() {
347 bail!("published Maven artifact is empty: {url}");
348 }
349 verified.push(url);
350 }
351 Ok(VerificationReport {
352 target: plan.name.clone(),
353 verified: true,
354 artifacts: verified,
355 })
356 }
357}
358
359fn extension(identity: &ArtifactIdentity) -> Option<&str> {
360 match identity {
361 ArtifactIdentity::MavenPackage { extension, .. } => Some(extension),
362 _ => None,
363 }
364}