Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 🔧
Expand Down
7 changes: 7 additions & 0 deletions src/api/data_types/chunking/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ pub struct AssembleBuildResponse {
pub artifact_url: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildInstallDetails {
pub is_installable: bool,
pub install_url: Option<String>,
}

/// VCS information for build app uploads
#[derive(Debug, Serialize)]
pub struct VcsInfo<'a> {
Expand Down
2 changes: 1 addition & 1 deletion src/api/data_types/chunking/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 20 additions & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -724,6 +723,26 @@ impl AuthenticatedApi<'_> {
.convert_rnf(ApiErrorKind::ProjectNotFound)
}

pub fn get_build_install_details(
&self,
org: &str,
build_id: &str,
) -> ApiResult<BuildInstallDetails> {
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<ApiResponse> {
self.request(Method::Get, url)?
.progress_bar_mode(ProgressBarMode::Response)
.send_into(dst)
}
Comment thread
runningcode marked this conversation as resolved.
Comment thread
runningcode marked this conversation as resolved.

/// 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.
Expand Down
106 changes: 106 additions & 0 deletions src/commands/build/download.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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;

const EXPERIMENTAL_WARNING: &str =
"[EXPERIMENTAL] The \"build download\" command is experimental. \
The command is subject to breaking changes, including removal, in any Sentry CLI release.";

pub fn make_command(command: Command) -> Command {
command
.about("[EXPERIMENTAL] Download a build artifact.")
.long_about(format!(
"Download a build artifact.\n\n{EXPERIMENTAL_WARNING}"
))
.org_arg()
.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(
Comment thread
szokeasaurusrex marked this conversation as resolved.
"The output file path. Defaults to \
'preprod_artifact_<build_id>.<ext>' 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) -> Result<&str> {
if url.contains("response_format=ipa") {
Ok("ipa")
} else if url.contains("response_format=apk") {
Ok("apk")
} else {
bail!("Unsupported build format in download URL.")
}
}

pub fn execute(matches: &ArgMatches) -> Result<()> {
eprintln!("{EXPERIMENTAL_WARNING}");
let config = Config::current();
let org = config.get_org(matches)?;
let build_id = matches
.get_one::<String>("build_id")
.expect("build_id is required");

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::<String>("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(())
}
2 changes: 2 additions & 0 deletions src/commands/build/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
44 changes: 44 additions & 0 deletions tests/integration/_cases/build/build-download-help.trycmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
```
$ sentry-cli build download --help
? success
Download a build artifact.

[EXPERIMENTAL] The "build download" command is experimental. The command is subject to breaking
changes, including removal, in any Sentry CLI release.

Usage: sentry-cli[EXE] build download [OPTIONS] --build-id <build_id>

Options:
-o, --org <ORG>
The organization ID or slug.

-b, --build-id <build_id>
The ID of the build to download.

--header <KEY:VALUE>
Custom headers that should be attached to all requests
in key:value format.

-p, --project <PROJECT>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are builds project or org scoped?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, the project arg is optional here i just tested.

The project ID or slug.

--auth-token <AUTH_TOKEN>
Use the given Sentry auth token.

--output <output>
The output file path. Defaults to 'preprod_artifact_<build_id>.<ext>' in the current
directory, where ext is ipa or apk depending on the platform.

--log-level <LOG_LEVEL>
Set the log output verbosity. [possible values: trace, debug, info, warn, error]

--quiet
Do not print any output while preserving correct exit code. This flag is currently
implemented only for selected subcommands.

[aliases: --silent]

-h, --help
Print help (see a summary with '-h')

```
1 change: 1 addition & 0 deletions tests/integration/_cases/build/build-help.trycmd
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Manage builds.
Usage: sentry-cli[EXE] build [OPTIONS] <COMMAND>

Commands:
download [EXPERIMENTAL] Download a build artifact.
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)
Expand Down
129 changes: 129 additions & 0 deletions tests/integration/build/download.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
use crate::integration::{AssertCommand, MockEndpointBuilder, TestManager};

#[test]
fn command_build_download_help() {
TestManager::new().register_trycmd_test("build/build-download-help.trycmd");
}

#[test]
fn command_build_download_not_installable() {
TestManager::new()
.mock_endpoint(
MockEndpointBuilder::new(
"GET",
"/api/0/organizations/wat-org/preprodartifacts/123/install-details/",
)
.with_response_body(r#"{"isInstallable": false, "installUrl": null}"#),
)
.assert_cmd(vec!["build", "download", "--build-id", "123"])
.with_default_token()
.run_and_assert(AssertCommand::Failure);
}

#[test]
fn command_build_download_apk() {
let manager = TestManager::new();
let server_url = manager.server_url();
let download_path = format!("{server_url}/download/build.apk?response_format=apk");
let install_details_response = serde_json::json!({
"isInstallable": true,
"installUrl": download_path,
})
.to_string();

let output = tempfile::NamedTempFile::new().expect("Failed to create temp file");
let output_path = output.path().to_str().unwrap().to_owned();

manager
.mock_endpoint(
MockEndpointBuilder::new(
"GET",
"/api/0/organizations/wat-org/preprodartifacts/456/install-details/",
)
.with_response_body(install_details_response),
)
.mock_endpoint(
MockEndpointBuilder::new("GET", "/download/build.apk?response_format=apk")
.with_response_body("fake apk content"),
)
.assert_cmd(vec![
"build",
"download",
"--build-id",
"456",
"--output",
&output_path,
])
.with_default_token()
.run_and_assert(AssertCommand::Success);

let content = std::fs::read_to_string(&output_path).expect("Failed to read downloaded file");
assert_eq!(content, "fake apk content");
}

#[test]
fn command_build_download_ipa_converts_plist_format() {
let manager = TestManager::new();
let server_url = manager.server_url();
// The install URL uses plist format, which should be converted to ipa
let install_url = format!("{server_url}/download/build.ipa?response_format=plist");
let install_details_response = serde_json::json!({
"isInstallable": true,
"installUrl": install_url,
})
.to_string();

let output = tempfile::NamedTempFile::new().expect("Failed to create temp file");
let output_path = output.path().to_str().unwrap().to_owned();

manager
.mock_endpoint(
MockEndpointBuilder::new(
"GET",
"/api/0/organizations/wat-org/preprodartifacts/789/install-details/",
)
.with_response_body(install_details_response),
)
// The mock should receive the converted URL with response_format=ipa
.mock_endpoint(
MockEndpointBuilder::new("GET", "/download/build.ipa?response_format=ipa")
.with_response_body("fake ipa content"),
)
.assert_cmd(vec![
"build",
"download",
"--build-id",
"789",
"--output",
&output_path,
])
.with_default_token()
.run_and_assert(AssertCommand::Success);

let content = std::fs::read_to_string(&output_path).expect("Failed to read downloaded file");
assert_eq!(content, "fake ipa content");
}

#[test]
fn command_build_download_unsupported_format() {
let manager = TestManager::new();
let server_url = manager.server_url();
let download_path = format!("{server_url}/download/build.zip?response_format=zip");
let install_details_response = serde_json::json!({
"isInstallable": true,
"installUrl": download_path,
})
.to_string();

manager
.mock_endpoint(
MockEndpointBuilder::new(
"GET",
"/api/0/organizations/wat-org/preprodartifacts/999/install-details/",
)
.with_response_body(install_details_response),
)
.assert_cmd(vec!["build", "download", "--build-id", "999"])
.with_default_token()
.run_and_assert(AssertCommand::Failure);
}
1 change: 1 addition & 0 deletions tests/integration/build/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::integration::TestManager;

mod download;
mod upload;

#[test]
Expand Down
Loading