Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RUMM-1744 Update release tool to conditionally validate Kronos.xcframework #704

Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions tools/distribution/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from src.git import clone_repo
from src.assets.gh_asset import GHAsset
from src.assets.podspec import CPPodspec
from src.semver import Version

DD_SDK_IOS_REPO_SSH = 'git@github.com:DataDog/dd-sdk-ios.git'
DD_SDK_IOS_REPO_NAME = 'dd-sdk-ios'
Expand Down Expand Up @@ -83,6 +84,8 @@
f'- overwrite_github = {overwrite_github}\n'
f'- dry_run = {dry_run}.')

print(f'🛠️ Git tag read to version: {Version.parse(git_tag)}')

publish_to_gh = not only_cocoapods
publish_to_cp = not only_github

Expand Down
187 changes: 150 additions & 37 deletions tools/distribution/src/assets/gh_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,136 @@
import glob
from tempfile import TemporaryDirectory, NamedTemporaryFile
from src.utils import remember_cwd, shell, read_sdk_version
from src.directory_matcher import DirectoryMatcher
from src.semver import Version

SWIFT_CONTENT = [
'Datadog.xcframework',
'DatadogObjc.xcframework',
'DatadogCrashReporting.xcframework',
'Kronos.xcframework',

class XCFrameworkValidator:
name: str

def should_be_included(self, in_version: Version) -> bool:
pass

def validate(self, zip_directory: DirectoryMatcher):
pass


class DatadogXCFrameworkValidator(XCFrameworkValidator):
name = 'Datadog.xcframework'

def should_be_included(self, in_version: Version):
return True # always expect `Datadog.xcframework`

def validate(self, zip_directory: DirectoryMatcher):
zip_directory.get('Datadog.xcframework').assert_it_has_files([
'ios-arm64',
'ios-arm64/BCSymbolMaps/*.bcsymbolmap',
'ios-arm64/dSYMs/*.dSYM',
'ios-arm64/**/arm64.swiftinterface',
'ios-arm64/**/arm64-apple-ios.swiftinterface',

'ios-arm64_x86_64-simulator',
'ios-arm64_x86_64-simulator/dSYMs/*.dSYM',
'ios-arm64_x86_64-simulator/**/arm64.swiftinterface',
'ios-arm64_x86_64-simulator/**/arm64-apple-ios-simulator.swiftinterface',
'ios-arm64_x86_64-simulator/**/x86_64.swiftinterface',
'ios-arm64_x86_64-simulator/**/x86_64-apple-ios-simulator.swiftinterface',
])


class DatadogObjcXCFrameworkValidator(XCFrameworkValidator):
name = 'DatadogObjc.xcframework'

def should_be_included(self, in_version: Version):
return True # always expect `DatadogObjc.xcframework`

def validate(self, zip_directory: DirectoryMatcher):
zip_directory.get('DatadogObjc.xcframework').assert_it_has_files([
'ios-arm64',
'ios-arm64/BCSymbolMaps/*.bcsymbolmap',
'ios-arm64/dSYMs/*.dSYM',
'ios-arm64/**/arm64.swiftinterface',
'ios-arm64/**/arm64-apple-ios.swiftinterface',

'ios-arm64_x86_64-simulator',
'ios-arm64_x86_64-simulator/**/arm64.swiftinterface',
'ios-arm64_x86_64-simulator/**/arm64-apple-ios-simulator.swiftinterface',
'ios-arm64_x86_64-simulator/**/x86_64.swiftinterface',
'ios-arm64_x86_64-simulator/**/x86_64-apple-ios-simulator.swiftinterface',
])


class DatadogCrashReportingXCFrameworkValidator(XCFrameworkValidator):
name = 'DatadogCrashReporting.xcframework'

def should_be_included(self, in_version: Version):
min_version = Version.parse('1.7.0') # Datadog Crash Reporting.xcframework was introduced in `1.7.0`
return in_version.is_newer_than_or_equal(min_version)

def validate(self, zip_directory: DirectoryMatcher):
zip_directory.get('DatadogCrashReporting.xcframework').assert_it_has_files([
'ios-arm64',
'ios-arm64/BCSymbolMaps/*.bcsymbolmap',
'ios-arm64/**/arm64.swiftinterface',
'ios-arm64/**/arm64-apple-ios.swiftinterface',

'ios-arm64_x86_64-simulator',
'ios-arm64_x86_64-simulator/dSYMs/*.dSYM',
'ios-arm64_x86_64-simulator/**/x86_64.swiftinterface',
'ios-arm64_x86_64-simulator/**/x86_64-apple-ios-simulator.swiftinterface',
])


class CrashReporterXCFrameworkValidator(XCFrameworkValidator):
name = 'CrashReporter.xcframework'

def should_be_included(self, in_version: Version):
min_version = Version.parse('1.7.0') # Datadog Crash Reporting.xcframework was introduced in `1.7.0`
return in_version.is_newer_than_or_equal(min_version)

def validate(self, zip_directory: DirectoryMatcher):
zip_directory.get('CrashReporter.xcframework').assert_it_has_files([
'ios-arm64_arm64e_armv7_armv7s',
'ios-arm64_i386_x86_64-simulator',
])


class KronosXCFrameworkValidator(XCFrameworkValidator):
name = 'Kronos.xcframework'

def should_be_included(self, in_version: Version):
min_version = Version.parse('1.5.0') # First version that depends on Kronos
max_version = Version.parse('1.8.99') # Last version that depends on Kronos
return in_version.is_newer_than_or_equal(min_version) and max_version.is_newer_than_or_equal(in_version)

def validate(self, zip_directory: DirectoryMatcher):
zip_directory.get('Kronos.xcframework').assert_it_has_files([
'ios-arm64_armv7',
'ios-arm64_armv7/BCSymbolMaps/*.bcsymbolmap',
'ios-arm64_armv7/dSYMs/*.dSYM',
'ios-arm64_armv7/**/arm.swiftinterface',
'ios-arm64_armv7/**/arm64-apple-ios.swiftinterface',
'ios-arm64_armv7/**/arm64.swiftinterface',
'ios-arm64_armv7/**/armv7-apple-ios.swiftinterface',
'ios-arm64_armv7/**/armv7.swiftinterface',

'ios-arm64_i386_x86_64-simulator',
'ios-arm64_i386_x86_64-simulator/dSYMs/*.dSYM',
'ios-arm64_i386_x86_64-simulator/**/arm64-apple-ios-simulator.swiftinterface',
'ios-arm64_i386_x86_64-simulator/**/i386-apple-ios-simulator.swiftinterface',
'ios-arm64_i386_x86_64-simulator/**/x86_64-apple-ios-simulator.swiftinterface',
'ios-arm64_i386_x86_64-simulator/**/x86_64.swiftinterface',
])


xcframeworks_validators: [XCFrameworkValidator] = [
DatadogXCFrameworkValidator(),
DatadogObjcXCFrameworkValidator(),
DatadogCrashReportingXCFrameworkValidator(),
CrashReporterXCFrameworkValidator(),
KronosXCFrameworkValidator(),
]
OBJC_CONTENT = ['CrashReporter.xcframework']
EXPECTED_ZIP_CONTENT = SWIFT_CONTENT + OBJC_CONTENT


class GHAsset:
"""
Expand All @@ -34,7 +155,7 @@ def __init__(self):

with NamedTemporaryFile(mode='w+', prefix='dd-gh-distro-', suffix='.xcconfig') as xcconfig:
xcconfig.write('BUILD_LIBRARY_FOR_DISTRIBUTION = YES\n')
xcconfig.seek(0) # without this line, content isn't actually written
xcconfig.seek(0) # without this line, content isn't actually written
os.environ['XCODE_XCCONFIG_FILE'] = xcconfig.name

# Produce XCFrameworks with carthage:
Expand All @@ -55,16 +176,7 @@ def __init__(self):
def __repr__(self):
return f'[GHAsset: path = {self.__path}]'

def __content_with_swiftinterface(self, dir: str) -> set:
# e.g: /TMP_DIR/X.xcframework/ios-arm64/X.framework/Modules/X.swiftmodule/arm64.swiftinterface
swiftinterfaces = glob.iglob(f'{dir}/*.xcframework/**/*.framework/Modules/*.swiftmodule/*.swiftinterface', recursive=True)
# e.g: X.xcframework/ios-arm64/X.framework/Modules/X.swiftmodule/arm64.swiftinterface
relative_paths = [abs_path.removeprefix(dir + '/') for abs_path in swiftinterfaces]
# e.g: X.xcframework
product_names = [rel_path[0:rel_path.find('/')] for rel_path in relative_paths]
return set(product_names)

def validate(self, git_tag: str):
def validate(self, git_tag: str): # 1.5.0
"""
Checks the `.zip` archive integrity with given `git_tag`.
"""
Expand All @@ -79,27 +191,28 @@ def validate(self, git_tag: str):
# Inspect the content of zip archive:
with TemporaryDirectory() as unzip_dir:
shell(f'unzip -q {self.__path} -d {unzip_dir}')
actual_files = os.listdir(unzip_dir)
expected_files = EXPECTED_ZIP_CONTENT
actual_files.sort(), expected_files.sort()

if set(actual_files) != set(expected_files):
raise Exception(f'The content of `.zip` archive is not correct: \n'
f' - actual {actual_files}\n'
f' - expected: {expected_files}')

missing_swiftinterface_content = set(SWIFT_CONTENT).difference(self.__content_with_swiftinterface(unzip_dir))
if missing_swiftinterface_content:
raise Exception(f'Frameworks missing .swiftinterface: \n {missing_swiftinterface_content} \n')

print(f' → the content of `.zip` archive is correct: \n'
f' - actual: {actual_files}\n'
f' - expected: {expected_files}')

print(f' → details on bundled `XCFrameworks`:')
for file_path in glob.iglob(f'{unzip_dir}/*.xcframework/*', recursive=True):

print(f' → GH asset (zip) content:')
for file_path in glob.iglob(f'{unzip_dir}/**', recursive=True):
print(f' - {file_path.removeprefix(unzip_dir)}')

dm = DirectoryMatcher(path=unzip_dir)
this_version = Version.parse(git_tag)

print(f' → Validating each `XCFramework`:')
validated_count = 0
for validator in xcframeworks_validators:
if validator.should_be_included(in_version=this_version):
validator.validate(zip_directory=dm)
print(f' → {validator.name} - OK')
validated_count += 1
else:
print(f' → {validator.name} - SKIPPING for {this_version}')

dm.assert_number_of_files(expected_count=validated_count) # assert there are no other files

print(f' → the content of `.zip` archive is correct')

def publish(self, git_tag: str, overwrite_existing: bool, dry_run: bool):
"""
Uploads the `.zip` archive to GH Release for given `git_tag`.
Expand Down
42 changes: 42 additions & 0 deletions tools/distribution/src/directory_matcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# -----------------------------------------------------------
# Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
# This product includes software developed at Datadog (https://www.datadoghq.com/).
# Copyright 2019-2020 Datadog, Inc.
# -----------------------------------------------------------

import os
import glob


class DirectoryMatcherException(Exception):
pass


class DirectoryMatcher:
path: str

def __init__(self, path: str):
if os.path.exists(path):
self.path = path
else:
raise DirectoryMatcherException(f'Directory does not exist: {path}')

def assert_number_of_files(self, expected_count: int):
actual_count = len(os.listdir(self.path))
if expected_count != actual_count:
raise DirectoryMatcherException(f'Expected {expected_count} files in "{self.path}", but '
f'found {actual_count} instead.')

def assert_it_has_file(self, file_path: str):
search_path = os.path.join(self.path, file_path)
result = list(glob.iglob(search_path, recursive=True))

if not result:
raise DirectoryMatcherException(f'Expected "{self.path}" to include {file_path}, but it is missing.')

def assert_it_has_files(self, file_paths: [str]):
for file_path in file_paths:
self.assert_it_has_file(file_path)

def get(self, file: str) -> 'DirectoryMatcher':
return DirectoryMatcher(path=os.path.join(self.path, file))
112 changes: 112 additions & 0 deletions tools/distribution/src/semver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# -----------------------------------------------------------
# Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
# This product includes software developed at Datadog (https://www.datadoghq.com/).
# Copyright 2019-2020 Datadog, Inc.
# -----------------------------------------------------------

# This file is a copied and enhanced version of `tools/nightly-unit-tests/src/semver.py`
# TODO: RUMM-1860 Share this code between both tools

import re
from dataclasses import dataclass
from typing import Optional

main_regex = r'([0-9]+)(\.[0-9]+)?(\.[0-9]+)?' # regex describing the main version component, e.g. '1.3.2'
pre_release_regex = r'-(alpha|beta|rc)([0-9]+)' # regex describing the pre-release version component, e.g. '-alpha3'


class VersionParsingException(Exception):
pass


@dataclass
class PreRelease:
identifier: str # 'alpha' | 'beta' | 'rc'
iteration: int # iteration of version within given identifier, e.g. 2 for 2nd 'beta'

def __repr__(self):
return f'-{self.identifier}.{self.iteration}'

def is_newer_than(self, other_version: 'PreRelease'):
grades = {'alpha': 1, 'beta': 2, 'rc': 3}

if grades[self.identifier] > grades[other_version.identifier]:
return True
elif grades[self.identifier] == grades[other_version.identifier]:
if self.iteration > other_version.iteration:
return True

return False


@dataclass
class Version:
major: int
minor: int
patch: int
pre_release: Optional[PreRelease] # optional pre-release version, e.g. '1.0.0-alpha3' has PreRelease('alpha', 3)

def __repr__(self):
pre_release_repr = '' if not self.pre_release else f'{self.pre_release}'
if self.patch == 0:
return f'{self.major}.{self.minor}{pre_release_repr}'
else:
return f'{self.major}.{self.minor}.{self.patch}{pre_release_repr}'

@staticmethod
def parse(string: str):
"""
Reads `Version` from string like '1.5' or '1.3.1-beta3', where '1.5' and 1.3.1' are main
version components and '-beta3' is a pre-release component.
"""
regex = re.compile(f'^(?P<m>{main_regex})(?P<pr>{pre_release_regex})?$')
match = re.match(regex, string)

if not match:
raise VersionParsingException(f'Invalid version string: {string} - not matching `{regex}`')

if m_string := match.groupdict().get('m'):
pr = None

if pr_string := match.groupdict().get('pr'):
if pr_match := re.match(re.compile(pre_release_regex), pr_string):
pr = PreRelease(
identifier=pr_match[1],
iteration=int(pr_match[2])
)
else:
raise VersionParsingException(f'Invalid pre-release version string: {pr_string}')

if m_match := re.match(re.compile(main_regex), m_string):
return Version(
major=0 if not m_match[1] else int(m_match[1]),
minor=0 if not m_match[2] else int(m_match[2][1:]),
patch=0 if not m_match[3] else int(m_match[3][1:]),
pre_release=pr
)
else:
raise VersionParsingException(f'Invalid main version string: {m_string}')
else:
raise VersionParsingException(f'Invalid version string: {string}')

def is_newer_than(self, other_version: 'Version'):
if self.major > other_version.major:
return True
elif self.major == other_version.major:
if self.minor > other_version.minor:
return True
elif self.minor == other_version.minor:
if self.patch > other_version.patch:
return True
elif self.patch == other_version.patch:
if self.pre_release and other_version.pre_release:
return self.pre_release.is_newer_than(other_version.pre_release)
elif self.pre_release and not other_version.pre_release:
return False
elif not self.pre_release and other_version.pre_release:
return True

return False

def is_newer_than_or_equal(self, other_version: 'Version'):
return self.is_newer_than(other_version=other_version) or self == other_version
Loading