aboutsummaryrefslogtreecommitdiff
path: root/release/src/main.rs
diff options
context:
space:
mode:
authorUlf Lilleengen <[email protected]>2025-08-14 13:36:39 +0200
committerUlf Lilleengen <[email protected]>2025-08-25 19:44:49 +0200
commit6a347f1f09b0076af868dcd63d9139081c92172b (patch)
tree27ccbc753f0950cbbdbd0cda73d24eb419b7cd96 /release/src/main.rs
parentac60eaeddd9c4accbe8dc20d0486382940723efb (diff)
feat: add semver checks and releasing to releaser
* List dependencies of a crate * List dependents of a crate * Perform semver-checks of a crate * Prepare a release for a crate and all dependents * Use a single release.toml for cargo-release * Add changelogs where missing
Diffstat (limited to 'release/src/main.rs')
-rw-r--r--release/src/main.rs519
1 files changed, 519 insertions, 0 deletions
diff --git a/release/src/main.rs b/release/src/main.rs
new file mode 100644
index 000000000..38bb728a8
--- /dev/null
+++ b/release/src/main.rs
@@ -0,0 +1,519 @@
1use std::collections::{BTreeMap, HashMap};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command as ProcessCommand;
5
6use anyhow::{anyhow, Result};
7use clap::{Parser, Subcommand};
8use petgraph::graph::{Graph, NodeIndex};
9use petgraph::visit::Bfs;
10use petgraph::{Directed, Direction};
11use serde::Deserialize;
12use toml_edit::{DocumentMut, Item, Value};
13
14/// Tool to traverse and operate on intra-repo Rust crate dependencies
15#[derive(Parser, Debug)]
16#[command(author, version, about)]
17struct Args {
18 /// Path to embassy repository
19 #[arg(short, long)]
20 repo: PathBuf,
21
22 /// Command to perform on each crate
23 #[command(subcommand)]
24 command: Command,
25}
26
27#[derive(Debug, Subcommand)]
28enum Command {
29 /// All crates and their direct dependencies
30 List,
31 /// List all dependencies for a crate
32 Dependencies {
33 /// Crate name to print dependencies for.
34 #[arg(value_name = "CRATE")]
35 crate_name: String,
36 },
37 /// List all dependencies for a crate
38 Dependents {
39 /// Crate name to print dependencies for.
40 #[arg(value_name = "CRATE")]
41 crate_name: String,
42 },
43
44 /// SemverCheck
45 SemverCheck {
46 /// Crate to check. Will traverse that crate an it's dependents. If not specified checks all crates.
47 #[arg(value_name = "CRATE")]
48 crate_name: String,
49 },
50 /// Prepare to release a crate and all dependents that needs updating
51 /// - Semver checks
52 /// - Bump versions and commit
53 /// - Create tag.
54 PrepareRelease {
55 /// Crate to release. Will traverse that crate an it's dependents. If not specified checks all crates.
56 #[arg(value_name = "CRATE")]
57 crate_name: String,
58 },
59}
60
61#[derive(Debug, Subcommand, Clone, Copy, PartialEq)]
62enum ReleaseKind {
63 Patch,
64 Minor,
65}
66
67#[derive(Debug, Deserialize)]
68struct CargoToml {
69 package: Option<Package>,
70 dependencies: Option<Deps>,
71}
72
73#[derive(Debug, Deserialize)]
74struct Package {
75 name: String,
76 version: Option<String>,
77}
78
79#[derive(Debug, Deserialize)]
80#[serde(untagged)]
81enum Dep {
82 Version(String),
83 DetailedTable(BTreeMap<String, toml::Value>),
84}
85
86type Deps = std::collections::BTreeMap<String, Dep>;
87
88#[derive(Debug, Clone, Deserialize)]
89struct CrateConfig {
90 features: Option<Vec<String>>,
91 target: Option<String>,
92}
93
94type ReleaseConfig = HashMap<String, CrateConfig>;
95
96fn load_release_config(repo: &Path) -> ReleaseConfig {
97 let config_path = repo.join("release/config.toml");
98 if !config_path.exists() {
99 return HashMap::new();
100 }
101 let content = fs::read_to_string(&config_path).expect("Failed to read release/config.toml");
102 toml::from_str(&content).expect("Invalid TOML format in release/config.toml")
103}
104
105fn update_version(c: &mut Crate, new_version: &str) -> Result<()> {
106 let path = &c.path;
107 c.id.version = new_version.to_string();
108 let content = fs::read_to_string(&path)?;
109 let mut doc: DocumentMut = content.parse()?;
110 for section in ["package"] {
111 if let Some(Item::Table(dep_table)) = doc.get_mut(section) {
112 dep_table.insert("version", Item::Value(Value::from(new_version)));
113 }
114 }
115 fs::write(&path, doc.to_string())?;
116 Ok(())
117}
118
119fn update_versions(to_update: &Crate, dep: &CrateId, new_version: &str) -> Result<()> {
120 let path = &to_update.path;
121 let content = fs::read_to_string(&path)?;
122 let mut doc: DocumentMut = content.parse()?;
123 let mut changed = false;
124 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
125 if let Some(Item::Table(dep_table)) = doc.get_mut(section) {
126 if let Some(item) = dep_table.get_mut(&dep.name) {
127 match item {
128 // e.g., foo = "0.1.0"
129 Item::Value(Value::String(_)) => {
130 *item = Item::Value(Value::from(new_version));
131 changed = true;
132 }
133 // e.g., foo = { version = "...", ... }
134 Item::Value(Value::InlineTable(inline)) => {
135 if inline.contains_key("version") {
136 inline["version"] = Value::from(new_version);
137 changed = true;
138 }
139 }
140 _ => {} // Leave unusual formats untouched
141 }
142 }
143 }
144 }
145
146 if changed {
147 fs::write(&path, doc.to_string())?;
148 println!("🔧 Updated {} to {} in {}", dep.name, new_version, path.display());
149 }
150 Ok(())
151}
152
153#[derive(Debug, Clone)]
154struct Crate {
155 id: CrateId,
156 path: PathBuf,
157 config: CrateConfig,
158 dependencies: Vec<CrateId>,
159}
160
161#[derive(Debug, Clone, PartialOrd, Ord)]
162struct CrateId {
163 name: String,
164 version: String,
165}
166
167impl PartialEq for CrateId {
168 fn eq(&self, other: &CrateId) -> bool {
169 self.name == other.name
170 }
171}
172
173impl Eq for CrateId {}
174impl std::hash::Hash for CrateId {
175 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
176 self.name.hash(state)
177 }
178}
179
180fn list_crates(path: &PathBuf) -> Result<BTreeMap<CrateId, Crate>> {
181 let d = std::fs::read_dir(path)?;
182 let release_config = load_release_config(path);
183 let mut crates = BTreeMap::new();
184 for c in d {
185 let entry = c?;
186 let name = entry.file_name().to_str().unwrap().to_string();
187 if entry.file_type()?.is_dir() && name.starts_with("embassy-") {
188 let entry = entry.path().join("Cargo.toml");
189 if entry.exists() {
190 let content = fs::read_to_string(&entry).unwrap_or_else(|_| {
191 panic!("Failed to read {:?}", entry);
192 });
193 let parsed: CargoToml = toml::from_str(&content).unwrap_or_else(|e| {
194 panic!("Failed to parse {:?}: {}", entry, e);
195 });
196 let p = parsed.package.unwrap();
197 let id = CrateId {
198 name: p.name.clone(),
199 version: p.version.unwrap(),
200 };
201
202 let mut dependencies = Vec::new();
203 if let Some(deps) = parsed.dependencies {
204 for (k, v) in deps {
205 if k.starts_with("embassy-") {
206 dependencies.push(CrateId {
207 name: k,
208 version: match v {
209 Dep::Version(v) => v,
210 Dep::DetailedTable(table) => {
211 table.get("version").unwrap().as_str().unwrap().to_string()
212 }
213 },
214 });
215 }
216 }
217 }
218
219 let path = path.join(entry);
220 if let Some(config) = release_config.get(&p.name) {
221 crates.insert(
222 id.clone(),
223 Crate {
224 id,
225 path,
226 dependencies,
227 config: config.clone(),
228 },
229 );
230 }
231 }
232 }
233 }
234 Ok(crates)
235}
236
237fn build_graph(crates: &BTreeMap<CrateId, Crate>) -> (Graph<CrateId, ()>, HashMap<CrateId, NodeIndex>) {
238 let mut graph = Graph::<CrateId, (), Directed>::new();
239 let mut node_indices: HashMap<CrateId, NodeIndex> = HashMap::new();
240
241 // Helper to insert or get existing node
242 let get_or_insert_node = |id: CrateId, graph: &mut Graph<CrateId, ()>, map: &mut HashMap<CrateId, NodeIndex>| {
243 if let Some(&idx) = map.get(&id) {
244 idx
245 } else {
246 let idx = graph.add_node(id.clone());
247 map.insert(id, idx);
248 idx
249 }
250 };
251
252 for krate in crates.values() {
253 get_or_insert_node(krate.id.clone(), &mut graph, &mut node_indices);
254 }
255
256 for krate in crates.values() {
257 // Insert crate node if not exists
258 let crate_idx = get_or_insert_node(krate.id.clone(), &mut graph, &mut node_indices);
259
260 // Insert dependencies and connect edges
261 for dep in krate.dependencies.iter() {
262 let dep_idx = get_or_insert_node(dep.clone(), &mut graph, &mut node_indices);
263 graph.add_edge(crate_idx, dep_idx, ());
264 }
265 }
266
267 (graph, node_indices)
268}
269
270fn main() -> Result<()> {
271 let args = Args::parse();
272
273 let root = args.repo.canonicalize()?; //.expect("Invalid root crate path");
274 let mut crates = list_crates(&root)?;
275 //println!("Crates: {:?}", crates);
276 let (mut graph, indices) = build_graph(&crates);
277
278 // use petgraph::dot::{Config, Dot};
279 // println!("{:?}", Dot::with_config(&graph, &[Config::EdgeNoLabel]));
280
281 match args.command {
282 Command::List => {
283 let ordered = petgraph::algo::toposort(&graph, None).unwrap();
284 for node in ordered.iter() {
285 if graph.neighbors_directed(*node, Direction::Incoming).count() == 0 {
286 let start = graph.node_weight(*node).unwrap();
287 let mut bfs = Bfs::new(&graph, *node);
288 while let Some(node) = bfs.next(&graph) {
289 let weight = graph.node_weight(node).unwrap();
290 if weight.name == start.name {
291 println!("+ {}-{}", weight.name, weight.version);
292 } else {
293 println!("|- {}-{}", weight.name, weight.version);
294 }
295 }
296 println!("");
297 }
298 }
299 }
300 Command::Dependencies { crate_name } => {
301 let idx = indices
302 .get(&CrateId {
303 name: crate_name.clone(),
304 version: "".to_string(),
305 })
306 .expect("unable to find crate in tree");
307 let mut bfs = Bfs::new(&graph, *idx);
308 while let Some(node) = bfs.next(&graph) {
309 let weight = graph.node_weight(node).unwrap();
310 if weight.name == crate_name {
311 println!("+ {}-{}", weight.name, weight.version);
312 } else {
313 println!("|- {}-{}", weight.name, weight.version);
314 }
315 }
316 }
317 Command::Dependents { crate_name } => {
318 let idx = indices
319 .get(&CrateId {
320 name: crate_name.clone(),
321 version: "".to_string(),
322 })
323 .expect("unable to find crate in tree");
324 let node = graph.node_weight(*idx).unwrap();
325 println!("+ {}-{}", node.name, node.version);
326 for parent in graph.neighbors_directed(*idx, Direction::Incoming) {
327 let weight = graph.node_weight(parent).unwrap();
328 println!("|- {}-{}", weight.name, weight.version);
329 }
330 }
331 Command::SemverCheck { crate_name } => {
332 let c = crates
333 .get(&CrateId {
334 name: crate_name.to_string(),
335 version: "".to_string(),
336 })
337 .unwrap();
338 check_semver(&c)?;
339 }
340 Command::PrepareRelease { crate_name } => {
341 let start = indices
342 .get(&CrateId {
343 name: crate_name.clone(),
344 version: "".to_string(),
345 })
346 .expect("unable to find crate in tree");
347
348 graph.reverse();
349
350 let mut bfs = Bfs::new(&graph, *start);
351
352 while let Some(node) = bfs.next(&graph) {
353 let weight = graph.node_weight(node).unwrap();
354 println!("Preparing {}", weight.name);
355 let mut c = crates.get_mut(weight).unwrap();
356 let ver = semver::Version::parse(&c.id.version)?;
357 let newver = if let Err(_) = check_semver(&c) {
358 println!("Semver check failed, bumping minor!");
359 semver::Version::new(ver.major, ver.minor + 1, 0)
360 } else {
361 semver::Version::new(ver.major, ver.minor, ver.patch + 1)
362 };
363
364 println!(
365 "Updating {} from {} -> {}",
366 weight.name,
367 c.id.version,
368 newver.to_string()
369 );
370 let newver = newver.to_string();
371
372 update_version(&mut c, &newver)?;
373 let c = crates.get(weight).unwrap();
374
375 // Update all nodes further down the tree
376 let mut bfs = Bfs::new(&graph, node);
377 while let Some(dep_node) = bfs.next(&graph) {
378 let dep_weight = graph.node_weight(dep_node).unwrap();
379 let dep = crates.get(dep_weight).unwrap();
380 update_versions(dep, &c.id, &newver)?;
381 }
382
383 // Update changelog
384 update_changelog(&root, &c)?;
385 }
386
387 let weight = graph.node_weight(*start).unwrap();
388 let c = crates.get(weight).unwrap();
389 publish_release(&root, &c, false)?;
390
391 println!("# Please inspect changes and run the following commands when happy:");
392
393 println!("git commit -a -m 'chore: prepare crate releases'");
394 let mut bfs = Bfs::new(&graph, *start);
395 while let Some(node) = bfs.next(&graph) {
396 let weight = graph.node_weight(node).unwrap();
397 println!("git tag {}-v{}", weight.name, weight.version);
398 }
399
400 println!("");
401 println!("# Run these commands to publish the crate and dependents:");
402
403 let mut bfs = Bfs::new(&graph, *start);
404 while let Some(node) = bfs.next(&graph) {
405 let weight = graph.node_weight(node).unwrap();
406 let c = crates.get(weight).unwrap();
407
408 let mut args: Vec<String> = vec![
409 "publish".to_string(),
410 "--manifest-path".to_string(),
411 c.path.display().to_string(),
412 ];
413
414 if let Some(features) = &c.config.features {
415 args.push("--features".into());
416 args.push(features.join(","));
417 }
418
419 if let Some(target) = &c.config.target {
420 args.push("--target".into());
421 args.push(target.clone());
422 }
423
424 /*
425 let mut dry_run = args.clone();
426 dry_run.push("--dry-run".to_string());
427
428 println!("cargo {}", dry_run.join(" "));
429 */
430 println!("cargo {}", args.join(" "));
431 }
432
433 println!("");
434 println!("# Run this command to push changes and tags:");
435 println!("git push --tags");
436 }
437 }
438 Ok(())
439}
440
441fn check_semver(c: &Crate) -> Result<()> {
442 let mut args: Vec<String> = vec![
443 "semver-checks".to_string(),
444 "--manifest-path".to_string(),
445 c.path.display().to_string(),
446 "--default-features".to_string(),
447 ];
448 if let Some(features) = &c.config.features {
449 args.push("--features".into());
450 args.push(features.join(","));
451 }
452
453 let status = ProcessCommand::new("cargo").args(&args).output()?;
454
455 println!("{}", core::str::from_utf8(&status.stdout).unwrap());
456 eprintln!("{}", core::str::from_utf8(&status.stderr).unwrap());
457 if !status.status.success() {
458 return Err(anyhow!("semver check failed"));
459 } else {
460 Ok(())
461 }
462}
463
464fn update_changelog(repo: &Path, c: &Crate) -> Result<()> {
465 let args: Vec<String> = vec![
466 "release".to_string(),
467 "replace".to_string(),
468 "--config".to_string(),
469 repo.join("release").join("release.toml").display().to_string(),
470 "--manifest-path".to_string(),
471 c.path.display().to_string(),
472 "--execute".to_string(),
473 "--no-confirm".to_string(),
474 ];
475
476 let status = ProcessCommand::new("cargo").args(&args).output()?;
477
478 println!("{}", core::str::from_utf8(&status.stdout).unwrap());
479 eprintln!("{}", core::str::from_utf8(&status.stderr).unwrap());
480 if !status.status.success() {
481 return Err(anyhow!("release replace failed"));
482 } else {
483 Ok(())
484 }
485}
486
487fn publish_release(_repo: &Path, c: &Crate, push: bool) -> Result<()> {
488 let mut args: Vec<String> = vec![
489 "publish".to_string(),
490 "--manifest-path".to_string(),
491 c.path.display().to_string(),
492 ];
493
494 if let Some(features) = &c.config.features {
495 args.push("--features".into());
496 args.push(features.join(","));
497 }
498
499 if let Some(target) = &c.config.target {
500 args.push("--target".into());
501 args.push(target.clone());
502 }
503
504 if !push {
505 args.push("--dry-run".to_string());
506 args.push("--allow-dirty".to_string());
507 args.push("--keep-going".to_string());
508 }
509
510 let status = ProcessCommand::new("cargo").args(&args).output()?;
511
512 println!("{}", core::str::from_utf8(&status.stdout).unwrap());
513 eprintln!("{}", core::str::from_utf8(&status.stderr).unwrap());
514 if !status.status.success() {
515 return Err(anyhow!("publish failed"));
516 } else {
517 Ok(())
518 }
519}