From 551c53d8d97ce2f6897ca7bff56ddf72c4a0b516 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 17 Mar 2026 18:43:59 +0100 Subject: [PATCH 1/4] feat(build): Add `build download` command (EME-272) Add a new `sentry-cli build download` subcommand that downloads installable builds (IPA/APK) from a project by build ID. The command fetches install details from the API, then downloads the installable artifact with the correct binary format. For iOS builds, the plist manifest format is automatically swapped to IPA. The default output filename uses the correct extension based on the platform (e.g., .apk for Android, .ipa for iOS). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api/data_types/chunking/build.rs | 7 ++ src/api/data_types/chunking/mod.rs | 2 +- src/api/mod.rs | 21 +++- src/commands/build/download.rs | 98 +++++++++++++++++++ src/commands/build/mod.rs | 2 + .../_cases/build/build-help.trycmd | 1 + 6 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/commands/build/download.rs diff --git a/src/api/data_types/chunking/build.rs b/src/api/data_types/chunking/build.rs index b76a9fcb38..b46e23bb01 100644 --- a/src/api/data_types/chunking/build.rs +++ b/src/api/data_types/chunking/build.rs @@ -28,6 +28,13 @@ pub struct AssembleBuildResponse { pub artifact_url: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildInstallDetails { + pub is_installable: bool, + pub install_url: Option, +} + /// VCS information for build app uploads #[derive(Debug, Serialize)] pub struct VcsInfo<'a> { diff --git a/src/api/data_types/chunking/mod.rs b/src/api/data_types/chunking/mod.rs index 8b5657ee1c..f5d5c86d62 100644 --- a/src/api/data_types/chunking/mod.rs +++ b/src/api/data_types/chunking/mod.rs @@ -10,7 +10,7 @@ mod hash_algorithm; mod upload; pub use self::artifact::{AssembleArtifactsResponse, ChunkedArtifactRequest}; -pub use self::build::{AssembleBuildResponse, ChunkedBuildRequest, VcsInfo}; +pub use self::build::{AssembleBuildResponse, BuildInstallDetails, ChunkedBuildRequest, VcsInfo}; pub use self::compression::ChunkCompression; pub use self::dif::{AssembleDifsRequest, AssembleDifsResponse, ChunkedDifRequest}; pub use self::file_state::ChunkedFileState; diff --git a/src/api/mod.rs b/src/api/mod.rs index df57300b16..9d49b9b1f9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -16,7 +16,6 @@ use std::borrow::Cow; use std::cell::RefCell; use std::collections::HashMap; use std::error::Error as _; -#[cfg(any(target_os = "macos", not(feature = "managed")))] use std::fs::File; use std::io::{self, Read as _, Write}; use std::rc::Rc; @@ -724,6 +723,26 @@ impl AuthenticatedApi<'_> { .convert_rnf(ApiErrorKind::ProjectNotFound) } + pub fn get_build_install_details( + &self, + org: &str, + build_id: &str, + ) -> ApiResult { + let url = format!( + "/organizations/{}/preprodartifacts/{}/install-details/", + PathArg(org), + PathArg(build_id) + ); + + self.get(&url)?.convert() + } + + pub fn download_installable_build(&self, url: &str, dst: &mut File) -> ApiResult { + self.request(Method::Get, url)? + .progress_bar_mode(ProgressBarMode::Response) + .send_into(dst) + } + /// List all organizations associated with the authenticated token /// in the given `Region`. If no `Region` is provided, we assume /// we're issuing a request to a monolith deployment. diff --git a/src/commands/build/download.rs b/src/commands/build/download.rs new file mode 100644 index 0000000000..0827ad2306 --- /dev/null +++ b/src/commands/build/download.rs @@ -0,0 +1,98 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use clap::{Arg, ArgMatches, Command}; +use log::info; + +use crate::api::Api; +use crate::config::Config; +use crate::utils::args::ArgExt as _; +use crate::utils::fs::TempFile; + +pub fn make_command(command: Command) -> Command { + command + .about("Download a build from a project.") + .long_about("Download a build from a project.\n\nThis feature only works with Sentry SaaS.") + .org_arg() + .project_arg(false) + .arg( + Arg::new("build_id") + .long("build-id") + .short('b') + .required(true) + .help("The ID of the build to download."), + ) + .arg(Arg::new("output").long("output").help( + "The output file path. Defaults to \ + 'preprod_artifact_.' in the current directory, \ + where ext is ipa or apk depending on the platform.", + )) +} + +/// For iOS builds, the install URL points to a plist manifest. +/// Replace the response_format to download the actual IPA binary instead. +fn ensure_binary_format(url: &str) -> String { + url.replace("response_format=plist", "response_format=ipa") +} + +/// Extract the file extension from the response_format query parameter. +fn extension_from_url(url: &str) -> &str { + if url.contains("response_format=ipa") { + "ipa" + } else if url.contains("response_format=apk") { + "apk" + } else { + "zip" + } +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + let config = Config::current(); + let org = config.get_org(matches)?; + let build_id = matches.get_one::("build_id").unwrap(); + + let api = Api::current(); + let authenticated_api = api.authenticated()?; + + info!("Fetching install details for build {build_id}"); + let details = authenticated_api.get_build_install_details(&org, build_id)?; + + if !details.is_installable { + bail!("Build {build_id} is not installable."); + } + + let install_url = details + .install_url + .ok_or_else(|| anyhow::anyhow!("Build {build_id} has no install URL."))?; + + let download_url = ensure_binary_format(&install_url); + + let output_path = match matches.get_one::("output") { + Some(path) => PathBuf::from(path), + None => { + let ext = extension_from_url(&download_url); + PathBuf::from(format!("preprod_artifact_{build_id}.{ext}")) + } + }; + + info!("Downloading build {build_id} to {}", output_path.display()); + + let tmp = TempFile::create()?; + let mut file = tmp.open()?; + let response = authenticated_api.download_installable_build(&download_url, &mut file)?; + + if response.failed() { + bail!( + "Failed to download build (server returned status {}).", + response.status() + ); + } + + drop(file); + fs::copy(tmp.path(), &output_path)?; + + println!("Successfully downloaded build to {}", output_path.display()); + + Ok(()) +} diff --git a/src/commands/build/mod.rs b/src/commands/build/mod.rs index d302aa0791..55b28d1dfb 100644 --- a/src/commands/build/mod.rs +++ b/src/commands/build/mod.rs @@ -3,11 +3,13 @@ use clap::{ArgMatches, Command}; use crate::utils::args::ArgExt as _; +pub mod download; pub mod snapshots; pub mod upload; macro_rules! each_subcommand { ($mac:ident) => { + $mac!(download); $mac!(snapshots); $mac!(upload); }; diff --git a/tests/integration/_cases/build/build-help.trycmd b/tests/integration/_cases/build/build-help.trycmd index a04443131c..535f77c803 100644 --- a/tests/integration/_cases/build/build-help.trycmd +++ b/tests/integration/_cases/build/build-help.trycmd @@ -6,6 +6,7 @@ Manage builds. Usage: sentry-cli[EXE] build [OPTIONS] Commands: + download Download a build from a project. snapshots [EXPERIMENTAL] Upload build snapshots to a project. upload Upload builds to a project. help Print this message or the help of the given subcommand(s) From 38241aa718c3d4212f50ca41f511107a94c6391b Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 17 Mar 2026 18:47:17 +0100 Subject: [PATCH 2/4] docs(changelog): Add entry for build download command Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe44079d9..e68542894c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### New Features ✨ + +- Add `sentry-cli build download` command to download installable builds (IPA/APK) by build ID ([#3221](https://github.com/getsentry/sentry-cli/pull/3221)). + ## 3.3.3 ### Internal Changes 🔧 From fc5832a80df9999346c905e3885899125425173d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 17 Mar 2026 18:49:30 +0100 Subject: [PATCH 3/4] fix(build): Error on unsupported download format Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/build/download.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/build/download.rs b/src/commands/build/download.rs index 0827ad2306..08c03bd911 100644 --- a/src/commands/build/download.rs +++ b/src/commands/build/download.rs @@ -37,13 +37,13 @@ fn ensure_binary_format(url: &str) -> String { } /// Extract the file extension from the response_format query parameter. -fn extension_from_url(url: &str) -> &str { +fn extension_from_url(url: &str) -> Result<&str> { if url.contains("response_format=ipa") { - "ipa" + Ok("ipa") } else if url.contains("response_format=apk") { - "apk" + Ok("apk") } else { - "zip" + bail!("Unsupported build format in download URL.") } } @@ -71,7 +71,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { let output_path = match matches.get_one::("output") { Some(path) => PathBuf::from(path), None => { - let ext = extension_from_url(&download_url); + let ext = extension_from_url(&download_url)?; PathBuf::from(format!("preprod_artifact_{build_id}.{ext}")) } }; From f79f33df5ba2fb08dda36f96e788f8ff6d296b1c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 17 Mar 2026 18:54:29 +0100 Subject: [PATCH 4/4] fix(build): Use expect instead of unwrap for clippy Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/build/download.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/build/download.rs b/src/commands/build/download.rs index 08c03bd911..a4c6b22f18 100644 --- a/src/commands/build/download.rs +++ b/src/commands/build/download.rs @@ -50,7 +50,9 @@ fn extension_from_url(url: &str) -> Result<&str> { pub fn execute(matches: &ArgMatches) -> Result<()> { let config = Config::current(); let org = config.get_org(matches)?; - let build_id = matches.get_one::("build_id").unwrap(); + let build_id = matches + .get_one::("build_id") + .expect("build_id is required"); let api = Api::current(); let authenticated_api = api.authenticated()?;