diff --git a/Cargo.lock b/Cargo.lock index 456b292df8a97fb657144e83144b65c945c4dedf..2cd005b41e0c5f68fb21688a04412c3e49747209 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19803,6 +19803,9 @@ dependencies = [ "cargo_metadata", "cargo_toml", "clap", + "indoc", + "toml 0.8.20", + "toml_edit", "workspace-hack", ] diff --git a/Cargo.toml b/Cargo.toml index cd4b169d92a55752ea9d7ce8087342310689a6a7..8bcde70b55f4bbc99fdafaddc057f571efa5abce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -669,6 +669,7 @@ tiny_http = "0.8" tokio = { version = "1" } tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } toml = "0.8" +toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] } tower-http = "0.4.4" tree-sitter = { version = "0.25.10", features = ["wasm"] } tree-sitter-bash = "0.25.0" diff --git a/tooling/xtask/Cargo.toml b/tooling/xtask/Cargo.toml index 097ece86cf404dfb658883007a285abe95989427..8f968e0ca6eb81b6bebaec5c17bfac2baf3d5c79 100644 --- a/tooling/xtask/Cargo.toml +++ b/tooling/xtask/Cargo.toml @@ -13,4 +13,7 @@ anyhow.workspace = true cargo_metadata.workspace = true cargo_toml.workspace = true clap = { workspace = true, features = ["derive"] } +toml.workspace = true +indoc.workspace = true +toml_edit.workspace = true workspace-hack.workspace = true diff --git a/tooling/xtask/src/main.rs b/tooling/xtask/src/main.rs index 0da9131fd3cd245a923e12253e3dacc1f5a58279..5b265392f4035c205c4387dd10f1410f6c04d064 100644 --- a/tooling/xtask/src/main.rs +++ b/tooling/xtask/src/main.rs @@ -18,6 +18,8 @@ enum CliCommand { Licenses(tasks::licenses::LicensesArgs), /// Checks that packages conform to a set of standards. PackageConformity(tasks::package_conformity::PackageConformityArgs), + /// Publishes GPUI and its dependencies to crates.io. + PublishGpui(tasks::publish_gpui::PublishGpuiArgs), } fn main() -> Result<()> { @@ -29,5 +31,6 @@ fn main() -> Result<()> { CliCommand::PackageConformity(args) => { tasks::package_conformity::run_package_conformity(args) } + CliCommand::PublishGpui(args) => tasks::publish_gpui::run_publish_gpui(args), } } diff --git a/tooling/xtask/src/tasks.rs b/tooling/xtask/src/tasks.rs index ea5f3afb560a452915f0b6bb5403d25378e2a8da..b73aeb0e7fce47980d61e326c8f41cebc06e07b2 100644 --- a/tooling/xtask/src/tasks.rs +++ b/tooling/xtask/src/tasks.rs @@ -1,3 +1,4 @@ pub mod clippy; pub mod licenses; pub mod package_conformity; +pub mod publish_gpui; diff --git a/tooling/xtask/src/tasks/publish_gpui.rs b/tooling/xtask/src/tasks/publish_gpui.rs new file mode 100644 index 0000000000000000000000000000000000000000..bf25b58fb7f8212f6188519583790f2149a107b5 --- /dev/null +++ b/tooling/xtask/src/tasks/publish_gpui.rs @@ -0,0 +1,386 @@ +#![allow(clippy::disallowed_methods, reason = "tooling is exempt")] +use std::io::{self, Write}; +use std::process::{Command, Output, Stdio}; + +use anyhow::{Context as _, Result, bail}; +use clap::Parser; + +#[derive(Parser)] +pub struct PublishGpuiArgs { + /// Optional pre-release identifier to append to the version (e.g., alpha, test.1). Always bumps the minor version. + #[arg(long)] + pre_release: Option, + + /// Perform a dry-run and wait for user confirmation before each publish + #[arg(long)] + dry_run: bool, +} + +pub fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> { + println!( + "Starting GPUI publish process{}...", + if args.dry_run { " (with dry-run)" } else { "" } + ); + + let start_time = std::time::Instant::now(); + check_workspace_root()?; + ensure_cargo_set_version()?; + check_git_clean()?; + + let current_version = read_gpui_version()?; + let new_version = bump_version(¤t_version, args.pre_release.as_deref())?; + println!( + "Updating GPUI version: {} -> {}", + current_version, new_version + ); + publish_dependencies(&new_version, args.dry_run)?; + publish_gpui(&new_version, args.dry_run)?; + println!("GPUI published in {}s", start_time.elapsed().as_secs_f32()); + Ok(()) +} + +fn read_gpui_version() -> Result { + let gpui_cargo_toml_path = "crates/gpui/Cargo.toml"; + let contents = std::fs::read_to_string(gpui_cargo_toml_path) + .context("Failed to read crates/gpui/Cargo.toml")?; + + let cargo_toml: toml::Value = + toml::from_str(&contents).context("Failed to parse crates/gpui/Cargo.toml")?; + + let version = cargo_toml + .get("package") + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + .context("Failed to find version in crates/gpui/Cargo.toml")?; + + Ok(version.to_string()) +} + +fn bump_version(current_version: &str, pre_release: Option<&str>) -> Result { + // Strip any existing metadata and pre-release + let without_metadata = current_version.split('+').next().unwrap(); + let base_version = without_metadata.split('-').next().unwrap(); + + // Parse major.minor.patch + let parts: Vec<&str> = base_version.split('.').collect(); + if parts.len() != 3 { + bail!("Invalid version format: {}", current_version); + } + + let major: u32 = parts[0].parse().context("Failed to parse major version")?; + let minor: u32 = parts[1].parse().context("Failed to parse minor version")?; + + // Always bump minor version + let new_version = format!("{}.{}.0", major, minor + 1); + + // Add pre-release if specified + if let Some(pre) = pre_release { + Ok(format!("{}-{}", new_version, pre)) + } else { + Ok(new_version) + } +} + +fn publish_dependencies(new_version: &str, dry_run: bool) -> Result<()> { + let gpui_dependencies = vec![ + ("zed-collections", "collections"), + ("zed-perf", "perf"), + ("zed-util-macros", "util_macros"), + ("zed-util", "util"), + ("gpui-macros", "gpui_macros"), + ("zed-http-client", "http_client"), + ("zed-derive-refineable", "derive_refineable"), + ("zed-refineable", "refineable"), + ("zed-semantic-version", "semantic_version"), + ("zed-sum-tree", "sum_tree"), + ("zed-media", "media"), + ]; + + for (crate_name, package_name) in gpui_dependencies { + println!( + "Publishing dependency: {} (package: {})", + crate_name, package_name + ); + + update_crate_version(crate_name, new_version)?; + update_workspace_dependency_version(package_name, new_version)?; + publish_crate(crate_name, dry_run)?; + + // println!("Waiting 60s for the rate limit..."); + // thread::sleep(Duration::from_secs(60)); + } + + Ok(()) +} + +fn publish_gpui(new_version: &str, dry_run: bool) -> Result<()> { + update_crate_version("gpui", new_version)?; + + publish_crate("gpui", dry_run)?; + + Ok(()) +} + +fn update_crate_version(package_name: &str, new_version: &str) -> Result<()> { + let output = run_command( + Command::new("cargo") + .arg("set-version") + .arg("--package") + .arg(package_name) + .arg(new_version), + )?; + + if !output.status.success() { + bail!("Failed to set version for package {}", package_name); + } + + Ok(()) +} + +fn publish_crate(crate_name: &str, dry_run: bool) -> Result<()> { + let publish_crate_impl = |crate_name, dry_run| { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); + + let mut command = Command::new(&cargo); + command + .arg("publish") + .arg("--allow-dirty") + .args(["-p", crate_name]); + + if dry_run { + command.arg("--dry-run"); + } + + run_command(&mut command)?; + + anyhow::Ok(()) + }; + + if dry_run { + publish_crate_impl(crate_name, true)?; + + print!("Press Enter to publish for real (or ctrl-c to abort)..."); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + } + + publish_crate_impl(crate_name, false)?; + + Ok(()) +} + +fn update_workspace_dependency_version(package_name: &str, new_version: &str) -> Result<()> { + let workspace_cargo_toml_path = "Cargo.toml"; + let contents = std::fs::read_to_string(workspace_cargo_toml_path) + .context("Failed to read workspace Cargo.toml")?; + + let updated = update_dependency_version_in_toml(&contents, package_name, new_version)?; + + std::fs::write(workspace_cargo_toml_path, updated) + .context("Failed to write workspace Cargo.toml")?; + + Ok(()) +} + +fn update_dependency_version_in_toml( + toml_contents: &str, + package_name: &str, + new_version: &str, +) -> Result { + let mut doc = toml_contents + .parse::() + .context("Failed to parse TOML")?; + + // Navigate to workspace.dependencies. + let dependency = doc + .get_mut("workspace") + .and_then(|w| w.get_mut("dependencies")) + .and_then(|d| d.get_mut(package_name)) + .context(format!( + "Failed to find {} in workspace dependencies", + package_name + ))?; + + // Update the version field if it exists + if let Some(dep_table) = dependency.as_table_like_mut() { + if dep_table.contains_key("version") { + dep_table.insert("version", toml_edit::value(new_version)); + } else { + bail!( + "No version field found for {} in workspace dependencies", + package_name + ); + } + } else { + bail!("{} is not a table in workspace dependencies", package_name); + } + + Ok(doc.to_string()) +} + +fn check_workspace_root() -> Result<()> { + let cwd = std::env::current_dir().context("Failed to get current directory")?; + + // Check if Cargo.toml exists in the current directory + let cargo_toml_path = cwd.join("Cargo.toml"); + if !cargo_toml_path.exists() { + bail!( + "Cargo.toml not found in current directory. Please run this command from the workspace root." + ); + } + + // Check if it's a workspace by looking for [workspace] section + let contents = + std::fs::read_to_string(&cargo_toml_path).context("Failed to read Cargo.toml")?; + + if !contents.contains("[workspace]") { + bail!( + "Current directory does not appear to be a workspace root. Please run this command from the workspace root." + ); + } + + Ok(()) +} + +fn ensure_cargo_set_version() -> Result<()> { + let output = run_command( + Command::new("which") + .arg("cargo-set-version") + .stdout(Stdio::piped()), + ) + .context("Failed to check for cargo-set-version")?; + + if !output.status.success() { + println!("cargo-set-version not found. Installing cargo-edit..."); + + let install_output = run_command(Command::new("cargo").arg("install").arg("cargo-edit"))?; + + if !install_output.status.success() { + bail!("Failed to install cargo-edit"); + } + } + + Ok(()) +} + +fn check_git_clean() -> Result<()> { + let output = run_command( + Command::new("git") + .args(["status", "--porcelain"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()), + )?; + + if !output.status.success() { + bail!("git status command failed"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + bail!( + "Working directory is not clean. Please commit or stash your changes before publishing." + ); + } + + Ok(()) +} + +fn run_command(command: &mut Command) -> Result { + let command_str = { + let program = command.get_program().to_string_lossy(); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join(" "); + + if args.is_empty() { + program.to_string() + } else { + format!("{} {}", program, args) + } + }; + eprintln!("+ {}", command_str); + + let output = command + .spawn() + .context("failed to spawn child process")? + .wait_with_output() + .context("failed to wait for child process")?; + + Ok(output) +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + + use super::*; + + #[test] + fn test_update_dependency_version_in_toml() { + let input = indoc! {r#" + [workspace] + resolver = "2" + + [workspace.dependencies] + # here's a comment + collections = { path = "crates/collections", package = "zed-collections", version = "0.1.0" } + + util = { path = "crates/util", package = "zed-util", version = "0.1.0" } + "#}; + + let result = update_dependency_version_in_toml(input, "collections", "0.2.0").unwrap(); + + let output = indoc! {r#" + [workspace] + resolver = "2" + + [workspace.dependencies] + # here's a comment + collections = { path = "crates/collections", package = "zed-collections", version = "0.2.0" } + + util = { path = "crates/util", package = "zed-util", version = "0.1.0" } + "#}; + + assert_eq!(result, output); + } + + #[test] + fn test_bump_version() { + // Test bumping minor version (default behavior) + assert_eq!(bump_version("0.1.0", None).unwrap(), "0.2.0"); + assert_eq!(bump_version("0.1.5", None).unwrap(), "0.2.0"); + assert_eq!(bump_version("1.42.7", None).unwrap(), "1.43.0"); + + // Test stripping pre-release and bumping minor + assert_eq!(bump_version("0.1.0-alpha.1", None).unwrap(), "0.2.0"); + assert_eq!(bump_version("0.1.0-beta", None).unwrap(), "0.2.0"); + + // Test stripping existing metadata and bumping + assert_eq!(bump_version("0.1.0+old.metadata", None).unwrap(), "0.2.0"); + + // Test bumping minor with pre-release + assert_eq!(bump_version("0.1.0", Some("alpha")).unwrap(), "0.2.0-alpha"); + + // Test bumping minor with complex pre-release identifier + assert_eq!( + bump_version("0.1.0", Some("test.1")).unwrap(), + "0.2.0-test.1" + ); + + // Test bumping from existing pre-release adds new pre-release + assert_eq!( + bump_version("0.1.0-alpha", Some("beta")).unwrap(), + "0.2.0-beta" + ); + + // Test bumping and stripping metadata while adding pre-release + assert_eq!( + bump_version("0.1.0+metadata", Some("alpha")).unwrap(), + "0.2.0-alpha" + ); + } +}