Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/launchpad/api/update_api_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ class UpdateData(BaseModel):
apple_app_info: Optional[AppleAppInfo] = None
android_app_info: Optional[AndroidAppInfo] = None
dequeued_at: Optional[datetime] = Field(None, description="Timestamp when message was dequeued from Kafka")
cli_version: Optional[str] = Field(None, description="sentry-cli version extracted from .sentry-cli-metadata.txt")
fastlane_plugin_version: Optional[str] = Field(
None, description="Fastlane plugin version extracted from .sentry-cli-metadata.txt"
)
gradle_plugin_version: Optional[str] = Field(
None, description="Gradle plugin version extracted from .sentry-cli-metadata.txt"
)

@field_serializer("dequeued_at")
def serialize_datetime(self, dt: datetime | None) -> str | None:
Expand Down
3 changes: 3 additions & 0 deletions src/launchpad/artifact_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,9 @@ def _get_artifact_type(artifact: Artifact) -> ArtifactType:
apple_app_info=apple_app_info,
android_app_info=android_app_info,
dequeued_at=dequeued_at,
cli_version=app_info.cli_version,
fastlane_plugin_version=app_info.fastlane_plugin_version,
Copy link
Member

Choose a reason for hiding this comment

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

Not sure I see a point of setting these here if you're already setting them in the apple_app_info/android_app_info - I'd recommend only one spot or the other

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It does seem redundant but it doesn't work without both changes.
This line takes the data from the AndroidAppInfo and AppleAppInfo and turns it in to the UpdateData in order to send it to the artifact update API. See the sentry PR here: getsentry/sentry#103062

I wasn't sure if your question was about the inheritance of the objects but both UpdateData and BaseAppInfo inherit from BaseModel so there isn't a shared base.

but maybe I am misinterpreting your question?

Copy link
Member

@rbro112 rbro112 Dec 11, 2025

Choose a reason for hiding this comment

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

apple_app_info and android_app_info are already top-level fields in the UpdateData model. You also have reference to those in the backend. In this case, you're still duplicating the fields, cli_version, fastlane_plugin_version and gradle_plugin_version. Those can and should be pulled from only one spot or another (top-level in UpdateData or in apple/android_app_info), not duplicated.

IMO there should be a PR here to update the backend handling to get those values from the android/apple_app_info fields and we should remove the top-level fields from UpdateData. Simpler and leverages the app_info models that are already being passed, and we don't duplicate data.

Copy link
Contributor Author

@runningcode runningcode Dec 12, 2025

Choose a reason for hiding this comment

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

Ok I see. I misinterpreted this. I think I got it correctly. I added the tooling fields to apple_app_info and android_app_info and created a backend PR to accept these fields like this as well getsentry/sentry#104846

There is still the duplication when we need to copy from the models to the api models but I think that is what you expected.

gradle_plugin_version=app_info.gradle_plugin_version,
)

return update_data.model_dump(exclude_none=True)
Expand Down
6 changes: 6 additions & 0 deletions src/launchpad/size/analyzers/android.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -50,12 +51,17 @@ 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,
fastlane_plugin_version=metadata.fastlane_plugin_version,
gradle_plugin_version=metadata.gradle_plugin_version,
)

return self.app_info
Expand Down
6 changes: 6 additions & 0 deletions src/launchpad/size/analyzers/apple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand All @@ -354,6 +357,9 @@ 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,
gradle_plugin_version=metadata.gradle_plugin_version,
)

def _get_profile_type(self, profile_data: dict[str, Any]) -> Tuple[str, str]:
Expand Down
7 changes: 7 additions & 0 deletions src/launchpad/size/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ 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 extracted from .sentry-cli-metadata.txt")
fastlane_plugin_version: str | None = Field(
None, description="Fastlane plugin version extracted from .sentry-cli-metadata.txt"
)
gradle_plugin_version: str | None = Field(
None, description="Gradle plugin version extracted from .sentry-cli-metadata.txt"
)


class FileAnalysis(BaseModel):
Expand Down
90 changes: 90 additions & 0 deletions src/launchpad/utils/metadata_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Utility for extracting metadata from .sentry-cli-metadata.txt files in artifacts."""

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:
"""Container for tooling version metadata extracted from artifacts."""

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 __repr__(self) -> str:
return f"ToolingMetadata(cli_version={self.cli_version}, fastlane_plugin_version={self.fastlane_plugin_version}, gradle_plugin_version={self.gradle_plugin_version})"


def extract_metadata_from_zip(zip_path: Path) -> ToolingMetadata:
"""Extract tooling metadata from a .sentry-cli-metadata.txt file in the root of a zip.
Args:
zip_path: Path to the zip file to search
Returns:
ToolingMetadata object with extracted version information
"""
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:
"""Parse the content of .sentry-cli-metadata.txt file.
Expected format:
sentry-cli-version: 2.58.2
sentry-fastlane-plugin: 1.2.3
sentry-gradle-plugin: 4.12.0
Args:
content: The text content of the metadata file
Returns:
ToolingMetadata object with parsed version information
"""
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"),
)
77 changes: 77 additions & 0 deletions tests/unit/utils/test_metadata_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Tests for metadata extraction from .sentry-cli-metadata.txt files."""

import tempfile
import zipfile

from pathlib import Path

from launchpad.utils.metadata_extractor import (
ToolingMetadata,
_parse_metadata_content,
extract_metadata_from_zip,
)


class TestParseMetadataContent:
"""Tests for parsing .sentry-cli-metadata.txt content."""

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:
"""Tests for extracting metadata from zip files."""

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:
"""Tests for ToolingMetadata container class."""

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
Loading