Skip to content
Open
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 accepts a `--metadata` parameter to include custom metadata in build uploads ([#2994](https://github.com/getsentry/sentry-cli/pull/2994)). Metadata can be specified as key-value pairs (e.g., `--metadata gradle-plugin=4.12.0 --metadata fastlane-plugin=1.2.3`) and 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
154 changes: 141 additions & 13 deletions src/commands/build/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Copy link
Contributor Author

@runningcode runningcode Dec 10, 2025

Choose a reason for hiding this comment

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

This will be printed out as debug for javascript when they use the SENTRY_PIPELINE variable. I think this message is helpful for debugging but might be confusing if we print it for javascript devs.

Alternatively, we can also detect the javascript tool and not print this in that case. WDYT?

None
}
} else {
debug!("SENTRY_PIPELINE format not recognized: {pipeline}");
None
}
})
.unzip();

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

Expand All @@ -171,10 +196,10 @@ 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(|| {
handle_directory(path, plugin_name.as_deref(), plugin_version.as_deref()).with_context(|| {
format!(
"Failed to generate uploadable bundle for directory {}",
path.display()
Expand Down Expand Up @@ -434,18 +459,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 +529,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 +558,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 +713,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 +729,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 +762,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 +799,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 +916,93 @@ 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(())
}
}
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
Loading