diff --git a/Cargo.toml b/Cargo.toml index 0d274ae..88725a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ vergen-gitcl = { version = "1", features = ["build", "rustc"] } reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls", "gzip", + "json", "stream", ] } tokio = { version = "1", features = ["rt-multi-thread", "fs", "process", "io-util"] } diff --git a/src/cli.rs b/src/cli.rs index d1441c1..04e647d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,7 +4,7 @@ use clap::{CommandFactory, Parser}; /// /// Update or revert to a specific Foundry version with ease. /// -/// By default, the latest stable version is installed from built binaries. +/// By default, the latest version is installed from built binaries. #[derive(Debug, Parser)] #[command(name = "foundryup", version = crate::config::LONG_VERSION, about)] pub(crate) struct Cli { @@ -20,7 +20,8 @@ pub(crate) struct Cli { #[arg(short = 'b', long, conflicts_with = "pr")] pub branch: Option, - /// Install a specific version from built binaries (e.g., stable, nightly, 0.3.0) + /// Install a specific version from built binaries (e.g., latest, nightly, nightly-, or + /// v1.2.3) #[arg(id = "ver", short = 'i', long = "install", value_name = "VERSION")] pub version: Option, diff --git a/src/config.rs b/src/config.rs index cab5cbd..42fe388 100644 --- a/src/config.rs +++ b/src/config.rs @@ -137,7 +137,7 @@ impl NetworkConfig { repo: "foundry-rs/foundry", bins: &["forge", "cast", "anvil", "chisel"], archive_prefix: "foundry", - default_version: "stable", + default_version: "latest", display_name: "foundry", has_attestation: true, }; diff --git a/src/download.rs b/src/download.rs index 3175dfc..aeadc46 100644 --- a/src/download.rs +++ b/src/download.rs @@ -66,6 +66,22 @@ impl Downloader { Ok(()) } + pub(crate) async fn download_json(&self, url: &str) -> Result { + let response = self + .client + .get(url) + .header("Accept", "application/vnd.github+json") + .send() + .await + .wrap_err_with(|| format!("failed to GET {url}"))?; + + if !response.status().is_success() { + bail!("failed to fetch {url}: HTTP {}", response.status()); + } + + response.json().await.wrap_err("failed to parse JSON response") + } + pub(crate) async fn download_to_string(&self, url: &str) -> Result { let response = self.client.get(url).send().await.wrap_err_with(|| format!("failed to GET {url}"))?; diff --git a/src/install.rs b/src/install.rs index dafa50b..dc54ced 100644 --- a/src/install.rs +++ b/src/install.rs @@ -29,16 +29,19 @@ pub(crate) async fn run(config: &Config, args: &Cli) -> Result<()> { } async fn install_prebuilt(config: &Config, args: &Cli) -> Result<()> { - let (version, tag) = - normalize_version(args.version.as_deref().unwrap_or(config.network.default_version)); - let repo = config.network.repo; - - say!("installing {} (version {version}, tag {tag})", config.network.display_name); - let target = Target::detect(args.platform.as_deref(), args.arch.as_deref())?; let downloader = Downloader::new()?; + let (version, tag) = resolve_version( + &downloader, + repo, + args.version.as_deref().unwrap_or(config.network.default_version), + ) + .await?; + + say!("installing {} (version {version}, tag {tag})", config.network.display_name); + let release_url = format!("https://github.com/{}/releases/download/{tag}/", config.network.repo); @@ -566,14 +569,80 @@ in your 'PATH' to allow the newly installed version to take precedence! Ok(()) } -fn normalize_version(version: &str) -> (String, String) { - if version.starts_with("nightly") { - ("nightly".to_string(), version.to_string()) - } else if version.starts_with(|c: char| c.is_ascii_digit()) { - let s = format!("v{version}"); - (s.clone(), s) - } else { - (version.to_string(), version.to_string()) +/// Resolves a version channel or identifier to a (version, tag) pair. +/// +/// - `"latest"` / `"stable"`: resolves to the latest non-prerelease release via the GitHub API +/// - `"nightly"`: resolves to the latest `nightly-{SHA}` prerelease via the GitHub API +/// - `"nightly-{SHA}"`: uses the specific nightly tag directly +/// - `"v1.2.3"` or `"1.2.3"`: uses the specific version tag directly +async fn resolve_version( + downloader: &Downloader, + repo: &str, + version: &str, +) -> Result<(String, String)> { + match version { + "latest" | "stable" => { + say!("resolving latest release..."); + let tag = resolve_latest_release(downloader, repo, Channel::Latest).await?; + Ok((tag.clone(), tag)) + } + "nightly" => { + say!("resolving latest nightly release..."); + let tag = resolve_latest_release(downloader, repo, Channel::Nightly).await?; + Ok(("nightly".to_string(), tag)) + } + v if v.starts_with("nightly-") => Ok(("nightly".to_string(), v.to_string())), + v if v.starts_with(|c: char| c.is_ascii_digit()) => { + let s = format!("v{v}"); + Ok((s.clone(), s)) + } + v => Ok((v.to_string(), v.to_string())), + } +} + +enum Channel { + Latest, + Nightly, +} + +/// Resolves the latest release tag from the GitHub API. +async fn resolve_latest_release( + downloader: &Downloader, + repo: &str, + channel: Channel, +) -> Result { + match channel { + Channel::Latest => { + // Use the dedicated /releases/latest endpoint which always returns + // the latest non-prerelease, non-draft release regardless of how + // many prereleases exist. + let url = format!("https://api.github.com/repos/{repo}/releases/latest"); + let release: serde_json::Value = downloader.download_json(&url).await?; + release["tag_name"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| eyre::eyre!("could not find a latest release for {repo}")) + } + Channel::Nightly => { + // The latest nightly is always the most recent prerelease, so the + // first page is sufficient. + let url = format!("https://api.github.com/repos/{repo}/releases?per_page=10"); + let releases: serde_json::Value = downloader.download_json(&url).await?; + let releases = + releases.as_array().ok_or_else(|| eyre::eyre!("unexpected API response"))?; + + for release in releases { + let tag = release["tag_name"].as_str().unwrap_or_default(); + let prerelease = release["prerelease"].as_bool().unwrap_or(false); + let draft = release["draft"].as_bool().unwrap_or(false); + + if !draft && prerelease && tag.starts_with("nightly-") { + return Ok(tag.to_string()); + } + } + + bail!("could not find a nightly release for {repo}") + } } } diff --git a/tests/it/main.rs b/tests/it/main.rs index 761399d..f53cae1 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -31,7 +31,7 @@ The installer for Foundry. Update or revert to a specific Foundry version with ease. -By default, the latest stable version is installed from built binaries. +By default, the latest version is installed from built binaries. Usage: foundryup[EXE] [OPTIONS] @@ -46,7 +46,8 @@ Options: Build and install a specific branch -i, --install - Install a specific version from built binaries (e.g., stable, nightly, 0.3.0) + Install a specific version from built binaries (e.g., latest, nightly, nightly-, or + v1.2.3) -l, --list List installed versions @@ -224,6 +225,10 @@ foundryup: - chisel [..] ]); } +#[test] +fn install_latest() { + test_install("latest"); +} #[test] fn install_stable() { test_install("stable"); @@ -233,6 +238,10 @@ fn install_nightly() { test_install("nightly"); } #[test] +fn install_nightly_specific() { + test_install("nightly-a249f5cc35685c7d0ac5871885e06da5da623d52"); +} +#[test] fn install_v1_5_0() { test_install("v1.5.0"); } @@ -246,11 +255,22 @@ fn use_version() { let temp_dir = tempfile::Builder::new().tempdir().unwrap(); let foundry_dir = temp_dir.path().join(".foundry"); - foundryup().env("FOUNDRY_DIR", &foundry_dir).args(["-i", "stable"]).assert().success(); + foundryup().env("FOUNDRY_DIR", &foundry_dir).args(["-i", "latest"]).assert().success(); + + // The resolved tag (e.g. v1.6.0) is used as the version directory name + let versions_dir = foundry_dir.join("versions/foundry-rs/foundry"); + let resolved_version = std::fs::read_dir(&versions_dir) + .unwrap() + .filter_map(|e| e.ok()) + .find(|e| e.file_name().to_string_lossy().starts_with('v')) + .expect("no version directory found") + .file_name() + .to_string_lossy() + .to_string(); foundryup() .env("FOUNDRY_DIR", &foundry_dir) - .args(["--use", "stable"]) + .args(["--use", &resolved_version]) .assert() .success() .stderr_eq(str![[r#" @@ -265,11 +285,11 @@ fn reinstall_uses_cache() { let temp_dir = tempfile::Builder::new().tempdir().unwrap(); let foundry_dir = temp_dir.path().join(".foundry"); - foundryup().env("FOUNDRY_DIR", &foundry_dir).args(["-i", "stable"]).assert().success(); + foundryup().env("FOUNDRY_DIR", &foundry_dir).args(["-i", "latest"]).assert().success(); foundryup() .env("FOUNDRY_DIR", &foundry_dir) - .args(["-i", "stable"]) + .args(["-i", "latest"]) .assert() .success() .stderr_eq(str![[r#"