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            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}