Skip to main content

release_tool/publisher/
github_maven.rs

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        // deploy-file requires no adopter model. An isolated launcher avoids evaluating
172        // the source reactor or resolving the frozen artifact POM's local-only parent.
173        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        // Validate every GAV's command before the first remote write.
285        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}