Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
5 changes: 3 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,7 +20,8 @@ pub(crate) struct Cli {
#[arg(short = 'b', long, conflicts_with = "pr")]
pub branch: Option<String>,

/// 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-<SHA>, or
/// v1.2.3)
#[arg(id = "ver", short = 'i', long = "install", value_name = "VERSION")]
pub version: Option<String>,

Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
16 changes: 16 additions & 0 deletions src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ impl Downloader {
Ok(())
}

pub(crate) async fn download_json(&self, url: &str) -> Result<serde_json::Value> {
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<String> {
let response =
self.client.get(url).send().await.wrap_err_with(|| format!("failed to GET {url}"))?;
Expand Down
97 changes: 83 additions & 14 deletions src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<String> {
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}")
}
}
}

Expand Down
32 changes: 26 additions & 6 deletions tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -46,7 +46,8 @@ Options:
Build and install a specific branch

-i, --install <VERSION>
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-<SHA>, or
v1.2.3)

-l, --list
List installed versions
Expand Down Expand Up @@ -224,6 +225,10 @@ foundryup: - chisel [..]
]);
}

#[test]
fn install_latest() {
test_install("latest");
}
#[test]
fn install_stable() {
test_install("stable");
Expand All @@ -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");
}
Expand All @@ -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#"
Expand All @@ -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#"
Expand Down
Loading