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
94 changes: 81 additions & 13 deletions src/commands/build/upload.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::io::Write as _;
use std::path::Path;

Expand Down Expand Up @@ -123,6 +124,13 @@ pub fn make_command(command: Command) -> Command {
.conflicts_with("force_git_metadata")
.help("Disable collection and sending of git metadata.")
)
.arg(
Arg::new("metadata")
.long("metadata")
.value_name("KEY=VALUE")
.help("Custom metadata to include in the build upload (e.g., --metadata gradle-plugin=4.12.0). Can be specified multiple times.")
.action(ArgAction::Append)
)
}

pub fn execute(matches: &ArgMatches) -> Result<()> {
Expand Down Expand Up @@ -150,6 +158,24 @@ 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 metadata key-value pairs
let metadata: HashMap<String, String> = matches
.get_many::<String>("metadata")
.map(|values| {
values
.filter_map(|s| {
let parts: Vec<&str> = s.splitn(2, '=').collect();
if parts.len() == 2 {
Some((parts[0].to_owned(), parts[1].to_owned()))
} else {
warn!("Ignoring invalid metadata format: {s}. Expected format: KEY=VALUE");
None
}
})
.collect()
})
.unwrap_or_default();

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

Expand All @@ -171,10 +197,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, &metadata)?
} else if path.is_dir() {
debug!("Normalizing directory: {}", path.display());

This comment was marked as outdated.

handle_directory(path).with_context(|| {
handle_directory(path, &metadata).with_context(|| {
format!(
"Failed to generate uploadable bundle for directory {}",
path.display()
Expand Down Expand Up @@ -434,18 +460,22 @@ fn collect_git_metadata(
}
}

fn handle_file(path: &Path, byteview: &ByteView) -> Result<TempFile> {
fn handle_file(
path: &Path,
byteview: &ByteView,
metadata: &HashMap<String, String>,
) -> 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, metadata))
.with_context(|| format!("Failed to process IPA file {}", path.display()));
}

normalize_file(path, byteview).with_context(|| {
normalize_file(path, byteview, metadata).with_context(|| {
format!(
"Failed to generate uploadable bundle for file {}",
path.display()
Expand Down Expand Up @@ -499,7 +529,11 @@ 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],
metadata: &HashMap<String, String>,
) -> Result<TempFile> {
debug!("Creating normalized zip for file: {}", path.display());

let temp_file = TempFile::create()?;
Expand All @@ -523,20 +557,20 @@ 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, metadata)?;

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

fn handle_directory(path: &Path) -> Result<TempFile> {
fn handle_directory(path: &Path, metadata: &HashMap<String, String>) -> 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(), metadata)
}

/// Returns artifact url if upload was successful.
Expand Down Expand Up @@ -674,7 +708,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(), &HashMap::new())?;
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 +724,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, &HashMap::new())?;

// Verify the resulting zip contains parsed assets
let zip_file = fs::File::open(result.path())?;
Expand Down Expand Up @@ -723,7 +757,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, &HashMap::new())?;

let zip_file = fs::File::open(result.path())?;
let mut archive = ZipArchive::new(zip_file)?;
Expand Down Expand Up @@ -760,7 +794,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(), &HashMap::new())?;
let zip_file = fs::File::open(result_zip.path())?;
let mut archive = ZipArchive::new(zip_file)?;

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

#[test]
fn test_metadata_includes_plugin_versions() -> 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 mut metadata = HashMap::new();
metadata.insert("gradle-plugin".to_owned(), "4.12.0".to_owned());
metadata.insert("fastlane-plugin".to_owned(), "1.2.3".to_owned());

let result_zip = normalize_directory(&test_dir, temp_dir.path(), &metadata)?;
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("gradle-plugin: 4.12.0"),
"Metadata should contain gradle-plugin"
);
assert!(
metadata_content.contains("fastlane-plugin: 1.2.3"),
"Metadata should contain fastlane-plugin"
);
Ok(())
}
}
19 changes: 17 additions & 2 deletions src/utils/build/normalize.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::collections::HashMap;
#[cfg(not(windows))]
use std::fs;
use std::fs::File;
Expand Down Expand Up @@ -94,17 +95,31 @@ fn metadata_file_options() -> SimpleFileOptions {

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

// Write metadata in sorted order for deterministic output
let mut keys: Vec<_> = metadata.keys().collect();
keys.sort();
for key in keys {
if let Some(value) = metadata.get(key) {
writeln!(zip, "{key}: {value}")?;
}
}
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,
metadata: &HashMap<String, String>,
) -> Result<TempFile> {
debug!("Creating normalized zip for directory: {}", path.display());

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

write_version_metadata(&mut zip)?;
write_version_metadata(&mut zip, metadata)?;

zip.finish()?;
debug!("Successfully created normalized zip for directory with {file_count} files");
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/_cases/build/build-upload-help-macos.trycmd
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ Options:
most CI environments.
--no-git-metadata
Disable collection and sending of git metadata.
--metadata <KEY=VALUE>
Custom metadata to include in the build upload (e.g., --metadata gradle-plugin=4.12.0).
Can be specified multiple times.
-h, --help
Print help

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ Options:
most CI environments.
--no-git-metadata
Disable collection and sending of git metadata.
--metadata <KEY=VALUE>
Custom metadata to include in the build upload (e.g., --metadata gradle-plugin=4.12.0).
Can be specified multiple times.
-h, --help
Print help

Expand Down