Skip to main content

release_tool/
doctor.rs

1use crate::command::{CommandRequest, CommandRunner};
2use crate::config::Config;
3use crate::config::TargetConfig;
4use crate::git::GitRepository;
5use anyhow::{Context, Result, bail};
6use serde::Deserialize;
7use std::path::Path;
8use std::sync::Arc;
9
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub enum CapabilityStatus {
12    Verified,
13    Unverifiable,
14}
15
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct Capability {
18    pub status: CapabilityStatus,
19    pub description: String,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct DoctorReport {
24    pub capabilities: Vec<Capability>,
25    pub actor: String,
26    pub token: Secret,
27}
28
29#[derive(Clone, Eq, PartialEq)]
30pub struct Secret(String);
31
32impl Secret {
33    pub fn new(value: impl Into<String>) -> Self {
34        Self(value.into())
35    }
36
37    pub fn expose(&self) -> &str {
38        &self.0
39    }
40}
41
42impl std::fmt::Debug for Secret {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        formatter.write_str("Secret([REDACTED])")
45    }
46}
47
48#[derive(Deserialize)]
49#[serde(rename_all = "camelCase")]
50struct RepositoryView {
51    name_with_owner: String,
52    viewer_permission: String,
53}
54
55pub fn run_doctor(
56    config: &Config,
57    root: &Path,
58    runner: Arc<dyn CommandRunner>,
59) -> Result<DoctorReport> {
60    check_project_requirements(config, root)?;
61    let staging = tempfile::tempdir().context("tool-owned staging directory is not writable")?;
62    std::fs::write(staging.path().join("probe"), b"release-tool doctor\n")
63        .context("tool-owned staging directory is not writable")?;
64    command(runner.as_ref(), root, "git", &["--version"])?;
65    command(runner.as_ref(), root, "gh", &["--version"])?;
66
67    let repository = GitRepository::new(root, Arc::clone(&runner));
68    repository.snapshot(&config.repository.branch)?;
69    let origin = repository.origin_url()?;
70    let identity = github_identity(&origin)
71        .with_context(|| format!("origin is not a GitHub repository URL: {origin}"))?;
72    if identity != config.repository.github {
73        bail!(
74            "configured repository `{}` does not match origin `{identity}`",
75            config.repository.github
76        );
77    }
78
79    command(
80        runner.as_ref(),
81        root,
82        "gh",
83        &["auth", "status", "--hostname", "github.com"],
84    )?;
85    let token = command(
86        runner.as_ref(),
87        root,
88        "gh",
89        &["auth", "token", "--hostname", "github.com"],
90    )?;
91    let token = token.trim().to_owned();
92    if token.is_empty() {
93        bail!("gh returned an empty GitHub token");
94    }
95    let actor = command(
96        runner.as_ref(),
97        root,
98        "gh",
99        &["api", "--hostname", "github.com", "user", "--jq", ".login"],
100    )?;
101    let actor = actor.trim().to_owned();
102    if actor.is_empty() {
103        bail!("gh returned an empty GitHub actor");
104    }
105    let view = command(
106        runner.as_ref(),
107        root,
108        "gh",
109        &[
110            "repo",
111            "view",
112            &config.repository.github,
113            "--json",
114            "nameWithOwner,viewerPermission",
115        ],
116    )?;
117    let view: RepositoryView =
118        serde_json::from_str(&view).context("invalid gh repo view output")?;
119    if view.name_with_owner != config.repository.github {
120        bail!(
121            "gh resolved `{}` instead of configured repository `{}`",
122            view.name_with_owner,
123            config.repository.github
124        );
125    }
126    if !matches!(
127        view.viewer_permission.as_str(),
128        "WRITE" | "MAINTAIN" | "ADMIN"
129    ) {
130        bail!(
131            "GitHub account only has {} permission for {}",
132            view.viewer_permission,
133            config.repository.github
134        );
135    }
136
137    Ok(DoctorReport {
138        capabilities: vec![
139            Capability {
140                status: CapabilityStatus::Verified,
141                description: "Git repository".to_owned(),
142            },
143            Capability {
144                status: CapabilityStatus::Verified,
145                description: format!("GitHub account: {actor}"),
146            },
147            Capability {
148                status: CapabilityStatus::Verified,
149                description: format!("repository permission: {}", view.viewer_permission),
150            },
151            Capability {
152                status: CapabilityStatus::Verified,
153                description: "tool-owned staging directory".to_owned(),
154            },
155            Capability {
156                status: CapabilityStatus::Unverifiable,
157                description: "destination write permission".to_owned(),
158            },
159        ],
160        actor,
161        token: Secret(token),
162    })
163}
164
165fn check_project_requirements(config: &Config, root: &Path) -> Result<()> {
166    if let Some(program) = config.hooks.preflight.first() {
167        require_command(root, program)?;
168    }
169    for target in &config.targets {
170        match target {
171            TargetConfig::DockerArchive {
172                build, local_check, ..
173            } => {
174                require_command(root, &build[0])?;
175                require_command(root, &local_check[0])?;
176                require_command(root, "docker")?;
177                require_command(root, "xz")?;
178            }
179            TargetConfig::MavenReactor {
180                wrapper,
181                pom,
182                projects,
183                remote_check,
184                ..
185            } => {
186                require_command(root, &wrapper.display().to_string())?;
187                let root_pom = root.join(pom);
188                if !root_pom.is_file() {
189                    bail!("Maven root POM does not exist: {}", root_pom.display());
190                }
191                for project in projects {
192                    let project_pom = root.join(project).join("pom.xml");
193                    if !project_pom.is_file() {
194                        bail!(
195                            "Maven project POM does not exist: {}",
196                            project_pom.display()
197                        );
198                    }
199                }
200                if let Some(program) = remote_check.first() {
201                    require_command(root, program)?;
202                }
203                let publisher = config
204                    .publishers
205                    .get(target.publisher())
206                    .context("Maven target publisher disappeared during doctor")?;
207                if let crate::config::PublisherConfig::GithubMaven { settings, .. } = publisher {
208                    let settings = root.join(settings);
209                    if !settings.is_file() {
210                        bail!("Maven settings file does not exist: {}", settings.display());
211                    }
212                }
213            }
214        }
215    }
216    Ok(())
217}
218
219fn require_command(root: &Path, program: &str) -> Result<()> {
220    let program_path = Path::new(program);
221    if program_path.components().count() > 1 || program_path.is_absolute() {
222        let path = if program_path.is_absolute() {
223            program_path.to_path_buf()
224        } else {
225            root.join(program_path)
226        };
227        if path.is_file() {
228            return Ok(());
229        }
230    } else if std::env::var_os("PATH")
231        .map(|path| {
232            std::env::split_paths(&path)
233                .map(|directory| directory.join(program))
234                .any(|candidate| candidate.is_file())
235        })
236        .unwrap_or(false)
237    {
238        return Ok(());
239    }
240    bail!("required command `{program}` was not found")
241}
242
243fn command(
244    runner: &dyn CommandRunner,
245    root: &Path,
246    program: &str,
247    arguments: &[&str],
248) -> Result<String> {
249    let request = CommandRequest::new(program, arguments.iter().copied(), root);
250    Ok(runner
251        .execute(&request)?
252        .require_success(&format!("{} {}", program, arguments.join(" ")))?
253        .stdout)
254}
255
256fn github_identity(url: &str) -> Option<String> {
257    let url = url.trim().trim_end_matches(".git");
258    if let Some(identity) = url.strip_prefix("https://github.com/") {
259        return Some(identity.to_owned());
260    }
261    if let Some(identity) = url.strip_prefix("git@github.com:") {
262        return Some(identity.to_owned());
263    }
264    if let Some(identity) = url.strip_prefix("ssh://git@github.com/") {
265        return Some(identity.to_owned());
266    }
267    None
268}