diff --git a/CHANGELOG.md b/CHANGELOG.md index f35a617911..0d210a894f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/commands/build/upload.rs b/src/commands/build/upload.rs index ac2c770cb9..e4ee2589af 100644 --- a/src/commands/build/upload.rs +++ b/src/commands/build/upload.rs @@ -150,6 +150,31 @@ 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); + // Parse plugin info from SENTRY_PIPELINE environment variable + // Format: "sentry-gradle-plugin/4.12.0" or "sentry-fastlane-plugin/1.2.3" + let (plugin_name, plugin_version) = config + .get_pipeline_env() + .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(); + let api = Api::current(); let authenticated_api = api.authenticated()?; @@ -171,15 +196,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()); - 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", @@ -434,18 +466,23 @@ fn collect_git_metadata( } } -fn handle_file(path: &Path, byteview: &ByteView) -> Result { +fn handle_file( + path: &Path, + byteview: &ByteView, + plugin_name: Option<&str>, + plugin_version: Option<&str>, +) -> Result { // 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() @@ -499,7 +536,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 { +fn normalize_file( + path: &Path, + bytes: &[u8], + plugin_name: Option<&str>, + plugin_version: Option<&str>, +) -> Result { debug!("Creating normalized zip for file: {}", path.display()); let temp_file = TempFile::create()?; @@ -523,20 +565,24 @@ fn normalize_file(path: &Path, bytes: &[u8]) -> Result { 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 { +fn handle_directory( + path: &Path, + plugin_name: Option<&str>, + plugin_version: Option<&str>, +) -> Result { 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. @@ -674,7 +720,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)?; @@ -690,7 +736,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())?; @@ -723,7 +769,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)?; @@ -760,7 +806,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)?; @@ -877,4 +923,96 @@ 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!( + 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(()) + } } diff --git a/src/utils/build/normalize.rs b/src/utils/build/normalize.rs index 31e8b8531d..110fcdea16 100644 --- a/src/utils/build/normalize.rs +++ b/src/utils/build/normalize.rs @@ -94,17 +94,29 @@ fn metadata_file_options() -> SimpleFileOptions { pub fn write_version_metadata( zip: &mut ZipWriter, + 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 { +pub fn normalize_directory( + path: &Path, + parsed_assets_path: &Path, + plugin_name: Option<&str>, + plugin_version: Option<&str>, +) -> Result { debug!("Creating normalized zip for directory: {}", path.display()); let temp_file = TempFile::create()?; @@ -133,7 +145,7 @@ pub fn normalize_directory(path: &Path, parsed_assets_path: &Path) -> Result