Skip to content

Commit 5153fd0

Browse files
runningcodeclaude
andauthored
feat(build): Track plugin versions from SENTRY_PIPELINE in build uploads (EME-606) (#2994)
## Summary Automatically track Sentry plugin versions in build uploads by parsing the existing `SENTRY_PIPELINE` environment variable. This enables size analysis and build distribution tracking by storing plugin version metadata in the PreprodArtifact database table. ## Changes ### Plugin Version Detection - Reads the existing `SENTRY_PIPELINE` environment variable - Parses format: `sentry-gradle-plugin/4.12.0` or `sentry-fastlane-plugin/1.2.3` - Only extracts versions for recognized Sentry plugins: - `sentry-gradle-plugin` - `sentry-fastlane-plugin` ### Metadata File Format The `.sentry-cli-metadata.txt` file inside uploaded zips now includes detected plugin info: ``` sentry-cli-version: 2.58.2 sentry-gradle-plugin: 4.12.0 ``` Related PRs: getsentry/launchpad#464 getsentry/sentry#103062 getsentry/sentry-android-gradle-plugin#1036 getsentry/sentry-fastlane-plugin#365 --------- Co-authored-by: Claude <[email protected]>
1 parent 695d673 commit 5153fd0

File tree

3 files changed

+230
-20
lines changed

3 files changed

+230
-20
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
## 2.58.3
1010

1111
### Improvements
12+
1213
- 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.
1314
- We can detect most common CI environments based on the environment variables these set.
1415
- 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.
16+
- 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.
1517
- 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.
1618

1719
### Fixes

src/commands/build/upload.rs

Lines changed: 214 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,33 @@ pub fn make_command(command: Command) -> Command {
125125
)
126126
}
127127

128+
/// Parse plugin info from SENTRY_PIPELINE environment variable.
129+
/// Format: "sentry-gradle-plugin/4.12.0" or "sentry-fastlane-plugin/1.2.3"
130+
/// Returns (plugin_name, plugin_version) if a recognized plugin is found, (None, None) otherwise.
131+
fn parse_plugin_from_pipeline(pipeline: Option<String>) -> (Option<String>, Option<String>) {
132+
pipeline
133+
.and_then(|pipeline| {
134+
let parts: Vec<&str> = pipeline.splitn(2, '/').collect();
135+
if parts.len() == 2 {
136+
let name = parts[0];
137+
let version = parts[1];
138+
139+
// Only extract known Sentry plugins
140+
if name == "sentry-gradle-plugin" || name == "sentry-fastlane-plugin" {
141+
debug!("Detected {name} version {version} from SENTRY_PIPELINE");
142+
Some((name.to_owned(), version.to_owned()))
143+
} else {
144+
debug!("SENTRY_PIPELINE contains unrecognized plugin: {name}");
145+
None
146+
}
147+
} else {
148+
debug!("SENTRY_PIPELINE format not recognized: {pipeline}");
149+
None
150+
}
151+
})
152+
.unzip()
153+
}
154+
128155
pub fn execute(matches: &ArgMatches) -> Result<()> {
129156
let config = Config::current();
130157
let path_strings = matches
@@ -150,6 +177,8 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
150177
let build_configuration = matches.get_one("build_configuration").map(String::as_str);
151178
let release_notes = matches.get_one("release_notes").map(String::as_str);
152179

180+
let (plugin_name, plugin_version) = parse_plugin_from_pipeline(config.get_pipeline_env());
181+
153182
let api = Api::current();
154183
let authenticated_api = api.authenticated()?;
155184

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

172201
let normalized_zip = if path.is_file() {
173202
debug!("Normalizing file: {}", path.display());
174-
handle_file(path, &byteview)?
203+
handle_file(
204+
path,
205+
&byteview,
206+
plugin_name.as_deref(),
207+
plugin_version.as_deref(),
208+
)?
175209
} else if path.is_dir() {
176210
debug!("Normalizing directory: {}", path.display());
177-
handle_directory(path).with_context(|| {
178-
format!(
179-
"Failed to generate uploadable bundle for directory {}",
180-
path.display()
181-
)
182-
})?
211+
handle_directory(path, plugin_name.as_deref(), plugin_version.as_deref()).with_context(
212+
|| {
213+
format!(
214+
"Failed to generate uploadable bundle for directory {}",
215+
path.display()
216+
)
217+
},
218+
)?
183219
} else {
184220
Err(anyhow!(
185221
"Path {} is neither a file nor a directory, cannot upload",
@@ -434,18 +470,23 @@ fn collect_git_metadata(
434470
}
435471
}
436472

437-
fn handle_file(path: &Path, byteview: &ByteView) -> Result<TempFile> {
473+
fn handle_file(
474+
path: &Path,
475+
byteview: &ByteView,
476+
plugin_name: Option<&str>,
477+
plugin_version: Option<&str>,
478+
) -> Result<TempFile> {
438479
// Handle IPA files by converting them to XCArchive
439480
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
440481
if is_zip_file(byteview) && is_ipa_file(byteview)? {
441482
debug!("Converting IPA file to XCArchive structure");
442483
let archive_temp_dir = TempDir::create()?;
443484
return ipa_to_xcarchive(path, byteview, &archive_temp_dir)
444-
.and_then(|path| handle_directory(&path))
485+
.and_then(|path| handle_directory(&path, plugin_name, plugin_version))
445486
.with_context(|| format!("Failed to process IPA file {}", path.display()));
446487
}
447488

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

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

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

526-
write_version_metadata(&mut zip)?;
572+
write_version_metadata(&mut zip, plugin_name, plugin_version)?;
527573

528574
zip.finish()?;
529575
debug!("Successfully created normalized zip for file");
530576
Ok(temp_file)
531577
}
532578

533-
fn handle_directory(path: &Path) -> Result<TempFile> {
579+
fn handle_directory(
580+
path: &Path,
581+
plugin_name: Option<&str>,
582+
plugin_version: Option<&str>,
583+
) -> Result<TempFile> {
534584
let temp_dir = TempDir::create()?;
535585
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
536586
if is_apple_app(path)? {
537587
handle_asset_catalogs(path, temp_dir.path());
538588
}
539-
normalize_directory(path, temp_dir.path())
589+
normalize_directory(path, temp_dir.path(), plugin_name, plugin_version)
540590
}
541591

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

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

692742
// Process the XCArchive directory
693-
let result = handle_directory(xcarchive_path)?;
743+
let result = handle_directory(xcarchive_path, None, None)?;
694744

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

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

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

763-
let result_zip = normalize_directory(&test_dir, temp_dir.path())?;
813+
let result_zip = normalize_directory(&test_dir, temp_dir.path(), None, None)?;
764814
let zip_file = fs::File::open(result_zip.path())?;
765815
let mut archive = ZipArchive::new(zip_file)?;
766816

@@ -877,4 +927,150 @@ mod tests {
877927
"head_ref should be empty with auto_collect=false and no explicit value"
878928
);
879929
}
930+
931+
#[test]
932+
fn test_metadata_includes_gradle_plugin_version() -> Result<()> {
933+
let temp_dir = crate::utils::fs::TempDir::create()?;
934+
let test_dir = temp_dir.path().join("TestApp.xcarchive");
935+
fs::create_dir_all(test_dir.join("Products"))?;
936+
fs::write(test_dir.join("Products").join("app.txt"), "test content")?;
937+
938+
let result_zip = normalize_directory(
939+
&test_dir,
940+
temp_dir.path(),
941+
Some("sentry-gradle-plugin"),
942+
Some("4.12.0"),
943+
)?;
944+
let zip_file = fs::File::open(result_zip.path())?;
945+
let mut archive = ZipArchive::new(zip_file)?;
946+
947+
// Find and read the metadata file
948+
let metadata_file = archive.by_name(".sentry-cli-metadata.txt")?;
949+
let metadata_content = std::io::read_to_string(metadata_file)?;
950+
951+
assert!(
952+
metadata_content.contains("sentry-cli-version:"),
953+
"Metadata should contain sentry-cli-version"
954+
);
955+
assert!(
956+
metadata_content.contains("sentry-gradle-plugin: 4.12.0"),
957+
"Metadata should contain sentry-gradle-plugin"
958+
);
959+
Ok(())
960+
}
961+
962+
#[test]
963+
fn test_metadata_includes_fastlane_plugin_version() -> Result<()> {
964+
let temp_dir = crate::utils::fs::TempDir::create()?;
965+
let test_dir = temp_dir.path().join("TestApp.xcarchive");
966+
fs::create_dir_all(test_dir.join("Products"))?;
967+
fs::write(test_dir.join("Products").join("app.txt"), "test content")?;
968+
969+
let result_zip = normalize_directory(
970+
&test_dir,
971+
temp_dir.path(),
972+
Some("sentry-fastlane-plugin"),
973+
Some("1.2.3"),
974+
)?;
975+
let zip_file = fs::File::open(result_zip.path())?;
976+
let mut archive = ZipArchive::new(zip_file)?;
977+
978+
// Find and read the metadata file
979+
let metadata_file = archive.by_name(".sentry-cli-metadata.txt")?;
980+
let metadata_content = std::io::read_to_string(metadata_file)?;
981+
982+
assert!(
983+
metadata_content.contains("sentry-cli-version:"),
984+
"Metadata should contain sentry-cli-version"
985+
);
986+
assert!(
987+
metadata_content.contains("sentry-fastlane-plugin: 1.2.3"),
988+
"Metadata should contain sentry-fastlane-plugin"
989+
);
990+
Ok(())
991+
}
992+
993+
#[test]
994+
fn test_metadata_without_plugin() -> Result<()> {
995+
let temp_dir = crate::utils::fs::TempDir::create()?;
996+
let test_dir = temp_dir.path().join("TestApp.xcarchive");
997+
fs::create_dir_all(test_dir.join("Products"))?;
998+
fs::write(test_dir.join("Products").join("app.txt"), "test content")?;
999+
1000+
// No plugin info provided
1001+
let result_zip = normalize_directory(&test_dir, temp_dir.path(), None, None)?;
1002+
let zip_file = fs::File::open(result_zip.path())?;
1003+
let mut archive = ZipArchive::new(zip_file)?;
1004+
1005+
let metadata_file = archive.by_name(".sentry-cli-metadata.txt")?;
1006+
let metadata_content = std::io::read_to_string(metadata_file)?;
1007+
1008+
// Should only contain sentry-cli-version
1009+
assert!(
1010+
metadata_content.contains("sentry-cli-version:"),
1011+
"Metadata should contain sentry-cli-version"
1012+
);
1013+
1014+
// Should not have any other lines besides the version
1015+
let line_count = metadata_content.lines().count();
1016+
assert_eq!(
1017+
line_count, 1,
1018+
"Should only have one line when no plugin info"
1019+
);
1020+
Ok(())
1021+
}
1022+
1023+
#[test]
1024+
fn test_parse_gradle_plugin_from_pipeline() {
1025+
let (name, version) =
1026+
parse_plugin_from_pipeline(Some("sentry-gradle-plugin/4.12.0".to_owned()));
1027+
assert_eq!(name, Some("sentry-gradle-plugin".to_owned()));
1028+
assert_eq!(version, Some("4.12.0".to_owned()));
1029+
}
1030+
1031+
#[test]
1032+
fn test_parse_fastlane_plugin_from_pipeline() {
1033+
let (name, version) =
1034+
parse_plugin_from_pipeline(Some("sentry-fastlane-plugin/1.2.3".to_owned()));
1035+
assert_eq!(name, Some("sentry-fastlane-plugin".to_owned()));
1036+
assert_eq!(version, Some("1.2.3".to_owned()));
1037+
}
1038+
1039+
#[test]
1040+
fn test_parse_unrecognized_plugin_from_pipeline() {
1041+
let (name, version) =
1042+
parse_plugin_from_pipeline(Some("some-other-plugin/1.0.0".to_owned()));
1043+
assert_eq!(name, None, "Unrecognized plugin should return None");
1044+
assert_eq!(version, None, "Unrecognized plugin should return None");
1045+
}
1046+
1047+
#[test]
1048+
fn test_parse_invalid_pipeline_format() {
1049+
let (name, version) = parse_plugin_from_pipeline(Some("no-slash-in-value".to_owned()));
1050+
assert_eq!(name, None, "Invalid format should return None");
1051+
assert_eq!(version, None, "Invalid format should return None");
1052+
}
1053+
1054+
#[test]
1055+
fn test_parse_empty_pipeline() {
1056+
let (name, version) = parse_plugin_from_pipeline(None);
1057+
assert_eq!(name, None, "Empty pipeline should return None");
1058+
assert_eq!(version, None, "Empty pipeline should return None");
1059+
}
1060+
1061+
#[test]
1062+
fn test_parse_pipeline_with_extra_slashes() {
1063+
let (name, version) =
1064+
parse_plugin_from_pipeline(Some("sentry-gradle-plugin/4.12.0/extra".to_owned()));
1065+
assert_eq!(
1066+
name,
1067+
Some("sentry-gradle-plugin".to_owned()),
1068+
"Should parse correctly even with extra slashes"
1069+
);
1070+
assert_eq!(
1071+
version,
1072+
Some("4.12.0/extra".to_owned()),
1073+
"Version should include everything after first slash"
1074+
);
1075+
}
8801076
}

src/utils/build/normalize.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,29 @@ fn metadata_file_options() -> SimpleFileOptions {
9494

9595
pub fn write_version_metadata<W: std::io::Write + std::io::Seek>(
9696
zip: &mut ZipWriter<W>,
97+
plugin_name: Option<&str>,
98+
plugin_version: Option<&str>,
9799
) -> Result<()> {
98100
let version = get_version();
99101
zip.start_file(".sentry-cli-metadata.txt", metadata_file_options())?;
100102
writeln!(zip, "sentry-cli-version: {version}")?;
103+
104+
// Write plugin info if available
105+
if let (Some(name), Some(version)) = (plugin_name, plugin_version) {
106+
writeln!(zip, "{name}: {version}")?;
107+
}
101108
Ok(())
102109
}
103110

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

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

136-
write_version_metadata(&mut zip)?;
148+
write_version_metadata(&mut zip, plugin_name, plugin_version)?;
137149

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

0 commit comments

Comments
 (0)