diff --git a/src/launchpad/api/update_api_models.py b/src/launchpad/api/update_api_models.py index a564c90b..0a4b8ae6 100644 --- a/src/launchpad/api/update_api_models.py +++ b/src/launchpad/api/update_api_models.py @@ -37,7 +37,11 @@ class PutSizePending(BaseModel): ] -class AppleAppInfo(BaseModel): +class BaseAppInfo(BaseModel): + cli_version: Optional[str] = Field(None, description="sentry-cli version used for uploading") + + +class AppleAppInfo(BaseAppInfo): is_simulator: bool codesigning_type: Optional[str] = None profile_name: Optional[str] = None @@ -48,10 +52,12 @@ class AppleAppInfo(BaseModel): certificate_expiration_date: Optional[str] = None missing_dsym_binaries: Optional[List[str]] = None build_date: Optional[str] = None + fastlane_plugin_version: Optional[str] = Field(None, description="Fastlane plugin version used for uploading") -class AndroidAppInfo(BaseModel): +class AndroidAppInfo(BaseAppInfo): has_proguard_mapping: bool + gradle_plugin_version: Optional[str] = Field(None, description="Gradle plugin version used for uploading") class UpdateData(BaseModel): diff --git a/src/launchpad/artifact_processor.py b/src/launchpad/artifact_processor.py index 716b3c7a..4f0a6f1e 100644 --- a/src/launchpad/artifact_processor.py +++ b/src/launchpad/artifact_processor.py @@ -442,12 +442,16 @@ def _get_artifact_type(artifact: Artifact) -> ArtifactType: certificate_expiration_date=app_info.certificate_expiration_date, missing_dsym_binaries=app_info.missing_dsym_binaries, build_date=app_info.build_date, + cli_version=app_info.cli_version, + fastlane_plugin_version=app_info.fastlane_plugin_version, ) android_app_info = None if isinstance(app_info, AndroidAppInfo): android_app_info = AndroidAppInfoModel( has_proguard_mapping=app_info.has_proguard_mapping, + cli_version=app_info.cli_version, + gradle_plugin_version=app_info.gradle_plugin_version, ) update_data = UpdateData( diff --git a/src/launchpad/size/analyzers/android.py b/src/launchpad/size/analyzers/android.py index a0bdd2fa..cff79e73 100644 --- a/src/launchpad/size/analyzers/android.py +++ b/src/launchpad/size/analyzers/android.py @@ -27,6 +27,7 @@ from launchpad.size.utils.android_bundle_size import calculate_apk_download_size, calculate_apk_install_size from launchpad.utils.file_utils import calculate_file_hash from launchpad.utils.logging import get_logger +from launchpad.utils.metadata_extractor import extract_metadata_from_zip logger = get_logger(__name__) @@ -50,12 +51,16 @@ def preprocess(self, artifact: AndroidArtifact) -> AndroidAppInfo: manifest_dict = artifact.get_manifest().model_dump() has_proguard_mapping = artifact.get_dex_mapping() is not None + metadata = extract_metadata_from_zip(artifact.path) + self.app_info = AndroidAppInfo( name=manifest_dict["application"]["label"] or "Unknown", version=manifest_dict["version_name"] or "Unknown", build=manifest_dict["version_code"] or "Unknown", app_id=manifest_dict["package_name"], has_proguard_mapping=has_proguard_mapping, + cli_version=metadata.cli_version, + gradle_plugin_version=metadata.gradle_plugin_version, ) return self.app_info diff --git a/src/launchpad/size/analyzers/apple.py b/src/launchpad/size/analyzers/apple.py index ac18f765..93846f14 100644 --- a/src/launchpad/size/analyzers/apple.py +++ b/src/launchpad/size/analyzers/apple.py @@ -44,6 +44,7 @@ from launchpad.utils.apple.code_signature_validator import CodeSignatureValidator from launchpad.utils.file_utils import get_file_size, to_nearest_block_size from launchpad.utils.logging import get_logger +from launchpad.utils.metadata_extractor import extract_metadata_from_zip from ..models.apple import ( AppleAnalysisResults, @@ -333,6 +334,8 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo: binaries = xcarchive.get_all_binary_paths() missing_dsym_binaries = [b.name for b in binaries if b.dsym_path is None] + metadata = extract_metadata_from_zip(xcarchive.path) + return AppleAppInfo( name=app_name, app_id=plist.get("CFBundleIdentifier", "unknown.bundle.id"), @@ -354,6 +357,8 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo: primary_icon_name=primary_icon_name, alternate_icon_names=alternate_icon_names, missing_dsym_binaries=missing_dsym_binaries, + cli_version=metadata.cli_version, + fastlane_plugin_version=metadata.fastlane_plugin_version, ) def _get_profile_type(self, profile_data: dict[str, Any]) -> Tuple[str, str]: diff --git a/src/launchpad/size/models/android.py b/src/launchpad/size/models/android.py index e09a6085..787e3bd9 100644 --- a/src/launchpad/size/models/android.py +++ b/src/launchpad/size/models/android.py @@ -30,6 +30,7 @@ class AndroidAppInfo(BaseAppInfo): model_config = ConfigDict(frozen=True) has_proguard_mapping: bool = Field(default=False, description="Whether the app has a proguard mapping file") + gradle_plugin_version: str | None = Field(None, description="Gradle plugin version used for uploading") class AndroidAnalysisResults(BaseAnalysisResults): diff --git a/src/launchpad/size/models/apple.py b/src/launchpad/size/models/apple.py index 48823ed6..55a33cea 100644 --- a/src/launchpad/size/models/apple.py +++ b/src/launchpad/size/models/apple.py @@ -70,6 +70,7 @@ class AppleAppInfo(BaseAppInfo): missing_dsym_binaries: List[str] = Field( default_factory=list, description="List of binary names that don't have corresponding dSYM files" ) + fastlane_plugin_version: str | None = Field(None, description="Fastlane plugin version used for uploading") class AppleInsightResults(BaseModel): diff --git a/src/launchpad/size/models/common.py b/src/launchpad/size/models/common.py index bbfd9442..fbde7836 100644 --- a/src/launchpad/size/models/common.py +++ b/src/launchpad/size/models/common.py @@ -28,6 +28,7 @@ class BaseAppInfo(BaseModel): version: str = Field(..., description="App version") build: str = Field(..., description="Build number") app_id: str = Field(..., description="App ID (bundle id on iOS, package name on Android)") + cli_version: str | None = Field(None, description="sentry-cli version used for uploading") class FileAnalysis(BaseModel): diff --git a/src/launchpad/utils/metadata_extractor.py b/src/launchpad/utils/metadata_extractor.py new file mode 100644 index 00000000..77dcf81b --- /dev/null +++ b/src/launchpad/utils/metadata_extractor.py @@ -0,0 +1,67 @@ +import zipfile + +from pathlib import Path +from typing import Dict, Optional + +from launchpad.utils.logging import get_logger + +logger = get_logger(__name__) + +METADATA_FILENAME = ".sentry-cli-metadata.txt" + + +class ToolingMetadata: + def __init__( + self, + cli_version: Optional[str] = None, + fastlane_plugin_version: Optional[str] = None, + gradle_plugin_version: Optional[str] = None, + ): + self.cli_version = cli_version + self.fastlane_plugin_version = fastlane_plugin_version + self.gradle_plugin_version = gradle_plugin_version + + +def extract_metadata_from_zip(zip_path: Path) -> ToolingMetadata: + try: + with zipfile.ZipFile(zip_path, "r") as zf: + # Only look for .sentry-cli-metadata.txt in the root of the zip + if METADATA_FILENAME not in zf.namelist(): + logger.debug(f"No {METADATA_FILENAME} found in root of {zip_path}") + return ToolingMetadata() + + logger.debug(f"Found metadata file: {METADATA_FILENAME}") + + with zf.open(METADATA_FILENAME) as f: + content = f.read().decode("utf-8") + return _parse_metadata_content(content) + + except Exception as e: + logger.warning(f"Failed to extract metadata from {zip_path}: {e}") + return ToolingMetadata() + + +def _parse_metadata_content(content: str) -> ToolingMetadata: + """Expected format: + sentry-cli-version: 2.58.2 + sentry-fastlane-plugin: 1.2.3 + sentry-gradle-plugin: 4.12.0 + """ + metadata: Dict[str, str] = {} + + for line in content.strip().split("\n"): + line = line.strip() + if not line or ":" not in line: + continue + + key, value = line.split(":", 1) + key = key.strip() + value = value.strip() + + metadata[key] = value + + return ToolingMetadata( + cli_version=metadata.get("sentry-cli-version"), + fastlane_plugin_version=metadata.get("sentry-fastlane-plugin"), + gradle_plugin_version=metadata.get("sentry-gradle-plugin"), + ) diff --git a/tests/unit/utils/test_metadata_extractor.py b/tests/unit/utils/test_metadata_extractor.py new file mode 100644 index 00000000..13a024af --- /dev/null +++ b/tests/unit/utils/test_metadata_extractor.py @@ -0,0 +1,69 @@ +import tempfile +import zipfile + +from pathlib import Path + +from launchpad.utils.metadata_extractor import ( + ToolingMetadata, + _parse_metadata_content, + extract_metadata_from_zip, +) + + +class TestParseMetadataContent: + def test_parse_all_fields(self): + content = """sentry-cli-version: 2.58.2 +sentry-fastlane-plugin: 1.2.3 +sentry-gradle-plugin: 4.12.0""" + metadata = _parse_metadata_content(content) + assert metadata.cli_version == "2.58.2" + assert metadata.fastlane_plugin_version == "1.2.3" + assert metadata.gradle_plugin_version == "4.12.0" + + def test_parse_empty_content(self): + content = "" + metadata = _parse_metadata_content(content) + assert metadata.cli_version is None + assert metadata.fastlane_plugin_version is None + assert metadata.gradle_plugin_version is None + + +class TestExtractMetadataFromZip: + def test_extract_from_zip_root(self): + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tf: + try: + with zipfile.ZipFile(tf.name, "w") as zf: + zf.writestr( + ".sentry-cli-metadata.txt", + "sentry-cli-version: 2.58.2\nsentry-fastlane-plugin: 1.2.3\nsentry-gradle-plugin: 4.12.0", + ) + zf.writestr("some-file.txt", "content") + + metadata = extract_metadata_from_zip(Path(tf.name)) + assert metadata.cli_version == "2.58.2" + assert metadata.fastlane_plugin_version == "1.2.3" + assert metadata.gradle_plugin_version == "4.12.0" + finally: + Path(tf.name).unlink() + + def test_extract_when_missing(self): + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tf: + try: + with zipfile.ZipFile(tf.name, "w") as zf: + zf.writestr("some-file.txt", "content") + zf.writestr("other-file.txt", "content") + + metadata = extract_metadata_from_zip(Path(tf.name)) + assert metadata.cli_version is None + assert metadata.fastlane_plugin_version is None + assert metadata.gradle_plugin_version is None + finally: + Path(tf.name).unlink() + + +class TestToolingMetadata: + def test_create_with_defaults(self): + metadata = ToolingMetadata() + assert metadata.cli_version is None + assert metadata.fastlane_plugin_version is None + assert metadata.gradle_plugin_version is None