Skip to content

Commit 72c6ab9

Browse files
authored
Merge branch 'master' into rz/feat/code-mappings-batching
2 parents e58b8e8 + ccf349e commit 72c6ab9

File tree

10 files changed

+317
-2
lines changed

10 files changed

+317
-2
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### New Features ✨
6+
7+
- Add `sentry-cli build download` command to download installable builds (IPA/APK) by build ID ([#3221](https://github.com/getsentry/sentry-cli/pull/3221)).
8+
39
## 3.3.3
410

511
### New Features ✨

src/api/data_types/chunking/build.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ pub struct AssembleBuildResponse {
2828
pub artifact_url: Option<String>,
2929
}
3030

31+
#[derive(Debug, Deserialize)]
32+
#[serde(rename_all = "camelCase")]
33+
pub struct BuildInstallDetails {
34+
pub is_installable: bool,
35+
pub install_url: Option<String>,
36+
}
37+
3138
/// VCS information for build app uploads
3239
#[derive(Debug, Serialize)]
3340
pub struct VcsInfo<'a> {

src/api/data_types/chunking/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ mod hash_algorithm;
1010
mod upload;
1111

1212
pub use self::artifact::{AssembleArtifactsResponse, ChunkedArtifactRequest};
13-
pub use self::build::{AssembleBuildResponse, ChunkedBuildRequest, VcsInfo};
13+
pub use self::build::{AssembleBuildResponse, BuildInstallDetails, ChunkedBuildRequest, VcsInfo};
1414
pub use self::compression::ChunkCompression;
1515
pub use self::dif::{AssembleDifsRequest, AssembleDifsResponse, ChunkedDifRequest};
1616
pub use self::file_state::ChunkedFileState;

src/api/mod.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ use std::borrow::Cow;
1616
use std::cell::RefCell;
1717
use std::collections::HashMap;
1818
use std::error::Error as _;
19-
#[cfg(any(target_os = "macos", not(feature = "managed")))]
2019
use std::fs::File;
2120
use std::io::{self, Read as _, Write};
2221
use std::rc::Rc;
@@ -724,6 +723,26 @@ impl AuthenticatedApi<'_> {
724723
.convert_rnf(ApiErrorKind::ProjectNotFound)
725724
}
726725

726+
pub fn get_build_install_details(
727+
&self,
728+
org: &str,
729+
build_id: &str,
730+
) -> ApiResult<BuildInstallDetails> {
731+
let url = format!(
732+
"/organizations/{}/preprodartifacts/{}/install-details/",
733+
PathArg(org),
734+
PathArg(build_id)
735+
);
736+
737+
self.get(&url)?.convert()
738+
}
739+
740+
pub fn download_installable_build(&self, url: &str, dst: &mut File) -> ApiResult<ApiResponse> {
741+
self.request(Method::Get, url)?
742+
.progress_bar_mode(ProgressBarMode::Response)
743+
.send_into(dst)
744+
}
745+
727746
/// List all organizations associated with the authenticated token
728747
/// in the given `Region`. If no `Region` is provided, we assume
729748
/// we're issuing a request to a monolith deployment.

src/commands/build/download.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use std::fs;
2+
use std::path::PathBuf;
3+
4+
use anyhow::{bail, Result};
5+
use clap::{Arg, ArgMatches, Command};
6+
use log::info;
7+
8+
use crate::api::Api;
9+
use crate::config::Config;
10+
use crate::utils::args::ArgExt as _;
11+
use crate::utils::fs::TempFile;
12+
13+
const EXPERIMENTAL_WARNING: &str =
14+
"[EXPERIMENTAL] The \"build download\" command is experimental. \
15+
The command is subject to breaking changes, including removal, in any Sentry CLI release.";
16+
17+
pub fn make_command(command: Command) -> Command {
18+
command
19+
.about("[EXPERIMENTAL] Download a build artifact.")
20+
.long_about(format!(
21+
"Download a build artifact.\n\n{EXPERIMENTAL_WARNING}"
22+
))
23+
.org_arg()
24+
.arg(
25+
Arg::new("build_id")
26+
.long("build-id")
27+
.short('b')
28+
.required(true)
29+
.help("The ID of the build to download."),
30+
)
31+
.arg(Arg::new("output").long("output").help(
32+
"The output file path. Defaults to \
33+
'preprod_artifact_<build_id>.<ext>' in the current directory, \
34+
where ext is ipa or apk depending on the platform.",
35+
))
36+
}
37+
38+
/// For iOS builds, the install URL points to a plist manifest.
39+
/// Replace the response_format to download the actual IPA binary instead.
40+
fn ensure_binary_format(url: &str) -> String {
41+
url.replace("response_format=plist", "response_format=ipa")
42+
}
43+
44+
/// Extract the file extension from the response_format query parameter.
45+
fn extension_from_url(url: &str) -> Result<&str> {
46+
if url.contains("response_format=ipa") {
47+
Ok("ipa")
48+
} else if url.contains("response_format=apk") {
49+
Ok("apk")
50+
} else {
51+
bail!("Unsupported build format in download URL.")
52+
}
53+
}
54+
55+
pub fn execute(matches: &ArgMatches) -> Result<()> {
56+
eprintln!("{EXPERIMENTAL_WARNING}");
57+
let config = Config::current();
58+
let org = config.get_org(matches)?;
59+
let build_id = matches
60+
.get_one::<String>("build_id")
61+
.expect("build_id is required");
62+
63+
let api = Api::current();
64+
let authenticated_api = api.authenticated()?;
65+
66+
info!("Fetching install details for build {build_id}");
67+
let details = authenticated_api.get_build_install_details(&org, build_id)?;
68+
69+
if !details.is_installable {
70+
bail!("Build {build_id} is not installable.");
71+
}
72+
73+
let install_url = details
74+
.install_url
75+
.ok_or_else(|| anyhow::anyhow!("Build {build_id} has no install URL."))?;
76+
77+
let download_url = ensure_binary_format(&install_url);
78+
79+
let output_path = match matches.get_one::<String>("output") {
80+
Some(path) => PathBuf::from(path),
81+
None => {
82+
let ext = extension_from_url(&download_url)?;
83+
PathBuf::from(format!("preprod_artifact_{build_id}.{ext}"))
84+
}
85+
};
86+
87+
info!("Downloading build {build_id} to {}", output_path.display());
88+
89+
let tmp = TempFile::create()?;
90+
let mut file = tmp.open()?;
91+
let response = authenticated_api.download_installable_build(&download_url, &mut file)?;
92+
93+
if response.failed() {
94+
bail!(
95+
"Failed to download build (server returned status {}).",
96+
response.status()
97+
);
98+
}
99+
100+
drop(file);
101+
fs::copy(tmp.path(), &output_path)?;
102+
103+
println!("Successfully downloaded build to {}", output_path.display());
104+
105+
Ok(())
106+
}

src/commands/build/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ use clap::{ArgMatches, Command};
33

44
use crate::utils::args::ArgExt as _;
55

6+
pub mod download;
67
pub mod snapshots;
78
pub mod upload;
89

910
macro_rules! each_subcommand {
1011
($mac:ident) => {
12+
$mac!(download);
1113
$mac!(snapshots);
1214
$mac!(upload);
1315
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
```
2+
$ sentry-cli build download --help
3+
? success
4+
Download a build artifact.
5+
6+
[EXPERIMENTAL] The "build download" command is experimental. The command is subject to breaking
7+
changes, including removal, in any Sentry CLI release.
8+
9+
Usage: sentry-cli[EXE] build download [OPTIONS] --build-id <build_id>
10+
11+
Options:
12+
-o, --org <ORG>
13+
The organization ID or slug.
14+
15+
-b, --build-id <build_id>
16+
The ID of the build to download.
17+
18+
--header <KEY:VALUE>
19+
Custom headers that should be attached to all requests
20+
in key:value format.
21+
22+
-p, --project <PROJECT>
23+
The project ID or slug.
24+
25+
--auth-token <AUTH_TOKEN>
26+
Use the given Sentry auth token.
27+
28+
--output <output>
29+
The output file path. Defaults to 'preprod_artifact_<build_id>.<ext>' in the current
30+
directory, where ext is ipa or apk depending on the platform.
31+
32+
--log-level <LOG_LEVEL>
33+
Set the log output verbosity. [possible values: trace, debug, info, warn, error]
34+
35+
--quiet
36+
Do not print any output while preserving correct exit code. This flag is currently
37+
implemented only for selected subcommands.
38+
39+
[aliases: --silent]
40+
41+
-h, --help
42+
Print help (see a summary with '-h')
43+
44+
```

tests/integration/_cases/build/build-help.trycmd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Manage builds.
66
Usage: sentry-cli[EXE] build [OPTIONS] <COMMAND>
77

88
Commands:
9+
download [EXPERIMENTAL] Download a build artifact.
910
snapshots [EXPERIMENTAL] Upload build snapshots to a project.
1011
upload Upload builds to a project.
1112
help Print this message or the help of the given subcommand(s)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
use crate::integration::{AssertCommand, MockEndpointBuilder, TestManager};
2+
3+
#[test]
4+
fn command_build_download_help() {
5+
TestManager::new().register_trycmd_test("build/build-download-help.trycmd");
6+
}
7+
8+
#[test]
9+
fn command_build_download_not_installable() {
10+
TestManager::new()
11+
.mock_endpoint(
12+
MockEndpointBuilder::new(
13+
"GET",
14+
"/api/0/organizations/wat-org/preprodartifacts/123/install-details/",
15+
)
16+
.with_response_body(r#"{"isInstallable": false, "installUrl": null}"#),
17+
)
18+
.assert_cmd(vec!["build", "download", "--build-id", "123"])
19+
.with_default_token()
20+
.run_and_assert(AssertCommand::Failure);
21+
}
22+
23+
#[test]
24+
fn command_build_download_apk() {
25+
let manager = TestManager::new();
26+
let server_url = manager.server_url();
27+
let download_path = format!("{server_url}/download/build.apk?response_format=apk");
28+
let install_details_response = serde_json::json!({
29+
"isInstallable": true,
30+
"installUrl": download_path,
31+
})
32+
.to_string();
33+
34+
let output = tempfile::NamedTempFile::new().expect("Failed to create temp file");
35+
let output_path = output.path().to_str().unwrap().to_owned();
36+
37+
manager
38+
.mock_endpoint(
39+
MockEndpointBuilder::new(
40+
"GET",
41+
"/api/0/organizations/wat-org/preprodartifacts/456/install-details/",
42+
)
43+
.with_response_body(install_details_response),
44+
)
45+
.mock_endpoint(
46+
MockEndpointBuilder::new("GET", "/download/build.apk?response_format=apk")
47+
.with_response_body("fake apk content"),
48+
)
49+
.assert_cmd(vec![
50+
"build",
51+
"download",
52+
"--build-id",
53+
"456",
54+
"--output",
55+
&output_path,
56+
])
57+
.with_default_token()
58+
.run_and_assert(AssertCommand::Success);
59+
60+
let content = std::fs::read_to_string(&output_path).expect("Failed to read downloaded file");
61+
assert_eq!(content, "fake apk content");
62+
}
63+
64+
#[test]
65+
fn command_build_download_ipa_converts_plist_format() {
66+
let manager = TestManager::new();
67+
let server_url = manager.server_url();
68+
// The install URL uses plist format, which should be converted to ipa
69+
let install_url = format!("{server_url}/download/build.ipa?response_format=plist");
70+
let install_details_response = serde_json::json!({
71+
"isInstallable": true,
72+
"installUrl": install_url,
73+
})
74+
.to_string();
75+
76+
let output = tempfile::NamedTempFile::new().expect("Failed to create temp file");
77+
let output_path = output.path().to_str().unwrap().to_owned();
78+
79+
manager
80+
.mock_endpoint(
81+
MockEndpointBuilder::new(
82+
"GET",
83+
"/api/0/organizations/wat-org/preprodartifacts/789/install-details/",
84+
)
85+
.with_response_body(install_details_response),
86+
)
87+
// The mock should receive the converted URL with response_format=ipa
88+
.mock_endpoint(
89+
MockEndpointBuilder::new("GET", "/download/build.ipa?response_format=ipa")
90+
.with_response_body("fake ipa content"),
91+
)
92+
.assert_cmd(vec![
93+
"build",
94+
"download",
95+
"--build-id",
96+
"789",
97+
"--output",
98+
&output_path,
99+
])
100+
.with_default_token()
101+
.run_and_assert(AssertCommand::Success);
102+
103+
let content = std::fs::read_to_string(&output_path).expect("Failed to read downloaded file");
104+
assert_eq!(content, "fake ipa content");
105+
}
106+
107+
#[test]
108+
fn command_build_download_unsupported_format() {
109+
let manager = TestManager::new();
110+
let server_url = manager.server_url();
111+
let download_path = format!("{server_url}/download/build.zip?response_format=zip");
112+
let install_details_response = serde_json::json!({
113+
"isInstallable": true,
114+
"installUrl": download_path,
115+
})
116+
.to_string();
117+
118+
manager
119+
.mock_endpoint(
120+
MockEndpointBuilder::new(
121+
"GET",
122+
"/api/0/organizations/wat-org/preprodartifacts/999/install-details/",
123+
)
124+
.with_response_body(install_details_response),
125+
)
126+
.assert_cmd(vec!["build", "download", "--build-id", "999"])
127+
.with_default_token()
128+
.run_and_assert(AssertCommand::Failure);
129+
}

tests/integration/build/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::integration::TestManager;
22

3+
mod download;
34
mod upload;
45

56
#[test]

0 commit comments

Comments
 (0)