xtask/cmd/semver_checks/
mod.rs1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::process::Stdio;
4
5use anyhow::{Context, Result};
6use clap::Parser;
7use regex::Regex;
8
9use crate::utils::{cargo_cmd, metadata};
10
11mod utils;
12use utils::{checkout_baseline, metadata_from_dir, workspace_crates_in_folder};
13
14#[derive(Debug, Clone, Parser)]
15pub struct SemverChecks {
16 #[clap(long, default_value = "main")]
18 baseline: String,
19
20 #[clap(long, default_value = "false")]
22 disable_hakari: bool,
23}
24
25impl SemverChecks {
26 pub fn run(self) -> Result<()> {
27 println!("<details>");
28 println!("<summary> 🛫 Startup details 🛫 </summary>");
29 let current_metadata = metadata().context("getting current metadata")?;
30 let current_crates_set = workspace_crates_in_folder(¤t_metadata, "crates");
31
32 let tmp_dir = PathBuf::from("target/semver-baseline");
33
34 let _worktree_cleanup = checkout_baseline(&self.baseline, &tmp_dir).context("checking out baseline")?;
36
37 let baseline_metadata = metadata_from_dir(&tmp_dir).context("getting baseline metadata")?;
38 let baseline_crates_set = workspace_crates_in_folder(&baseline_metadata, &tmp_dir.join("crates").to_string_lossy());
39
40 let common_crates: HashSet<_> = current_metadata
41 .packages
42 .iter()
43 .map(|p| p.name.clone())
44 .filter(|name| current_crates_set.contains(name) && baseline_crates_set.contains(name))
45 .collect();
46
47 let mut crates: Vec<_> = common_crates.iter().cloned().collect();
48 crates.sort();
49
50 println!("<details>");
51 println!("<summary> 📦 Processing crates 📦 </summary>");
52 println!();
54 for krate in crates {
55 println!("- `{krate}`");
56 }
57 println!("</details>");
59 println!("</details>");
61
62 if self.disable_hakari {
63 cargo_cmd().args(["hakari", "disable"]).status().context("disabling hakari")?;
64 }
65
66 let mut args = vec![
67 "semver-checks",
68 "check-release",
69 "--baseline-root",
70 tmp_dir.to_str().unwrap(),
71 "--all-features",
72 ];
73
74 for package in &common_crates {
75 args.push("--package");
76 args.push(package);
77 }
78
79 let output = cargo_cmd()
80 .env("CARGO_TERM_COLOR", "never")
81 .args(&args)
82 .stdout(Stdio::piped())
83 .stderr(Stdio::piped())
84 .output()
85 .context("running semver-checks")?;
86
87 let mut semver_output = String::new();
88 semver_output.push_str(&String::from_utf8_lossy(&output.stdout));
89 semver_output.push_str(&String::from_utf8_lossy(&output.stderr));
90
91 if semver_output.trim().is_empty() {
92 anyhow::bail!("No semver-checks output received. The command may have failed.");
93 }
94
95 println!();
97
98 let check_re = Regex::new(r"^Checking\s+(?P<crate>\S+)\s+v(?P<curr>\d+\.\d+\.\d+)(?:\s+->\s+v\d+\.\d+\.\d+)?")
103 .context("compiling check regex")?;
104
105 let summary_re = Regex::new(r"^Summary semver requires new (?P<update_type>major|minor) version:")
109 .context("compiling summary regex")?;
110
111 let commit_hash = std::env::var("SHA").unwrap();
112 let scuffle_commit_url = format!("https://github.com/ScuffleCloud/scuffle/blob/{commit_hash}");
113
114 let mut current_crate: Option<(String, String)> = None;
115 let mut summary: Vec<String> = Vec::new();
116 let mut description: Vec<String> = Vec::new();
117 let mut error_count = 0;
118
119 let mut lines = semver_output.lines().peekable();
120 while let Some(line) = lines.next() {
121 let trimmed = line.trim_start();
122
123 if trimmed.starts_with("Checking") {
124 if let Some(caps) = check_re.captures(trimmed) {
126 let crate_name = caps.name("crate").unwrap().as_str().to_string();
127 let current_version = caps.name("curr").unwrap().as_str().to_string();
128 current_crate = Some((crate_name, current_version));
129 }
130 } else if trimmed.starts_with("Summary") {
131 if let Some(caps) = summary_re.captures(trimmed) {
132 let update_type = caps.name("update_type").unwrap().as_str();
133 if let Some((crate_name, current_version)) = current_crate.take() {
134 let new_version = new_version_number(¤t_version, update_type)?;
135 error_count += 1;
136
137 summary.push(format!("### 🔖 Error `#{error_count}`"));
139 summary.push(format!("⚠️ {update_type} update required for `{crate_name}`."));
140 summary.push(format!(
141 "🛠️ Please update the version from `v{current_version}` to `{new_version}`."
142 ));
143
144 summary.push("<details>".to_string());
145 summary.push(format!("<summary> 📜 {crate_name} logs 📜 </summary>"));
146 summary.append(&mut description);
147 summary.push("</details>".to_string());
148
149 summary.push("".to_string());
151 }
152 }
153 } else if trimmed.starts_with("---") {
154 let mut is_failed_in_block = false;
155
156 for desc_line in lines.by_ref() {
157 let desc_trimmed = desc_line.trim_start();
158
159 if desc_trimmed.starts_with("Checking")
160 || desc_trimmed.starts_with("Built")
161 || desc_trimmed.starts_with("Building")
162 || desc_trimmed.starts_with("Parsing")
163 || desc_trimmed.starts_with("Parsed")
164 || desc_trimmed.starts_with("Finished")
165 || desc_trimmed.starts_with("Summary")
166 {
167 if is_failed_in_block {
170 description.push("</details>".to_string());
171 }
172 break;
173 } else if desc_trimmed.starts_with("Failed in:") {
174 is_failed_in_block = true;
176 description.push("<details>".to_string());
177 description.push("<summary> 🎈 Failed in the following locations 🎈 </summary>".to_string());
178 } else if desc_trimmed.is_empty() && is_failed_in_block {
179 is_failed_in_block = false;
181 description.push("</details>".to_string());
182 } else if is_failed_in_block {
183 description.push("".to_string());
185
186 let file_loc = desc_trimmed
187 .split_whitespace()
188 .last()
189 .unwrap()
190 .strip_prefix("/home/runner/work/scuffle/scuffle/")
191 .unwrap()
192 .replace(":", "#L");
193
194 description.push(format!("- {scuffle_commit_url}/{file_loc}"));
195 } else {
196 description.push(desc_trimmed.to_string());
197 }
198 }
199 }
200 }
201
202 println!("# Semver-checks summary");
204 if error_count > 0 {
205 let s = if error_count == 1 { "" } else { "S" };
206 println!("\n### 🚩 {error_count} ERROR{s} FOUND 🚩");
207
208 if error_count >= 5 {
210 summary.insert(0, "<details>".to_string());
211 summary.insert(1, "<summary> 🦗 Open for error description 🦗 </summary>".to_string());
212 summary.push("</details>".to_string());
213 }
214
215 for line in summary {
216 println!("{line}");
217 }
218 } else {
219 println!("## ✅ No semver violations found! ✅");
220 }
221
222 println!();
224
225 Ok(())
226 }
227}
228
229fn new_version_number(version: &str, update_type: &str) -> Result<String> {
230 let version = version.strip_prefix('v').unwrap_or(version);
231 let mut parts: Vec<u64> = version
232 .split('.')
233 .map(|s| s.parse::<u64>())
234 .collect::<Result<_, _>>()
235 .context("parsing version numbers")?;
236 if parts.len() != 3 {
237 anyhow::bail!("expected version format vX.Y.Z, got: {version}");
238 }
239 match update_type {
240 "minor" => parts[2] += 1,
241 "major" => parts[1] += 1,
242 _ => anyhow::bail!("Failed to parse update type: {update_type}"),
243 }
244 Ok(format!("v{}.{}.{}", parts[0], parts[1], parts[2]))
245}