From 279c900acf591374467aeefbd1bd13a3a391deec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20L=C3=A4tt?= Date: Fri, 5 Jul 2024 13:23:11 +0300 Subject: [PATCH] Add support for certificate type `DEVELOPER_ID_APPLICATION_G2` (#415) --- .pre-commit-config.yaml | 10 +- CHANGELOG.md | 12 ++ pyproject.toml | 14 +- src/codemagic/__version__.py | 2 +- src/codemagic/apple/resources/enums.py | 52 +++++++ .../certificates_action_group.py | 57 ++----- .../actions/fetch_signing_files_action.py | 15 +- .../resources/enums/test_certificate_type.py | 147 ++++++++++++++++++ 8 files changed, 242 insertions(+), 67 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9127138d..fe45af72 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,24 +1,24 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.6.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/add-trailing-comma - rev: v2.5.1 + rev: v3.1.0 hooks: - id: add-trailing-comma - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 24.4.2 hooks: - id: black language_version: python3.11 - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.275 + rev: v0.5.0 hooks: - id: ruff - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.4.1 + rev: v1.10.1 hooks: - id: mypy additional_dependencies: [types-requests] diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e70684..358b63f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +Version 0.53.3 +------------- + +This release contains changes from [PR #415](https://github.com/codemagic-ci-cd/cli-tools/pull/415). + +**Bugfixes** +- Support signing certificates with type `DEVELOPER_ID_APPLICATION_G2` for `app-store-connect`. + +**Development** +- Update `ruff` settings to be compatible with latest version. +- Update `pre-commit` hook versions. + Version 0.53.2 ------------- diff --git a/pyproject.toml b/pyproject.toml index c443ee87..ad9f0685 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "codemagic-cli-tools" -version = "0.53.2" +version = "0.53.3" description = "CLI tools used in Codemagic builds" readme = "README.md" authors = [ @@ -65,6 +65,11 @@ build-backend = "poetry.core.masonry.api" line-length = 120 [tool.ruff] +line-length = 120 +target-version = "py37" +exclude = [".venv", "stubs"] + +[tool.ruff.lint] select = [ "F", # https://beta.ruff.rs/docs/rules/#pyflakes-f "E", # https://beta.ruff.rs/docs/rules/#error-e @@ -75,11 +80,8 @@ select = [ "ISC", # https://beta.ruff.rs/docs/rules/#flake8-implicit-str-concat-isc "ASYNC", # https://beta.ruff.rs/docs/rules/#flake8-async-async ] -line-length = 120 -target-version = "py37" -exclude = [".venv", "stubs"] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "doc.py" = ["E402"] "src/codemagic/apple/resources/*.py" = ["N815"] @@ -87,7 +89,7 @@ exclude = [".venv", "stubs"] "src/codemagic/google_play/resources/*.py" = ["N815"] "src/codemagic/models/export_options.py" = ["N815"] -[tool.ruff.isort] +[tool.ruff.lint.isort] force-single-line = true [tool.mypy] diff --git a/src/codemagic/__version__.py b/src/codemagic/__version__.py index 4e46094b..d51b2572 100644 --- a/src/codemagic/__version__.py +++ b/src/codemagic/__version__.py @@ -1,5 +1,5 @@ __title__ = "codemagic-cli-tools" __description__ = "CLI tools used in Codemagic builds" -__version__ = "0.53.2.dev" +__version__ = "0.53.3.dev" __url__ = "https://github.com/codemagic-ci-cd/cli-tools" __licence__ = "GNU General Public License v3.0" diff --git a/src/codemagic/apple/resources/enums.py b/src/codemagic/apple/resources/enums.py index d9f3d672..3a333dab 100644 --- a/src/codemagic/apple/resources/enums.py +++ b/src/codemagic/apple/resources/enums.py @@ -1,7 +1,12 @@ from __future__ import annotations import re +from collections import OrderedDict +from typing import List +from typing import Optional +from typing import Sequence from typing import Tuple +from typing import Union from codemagic.models.enums import ResourceEnum from codemagic.utilities.decorators import deprecated @@ -157,7 +162,13 @@ def get_default_display_name(cls, capability_type_value: str) -> str: class CertificateType(ResourceEnum): + """ + https://developer.apple.com/documentation/appstoreconnectapi/certificatetype + """ + DEVELOPER_ID_APPLICATION = "DEVELOPER_ID_APPLICATION" + # Undocumented developer ID certificate with profile type "G2 Sub-CA" + DEVELOPER_ID_APPLICATION_G2 = "DEVELOPER_ID_APPLICATION_G2" DEVELOPER_ID_KEXT = "DEVELOPER_ID_KEXT" DEVELOPMENT = "DEVELOPMENT" DISTRIBUTION = "DISTRIBUTION" @@ -200,6 +211,47 @@ def from_profile_type(cls, profile_type: ProfileType) -> CertificateType: else: raise ValueError(f"Certificate type for profile type {profile_type} is unknown") + @classmethod + def resolve_applicable_types( + cls, + certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]] = None, + profile_type: Optional[ProfileType] = None, + ) -> List[CertificateType]: + """ + Construct a list of unique certificate types based on the provided certificate and + provisioning profile types. Resolved types are ordered so that + - provided `certificate_types` come first in original ordering (if given), + - which is followed by `profile_type` primary accompanying certificate type and + finally `profile_type`'s secondary matching certificate type in case it exists. + """ + + types: List[CertificateType] = [] + + if isinstance(certificate_types, CertificateType): + types.append(certificate_types) + elif certificate_types: + types.extend(certificate_types) + + if profile_type: + types.append(CertificateType.from_profile_type(profile_type)) + # Include iOS and Mac App distribution certificate types backwards compatibility. + # In the past iOS and Mac App Store profiles used to map to iOS and Mac App distribution + # certificates, and consequently they too can be used with those profiles. + if profile_type is ProfileType.IOS_APP_STORE or profile_type is ProfileType.IOS_APP_ADHOC: + types.append(CertificateType.IOS_DISTRIBUTION) + elif profile_type is ProfileType.MAC_APP_STORE: + types.append(CertificateType.MAC_APP_DISTRIBUTION) + + # Developer ID profiles can also be used with undocumented (as of 04.07.24) flavor + # of developer ID application certificates that have special G2 suffix in the type name. + # Said profiles themselves have the same type as before regardless of whether they + # are of type "G2 Sub-GA" or "Previous Sub-GA". + if profile_type in (ProfileType.MAC_APP_DIRECT, ProfileType.MAC_CATALYST_APP_DIRECT): + types.append(CertificateType.DEVELOPER_ID_APPLICATION_G2) + + # Remove duplicate entries from the list in order-preserving way. + return list(OrderedDict.fromkeys(types)) + class ContentRightsDeclaration(ResourceEnum): DOES_NOT_USE_THIRD_PARTY_CONTENT = "DOES_NOT_USE_THIRD_PARTY_CONTENT" diff --git a/src/codemagic/tools/app_store_connect/action_groups/certificates_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/certificates_action_group.py index da9c3287..8f6b4246 100644 --- a/src/codemagic/tools/app_store_connect/action_groups/certificates_action_group.py +++ b/src/codemagic/tools/app_store_connect/action_groups/certificates_action_group.py @@ -6,7 +6,6 @@ from typing import List from typing import Optional from typing import Sequence -from typing import Set from typing import Union from typing import cast @@ -166,10 +165,23 @@ def list_certificates( raise AppStoreConnectError("Cannot create or save resource without certificate private key") _certificate_type: Optional[CertificateType] = _deprecated_kwargs.get("certificate_type") - certificate_types_filter = self._resolve_certificate_types(_certificate_type, certificate_types, profile_type) + if isinstance(_certificate_type, CertificateType): + warning = ( + "Deprecation warning! Keyword argument " + '"certificate_type: Optional[CertificateType]" is deprecated in favor of ' + '"certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]]", ' + "and is subject for removal." + ) + self.logger.warning(Colors.RED(warning)) + certificate_types = _certificate_type + + certificate_types_filter = CertificateType.resolve_applicable_types( + certificate_types=certificate_types, + profile_type=profile_type, + ) certificate_filter = self.api_client.signing_certificates.Filter( - certificate_type=certificate_types_filter, + certificate_type=certificate_types_filter if certificate_types_filter else None, display_name=display_name, ) certificates = self._list_resources( @@ -196,42 +208,3 @@ def list_certificates( ) return certificates - - def _resolve_certificate_types( - self, - certificate_type: Optional[CertificateType], - certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]], - profile_type: Optional[ProfileType], - ) -> Optional[List[CertificateType]]: - types: Set[CertificateType] = set() - - if isinstance(certificate_types, CertificateType): - types.add(certificate_types) - elif certificate_types is not None: - types.update(certificate_types) - - if isinstance(certificate_type, CertificateType): - warning = ( - "Deprecation warning! Keyword argument " - '"certificate_type: Optional[CertificateType]" is deprecated in favor of ' - '"certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]] = None", ' - "and is subject for removal." - ) - self.logger.warning(Colors.RED(warning)) - types.add(certificate_type) - - if profile_type: - types.add(CertificateType.from_profile_type(profile_type)) - # Include iOS and Mac App distribution certificate types backwards compatibility. - # In the past iOS and Mac App Store profiles used to map to iOS and Mac App distribution - # certificates, and consequently they too can be used with those profiles. - if profile_type is ProfileType.IOS_APP_STORE: - types.add(CertificateType.IOS_DISTRIBUTION) - elif profile_type is ProfileType.IOS_APP_ADHOC: - types.add(CertificateType.IOS_DISTRIBUTION) - elif profile_type is ProfileType.MAC_APP_STORE: - types.add(CertificateType.MAC_APP_DISTRIBUTION) - elif profile_type is ProfileType.MAC_APP_DIRECT: - types.add(CertificateType.DEVELOPER_ID_APPLICATION) - - return list(types) if types else None diff --git a/src/codemagic/tools/app_store_connect/actions/fetch_signing_files_action.py b/src/codemagic/tools/app_store_connect/actions/fetch_signing_files_action.py index 5c04206b..5f7484c4 100644 --- a/src/codemagic/tools/app_store_connect/actions/fetch_signing_files_action.py +++ b/src/codemagic/tools/app_store_connect/actions/fetch_signing_files_action.py @@ -121,18 +121,7 @@ def _get_or_create_certificates( certificate_key_password: Optional[Types.CertificateKeyPasswordArgument], create_resource: bool, ) -> List[SigningCertificate]: - certificate_types = [CertificateType.from_profile_type(profile_type)] - # Include iOS and Mac App distribution certificate types backwards compatibility. - # In the past iOS and Mac App Store profiles used to map to iOS and Mac App distribution - # certificates, and we want to keep using existing certificates for as long as possible. - if profile_type is ProfileType.IOS_APP_STORE: - certificate_types.append(CertificateType.IOS_DISTRIBUTION) - elif profile_type is ProfileType.IOS_APP_ADHOC: - certificate_types.append(CertificateType.IOS_DISTRIBUTION) - elif profile_type is ProfileType.MAC_APP_STORE: - certificate_types.append(CertificateType.MAC_APP_DISTRIBUTION) - elif profile_type is ProfileType.MAC_APP_DIRECT: - certificate_types.append(CertificateType.DEVELOPER_ID_APPLICATION) + certificate_types = CertificateType.resolve_applicable_types(profile_type=profile_type) certificates = self.list_certificates( certificate_types=certificate_types, @@ -218,7 +207,7 @@ def _create_missing_profiles( platform: Optional[BundleIdPlatform] = None, ) -> Iterator[Profile]: if not bundle_ids_without_profiles: - return [] + return if platform is None: platform = bundle_ids_without_profiles[0].attributes.platform diff --git a/tests/apple/resources/enums/test_certificate_type.py b/tests/apple/resources/enums/test_certificate_type.py index 91033d85..5d79450b 100644 --- a/tests/apple/resources/enums/test_certificate_type.py +++ b/tests/apple/resources/enums/test_certificate_type.py @@ -1,3 +1,6 @@ +from typing import List +from typing import Sequence + import pytest from codemagic.apple.resources import CertificateType from codemagic.apple.resources import ProfileType @@ -10,3 +13,147 @@ def test_from_profile_type(profile_type: ProfileType): """ certificate_type = CertificateType.from_profile_type(profile_type) assert isinstance(certificate_type, CertificateType) + + +@pytest.mark.parametrize("certificate_type", list(CertificateType)) +def test_resolve_applicable_types_using_literal_certificate_type(certificate_type: CertificateType): + """ + Check that type can be resolved when CertificateType literal instance is passed via + `certificate_types` keyword argument. Expected result is to have a list that contains + only the passed type. + """ + resolved_types = CertificateType.resolve_applicable_types(certificate_types=certificate_type) + assert resolved_types == [certificate_type] + + +@pytest.mark.parametrize( + "certificate_types", + ( + (CertificateType.IOS_DISTRIBUTION,), + (CertificateType.IOS_DISTRIBUTION, CertificateType.IOS_DEVELOPMENT), + [CertificateType.IOS_DISTRIBUTION, CertificateType.IOS_DEVELOPMENT], + [CertificateType.MAC_APP_DEVELOPMENT, CertificateType.MAC_APP_DISTRIBUTION], + [ + CertificateType.MAC_APP_DEVELOPMENT, + CertificateType.MAC_INSTALLER_DISTRIBUTION, + CertificateType.MAC_APP_DISTRIBUTION, + ], + ), +) +def test_resolve_applicable_types_using_multiple_certificate_types(certificate_types: Sequence[CertificateType]): + """ + Check that type can be resolved when number of CertificateType instances are passed via + `certificate_types` keyword argument as a sequence. Expected result is to have a list that + contains all the passed types without duplicates and nothing else. + """ + resolved_types = CertificateType.resolve_applicable_types(certificate_types=certificate_types) + assert resolved_types == list(certificate_types) + + +def test_resolve_applicable_types_using_multiple_certificate_types_omit_duplicates(): + """ + Check that duplicates are removed from given certificate types when resolving result. + Return value should contain each resolved type only once and in the order in which they + appeared first. + """ + resolved_types = CertificateType.resolve_applicable_types( + certificate_types=[ + CertificateType.MAC_INSTALLER_DISTRIBUTION, + CertificateType.MAC_APP_DEVELOPMENT, + CertificateType.MAC_APP_DEVELOPMENT, + CertificateType.MAC_INSTALLER_DISTRIBUTION, + CertificateType.MAC_APP_DEVELOPMENT, + CertificateType.MAC_APP_DISTRIBUTION, + ], + ) + assert resolved_types == [ + CertificateType.MAC_INSTALLER_DISTRIBUTION, + CertificateType.MAC_APP_DEVELOPMENT, + CertificateType.MAC_APP_DISTRIBUTION, + ] + + +@pytest.mark.parametrize( + ("profile_type", "expected_certificate_type"), + ( + (ProfileType.IOS_APP_DEVELOPMENT, CertificateType.IOS_DEVELOPMENT), + (ProfileType.IOS_APP_INHOUSE, CertificateType.DISTRIBUTION), + (ProfileType.MAC_APP_DEVELOPMENT, CertificateType.MAC_APP_DEVELOPMENT), + (ProfileType.MAC_CATALYST_APP_DEVELOPMENT, CertificateType.DEVELOPMENT), + (ProfileType.MAC_CATALYST_APP_STORE, CertificateType.DISTRIBUTION), + (ProfileType.TVOS_APP_ADHOC, CertificateType.DISTRIBUTION), + (ProfileType.TVOS_APP_DEVELOPMENT, CertificateType.DEVELOPMENT), + (ProfileType.TVOS_APP_INHOUSE, CertificateType.DISTRIBUTION), + (ProfileType.TVOS_APP_STORE, CertificateType.DISTRIBUTION), + ), +) +def test_resolve_applicable_types_using_profile_type_with_one_match( + profile_type: ProfileType, + expected_certificate_type: CertificateType, +): + """ + Most provisioning profile types are in one-to-one correspondence with a certain + certificate type. Check that expected certificate type is resolved for such profiles, + and nothing else. + """ + resolved_types = CertificateType.resolve_applicable_types(profile_type=profile_type) + assert resolved_types == [expected_certificate_type] + + +@pytest.mark.parametrize( + ("profile_type", "expected_certificate_types"), + ( + ( + ProfileType.IOS_APP_ADHOC, + [CertificateType.DISTRIBUTION, CertificateType.IOS_DISTRIBUTION], + ), + ( + ProfileType.IOS_APP_STORE, + [CertificateType.DISTRIBUTION, CertificateType.IOS_DISTRIBUTION], + ), + ( + ProfileType.MAC_APP_DIRECT, + [CertificateType.DEVELOPER_ID_APPLICATION, CertificateType.DEVELOPER_ID_APPLICATION_G2], + ), + ( + ProfileType.MAC_CATALYST_APP_DIRECT, + [CertificateType.DEVELOPER_ID_APPLICATION, CertificateType.DEVELOPER_ID_APPLICATION_G2], + ), + ( + ProfileType.MAC_APP_STORE, + [CertificateType.DISTRIBUTION, CertificateType.MAC_APP_DISTRIBUTION], + ), + ), +) +def test_resolve_applicable_types_using_profile_type_with_many_matches( + profile_type: ProfileType, + expected_certificate_types: List[CertificateType], +): + """ + Some provisioning profile types can be used with more than one type of certificates. + Check that for those profile types all the allowed certificate types are resolved, + and nothing else. Additionally, the secondary resolved type should be always last. + """ + resolved_types = CertificateType.resolve_applicable_types(profile_type=profile_type) + assert resolved_types == expected_certificate_types + + +def test_resolve_applicable_types_with_multiple_arguments(): + """ + Check that when different arguments are passed to the resolver at once, then + all of them are respected and used accordingly. + """ + resolved_types = CertificateType.resolve_applicable_types( + profile_type=ProfileType.IOS_APP_STORE, + certificate_types=[ + CertificateType.DEVELOPER_ID_APPLICATION, + CertificateType.DEVELOPER_ID_APPLICATION_G2, + ], + ) + expected_certificate_types = [ + CertificateType.DEVELOPER_ID_APPLICATION, # From "certificate_types" argument + CertificateType.DEVELOPER_ID_APPLICATION_G2, # From "certificate_types" argument + CertificateType.DISTRIBUTION, # From given profile type + CertificateType.IOS_DISTRIBUTION, # From given profile type + ] + assert resolved_types == expected_certificate_types