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}