publish_gpui.rs

  1#![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
  2use std::io::{self, Write};
  3use std::process::{Command, Output, Stdio};
  4
  5use anyhow::{Context as _, Result, bail};
  6use clap::Parser;
  7
  8#[derive(Parser)]
  9pub struct PublishGpuiArgs {
 10    /// Optional pre-release identifier to append to the version (e.g., alpha, test.1). Always bumps the minor version.
 11    #[arg(long)]
 12    pre_release: Option<String>,
 13
 14    /// Perform a dry-run and wait for user confirmation before each publish
 15    #[arg(long)]
 16    dry_run: bool,
 17}
 18
 19pub fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> {
 20    println!(
 21        "Starting GPUI publish process{}...",
 22        if args.dry_run { " (with dry-run)" } else { "" }
 23    );
 24
 25    let start_time = std::time::Instant::now();
 26    check_workspace_root()?;
 27    ensure_cargo_set_version()?;
 28    check_git_clean()?;
 29
 30    let current_version = read_gpui_version()?;
 31    let new_version = bump_version(&current_version, args.pre_release.as_deref())?;
 32    println!(
 33        "Updating GPUI version: {} -> {}",
 34        current_version, new_version
 35    );
 36    publish_dependencies(&new_version, args.dry_run)?;
 37    publish_gpui(&new_version, args.dry_run)?;
 38    println!("GPUI published in {}s", start_time.elapsed().as_secs_f32());
 39    Ok(())
 40}
 41
 42fn read_gpui_version() -> Result<String> {
 43    let gpui_cargo_toml_path = "crates/gpui/Cargo.toml";
 44    let contents = std::fs::read_to_string(gpui_cargo_toml_path)
 45        .context("Failed to read crates/gpui/Cargo.toml")?;
 46
 47    let cargo_toml: toml::Value =
 48        toml::from_str(&contents).context("Failed to parse crates/gpui/Cargo.toml")?;
 49
 50    let version = cargo_toml
 51        .get("package")
 52        .and_then(|p| p.get("version"))
 53        .and_then(|v| v.as_str())
 54        .context("Failed to find version in crates/gpui/Cargo.toml")?;
 55
 56    Ok(version.to_string())
 57}
 58
 59fn bump_version(current_version: &str, pre_release: Option<&str>) -> Result<String> {
 60    // Strip any existing metadata and pre-release
 61    let without_metadata = current_version.split('+').next().unwrap();
 62    let base_version = without_metadata.split('-').next().unwrap();
 63
 64    // Parse major.minor.patch
 65    let parts: Vec<&str> = base_version.split('.').collect();
 66    if parts.len() != 3 {
 67        bail!("Invalid version format: {}", current_version);
 68    }
 69
 70    let major: u32 = parts[0].parse().context("Failed to parse major version")?;
 71    let minor: u32 = parts[1].parse().context("Failed to parse minor version")?;
 72
 73    // Always bump minor version
 74    let new_version = format!("{}.{}.0", major, minor + 1);
 75
 76    // Add pre-release if specified
 77    if let Some(pre) = pre_release {
 78        Ok(format!("{}-{}", new_version, pre))
 79    } else {
 80        Ok(new_version)
 81    }
 82}
 83
 84fn publish_dependencies(new_version: &str, dry_run: bool) -> Result<()> {
 85    let gpui_dependencies = vec![
 86        ("zed-collections", "collections"),
 87        ("zed-perf", "perf"),
 88        ("zed-util-macros", "util_macros"),
 89        ("zed-util", "util"),
 90        ("gpui-macros", "gpui_macros"),
 91        ("zed-http-client", "http_client"),
 92        ("zed-derive-refineable", "derive_refineable"),
 93        ("zed-refineable", "refineable"),
 94        ("zed-semantic-version", "semantic_version"),
 95        ("zed-sum-tree", "sum_tree"),
 96        ("zed-media", "media"),
 97    ];
 98
 99    for (crate_name, package_name) in gpui_dependencies {
100        println!(
101            "Publishing dependency: {} (package: {})",
102            crate_name, package_name
103        );
104
105        update_crate_version(crate_name, new_version)?;
106        update_workspace_dependency_version(package_name, new_version)?;
107        publish_crate(crate_name, dry_run)?;
108
109        // println!("Waiting 60s for the rate limit...");
110        // thread::sleep(Duration::from_secs(60));
111    }
112
113    Ok(())
114}
115
116fn publish_gpui(new_version: &str, dry_run: bool) -> Result<()> {
117    update_crate_version("gpui", new_version)?;
118
119    publish_crate("gpui", dry_run)?;
120
121    Ok(())
122}
123
124fn update_crate_version(package_name: &str, new_version: &str) -> Result<()> {
125    let output = run_command(
126        Command::new("cargo")
127            .arg("set-version")
128            .arg("--package")
129            .arg(package_name)
130            .arg(new_version),
131    )?;
132
133    if !output.status.success() {
134        bail!("Failed to set version for package {}", package_name);
135    }
136
137    Ok(())
138}
139
140fn publish_crate(crate_name: &str, dry_run: bool) -> Result<()> {
141    let publish_crate_impl = |crate_name, dry_run| {
142        let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
143
144        let mut command = Command::new(&cargo);
145        command
146            .arg("publish")
147            .arg("--allow-dirty")
148            .args(["-p", crate_name]);
149
150        if dry_run {
151            command.arg("--dry-run");
152        }
153
154        run_command(&mut command)?;
155
156        anyhow::Ok(())
157    };
158
159    if dry_run {
160        publish_crate_impl(crate_name, true)?;
161
162        print!("Press Enter to publish for real (or ctrl-c to abort)...");
163        io::stdout().flush()?;
164
165        let mut input = String::new();
166        io::stdin().read_line(&mut input)?;
167    }
168
169    publish_crate_impl(crate_name, false)?;
170
171    Ok(())
172}
173
174fn update_workspace_dependency_version(package_name: &str, new_version: &str) -> Result<()> {
175    let workspace_cargo_toml_path = "Cargo.toml";
176    let contents = std::fs::read_to_string(workspace_cargo_toml_path)
177        .context("Failed to read workspace Cargo.toml")?;
178
179    let updated = update_dependency_version_in_toml(&contents, package_name, new_version)?;
180
181    std::fs::write(workspace_cargo_toml_path, updated)
182        .context("Failed to write workspace Cargo.toml")?;
183
184    Ok(())
185}
186
187fn update_dependency_version_in_toml(
188    toml_contents: &str,
189    package_name: &str,
190    new_version: &str,
191) -> Result<String> {
192    let mut doc = toml_contents
193        .parse::<toml_edit::DocumentMut>()
194        .context("Failed to parse TOML")?;
195
196    // Navigate to workspace.dependencies.<package_name>
197    let dependency = doc
198        .get_mut("workspace")
199        .and_then(|w| w.get_mut("dependencies"))
200        .and_then(|d| d.get_mut(package_name))
201        .context(format!(
202            "Failed to find {} in workspace dependencies",
203            package_name
204        ))?;
205
206    // Update the version field if it exists
207    if let Some(dep_table) = dependency.as_table_like_mut() {
208        if dep_table.contains_key("version") {
209            dep_table.insert("version", toml_edit::value(new_version));
210        } else {
211            bail!(
212                "No version field found for {} in workspace dependencies",
213                package_name
214            );
215        }
216    } else {
217        bail!("{} is not a table in workspace dependencies", package_name);
218    }
219
220    Ok(doc.to_string())
221}
222
223fn check_workspace_root() -> Result<()> {
224    let cwd = std::env::current_dir().context("Failed to get current directory")?;
225
226    // Check if Cargo.toml exists in the current directory
227    let cargo_toml_path = cwd.join("Cargo.toml");
228    if !cargo_toml_path.exists() {
229        bail!(
230            "Cargo.toml not found in current directory. Please run this command from the workspace root."
231        );
232    }
233
234    // Check if it's a workspace by looking for [workspace] section
235    let contents =
236        std::fs::read_to_string(&cargo_toml_path).context("Failed to read Cargo.toml")?;
237
238    if !contents.contains("[workspace]") {
239        bail!(
240            "Current directory does not appear to be a workspace root. Please run this command from the workspace root."
241        );
242    }
243
244    Ok(())
245}
246
247fn ensure_cargo_set_version() -> Result<()> {
248    let output = run_command(
249        Command::new("which")
250            .arg("cargo-set-version")
251            .stdout(Stdio::piped()),
252    )
253    .context("Failed to check for cargo-set-version")?;
254
255    if !output.status.success() {
256        println!("cargo-set-version not found. Installing cargo-edit...");
257
258        let install_output = run_command(Command::new("cargo").arg("install").arg("cargo-edit"))?;
259
260        if !install_output.status.success() {
261            bail!("Failed to install cargo-edit");
262        }
263    }
264
265    Ok(())
266}
267
268fn check_git_clean() -> Result<()> {
269    let output = run_command(
270        Command::new("git")
271            .args(["status", "--porcelain"])
272            .stdout(Stdio::piped())
273            .stderr(Stdio::piped()),
274    )?;
275
276    if !output.status.success() {
277        bail!("git status command failed");
278    }
279
280    let stdout = String::from_utf8_lossy(&output.stdout);
281    if !stdout.trim().is_empty() {
282        bail!(
283            "Working directory is not clean. Please commit or stash your changes before publishing."
284        );
285    }
286
287    Ok(())
288}
289
290fn run_command(command: &mut Command) -> Result<Output> {
291    let command_str = {
292        let program = command.get_program().to_string_lossy();
293        let args = command
294            .get_args()
295            .map(|arg| arg.to_string_lossy())
296            .collect::<Vec<_>>()
297            .join(" ");
298
299        if args.is_empty() {
300            program.to_string()
301        } else {
302            format!("{} {}", program, args)
303        }
304    };
305    eprintln!("+ {}", command_str);
306
307    let output = command
308        .spawn()
309        .context("failed to spawn child process")?
310        .wait_with_output()
311        .context("failed to wait for child process")?;
312
313    Ok(output)
314}
315
316#[cfg(test)]
317mod tests {
318    use indoc::indoc;
319
320    use super::*;
321
322    #[test]
323    fn test_update_dependency_version_in_toml() {
324        let input = indoc! {r#"
325            [workspace]
326            resolver = "2"
327
328            [workspace.dependencies]
329            # here's a comment
330            collections = { path = "crates/collections", package = "zed-collections", version = "0.1.0" }
331
332            util = { path = "crates/util", package = "zed-util", version = "0.1.0" }
333        "#};
334
335        let result = update_dependency_version_in_toml(input, "collections", "0.2.0").unwrap();
336
337        let output = indoc! {r#"
338            [workspace]
339            resolver = "2"
340
341            [workspace.dependencies]
342            # here's a comment
343            collections = { path = "crates/collections", package = "zed-collections", version = "0.2.0" }
344
345            util = { path = "crates/util", package = "zed-util", version = "0.1.0" }
346        "#};
347
348        assert_eq!(result, output);
349    }
350
351    #[test]
352    fn test_bump_version() {
353        // Test bumping minor version (default behavior)
354        assert_eq!(bump_version("0.1.0", None).unwrap(), "0.2.0");
355        assert_eq!(bump_version("0.1.5", None).unwrap(), "0.2.0");
356        assert_eq!(bump_version("1.42.7", None).unwrap(), "1.43.0");
357
358        // Test stripping pre-release and bumping minor
359        assert_eq!(bump_version("0.1.0-alpha.1", None).unwrap(), "0.2.0");
360        assert_eq!(bump_version("0.1.0-beta", None).unwrap(), "0.2.0");
361
362        // Test stripping existing metadata and bumping
363        assert_eq!(bump_version("0.1.0+old.metadata", None).unwrap(), "0.2.0");
364
365        // Test bumping minor with pre-release
366        assert_eq!(bump_version("0.1.0", Some("alpha")).unwrap(), "0.2.0-alpha");
367
368        // Test bumping minor with complex pre-release identifier
369        assert_eq!(
370            bump_version("0.1.0", Some("test.1")).unwrap(),
371            "0.2.0-test.1"
372        );
373
374        // Test bumping from existing pre-release adds new pre-release
375        assert_eq!(
376            bump_version("0.1.0-alpha", Some("beta")).unwrap(),
377            "0.2.0-beta"
378        );
379
380        // Test bumping and stripping metadata while adding pre-release
381        assert_eq!(
382            bump_version("0.1.0+metadata", Some("alpha")).unwrap(),
383            "0.2.0-alpha"
384        );
385    }
386}