diff --git a/CHANGELOG.md b/CHANGELOG.md index 3693857e..03ee7c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +Version 0.43.0 +------------- + +Additions and changes from [pull request #340](https://github.com/codemagic-ci-cd/cli-tools/pull/340). Resolves [issue #339](https://github.com/codemagic-ci-cd/cli-tools/issues/339). + +**Features** +- Support submitting macOS packages to TestFlight using `app-store-connect publish --testflight`. +- Add new action `app-store-connect builds beta-details` to show beta detail information for specific build. +- Waiting for App Store Connect build processing also waits for beta builds details to be processed before returning. + +**Development** +- Add new client method `read_beta_detail` to builds resource manager in `src/codemagic/apple/app_store_connect/builds/builds.py`. +- Add new definitions for App Store Connect models: + - `BuildBetaDetail` for https://developer.apple.com/documentation/appstoreconnectapi/buildbetadetail, + - `ExternalBetaState` enumeration for https://developer.apple.com/documentation/appstoreconnectapi/externalbetastate, + - `InternalBetaState` for https://developer.apple.com/documentation/appstoreconnectapi/internalbetastate. + +**Documentation** +- Add documentation for action `app-store-connect builds betat-details`. + +Special thanks for contribution to [@nilsreichardt](https://github.com/nilsreichardt). + Version 0.42.2 ------------- diff --git a/docs/app-store-connect/builds.md b/docs/app-store-connect/builds.md index cee7934b..ce759db6 100644 --- a/docs/app-store-connect/builds.md +++ b/docs/app-store-connect/builds.md @@ -96,6 +96,7 @@ Enable verbose logging for commands |[`get`](builds/get.md)|Get information about a specific build| |[`app`](builds/app.md)|Get the App details for a specific build.| |[`app-store-version`](builds/app-store-version.md)|Get the App Store version of a specific build.| +|[`beta-details`](builds/beta-details.md)|Get Build Beta Details Information of a specific build.| |[`pre-release-version`](builds/pre-release-version.md)|Get the prerelease version for a specific build| |[`submit-to-app-store`](builds/submit-to-app-store.md)|Submit build to App Store review| |[`submit-to-testflight`](builds/submit-to-testflight.md)|Submit build to TestFlight| diff --git a/docs/app-store-connect/builds/beta-details.md b/docs/app-store-connect/builds/beta-details.md new file mode 100644 index 00000000..2090e08c --- /dev/null +++ b/docs/app-store-connect/builds/beta-details.md @@ -0,0 +1,95 @@ + +beta-details +============ + + +**Get Build Beta Details Information of a specific build.** +### Usage +```bash +app-store-connect builds beta-details [-h] [--log-stream STREAM] [--no-color] [--version] [-s] [-v] + [--log-api-calls] + [--api-unauthorized-retries UNAUTHORIZED_REQUEST_RETRIES] + [--api-server-error-retries SERVER_ERROR_RETRIES] + [--disable-jwt-cache] + [--json] + [--issuer-id ISSUER_ID] + [--key-id KEY_IDENTIFIER] + [--private-key PRIVATE_KEY] + [--certificates-dir CERTIFICATES_DIRECTORY] + [--profiles-dir PROFILES_DIRECTORY] + BUILD_ID_RESOURCE_ID +``` +### Required arguments for action `beta-details` + +##### `BUILD_ID_RESOURCE_ID` + + +Alphanumeric ID value of the Build +### Optional arguments for command `app-store-connect` + +##### `--log-api-calls` + + +Turn on logging for App Store Connect API HTTP requests +##### `--api-unauthorized-retries, -r=UNAUTHORIZED_REQUEST_RETRIES` + + +Specify how many times the App Store Connect API request should be retried in case the called request fails due to an authentication error (401 Unauthorized response from the server). In case of the above authentication error, the request is retried usinga new JSON Web Token as many times until the number of retries is exhausted. If not given, the value will be checked from the environment variable `APP_STORE_CONNECT_API_UNAUTHORIZED_RETRIES`. [Default: 3] +##### `--api-server-error-retries=SERVER_ERROR_RETRIES` + + +Specify how many times the App Store Connect API request should be retried in case the called request fails due to a server error (response with status code 5xx). In case of server error, the request is retried until the number of retries is exhausted. If not given, the value will be checked from the environment variable `APP_STORE_CONNECT_API_SERVER_ERROR_RETRIES`. [Default: 3] +##### `--disable-jwt-cache` + + +Turn off caching App Store Connect JSON Web Tokens to disk. By default generated tokens are cached to disk to be reused between separate processes, which can can reduce number of false positive authentication errors from App Store Connect API. If not given, the value will be checked from the environment variable `APP_STORE_CONNECT_DISABLE_JWT_CACHE`. +##### `--json` + + +Whether to show the resource in JSON format +##### `--issuer-id=ISSUER_ID` + + +App Store Connect API Key Issuer ID. Identifies the issuer who created the authentication token. Learn more at https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api. If not given, the value will be checked from the environment variable `APP_STORE_CONNECT_ISSUER_ID`. Alternatively to entering `ISSUER_ID` in plaintext, it may also be specified using the `@env:` prefix followed by an environment variable name, or the `@file:` prefix followed by a path to the file containing the value. Example: `@env:` uses the value in the environment variable named ``, and `@file:` uses the value from the file at ``. +##### `--key-id=KEY_IDENTIFIER` + + +App Store Connect API Key ID. Learn more at https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api. If not given, the value will be checked from the environment variable `APP_STORE_CONNECT_KEY_IDENTIFIER`. Alternatively to entering `KEY_IDENTIFIER` in plaintext, it may also be specified using the `@env:` prefix followed by an environment variable name, or the `@file:` prefix followed by a path to the file containing the value. Example: `@env:` uses the value in the environment variable named ``, and `@file:` uses the value from the file at ``. +##### `--private-key=PRIVATE_KEY` + + +App Store Connect API private key used for JWT authentication to communicate with Apple services. Learn more at https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api. If not provided, the key will be searched from the following directories in sequence for a private key file with the name `AuthKey_.p8`: private_keys, ~/private_keys, ~/.private_keys, ~/.appstoreconnect/private_keys, where is the value of `--key-id`. If not given, the value will be checked from the environment variable `APP_STORE_CONNECT_PRIVATE_KEY`. Alternatively to entering `PRIVATE_KEY` in plaintext, it may also be specified using the `@env:` prefix followed by an environment variable name, or the `@file:` prefix followed by a path to the file containing the value. Example: `@env:` uses the value in the environment variable named ``, and `@file:` uses the value from the file at ``. +##### `--certificates-dir=CERTIFICATES_DIRECTORY` + + +Directory where the code signing certificates will be saved. Default: `$HOME/Library/MobileDevice/Certificates` +##### `--profiles-dir=PROFILES_DIRECTORY` + + +Directory where the provisioning profiles will be saved. Default: `$HOME/Library/MobileDevice/Provisioning Profiles` +### Common options + +##### `-h, --help` + + +show this help message and exit +##### `--log-stream=stderr | stdout` + + +Log output stream. Default `stderr` +##### `--no-color` + + +Do not use ANSI colors to format terminal output +##### `--version` + + +Show tool version and exit +##### `-s, --silent` + + +Disable log output for commands +##### `-v, --verbose` + + +Enable verbose logging for commands diff --git a/pyproject.toml b/pyproject.toml index ebd39dc5..33ebabd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "codemagic-cli-tools" -version = "0.42.2" +version = "0.43.0" description = "CLI tools used in Codemagic builds" readme = "README.md" authors = [ diff --git a/src/codemagic/__version__.py b/src/codemagic/__version__.py index 0649a062..804c398a 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.42.2.dev" +__version__ = "0.43.0.dev" __url__ = "https://github.com/codemagic-ci-cd/cli-tools" __licence__ = "GNU General Public License v3.0" diff --git a/src/codemagic/apple/app_store_connect/builds/builds.py b/src/codemagic/apple/app_store_connect/builds/builds.py index 7f1f6686..0d8f552e 100644 --- a/src/codemagic/apple/app_store_connect/builds/builds.py +++ b/src/codemagic/apple/app_store_connect/builds/builds.py @@ -12,6 +12,7 @@ from codemagic.apple.resources import AppStoreVersion from codemagic.apple.resources import BetaReviewState from codemagic.apple.resources import Build +from codemagic.apple.resources import BuildBetaDetail from codemagic.apple.resources import BuildProcessingState from codemagic.apple.resources import LinkedResourceData from codemagic.apple.resources import PreReleaseVersion @@ -118,6 +119,18 @@ def read_pre_release_version(self, build: Union[Build, ResourceId]) -> Optional[ return None return PreReleaseVersion(response["data"]) + def read_beta_detail(self, build: Union[Build, ResourceId]) -> BuildBetaDetail: + """ + https://developer.apple.com/documentation/appstoreconnectapi/read_the_build_beta_details_information_of_a_build + """ + url = None + if isinstance(build, Build) and build.relationships is not None: + url = build.relationships.buildBetaDetail.links.related + if url is None: + url = f"{self.client.API_URL}/builds/{build}/buildBetaDetail" + response = self.client.session.get(url).json() + return BuildBetaDetail(response["data"]) + def read_with_include( self, build: Union[LinkedResourceData, ResourceId], diff --git a/src/codemagic/apple/resources/__init__.py b/src/codemagic/apple/resources/__init__.py index f0f69797..2f180f12 100644 --- a/src/codemagic/apple/resources/__init__.py +++ b/src/codemagic/apple/resources/__init__.py @@ -8,6 +8,7 @@ from .beta_build_localization import BetaBuildLocalization from .beta_group import BetaGroup from .build import Build +from .build_beta_detail import BuildBetaDetail from .bundle_id import BundleId from .bundle_id_capability import BundleIdCapability from .bundle_id_capability import CapabilitySetting @@ -24,6 +25,8 @@ from .enums import ContentRightsDeclaration from .enums import DeviceClass from .enums import DeviceStatus +from .enums import ExternalBetaState +from .enums import InternalBetaState from .enums import Locale from .enums import Platform from .enums import ProfileState diff --git a/src/codemagic/apple/resources/build_beta_detail.py b/src/codemagic/apple/resources/build_beta_detail.py new file mode 100644 index 00000000..093d7e11 --- /dev/null +++ b/src/codemagic/apple/resources/build_beta_detail.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from .enums import ExternalBetaState +from .enums import InternalBetaState +from .resource import Relationship +from .resource import Resource + + +class BuildBetaDetail(Resource): + """ + https://developer.apple.com/documentation/appstoreconnectapi/buildbetadetail + """ + + attributes: Attributes + relationships: Optional[Relationships] = None + + @dataclass + class Attributes(Resource.Attributes): + autoNotifyEnabled: bool + externalBuildState: ExternalBetaState + internalBuildState: InternalBetaState + + def __post_init__(self): + if isinstance(self.externalBuildState, str): + self.externalBuildState = ExternalBetaState(self.externalBuildState) + if isinstance(self.internalBuildState, str): + self.internalBuildState = InternalBetaState(self.internalBuildState) + + @dataclass + class Relationships(Resource.Relationships): + build: Relationship diff --git a/src/codemagic/apple/resources/enums.py b/src/codemagic/apple/resources/enums.py index ce726197..33075352 100644 --- a/src/codemagic/apple/resources/enums.py +++ b/src/codemagic/apple/resources/enums.py @@ -170,6 +170,31 @@ class DeviceStatus(ResourceEnum): ENABLED = "ENABLED" +class ExternalBetaState(ResourceEnum): + BETA_APPROVED = "BETA_APPROVED" + BETA_REJECTED = "BETA_REJECTED" + EXPIRED = "EXPIRED" + IN_BETA_REVIEW = "IN_BETA_REVIEW" + IN_BETA_TESTING = "IN_BETA_TESTING" + IN_EXPORT_COMPLIANCE_REVIEW = "IN_EXPORT_COMPLIANCE_REVIEW" + MISSING_EXPORT_COMPLIANCE = "MISSING_EXPORT_COMPLIANCE" + PROCESSING = "PROCESSING" + PROCESSING_EXCEPTION = "PROCESSING_EXCEPTION" + READY_FOR_BETA_SUBMISSION = "READY_FOR_BETA_SUBMISSION" + READY_FOR_BETA_TESTING = "READY_FOR_BETA_TESTING" + WAITING_FOR_BETA_REVIEW = "WAITING_FOR_BETA_REVIEW" + + +class InternalBetaState(ResourceEnum): + EXPIRED = "EXPIRED" + IN_BETA_TESTING = "IN_BETA_TESTING" + IN_EXPORT_COMPLIANCE_REVIEW = "IN_EXPORT_COMPLIANCE_REVIEW" + MISSING_EXPORT_COMPLIANCE = "MISSING_EXPORT_COMPLIANCE" + PROCESSING = "PROCESSING" + PROCESSING_EXCEPTION = "PROCESSING_EXCEPTION" + READY_FOR_BETA_TESTING = "READY_FOR_BETA_TESTING" + + class Platform(ResourceEnum): IOS = "IOS" MAC_OS = "MAC_OS" @@ -246,6 +271,7 @@ class ResourceType(ResourceEnum): BETA_APP_LOCALIZATIONS = "betaAppLocalizations" BETA_APP_REVIEW_DETAILS = "betaAppReviewDetails" BETA_APP_REVIEW_SUBMISSIONS = "betaAppReviewSubmissions" + BETA_BUILD_DETAILS = "buildBetaDetails" BETA_BUILD_LOCALIZATIONS = "betaBuildLocalizations" BETA_GROUPS = "betaGroups" BUILDS = "builds" diff --git a/src/codemagic/tools/_app_store_connect/action_groups/builds_action_group.py b/src/codemagic/tools/_app_store_connect/action_groups/builds_action_group.py index c2ed137a..c59d255d 100644 --- a/src/codemagic/tools/_app_store_connect/action_groups/builds_action_group.py +++ b/src/codemagic/tools/_app_store_connect/action_groups/builds_action_group.py @@ -21,7 +21,10 @@ from codemagic.apple.resources import BetaAppLocalization from codemagic.apple.resources import BetaAppReviewSubmission from codemagic.apple.resources import Build +from codemagic.apple.resources import BuildBetaDetail from codemagic.apple.resources import BuildProcessingState +from codemagic.apple.resources import ExternalBetaState +from codemagic.apple.resources import InternalBetaState from codemagic.apple.resources import Locale from codemagic.apple.resources import Platform from codemagic.apple.resources import PreReleaseVersion @@ -155,6 +158,23 @@ def get_build_app( should_print, ) + @cli.action( + "beta-details", + BuildArgument.BUILD_ID_RESOURCE_ID, + action_group=AppStoreConnectActionGroup.BUILDS, + ) + def get_build_beta_detail(self, build_id: ResourceId, should_print: bool = True) -> BuildBetaDetail: + """ + Get Build Beta Details Information of a specific build. + """ + return self._get_related_resource( + build_id, + Build, + BuildBetaDetail, + self.api_client.builds.read_beta_detail, + should_print, + ) + @cli.action( "add-beta-test-info", BuildArgument.BUILD_ID_RESOURCE_ID, @@ -417,15 +437,15 @@ def _create_review_submission(self, app: App, platform: Platform) -> ReviewSubmi return review_submission - def wait_until_build_is_processed( + def _wait_until_build_is_processed( self, build: Build, + processing_started_at: float, max_processing_minutes: int, - retry_wait_seconds: int = 30, + retry_wait_seconds: int, ) -> Build: is_first_attempt = True - start_waiting = time.time() - while time.time() - start_waiting < max_processing_minutes * 60: + while time.time() - processing_started_at < max_processing_minutes * 60: if build.attributes.processingState is BuildProcessingState.PROCESSING: if is_first_attempt: self._log_build_processing_message(build.id, max_processing_minutes) @@ -456,6 +476,84 @@ def wait_until_build_is_processed( ), ) + def _wait_until_build_beta_detail_is_processed( + self, + build: Build, + processing_started_at: float, + max_processing_minutes: int, + retry_wait_seconds: int, + ) -> BuildBetaDetail: + is_first_attempt = True + build_beta_detail = None + while time.time() - processing_started_at < max_processing_minutes * 60: + try: + build_beta_detail = self.api_client.builds.read_beta_detail(build) + except AppStoreConnectApiError as api_error: + raise AppStoreConnectError(str(api_error)) + + if ( + build_beta_detail.attributes.externalBuildState is not ExternalBetaState.PROCESSING + or build_beta_detail.attributes.internalBuildState is not InternalBetaState.PROCESSING + ): + if not is_first_attempt: + self.logger.info( + Colors.BLUE("Processing build %s beta detail %s is completed\n"), + build.id, + build_beta_detail.id, + ) + return build_beta_detail + + if is_first_attempt: + self._log_build_beta_detail_processing_message(build.id, max_processing_minutes) + + msg_template = ( + "Build %s beta details %s are still being processed on App Store Connect side, " + "waiting %d seconds and checking again" + ) + self.logger.info(msg_template, build.id, build_beta_detail.id, retry_wait_seconds) + time.sleep(retry_wait_seconds) + is_first_attempt = False + + build_beta_detail_id = build_beta_detail.id if build_beta_detail else "N/A" + raise IOError( + ( + f"Waiting for build {build.id} beta detail {build_beta_detail_id} processing " + f"timed out in {max_processing_minutes} minutes. " + f"You can configure maximum timeout using {PublishArgument.MAX_BUILD_PROCESSING_WAIT.flag} " + f"command line option, or {Types.MaxBuildProcessingWait.environment_variable_key} environment variable." + ), + ) + + def wait_until_build_is_processed( + self, + build: Build, + max_processing_minutes: int, + retry_wait_seconds: int = 30, + ) -> Build: + """ + Wait until + 1. build's processing state becomes 'processed', and + 2. beta details of the build report that both external and internal build + state are not processing anymore. + Returns updated build instance that is already processed. + """ + processing_started_at = time.time() + + build = self._wait_until_build_is_processed( + build, + processing_started_at, + max_processing_minutes, + retry_wait_seconds, + ) + self._wait_until_build_beta_detail_is_processed( + build, + processing_started_at, + max_processing_minutes, + retry_wait_seconds, + ) + + return build + def _log_build_processing_message(self, build_id: ResourceId, max_processing_minutes: int): processing_message_template = ( "\n" @@ -465,6 +563,15 @@ def _log_build_processing_message(self, build_id: ResourceId, max_processing_min ) self.logger.info(Colors.BLUE(processing_message_template), build_id, max_processing_minutes) + def _log_build_beta_detail_processing_message(self, build_beta_detail_id: ResourceId, max_processing_minutes: int): + processing_message_template = ( + "\n" + "Processing build beta detail information by Apple can take some time after " + "the build is already processed. Timeout for waiting the processing " + "to finish for build beta detail %s is set to %d minutes." + ) + self.logger.info(Colors.BLUE(processing_message_template), build_beta_detail_id, max_processing_minutes) + def _assert_app_has_testflight_information(self, app: App): missing_beta_app_information = self._get_missing_beta_app_information(app) missing_beta_app_review_information = self._get_missing_beta_app_review_information(app) diff --git a/src/codemagic/tools/_app_store_connect/actions/publish_action.py b/src/codemagic/tools/_app_store_connect/actions/publish_action.py index 36759369..93adab39 100644 --- a/src/codemagic/tools/_app_store_connect/actions/publish_action.py +++ b/src/codemagic/tools/_app_store_connect/actions/publish_action.py @@ -172,7 +172,7 @@ def _get_altool( @classmethod def _get_app_store_connect_submit_options( cls, - ipa: Ipa, + application_package: Union[Ipa, MacOsPackage], submit_to_testflight: Optional[bool], submit_to_app_store: Optional[bool], # Submit to TestFlight arguments @@ -206,10 +206,7 @@ def _get_app_store_connect_submit_options( add_beta_test_info_options = None if not platform: - if ipa.is_for_tvos(): - platform = Platform.TV_OS - else: - platform = Platform.IOS + platform = cls._get_application_package_platform(application_package) if submit_to_testflight: submit_to_testflight_options = SubmitToTestFlightOptions( @@ -262,6 +259,14 @@ def _get_app_store_connect_submit_options( add_build_to_beta_group_options, ) + @staticmethod + def _get_application_package_platform(application_package: Union[Ipa, MacOsPackage]) -> Platform: + if isinstance(application_package, MacOsPackage): + return Platform.MAC_OS + if application_package.is_for_tvos(): + return Platform.TV_OS + return Platform.IOS + @cli.action( "publish", *ACTION_ARGUMENTS, @@ -311,18 +316,15 @@ def publish( Types.AltoolRetriesCount.resolve_value(altool_retries_count), Types.AltoolRetryWait.resolve_value(altool_retry_wait), ) - if isinstance(application_package, Ipa): - self._process_ipa_after_upload( + self._process_application_after_upload( + application_package, + *self._get_app_store_connect_submit_options( application_package, - *self._get_app_store_connect_submit_options( - application_package, - submit_to_testflight, - submit_to_app_store, - **app_store_connect_submit_options, - ), - ) - else: - continue # Cannot submit macOS packages to TestFlight, skip + submit_to_testflight, + submit_to_app_store, + **app_store_connect_submit_options, + ), + ) except (AppStoreConnectError, IOError, ValueError) as error: failed_packages.append(str(application_package.path)) self.logger.error(Colors.RED(error.args[0])) @@ -364,19 +366,19 @@ def _publish_application_package( else: self.logger.info(Colors.YELLOW('\nSkip uploading "%s" to App Store Connect'), application_package.path) - def _process_ipa_after_upload( + def _process_application_after_upload( self, - ipa: Ipa, + application_package: Union[Ipa, MacOsPackage], testflight_options: Optional[SubmitToTestFlightOptions], app_store_options: Optional[SubmitToAppStoreOptions], beta_test_info_options: Optional[AddBetaTestInfoOptions], beta_group_options: Optional[AddBuildToBetaGroupOptions], ) -> None: if not any([testflight_options, app_store_options, beta_test_info_options, beta_group_options]): - return # Nothing to do with the ipa... + return # Nothing to do with the application... - app = self._get_uploaded_build_application(ipa) - build = self._get_uploaded_build(app, ipa) + app = self._get_uploaded_build_application(application_package) + build = self._get_uploaded_build(app, application_package) if beta_test_info_options: self.add_beta_test_info(build.id, **beta_test_info_options.__dict__) @@ -398,7 +400,7 @@ def _process_ipa_after_upload( app_store_submission_kwargs = { **app_store_options.__dict__, "max_build_processing_wait": 0, # Overwrite waiting since we already waited above. - "version_string": app_store_options.version_string or ipa.version, + "version_string": app_store_options.version_string or application_package.version, } self.submit_to_app_store(build.id, **app_store_submission_kwargs) # type: ignore diff --git a/tests/apple/app_store_connect/builds/test_builds.py b/tests/apple/app_store_connect/builds/test_builds.py index a5e439f8..7601a177 100644 --- a/tests/apple/app_store_connect/builds/test_builds.py +++ b/tests/apple/app_store_connect/builds/test_builds.py @@ -4,6 +4,9 @@ from codemagic.apple.app_store_connect.builds import Builds from codemagic.apple.resources import AppStoreVersion from codemagic.apple.resources import Build +from codemagic.apple.resources import BuildBetaDetail +from codemagic.apple.resources import ExternalBetaState +from codemagic.apple.resources import InternalBetaState from codemagic.apple.resources import ResourceId from codemagic.apple.resources import ResourceType @@ -36,6 +39,15 @@ def test_read_app_store_version_not_exists(self): app_store_version = self.api_client.builds.read_app_store_version(build_id) assert app_store_version is None + def test_read_beta_detail(self): + build_id = ResourceId("3bf3e846-3d31-4e1e-ab0f-0834fb9f9a26") + build_beta_detail = self.api_client.builds.read_beta_detail(build_id) + assert isinstance(build_beta_detail, BuildBetaDetail) + assert build_beta_detail.attributes.internalBuildState == InternalBetaState.EXPIRED + assert build_beta_detail.attributes.externalBuildState == ExternalBetaState.EXPIRED + assert build_beta_detail.attributes.autoNotifyEnabled is True + assert build_id in build_beta_detail.relationships.build.links.related + @pytest.mark.parametrize( "python_field_name, apple_filter_name",