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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
## Unreleased

### Improvements

- For the `sentry-cli build upload` command, we now only auto-detect Git metadata when we detect we are running in a CI environment, unless the user manually overrides this behavior ([#2974](https://github.com/getsentry/sentry-cli/pull/2974)). This change prevents local development builds from triggiering GitHub status checks for size analysis.
- We can detect most common CI environments based on the environment variables these set.
- We introduced two new arguments, `--force-git-metadata` and `--no-git-metadata`, which force-enable and force-disable automatic Git data collection, respectively, overriding the default behavior.
- The `sentry-cli build upload` command now automatically tracks Sentry plugin versions from the `SENTRY_PIPELINE` environment variable ([#2994](https://github.com/getsentry/sentry-cli/pull/2994)). When `SENTRY_PIPELINE` contains a recognized Sentry plugin (e.g., `sentry-gradle-plugin/4.12.0` or `sentry-fastlane-plugin/1.2.3`), the plugin version is written to the `.sentry-cli-metadata.txt` file in uploaded build archives, enabling the backend to store metadata for size analysis and build distribution tracking.
- The `sentry-cli build upload` command now automatically detects the correct branch or tag reference in non-PR GitHub Actions workflows ([#2976](https://github.com/getsentry/sentry-cli/pull/2976)). Previously, `--head-ref` was only auto-detected for pull request workflows. Now it works for push, release, and other workflow types by using the `GITHUB_REF_NAME` environment variable.

## 2.58.2
Expand Down
232 changes: 214 additions & 18 deletions src/commands/build/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,33 @@ pub fn make_command(command: Command) -> Command {
)
}

/// Parse plugin info from SENTRY_PIPELINE environment variable.
/// Format: "sentry-gradle-plugin/4.12.0" or "sentry-fastlane-plugin/1.2.3"
/// Returns (plugin_name, plugin_version) if a recognized plugin is found, (None, None) otherwise.
fn parse_plugin_from_pipeline(pipeline: Option<String>) -> (Option<String>, Option<String>) {
pipeline
.and_then(|pipeline| {
let parts: Vec<&str> = pipeline.splitn(2, '/').collect();
if parts.len() == 2 {
let name = parts[0];
let version = parts[1];

// Only extract known Sentry plugins
if name == "sentry-gradle-plugin" || name == "sentry-fastlane-plugin" {
debug!("Detected {name} version {version} from SENTRY_PIPELINE");
Some((name.to_owned(), version.to_owned()))
} else {
debug!("SENTRY_PIPELINE contains unrecognized plugin: {name}");
None
}
} else {
debug!("SENTRY_PIPELINE format not recognized: {pipeline}");
None
}
})
.unzip()
}

pub fn execute(matches: &ArgMatches) -> Result<()> {
let config = Config::current();
let path_strings = matches
Expand All @@ -150,6 +177,8 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
let build_configuration = matches.get_one("build_configuration").map(String::as_str);
let release_notes = matches.get_one("release_notes").map(String::as_str);

let (plugin_name, plugin_version) = parse_plugin_from_pipeline(config.get_pipeline_env());

let api = Api::current();
let authenticated_api = api.authenticated()?;

Expand All @@ -171,15 +200,22 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {

let normalized_zip = if path.is_file() {
debug!("Normalizing file: {}", path.display());
handle_file(path, &byteview)?
handle_file(
path,
&byteview,
plugin_name.as_deref(),
plugin_version.as_deref(),
)?
} else if path.is_dir() {
debug!("Normalizing directory: {}", path.display());

This comment was marked as outdated.

handle_directory(path).with_context(|| {
format!(
"Failed to generate uploadable bundle for directory {}",
path.display()
)
})?
handle_directory(path, plugin_name.as_deref(), plugin_version.as_deref()).with_context(
|| {
format!(
"Failed to generate uploadable bundle for directory {}",
path.display()
)
},
)?
} else {
Err(anyhow!(
"Path {} is neither a file nor a directory, cannot upload",
Expand Down Expand Up @@ -434,18 +470,23 @@ fn collect_git_metadata(
}
}

fn handle_file(path: &Path, byteview: &ByteView) -> Result<TempFile> {
fn handle_file(
path: &Path,
byteview: &ByteView,
plugin_name: Option<&str>,
plugin_version: Option<&str>,
) -> Result<TempFile> {
// Handle IPA files by converting them to XCArchive
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
if is_zip_file(byteview) && is_ipa_file(byteview)? {
debug!("Converting IPA file to XCArchive structure");
let archive_temp_dir = TempDir::create()?;
return ipa_to_xcarchive(path, byteview, &archive_temp_dir)
.and_then(|path| handle_directory(&path))
.and_then(|path| handle_directory(&path, plugin_name, plugin_version))
.with_context(|| format!("Failed to process IPA file {}", path.display()));
}

normalize_file(path, byteview).with_context(|| {
normalize_file(path, byteview, plugin_name, plugin_version).with_context(|| {
format!(
"Failed to generate uploadable bundle for file {}",
path.display()
Expand Down Expand Up @@ -499,7 +540,12 @@ fn validate_is_supported_build(path: &Path, bytes: &[u8]) -> Result<()> {
}

// For APK and AAB files, we'll copy them directly into the zip
fn normalize_file(path: &Path, bytes: &[u8]) -> Result<TempFile> {
fn normalize_file(
path: &Path,
bytes: &[u8],
plugin_name: Option<&str>,
plugin_version: Option<&str>,
) -> Result<TempFile> {
debug!("Creating normalized zip for file: {}", path.display());

let temp_file = TempFile::create()?;
Expand All @@ -523,20 +569,24 @@ fn normalize_file(path: &Path, bytes: &[u8]) -> Result<TempFile> {
zip.start_file(file_name, options)?;
zip.write_all(bytes)?;

write_version_metadata(&mut zip)?;
write_version_metadata(&mut zip, plugin_name, plugin_version)?;

zip.finish()?;
debug!("Successfully created normalized zip for file");
Ok(temp_file)
}

fn handle_directory(path: &Path) -> Result<TempFile> {
fn handle_directory(
path: &Path,
plugin_name: Option<&str>,
plugin_version: Option<&str>,
) -> Result<TempFile> {
let temp_dir = TempDir::create()?;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
if is_apple_app(path)? {
handle_asset_catalogs(path, temp_dir.path());
}
normalize_directory(path, temp_dir.path())
normalize_directory(path, temp_dir.path(), plugin_name, plugin_version)
}

/// Returns artifact url if upload was successful.
Expand Down Expand Up @@ -674,7 +724,7 @@ mod tests {
fs::create_dir_all(test_dir.join("Products"))?;
fs::write(test_dir.join("Products").join("app.txt"), "test content")?;

let result_zip = normalize_directory(&test_dir, temp_dir.path())?;
let result_zip = normalize_directory(&test_dir, temp_dir.path(), None, None)?;
let zip_file = fs::File::open(result_zip.path())?;
let mut archive = ZipArchive::new(zip_file)?;
let file = archive.by_index(0)?;
Expand All @@ -690,7 +740,7 @@ mod tests {
let xcarchive_path = Path::new("tests/integration/_fixtures/build/archive.xcarchive");

// Process the XCArchive directory
let result = handle_directory(xcarchive_path)?;
let result = handle_directory(xcarchive_path, None, None)?;

// Verify the resulting zip contains parsed assets
let zip_file = fs::File::open(result.path())?;
Expand Down Expand Up @@ -723,7 +773,7 @@ mod tests {
let byteview = ByteView::open(ipa_path)?;

// Process the IPA file - this should work even without asset catalogs
let result = handle_file(ipa_path, &byteview)?;
let result = handle_file(ipa_path, &byteview, None, None)?;

let zip_file = fs::File::open(result.path())?;
let mut archive = ZipArchive::new(zip_file)?;
Expand Down Expand Up @@ -760,7 +810,7 @@ mod tests {
let symlink_path = test_dir.join("Products").join("app_link.txt");
symlink("app.txt", &symlink_path)?;

let result_zip = normalize_directory(&test_dir, temp_dir.path())?;
let result_zip = normalize_directory(&test_dir, temp_dir.path(), None, None)?;
let zip_file = fs::File::open(result_zip.path())?;
let mut archive = ZipArchive::new(zip_file)?;

Expand Down Expand Up @@ -877,4 +927,150 @@ mod tests {
"head_ref should be empty with auto_collect=false and no explicit value"
);
}

#[test]
fn test_metadata_includes_gradle_plugin_version() -> Result<()> {
let temp_dir = crate::utils::fs::TempDir::create()?;
let test_dir = temp_dir.path().join("TestApp.xcarchive");
fs::create_dir_all(test_dir.join("Products"))?;
fs::write(test_dir.join("Products").join("app.txt"), "test content")?;

let result_zip = normalize_directory(
&test_dir,
temp_dir.path(),
Some("sentry-gradle-plugin"),
Some("4.12.0"),
)?;
let zip_file = fs::File::open(result_zip.path())?;
let mut archive = ZipArchive::new(zip_file)?;

// Find and read the metadata file
let metadata_file = archive.by_name(".sentry-cli-metadata.txt")?;
let metadata_content = std::io::read_to_string(metadata_file)?;

assert!(
metadata_content.contains("sentry-cli-version:"),
"Metadata should contain sentry-cli-version"
);
assert!(
metadata_content.contains("sentry-gradle-plugin: 4.12.0"),
"Metadata should contain sentry-gradle-plugin"
);
Ok(())
}

#[test]
fn test_metadata_includes_fastlane_plugin_version() -> Result<()> {
let temp_dir = crate::utils::fs::TempDir::create()?;
let test_dir = temp_dir.path().join("TestApp.xcarchive");
fs::create_dir_all(test_dir.join("Products"))?;
fs::write(test_dir.join("Products").join("app.txt"), "test content")?;

let result_zip = normalize_directory(
&test_dir,
temp_dir.path(),
Some("sentry-fastlane-plugin"),
Some("1.2.3"),
)?;
let zip_file = fs::File::open(result_zip.path())?;
let mut archive = ZipArchive::new(zip_file)?;

// Find and read the metadata file
let metadata_file = archive.by_name(".sentry-cli-metadata.txt")?;
let metadata_content = std::io::read_to_string(metadata_file)?;

assert!(
metadata_content.contains("sentry-cli-version:"),
"Metadata should contain sentry-cli-version"
);
assert!(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

technically it is impossible to have both. i could also add an assertion that both are not set but I didn't think it was worth it.

metadata_content.contains("sentry-fastlane-plugin: 1.2.3"),
"Metadata should contain sentry-fastlane-plugin"
);
Ok(())
}

#[test]
fn test_metadata_without_plugin() -> Result<()> {
let temp_dir = crate::utils::fs::TempDir::create()?;
let test_dir = temp_dir.path().join("TestApp.xcarchive");
fs::create_dir_all(test_dir.join("Products"))?;
fs::write(test_dir.join("Products").join("app.txt"), "test content")?;

// No plugin info provided
let result_zip = normalize_directory(&test_dir, temp_dir.path(), None, None)?;
let zip_file = fs::File::open(result_zip.path())?;
let mut archive = ZipArchive::new(zip_file)?;

let metadata_file = archive.by_name(".sentry-cli-metadata.txt")?;
let metadata_content = std::io::read_to_string(metadata_file)?;

// Should only contain sentry-cli-version
assert!(
metadata_content.contains("sentry-cli-version:"),
"Metadata should contain sentry-cli-version"
);

// Should not have any other lines besides the version
let line_count = metadata_content.lines().count();
assert_eq!(
line_count, 1,
"Should only have one line when no plugin info"
);
Ok(())
}

#[test]
fn test_parse_gradle_plugin_from_pipeline() {
let (name, version) =
parse_plugin_from_pipeline(Some("sentry-gradle-plugin/4.12.0".to_owned()));
assert_eq!(name, Some("sentry-gradle-plugin".to_owned()));
assert_eq!(version, Some("4.12.0".to_owned()));
}

#[test]
fn test_parse_fastlane_plugin_from_pipeline() {
let (name, version) =
parse_plugin_from_pipeline(Some("sentry-fastlane-plugin/1.2.3".to_owned()));
assert_eq!(name, Some("sentry-fastlane-plugin".to_owned()));
assert_eq!(version, Some("1.2.3".to_owned()));
}

#[test]
fn test_parse_unrecognized_plugin_from_pipeline() {
let (name, version) =
parse_plugin_from_pipeline(Some("some-other-plugin/1.0.0".to_owned()));
assert_eq!(name, None, "Unrecognized plugin should return None");
assert_eq!(version, None, "Unrecognized plugin should return None");
}

#[test]
fn test_parse_invalid_pipeline_format() {
let (name, version) = parse_plugin_from_pipeline(Some("no-slash-in-value".to_owned()));
assert_eq!(name, None, "Invalid format should return None");
assert_eq!(version, None, "Invalid format should return None");
}

#[test]
fn test_parse_empty_pipeline() {
let (name, version) = parse_plugin_from_pipeline(None);
assert_eq!(name, None, "Empty pipeline should return None");
assert_eq!(version, None, "Empty pipeline should return None");
}

#[test]
fn test_parse_pipeline_with_extra_slashes() {
let (name, version) =
parse_plugin_from_pipeline(Some("sentry-gradle-plugin/4.12.0/extra".to_owned()));
assert_eq!(
name,
Some("sentry-gradle-plugin".to_owned()),
"Should parse correctly even with extra slashes"
);
assert_eq!(
version,
Some("4.12.0/extra".to_owned()),
"Version should include everything after first slash"
);
}
}
16 changes: 14 additions & 2 deletions src/utils/build/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,29 @@ fn metadata_file_options() -> SimpleFileOptions {

pub fn write_version_metadata<W: std::io::Write + std::io::Seek>(
zip: &mut ZipWriter<W>,
plugin_name: Option<&str>,
plugin_version: Option<&str>,
) -> Result<()> {
let version = get_version();
zip.start_file(".sentry-cli-metadata.txt", metadata_file_options())?;
writeln!(zip, "sentry-cli-version: {version}")?;

// Write plugin info if available
if let (Some(name), Some(version)) = (plugin_name, plugin_version) {
writeln!(zip, "{name}: {version}")?;
}
Ok(())
}

// For XCArchive directories, we'll zip the entire directory
// It's important to not change the contents of the directory or the size
// analysis will be wrong and the code signature will break.
pub fn normalize_directory(path: &Path, parsed_assets_path: &Path) -> Result<TempFile> {
pub fn normalize_directory(
path: &Path,
parsed_assets_path: &Path,
plugin_name: Option<&str>,
plugin_version: Option<&str>,
) -> Result<TempFile> {
debug!("Creating normalized zip for directory: {}", path.display());

let temp_file = TempFile::create()?;
Expand Down Expand Up @@ -133,7 +145,7 @@ pub fn normalize_directory(path: &Path, parsed_assets_path: &Path) -> Result<Tem
)?;
}

write_version_metadata(&mut zip)?;
write_version_metadata(&mut zip, plugin_name, plugin_version)?;

zip.finish()?;
debug!("Successfully created normalized zip for directory with {file_count} files");
Expand Down