From 6a347f1f09b0076af868dcd63d9139081c92172b Mon Sep 17 00:00:00 2001 From: Ulf Lilleengen Date: Thu, 14 Aug 2025 13:36:39 +0200 Subject: 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 --- release/Cargo.toml | 15 ++ release/config.toml | 45 +++++ release/release.toml | 5 + release/src/main.rs | 519 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 584 insertions(+) create mode 100644 release/Cargo.toml create mode 100644 release/release.toml create mode 100644 release/src/main.rs (limited to 'release') diff --git a/release/Cargo.toml b/release/Cargo.toml new file mode 100644 index 000000000..cc1b155e2 --- /dev/null +++ b/release/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "embassy-release" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4.5.1", features = ["derive"] } +walkdir = "2.5.0" +toml = "0.8.8" +toml_edit = { version = "0.23.1", features = ["serde"] } +serde = { version = "1.0.198", features = ["derive"] } +regex = "1.10.4" +anyhow = "1" +petgraph = "0.8.2" +semver = "1.0.26" diff --git a/release/config.toml b/release/config.toml index 2292f4077..6b23217fa 100644 --- a/release/config.toml +++ b/release/config.toml @@ -1 +1,46 @@ + +embassy-stm32 = { features = ["defmt", "unstable-pac", "exti", "time-driver-any", "time", "stm32h755zi-cm7", "dual-bank"], target = "thumbv7em-none-eabi" } +embassy-nrf = { features = ["nrf52840", "time", "defmt", "unstable-pac", "gpiote", "time-driver-rtc1"], target = "thumbv7em-none-eabihf" } + embassy-rp = { features = ["defmt", "unstable-pac", "time-driver", "rp2040"], target = "thumbv6m-none-eabi" } +cyw43 = { features = ["defmt", "firmware-logs"], target = "thumbv6m-none-eabi" } +#cyw43-pio = { features = ["defmt", "embassy-rp/rp2040"], target = "thumbv6m-none-eabi" } + +embassy-boot = { features = ["defmt"] } +#embassy-boot-nrf = { features = ["defmt", "embassy-nrf/nrf52840"], target = "thumbv7em-none-eabihf" } +#embassy-boot-rp = { features = ["defmt", "embassy-rp/rp2040"], target = "thumbv6m-none-eabi" } +#embassy-boot-stm32 = { features = ["defmt", "embassy-stm32/stm32f429zi"], target = "thumbv7em-none-eabi" } + +embassy-time = { features = ["defmt", "std"] } +embassy-time-driver = { } +embassy-time-queue-utils = { features = ["defmt"] } + +embassy-futures = { } +embassy-embedded-hal = { features = ["time"] } +embassy-hal-internal = { } +embassy-executor = { features = ["defmt", "arch-cortex-m", "executor-thread", "executor-interrupt"], target = "thumbv7em-none-eabi" } +embassy-executor-macros = { } +embassy-sync = { } + +embassy-net = { features = ["defmt", "tcp", "udp", "raw", "dns", "icmp", "dhcpv4", "proto-ipv6", "medium-ethernet", "medium-ip", "medium-ieee802154", "multicast", "dhcpv4-hostname"] } +embassy-net-ppp = { } +embassy-net-esp-hosted = {} +embassy-net-driver-channel = {} +embassy-net-wiznet = {} +embassy-net-nrf91 = { features = ["defmt", "nrf9160"] } +embassy-net-driver = {} +embassy-net-tuntap = {} +embassy-net-adin1110 = {} +embassy-net-enc28j60 = {} + +embassy-usb-driver = { } +embassy-usb-dfu = { features = ["dfu"] } +embassy-usb-synopsys-otg = { } +embassy-usb = { features = ["defmt", "usbd-hid"] } +embassy-usb-logger = { } + +# Unreleased +# embassy-stm32-wpan = {} +# embassy-imxrt = {} +# embassy-nxp = {} +# embassy-mspm0 = {} diff --git a/release/release.toml b/release/release.toml new file mode 100644 index 000000000..fb6feaf21 --- /dev/null +++ b/release/release.toml @@ -0,0 +1,5 @@ +pre-release-replacements = [ + {file="CHANGELOG.md", search="Unreleased", replace="{{version}}", min=1}, + {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", min=1}, + {file="CHANGELOG.md", search="", replace="\n## Unreleased - ReleaseDate\n", exactly=1}, +] 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 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; + +use anyhow::{anyhow, Result}; +use clap::{Parser, Subcommand}; +use petgraph::graph::{Graph, NodeIndex}; +use petgraph::visit::Bfs; +use petgraph::{Directed, Direction}; +use serde::Deserialize; +use toml_edit::{DocumentMut, Item, Value}; + +/// Tool to traverse and operate on intra-repo Rust crate dependencies +#[derive(Parser, Debug)] +#[command(author, version, about)] +struct Args { + /// Path to embassy repository + #[arg(short, long)] + repo: PathBuf, + + /// Command to perform on each crate + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// All crates and their direct dependencies + List, + /// List all dependencies for a crate + Dependencies { + /// Crate name to print dependencies for. + #[arg(value_name = "CRATE")] + crate_name: String, + }, + /// List all dependencies for a crate + Dependents { + /// Crate name to print dependencies for. + #[arg(value_name = "CRATE")] + crate_name: String, + }, + + /// SemverCheck + SemverCheck { + /// Crate to check. Will traverse that crate an it's dependents. If not specified checks all crates. + #[arg(value_name = "CRATE")] + crate_name: String, + }, + /// Prepare to release a crate and all dependents that needs updating + /// - Semver checks + /// - Bump versions and commit + /// - Create tag. + PrepareRelease { + /// Crate to release. Will traverse that crate an it's dependents. If not specified checks all crates. + #[arg(value_name = "CRATE")] + crate_name: String, + }, +} + +#[derive(Debug, Subcommand, Clone, Copy, PartialEq)] +enum ReleaseKind { + Patch, + Minor, +} + +#[derive(Debug, Deserialize)] +struct CargoToml { + package: Option, + dependencies: Option, +} + +#[derive(Debug, Deserialize)] +struct Package { + name: String, + version: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum Dep { + Version(String), + DetailedTable(BTreeMap), +} + +type Deps = std::collections::BTreeMap; + +#[derive(Debug, Clone, Deserialize)] +struct CrateConfig { + features: Option>, + target: Option, +} + +type ReleaseConfig = HashMap; + +fn load_release_config(repo: &Path) -> ReleaseConfig { + let config_path = repo.join("release/config.toml"); + if !config_path.exists() { + return HashMap::new(); + } + let content = fs::read_to_string(&config_path).expect("Failed to read release/config.toml"); + toml::from_str(&content).expect("Invalid TOML format in release/config.toml") +} + +fn update_version(c: &mut Crate, new_version: &str) -> Result<()> { + let path = &c.path; + c.id.version = new_version.to_string(); + let content = fs::read_to_string(&path)?; + let mut doc: DocumentMut = content.parse()?; + for section in ["package"] { + if let Some(Item::Table(dep_table)) = doc.get_mut(section) { + dep_table.insert("version", Item::Value(Value::from(new_version))); + } + } + fs::write(&path, doc.to_string())?; + Ok(()) +} + +fn update_versions(to_update: &Crate, dep: &CrateId, new_version: &str) -> Result<()> { + let path = &to_update.path; + let content = fs::read_to_string(&path)?; + let mut doc: DocumentMut = content.parse()?; + let mut changed = false; + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(Item::Table(dep_table)) = doc.get_mut(section) { + if let Some(item) = dep_table.get_mut(&dep.name) { + match item { + // e.g., foo = "0.1.0" + Item::Value(Value::String(_)) => { + *item = Item::Value(Value::from(new_version)); + changed = true; + } + // e.g., foo = { version = "...", ... } + Item::Value(Value::InlineTable(inline)) => { + if inline.contains_key("version") { + inline["version"] = Value::from(new_version); + changed = true; + } + } + _ => {} // Leave unusual formats untouched + } + } + } + } + + if changed { + fs::write(&path, doc.to_string())?; + println!("🔧 Updated {} to {} in {}", dep.name, new_version, path.display()); + } + Ok(()) +} + +#[derive(Debug, Clone)] +struct Crate { + id: CrateId, + path: PathBuf, + config: CrateConfig, + dependencies: Vec, +} + +#[derive(Debug, Clone, PartialOrd, Ord)] +struct CrateId { + name: String, + version: String, +} + +impl PartialEq for CrateId { + fn eq(&self, other: &CrateId) -> bool { + self.name == other.name + } +} + +impl Eq for CrateId {} +impl std::hash::Hash for CrateId { + fn hash(&self, state: &mut H) { + self.name.hash(state) + } +} + +fn list_crates(path: &PathBuf) -> Result> { + let d = std::fs::read_dir(path)?; + let release_config = load_release_config(path); + let mut crates = BTreeMap::new(); + for c in d { + let entry = c?; + let name = entry.file_name().to_str().unwrap().to_string(); + if entry.file_type()?.is_dir() && name.starts_with("embassy-") { + let entry = entry.path().join("Cargo.toml"); + if entry.exists() { + let content = fs::read_to_string(&entry).unwrap_or_else(|_| { + panic!("Failed to read {:?}", entry); + }); + let parsed: CargoToml = toml::from_str(&content).unwrap_or_else(|e| { + panic!("Failed to parse {:?}: {}", entry, e); + }); + let p = parsed.package.unwrap(); + let id = CrateId { + name: p.name.clone(), + version: p.version.unwrap(), + }; + + let mut dependencies = Vec::new(); + if let Some(deps) = parsed.dependencies { + for (k, v) in deps { + if k.starts_with("embassy-") { + dependencies.push(CrateId { + name: k, + version: match v { + Dep::Version(v) => v, + Dep::DetailedTable(table) => { + table.get("version").unwrap().as_str().unwrap().to_string() + } + }, + }); + } + } + } + + let path = path.join(entry); + if let Some(config) = release_config.get(&p.name) { + crates.insert( + id.clone(), + Crate { + id, + path, + dependencies, + config: config.clone(), + }, + ); + } + } + } + } + Ok(crates) +} + +fn build_graph(crates: &BTreeMap) -> (Graph, HashMap) { + let mut graph = Graph::::new(); + let mut node_indices: HashMap = HashMap::new(); + + // Helper to insert or get existing node + let get_or_insert_node = |id: CrateId, graph: &mut Graph, map: &mut HashMap| { + if let Some(&idx) = map.get(&id) { + idx + } else { + let idx = graph.add_node(id.clone()); + map.insert(id, idx); + idx + } + }; + + for krate in crates.values() { + get_or_insert_node(krate.id.clone(), &mut graph, &mut node_indices); + } + + for krate in crates.values() { + // Insert crate node if not exists + let crate_idx = get_or_insert_node(krate.id.clone(), &mut graph, &mut node_indices); + + // Insert dependencies and connect edges + for dep in krate.dependencies.iter() { + let dep_idx = get_or_insert_node(dep.clone(), &mut graph, &mut node_indices); + graph.add_edge(crate_idx, dep_idx, ()); + } + } + + (graph, node_indices) +} + +fn main() -> Result<()> { + let args = Args::parse(); + + let root = args.repo.canonicalize()?; //.expect("Invalid root crate path"); + let mut crates = list_crates(&root)?; + //println!("Crates: {:?}", crates); + let (mut graph, indices) = build_graph(&crates); + + // use petgraph::dot::{Config, Dot}; + // println!("{:?}", Dot::with_config(&graph, &[Config::EdgeNoLabel])); + + match args.command { + Command::List => { + let ordered = petgraph::algo::toposort(&graph, None).unwrap(); + for node in ordered.iter() { + if graph.neighbors_directed(*node, Direction::Incoming).count() == 0 { + let start = graph.node_weight(*node).unwrap(); + let mut bfs = Bfs::new(&graph, *node); + while let Some(node) = bfs.next(&graph) { + let weight = graph.node_weight(node).unwrap(); + if weight.name == start.name { + println!("+ {}-{}", weight.name, weight.version); + } else { + println!("|- {}-{}", weight.name, weight.version); + } + } + println!(""); + } + } + } + Command::Dependencies { crate_name } => { + let idx = indices + .get(&CrateId { + name: crate_name.clone(), + version: "".to_string(), + }) + .expect("unable to find crate in tree"); + let mut bfs = Bfs::new(&graph, *idx); + while let Some(node) = bfs.next(&graph) { + let weight = graph.node_weight(node).unwrap(); + if weight.name == crate_name { + println!("+ {}-{}", weight.name, weight.version); + } else { + println!("|- {}-{}", weight.name, weight.version); + } + } + } + Command::Dependents { crate_name } => { + let idx = indices + .get(&CrateId { + name: crate_name.clone(), + version: "".to_string(), + }) + .expect("unable to find crate in tree"); + let node = graph.node_weight(*idx).unwrap(); + println!("+ {}-{}", node.name, node.version); + for parent in graph.neighbors_directed(*idx, Direction::Incoming) { + let weight = graph.node_weight(parent).unwrap(); + println!("|- {}-{}", weight.name, weight.version); + } + } + Command::SemverCheck { crate_name } => { + let c = crates + .get(&CrateId { + name: crate_name.to_string(), + version: "".to_string(), + }) + .unwrap(); + check_semver(&c)?; + } + Command::PrepareRelease { crate_name } => { + let start = indices + .get(&CrateId { + name: crate_name.clone(), + version: "".to_string(), + }) + .expect("unable to find crate in tree"); + + graph.reverse(); + + let mut bfs = Bfs::new(&graph, *start); + + while let Some(node) = bfs.next(&graph) { + let weight = graph.node_weight(node).unwrap(); + println!("Preparing {}", weight.name); + let mut c = crates.get_mut(weight).unwrap(); + let ver = semver::Version::parse(&c.id.version)?; + let newver = if let Err(_) = check_semver(&c) { + println!("Semver check failed, bumping minor!"); + semver::Version::new(ver.major, ver.minor + 1, 0) + } else { + semver::Version::new(ver.major, ver.minor, ver.patch + 1) + }; + + println!( + "Updating {} from {} -> {}", + weight.name, + c.id.version, + newver.to_string() + ); + let newver = newver.to_string(); + + update_version(&mut c, &newver)?; + let c = crates.get(weight).unwrap(); + + // Update all nodes further down the tree + let mut bfs = Bfs::new(&graph, node); + while let Some(dep_node) = bfs.next(&graph) { + let dep_weight = graph.node_weight(dep_node).unwrap(); + let dep = crates.get(dep_weight).unwrap(); + update_versions(dep, &c.id, &newver)?; + } + + // Update changelog + update_changelog(&root, &c)?; + } + + let weight = graph.node_weight(*start).unwrap(); + let c = crates.get(weight).unwrap(); + publish_release(&root, &c, false)?; + + println!("# Please inspect changes and run the following commands when happy:"); + + println!("git commit -a -m 'chore: prepare crate releases'"); + let mut bfs = Bfs::new(&graph, *start); + while let Some(node) = bfs.next(&graph) { + let weight = graph.node_weight(node).unwrap(); + println!("git tag {}-v{}", weight.name, weight.version); + } + + println!(""); + println!("# Run these commands to publish the crate and dependents:"); + + let mut bfs = Bfs::new(&graph, *start); + while let Some(node) = bfs.next(&graph) { + let weight = graph.node_weight(node).unwrap(); + let c = crates.get(weight).unwrap(); + + let mut args: Vec = vec![ + "publish".to_string(), + "--manifest-path".to_string(), + c.path.display().to_string(), + ]; + + if let Some(features) = &c.config.features { + args.push("--features".into()); + args.push(features.join(",")); + } + + if let Some(target) = &c.config.target { + args.push("--target".into()); + args.push(target.clone()); + } + + /* + let mut dry_run = args.clone(); + dry_run.push("--dry-run".to_string()); + + println!("cargo {}", dry_run.join(" ")); + */ + println!("cargo {}", args.join(" ")); + } + + println!(""); + println!("# Run this command to push changes and tags:"); + println!("git push --tags"); + } + } + Ok(()) +} + +fn check_semver(c: &Crate) -> Result<()> { + let mut args: Vec = vec![ + "semver-checks".to_string(), + "--manifest-path".to_string(), + c.path.display().to_string(), + "--default-features".to_string(), + ]; + if let Some(features) = &c.config.features { + args.push("--features".into()); + args.push(features.join(",")); + } + + let status = ProcessCommand::new("cargo").args(&args).output()?; + + println!("{}", core::str::from_utf8(&status.stdout).unwrap()); + eprintln!("{}", core::str::from_utf8(&status.stderr).unwrap()); + if !status.status.success() { + return Err(anyhow!("semver check failed")); + } else { + Ok(()) + } +} + +fn update_changelog(repo: &Path, c: &Crate) -> Result<()> { + let args: Vec = vec![ + "release".to_string(), + "replace".to_string(), + "--config".to_string(), + repo.join("release").join("release.toml").display().to_string(), + "--manifest-path".to_string(), + c.path.display().to_string(), + "--execute".to_string(), + "--no-confirm".to_string(), + ]; + + let status = ProcessCommand::new("cargo").args(&args).output()?; + + println!("{}", core::str::from_utf8(&status.stdout).unwrap()); + eprintln!("{}", core::str::from_utf8(&status.stderr).unwrap()); + if !status.status.success() { + return Err(anyhow!("release replace failed")); + } else { + Ok(()) + } +} + +fn publish_release(_repo: &Path, c: &Crate, push: bool) -> Result<()> { + let mut args: Vec = vec![ + "publish".to_string(), + "--manifest-path".to_string(), + c.path.display().to_string(), + ]; + + if let Some(features) = &c.config.features { + args.push("--features".into()); + args.push(features.join(",")); + } + + if let Some(target) = &c.config.target { + args.push("--target".into()); + args.push(target.clone()); + } + + if !push { + args.push("--dry-run".to_string()); + args.push("--allow-dirty".to_string()); + args.push("--keep-going".to_string()); + } + + let status = ProcessCommand::new("cargo").args(&args).output()?; + + println!("{}", core::str::from_utf8(&status.stdout).unwrap()); + eprintln!("{}", core::str::from_utf8(&status.stderr).unwrap()); + if !status.status.success() { + return Err(anyhow!("publish failed")); + } else { + Ok(()) + } +} -- cgit