From 7645e81d532ed75abc541a80b94ee4cddb20a854 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Thu, 19 Mar 2026 13:48:01 -0500 Subject: [PATCH 01/60] Attempting to do CI to run unit tests on all PRs. Limiting to just iPhone for now --- .github/workflows/pull-request-ci.yml | 62 +++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/pull-request-ci.yml diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml new file mode 100644 index 00000000..0d0bdb37 --- /dev/null +++ b/.github/workflows/pull-request-ci.yml @@ -0,0 +1,62 @@ +name: Pull Request CI + +on: + pull_request: + workflow_dispatch: + +concurrency: + group: pull-request-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + name: Unit Tests (${{ matrix.scheme }}) + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + scheme: + - libPhoneNumber + - libPhoneNumberGeocoding + - libPhoneNumberShortNumber + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Run unit tests + env: + SCHEME: ${{ matrix.scheme }} + run: | + set -eo pipefail + + xcodebuild \ + -project libPhoneNumber.xcodeproj \ + -scheme "$SCHEME" \ + -destination 'platform=iOS Simulator,name=iPhone,OS=latest' \ + CODE_SIGNING_ALLOWED=NO \ + test + + podspec-lint: + name: Podspec Lint (${{ matrix.podspec }}) + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + podspec: + - libPhoneNumber-iOS.podspec + - libPhoneNumberGeocoding.podspec + - libPhoneNumberShortNumber.podspec + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Ensure CocoaPods is installed + run: | + if ! command -v pod >/dev/null; then + gem install cocoapods + fi + + - name: Lint podspec + run: pod lib lint "${{ matrix.podspec }}" --verbose From 96581e0f090d601ffef6215b8b21e57483d87909 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Thu, 19 Mar 2026 14:04:35 -0500 Subject: [PATCH 02/60] Specify "iPhone 17" --- .github/workflows/pull-request-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 0d0bdb37..7ba1c661 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -33,7 +33,7 @@ jobs: xcodebuild \ -project libPhoneNumber.xcodeproj \ -scheme "$SCHEME" \ - -destination 'platform=iOS Simulator,name=iPhone,OS=latest' \ + -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17' \ CODE_SIGNING_ALLOWED=NO \ test From c82e40f42e6046df32c11f8313d963fbb8e850ee Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Thu, 19 Mar 2026 15:31:05 -0500 Subject: [PATCH 03/60] Adding .xctestplans into the xcode project file and remo --- libPhoneNumber.xcodeproj/project.pbxproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libPhoneNumber.xcodeproj/project.pbxproj b/libPhoneNumber.xcodeproj/project.pbxproj index a8dbd477..24097528 100755 --- a/libPhoneNumber.xcodeproj/project.pbxproj +++ b/libPhoneNumber.xcodeproj/project.pbxproj @@ -196,6 +196,9 @@ BB3F7C672EBD34DB0091CF5B /* _HeaderOnlyShim.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = _HeaderOnlyShim.c; sourceTree = ""; }; BB3F7C682EBD34DB0091CF5B /* NBGeneratedShortNumberMetaData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NBGeneratedShortNumberMetaData.h; sourceTree = ""; }; BB3F7C692EBD34DB0091CF5B /* NBShortNumberMetadataHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NBShortNumberMetadataHelper.h; sourceTree = ""; }; + BB4248A42F6C8AB300A7438E /* libPhoneNumberTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = libPhoneNumberTests.xctestplan; sourceTree = ""; }; + BB4248A52F6C8AEE00A7438E /* libPhoneNumberShortNumberTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = libPhoneNumberShortNumberTests.xctestplan; sourceTree = ""; }; + BB4248A72F6C8B5600A7438E /* libPhoneNumberGeocodingTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = libPhoneNumberGeocodingTests.xctestplan; sourceTree = ""; }; BB6A710C2EBD02F500292CA8 /* libPhoneNumberMetaDataForTesting.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = libPhoneNumberMetaDataForTesting.zip; sourceTree = ""; }; BB6A710D2EBD02F500292CA8 /* NBTestingMetaData.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NBTestingMetaData.h; sourceTree = ""; }; BB6A71132EBD306400292CA8 /* _HeaderOnlyShim.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = _HeaderOnlyShim.c; sourceTree = ""; }; @@ -315,6 +318,7 @@ isa = PBXGroup; children = ( 0FC0D37A24A2A05E0087AFCF /* Info.plist */, + BB4248A72F6C8B5600A7438E /* libPhoneNumberGeocodingTests.xctestplan */, 94C9AF0E24B3AAF900469F54 /* NBPhoneNumberOfflineGeocoderTest.m */, CA55ED88296F51E0005E98A1 /* TestingSource.bundle */, ); @@ -325,6 +329,7 @@ isa = PBXGroup; children = ( 1485C52B1E06F4930092F541 /* Info.plist */, + BB4248A42F6C8AB300A7438E /* libPhoneNumberTests.xctestplan */, 1485C5231E06F4890092F541 /* NBAsYouTypeFormatterTest.m */, 0FAE11902037959800193503 /* NBPhoneNumberParsingPerfTest.m */, 1485C5251E06F4890092F541 /* NBPhoneNumberUtilTest.m */, @@ -351,6 +356,7 @@ isa = PBXGroup; children = ( 9407259A24BE768A0011AE05 /* Info.plist */, + BB4248A52F6C8AEE00A7438E /* libPhoneNumberShortNumberTests.xctestplan */, 940725AB24BF63050011AE05 /* NBShortNumberInfoTest.m */, 940725B024BF7B040011AE05 /* NBShortNumberTestHelper.h */, 940725B124BF7B040011AE05 /* NBShortNumberTestHelper.m */, From c93cd9f1ec19de9f83c731dbd1c7cb0b954b8e55 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Tue, 7 Apr 2026 17:45:06 -0500 Subject: [PATCH 04/60] Generate code coverage --- libPhoneNumber.xcodeproj/project.pbxproj | 65 ++++--------------- .../xcschemes/libPhoneNumber.xcscheme | 13 ++-- .../libPhoneNumberGeocoding.xcscheme | 22 +++---- .../libPhoneNumberShortNumber.xcscheme | 24 +++---- .../libPhoneNumberGeocodingTests.xctestplan | 14 +++- .../libPhoneNumberShortNumberTests.xctestplan | 13 +++- .../libPhoneNumberTests.xctestplan | 13 +++- 7 files changed, 73 insertions(+), 91 deletions(-) diff --git a/libPhoneNumber.xcodeproj/project.pbxproj b/libPhoneNumber.xcodeproj/project.pbxproj index 24097528..5311f0bd 100755 --- a/libPhoneNumber.xcodeproj/project.pbxproj +++ b/libPhoneNumber.xcodeproj/project.pbxproj @@ -36,8 +36,8 @@ 8B0FD2FF1E4A88AC0049DF81 /* NSArray+NBAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 8B0FD2FA1E4A88AC0049DF81 /* NSArray+NBAdditions.m */; }; 9407259424BE768A0011AE05 /* libPhoneNumberShortNumber.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9407258B24BE768A0011AE05 /* libPhoneNumberShortNumber.framework */; }; 9407259B24BE768A0011AE05 /* libPhoneNumberShortNumber.h in Headers */ = {isa = PBXBuildFile; fileRef = 9407258D24BE768A0011AE05 /* libPhoneNumberShortNumber.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 940725A224BE769D0011AE05 /* libPhoneNumber.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; platformFilter = ios; }; - 940725A324BE769D0011AE05 /* libPhoneNumber.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; platformFilter = ios; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 940725A224BE769D0011AE05 /* libPhoneNumber.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; }; + 940725A324BE769D0011AE05 /* libPhoneNumber.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 940725A924BE77420011AE05 /* NBShortNumberUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = 940725A724BE77420011AE05 /* NBShortNumberUtil.h */; settings = {ATTRIBUTES = (Public, ); }; }; 940725AA24BE77420011AE05 /* NBShortNumberUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = 940725A824BE77420011AE05 /* NBShortNumberUtil.m */; }; 940725AC24BF63050011AE05 /* NBShortNumberInfoTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 940725AB24BF63050011AE05 /* NBShortNumberInfoTest.m */; }; @@ -80,6 +80,7 @@ BB9B604A2EBE9A7800C48233 /* libPhoneNumber.h in Headers */ = {isa = PBXBuildFile; fileRef = BB9B60492EBE9A7800C48233 /* libPhoneNumber.h */; settings = {ATTRIBUTES = (Public, ); }; }; BB9B604B2EBE9B0C00C48233 /* libPhoneNumber.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; platformFilter = ios; }; BB9B604C2EBE9B2200C48233 /* libPhoneNumber.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 34ACBB851B7122AC0064B3BD /* libPhoneNumber.framework */; platformFilter = ios; }; + BB9FA4CB2F85BF9800CCF4FC /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = BB9FA4CA2F85BF8600CCF4FC /* libsqlite3.tbd */; }; BBF66CA92EBBF9B9005E3382 /* NBGeneratedPhoneNumberMetaData.m in Sources */ = {isa = PBXBuildFile; fileRef = BBF66CA82EBBF9B9005E3382 /* NBGeneratedPhoneNumberMetaData.m */; }; BBF66CAB2EBBF9E2005E3382 /* NBGeneratedShortNumberMetaData.m in Sources */ = {isa = PBXBuildFile; fileRef = BBF66CAA2EBBF9E2005E3382 /* NBGeneratedShortNumberMetaData.m */; }; BBF66CB02EBC0783005E3382 /* NSBundle+Extensions.m in Sources */ = {isa = PBXBuildFile; fileRef = BBF66CAD2EBC0783005E3382 /* NSBundle+Extensions.m */; }; @@ -216,6 +217,7 @@ BB9B60432EBE9A0500C48233 /* _HeaderOnlyShim.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = _HeaderOnlyShim.c; sourceTree = ""; }; BB9B60442EBE9A0500C48233 /* GeocodingMetaData.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = GeocodingMetaData.bundle; sourceTree = ""; }; BB9B60492EBE9A7800C48233 /* libPhoneNumber.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = libPhoneNumber.h; sourceTree = ""; }; + BB9FA4CA2F85BF8600CCF4FC /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; BBCABBF32EE0E61E0011A4C7 /* updateProjectVersions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = updateProjectVersions.swift; sourceTree = ""; }; BBCABBF52EE0F6E90011A4C7 /* versionCommitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = versionCommitter.swift; sourceTree = ""; }; BBF66CA82EBBF9B9005E3382 /* NBGeneratedPhoneNumberMetaData.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NBGeneratedPhoneNumberMetaData.m; sourceTree = ""; }; @@ -250,6 +252,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BB9FA4CB2F85BF9800CCF4FC /* libsqlite3.tbd in Frameworks */, 0FC0D36D24A29F680087AFCF /* libPhoneNumber.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -483,6 +486,7 @@ FD7A061F167715A0004BBEB6 /* Frameworks */ = { isa = PBXGroup; children = ( + BB9FA4CA2F85BF8600CCF4FC /* libsqlite3.tbd */, CAA5E78C29F84B7B00550AA7 /* Contacts.framework */, FD7A0624167715A0004BBEB6 /* CoreGraphics.framework */, FD7A0622167715A0004BBEB6 /* Foundation.framework */, @@ -714,9 +718,10 @@ FD7A0613167715A0004BBEB6 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0830; LastTestingUpgradeCheck = 0510; - LastUpgradeCheck = 1640; + LastUpgradeCheck = 2640; ORGANIZATIONNAME = Google; TargetAttributes = { 0FC0D36324A29F510087AFCF = { @@ -919,7 +924,6 @@ }; 940725A524BE769D0011AE05 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - platformFilter = ios; target = 34ACBB841B7122AC0064B3BD /* libPhoneNumber */; targetProxy = 940725A424BE769D0011AE05 /* PBXContainerItemProxy */; }; @@ -931,8 +935,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -940,13 +942,11 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = dwarf; - DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = MQV8HVXR99; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberGeocoding/Info.plist; @@ -973,8 +973,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -983,14 +981,12 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = MQV8HVXR99; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberGeocoding/Info.plist; @@ -1017,9 +1013,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_CODE_COVERAGE = NO; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1027,8 +1020,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = dwarf; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberGeocodingTests/Info.plist; @@ -1050,9 +1041,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_CODE_COVERAGE = NO; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1062,8 +1050,6 @@ COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberGeocodingTests/Info.plist; @@ -1085,14 +1071,12 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = NO; CLANG_ANALYZER_NONNULL = YES; - CLANG_ENABLE_CODE_COVERAGE = NO; CLANG_ENABLE_MODULES = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_SUSPICIOUS_MOVES = YES; DEBUG_INFORMATION_FORMAT = dwarf; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; @@ -1113,7 +1097,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = NO; CLANG_ANALYZER_NONNULL = YES; - CLANG_ENABLE_CODE_COVERAGE = NO; CLANG_ENABLE_MODULES = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1122,7 +1105,6 @@ COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = NO; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; @@ -1141,7 +1123,6 @@ 34ACBB9E1B7122AC0064B3BD /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CLANG_ENABLE_MODULES = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; @@ -1149,9 +1130,7 @@ CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -1183,7 +1162,6 @@ 34ACBB9F1B7122AC0064B3BD /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - CLANG_ENABLE_MODULES = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; @@ -1191,17 +1169,14 @@ CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; @@ -1230,7 +1205,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; @@ -1239,12 +1213,10 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = dwarf; - DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 973LHT5R86; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberShortNumber/Info.plist; @@ -1271,7 +1243,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; @@ -1281,13 +1252,11 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 973LHT5R86; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberShortNumber/Info.plist; @@ -1314,8 +1283,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1324,7 +1291,6 @@ CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 973LHT5R86; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberShortNumberTests/Info.plist; @@ -1346,8 +1312,6 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; @@ -1358,7 +1322,6 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 973LHT5R86; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; INFOPLIST_FILE = libPhoneNumberShortNumberTests/Info.plist; @@ -1400,15 +1363,14 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_IDENTITY = ""; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1.4.0; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; - GCC_GENERATE_TEST_COVERAGE_FILES = YES; - GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = YES; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -1428,6 +1390,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; TVOS_DEPLOYMENT_TARGET = 12.0; @@ -1462,13 +1425,12 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_IDENTITY = ""; COPY_PHASE_STRIP = YES; CURRENT_PROJECT_VERSION = 1.4.0; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_GENERATE_TEST_COVERAGE_FILES = YES; - GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = YES; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES; @@ -1481,6 +1443,7 @@ MARKETING_VERSION = 1.4.0; OTHER_CFLAGS = ""; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumber.xcscheme b/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumber.xcscheme index 4bab9f45..e19219da 100644 --- a/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumber.xcscheme +++ b/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumber.xcscheme @@ -1,10 +1,10 @@ + buildImplicitDependencies = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + diff --git a/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumberGeocoding.xcscheme b/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumberGeocoding.xcscheme index 440e8886..7be2b763 100644 --- a/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumberGeocoding.xcscheme +++ b/libPhoneNumber.xcodeproj/xcshareddata/xcschemes/libPhoneNumberGeocoding.xcscheme @@ -1,10 +1,10 @@ + buildImplicitDependencies = "NO"> - - - - - - + + + + + LastUpgradeVersion = "2640" + version = "1.7"> + buildImplicitDependencies = "NO"> - - - - - - + + + + Date: Tue, 7 Apr 2026 18:01:29 -0500 Subject: [PATCH 05/60] Add code coverage results --- .github/workflows/pull-request-ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 7ba1c661..c5190d71 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -29,14 +29,24 @@ jobs: SCHEME: ${{ matrix.scheme }} run: | set -eo pipefail + mkdir -p TestResults xcodebuild \ -project libPhoneNumber.xcodeproj \ -scheme "$SCHEME" \ -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17' \ + -resultBundlePath "TestResults/${SCHEME}.xcresult" \ + -enableCodeCoverage YES \ CODE_SIGNING_ALLOWED=NO \ test + - name: Upload unit test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: project-unit-tests-${{ matrix.scheme }} + path: TestResults/${{ matrix.scheme }}.xcresult + podspec-lint: name: Podspec Lint (${{ matrix.podspec }}) runs-on: macos-latest From 31c0f504b76a1f628e1f8502c9c056afd67c97d5 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 11:19:44 -0500 Subject: [PATCH 06/60] Upload xcresult as an xcresult Dynamically determine the newest iPhone model and run tests on that --- .github/workflows/pull-request-ci.yml | 38 +++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index c5190d71..c99a1e0e 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -24,6 +24,40 @@ jobs: - name: Check out repository uses: actions/checkout@v4 + - name: Resolve iPhone simulator destination + id: destination + run: | + set -eo pipefail + + destination_id="$( + xcrun simctl list devices --json | + python3 -c ' +import json +import re +import sys + +matches = [] +for runtime, entries in json.load(sys.stdin).get("devices", {}).items(): + runtime_match = re.match(r"com\\.apple\\.CoreSimulator\\.SimRuntime\\.iOS-(\\d+)-(\\d+)", runtime) + if not runtime_match: + continue + + os_version = tuple(int(part) for part in runtime_match.groups()) + for entry in entries: + name = (entry.get("name") or "").strip() + model_match = re.match(r"iPhone\\s+(\\d+)\\b", name) + if entry.get("isAvailable") and entry.get("udid") and model_match: + matches.append((os_version, int(model_match.group(1)), entry["udid"])) + +if not matches: + raise SystemExit("No iPhone simulator destinations found") + +print(max(matches)[2]) +' + )" + + echo "destination=id=$destination_id" >> "$GITHUB_OUTPUT" + - name: Run unit tests env: SCHEME: ${{ matrix.scheme }} @@ -34,7 +68,7 @@ jobs: xcodebuild \ -project libPhoneNumber.xcodeproj \ -scheme "$SCHEME" \ - -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17' \ + -destination "${{ steps.destination.outputs.destination }}" \ -resultBundlePath "TestResults/${SCHEME}.xcresult" \ -enableCodeCoverage YES \ CODE_SIGNING_ALLOWED=NO \ @@ -44,7 +78,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: project-unit-tests-${{ matrix.scheme }} + name: project-unit-tests-${{ matrix.scheme }}.xcresult path: TestResults/${{ matrix.scheme }}.xcresult podspec-lint: From 4c1efa3b900a05f287e98b50c0924566d69f4788 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 11:32:48 -0500 Subject: [PATCH 07/60] Attempting to indent the imbedded python script --- .github/workflows/pull-request-ci.yml | 44 +++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index c99a1e0e..8d09f485 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -32,28 +32,28 @@ jobs: destination_id="$( xcrun simctl list devices --json | python3 -c ' -import json -import re -import sys - -matches = [] -for runtime, entries in json.load(sys.stdin).get("devices", {}).items(): - runtime_match = re.match(r"com\\.apple\\.CoreSimulator\\.SimRuntime\\.iOS-(\\d+)-(\\d+)", runtime) - if not runtime_match: - continue - - os_version = tuple(int(part) for part in runtime_match.groups()) - for entry in entries: - name = (entry.get("name") or "").strip() - model_match = re.match(r"iPhone\\s+(\\d+)\\b", name) - if entry.get("isAvailable") and entry.get("udid") and model_match: - matches.append((os_version, int(model_match.group(1)), entry["udid"])) - -if not matches: - raise SystemExit("No iPhone simulator destinations found") - -print(max(matches)[2]) -' + import json + import re + import sys + + matches = [] + for runtime, entries in json.load(sys.stdin).get("devices", {}).items(): + runtime_match = re.match(r"com\\.apple\\.CoreSimulator\\.SimRuntime\\.iOS-(\\d+)-(\\d+)", runtime) + if not runtime_match: + continue + + os_version = tuple(int(part) for part in runtime_match.groups()) + for entry in entries: + name = (entry.get("name") or "").strip() + model_match = re.match(r"iPhone\\s+(\\d+)\\b", name) + if entry.get("isAvailable") and entry.get("udid") and model_match: + matches.append((os_version, int(model_match.group(1)), entry["udid"])) + + if not matches: + raise SystemExit("No iPhone simulator destinations found") + + print(max(matches)[2]) + ' )" echo "destination=id=$destination_id" >> "$GITHUB_OUTPUT" From fd2ac21928277403df35f4e90c0e4dda2e5ffe26 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 11:39:58 -0500 Subject: [PATCH 08/60] Debugging around the xcrun simctl command --- .github/workflows/pull-request-ci.yml | 40 +++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 8d09f485..b52284c8 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -29,6 +29,16 @@ jobs: run: | set -eo pipefail + echo "macOS version:" + sw_vers + echo "xcodebuild path: $(xcode-select -p)" + echo "xcodebuild version:" + xcodebuild -version + echo "xcrun path: $(command -v xcrun)" + echo "simctl path: $(xcrun --find simctl)" + echo "Available runtimes:" + xcrun simctl list runtimes + destination_id="$( xcrun simctl list devices --json | python3 -c ' @@ -36,26 +46,50 @@ jobs: import re import sys + devices = json.load(sys.stdin).get("devices", {}) + print(f"Discovered {len(devices)} simulator runtime groups", file=sys.stderr) + matches = [] - for runtime, entries in json.load(sys.stdin).get("devices", {}).items(): + for runtime, entries in devices.items(): + print(f"Inspecting runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) runtime_match = re.match(r"com\\.apple\\.CoreSimulator\\.SimRuntime\\.iOS-(\\d+)-(\\d+)", runtime) if not runtime_match: + print(" Skipping non-iOS runtime", file=sys.stderr) continue os_version = tuple(int(part) for part in runtime_match.groups()) for entry in entries: name = (entry.get("name") or "").strip() model_match = re.match(r"iPhone\\s+(\\d+)\\b", name) - if entry.get("isAvailable") and entry.get("udid") and model_match: + is_available = entry.get("isAvailable") + udid = entry.get("udid") + + if is_available and udid and model_match: + print( + f" Candidate: name={name!r} os={os_version} model={model_match.group(1)} udid={udid}", + file=sys.stderr, + ) matches.append((os_version, int(model_match.group(1)), entry["udid"])) + else: + print( + f" Ignoring device: name={name!r} available={is_available} " + f"has_udid={bool(udid)} matches_iPhone={bool(model_match)}", + file=sys.stderr, + ) if not matches: raise SystemExit("No iPhone simulator destinations found") - print(max(matches)[2]) + selected = max(matches) + print( + f"Selected destination: os={selected[0]} model={selected[1]} udid={selected[2]}", + file=sys.stderr, + ) + print(selected[2]) ' )" + echo "Resolved destination id: $destination_id" echo "destination=id=$destination_id" >> "$GITHUB_OUTPUT" - name: Run unit tests From 9ae206afe0e9591f70604b98fca8d673dc11faa8 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 11:44:11 -0500 Subject: [PATCH 09/60] Try to NOT escape out the back slashes in the regex strings --- .github/workflows/pull-request-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index b52284c8..364f472b 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -52,7 +52,7 @@ jobs: matches = [] for runtime, entries in devices.items(): print(f"Inspecting runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) - runtime_match = re.match(r"com\\.apple\\.CoreSimulator\\.SimRuntime\\.iOS-(\\d+)-(\\d+)", runtime) + runtime_match = re.match(r"com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)", runtime) if not runtime_match: print(" Skipping non-iOS runtime", file=sys.stderr) continue @@ -60,7 +60,7 @@ jobs: os_version = tuple(int(part) for part in runtime_match.groups()) for entry in entries: name = (entry.get("name") or "").strip() - model_match = re.match(r"iPhone\\s+(\\d+)\\b", name) + model_match = re.match(r"iPhone\s+(\d+)\b", name) is_available = entry.get("isAvailable") udid = entry.get("udid") From b9db29207acc7576bcd218857c311f1370d44f78 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 11:59:39 -0500 Subject: [PATCH 10/60] Trim up the output. --- .github/workflows/pull-request-ci.yml | 45 +++++---------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 364f472b..d394591e 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -29,16 +29,6 @@ jobs: run: | set -eo pipefail - echo "macOS version:" - sw_vers - echo "xcodebuild path: $(xcode-select -p)" - echo "xcodebuild version:" - xcodebuild -version - echo "xcrun path: $(command -v xcrun)" - echo "simctl path: $(xcrun --find simctl)" - echo "Available runtimes:" - xcrun simctl list runtimes - destination_id="$( xcrun simctl list devices --json | python3 -c ' @@ -46,50 +36,31 @@ jobs: import re import sys - devices = json.load(sys.stdin).get("devices", {}) - print(f"Discovered {len(devices)} simulator runtime groups", file=sys.stderr) - matches = [] - for runtime, entries in devices.items(): - print(f"Inspecting runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) + for runtime, entries in json.load(sys.stdin).get("devices", {}).items(): runtime_match = re.match(r"com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)", runtime) if not runtime_match: - print(" Skipping non-iOS runtime", file=sys.stderr) continue + print(f"Runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) + os_version = tuple(int(part) for part in runtime_match.groups()) for entry in entries: name = (entry.get("name") or "").strip() model_match = re.match(r"iPhone\s+(\d+)\b", name) - is_available = entry.get("isAvailable") - udid = entry.get("udid") - - if is_available and udid and model_match: - print( - f" Candidate: name={name!r} os={os_version} model={model_match.group(1)} udid={udid}", - file=sys.stderr, - ) - matches.append((os_version, int(model_match.group(1)), entry["udid"])) - else: - print( - f" Ignoring device: name={name!r} available={is_available} " - f"has_udid={bool(udid)} matches_iPhone={bool(model_match)}", - file=sys.stderr, - ) + if entry.get("isAvailable") and entry.get("udid") and model_match: + matches.append((os_version, int(model_match.group(1)), model, entry["udid"])) + print(f" {name} - {os_version[0]}.{os_version[1]} ({udid})", file=sys.stderr) if not matches: raise SystemExit("No iPhone simulator destinations found") selected = max(matches) - print( - f"Selected destination: os={selected[0]} model={selected[1]} udid={selected[2]}", - file=sys.stderr, - ) - print(selected[2]) + print(f"Selected: {selected[2]} - {selected[0][0]}.{selected[0][1]} (selected[3])", file=sys.stderr) + print(selected[3]) ' )" - echo "Resolved destination id: $destination_id" echo "destination=id=$destination_id" >> "$GITHUB_OUTPUT" - name: Run unit tests From 36f078856ae2e23e790ba38ae16a1041fc8bef6b Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 12:00:46 -0500 Subject: [PATCH 11/60] Fix model name --- .github/workflows/pull-request-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index d394591e..e40f3cb7 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -49,7 +49,7 @@ jobs: name = (entry.get("name") or "").strip() model_match = re.match(r"iPhone\s+(\d+)\b", name) if entry.get("isAvailable") and entry.get("udid") and model_match: - matches.append((os_version, int(model_match.group(1)), model, entry["udid"])) + matches.append((os_version, int(model_match.group(1)), name, entry["udid"])) print(f" {name} - {os_version[0]}.{os_version[1]} ({udid})", file=sys.stderr) if not matches: From c696a833b15f7d0b5f955ddd463c15cdb76b89bb Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 12:03:52 -0500 Subject: [PATCH 12/60] no udid variable...dummy --- .github/workflows/pull-request-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index e40f3cb7..40d938b3 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -50,7 +50,7 @@ jobs: model_match = re.match(r"iPhone\s+(\d+)\b", name) if entry.get("isAvailable") and entry.get("udid") and model_match: matches.append((os_version, int(model_match.group(1)), name, entry["udid"])) - print(f" {name} - {os_version[0]}.{os_version[1]} ({udid})", file=sys.stderr) + print(f" {name} - {os_version[0]}.{os_version[1]} ({entry["udid"]})", file=sys.stderr) if not matches: raise SystemExit("No iPhone simulator destinations found") From 0f75a1889f02e7e4fbf5d06e2d0f994d91ff4328 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 12:06:15 -0500 Subject: [PATCH 13/60] I made myself laugh! --- .github/workflows/pull-request-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 40d938b3..2da39f37 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -56,7 +56,7 @@ jobs: raise SystemExit("No iPhone simulator destinations found") selected = max(matches) - print(f"Selected: {selected[2]} - {selected[0][0]}.{selected[0][1]} (selected[3])", file=sys.stderr) + print(f"Selected: {selected[2]} - {selected[0][0]}.{selected[0][1]} ({selected[3]})", file=sys.stderr) print(selected[3]) ' )" From 2ca5e8298fd6c06b5c69a59b631ca2681b3c542b Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 12:27:32 -0500 Subject: [PATCH 14/60] Prefer arm64 --- .github/workflows/pull-request-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 2da39f37..1ac754f1 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -61,7 +61,7 @@ jobs: ' )" - echo "destination=id=$destination_id" >> "$GITHUB_OUTPUT" + echo "destination=id=$destination_id,arch=arm64" >> "$GITHUB_OUTPUT" - name: Run unit tests env: From a6ecc7293405696e3bb836bbb0b3f90bb7d546e9 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 12:33:43 -0500 Subject: [PATCH 15/60] Update to cleanly support Node.js 24 (and 20) --- .github/workflows/pull-request-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 1ac754f1..9a05174f 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Resolve iPhone simulator destination id: destination @@ -81,7 +81,7 @@ jobs: - name: Upload unit test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: project-unit-tests-${{ matrix.scheme }}.xcresult path: TestResults/${{ matrix.scheme }}.xcresult @@ -99,7 +99,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Ensure CocoaPods is installed run: | From 232308f6dc8e3b5c70410696fe1235a786048168 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 13:04:02 -0500 Subject: [PATCH 16/60] Attempting to publish up the code coverage results for pull requests --- .coveralls.yml | 1 - .github/workflows/pull-request-ci.yml | 124 +++++++++++++++++++++++++- .slather.yml | 12 --- .travis.yml | 17 ---- README.md | 3 +- spec_helper.rb | 2 - 6 files changed, 122 insertions(+), 37 deletions(-) delete mode 100644 .coveralls.yml delete mode 100644 .slather.yml delete mode 100644 .travis.yml delete mode 100644 spec_helper.rb diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index 91600595..00000000 --- a/.coveralls.yml +++ /dev/null @@ -1 +0,0 @@ -service_name: travis-ci diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 9a05174f..6d6e0c45 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -50,13 +50,13 @@ jobs: model_match = re.match(r"iPhone\s+(\d+)\b", name) if entry.get("isAvailable") and entry.get("udid") and model_match: matches.append((os_version, int(model_match.group(1)), name, entry["udid"])) - print(f" {name} - {os_version[0]}.{os_version[1]} ({entry["udid"]})", file=sys.stderr) + print(f" {name} (iOS {os_version[0]}.{os_version[1]}) [{entry["udid"]}]", file=sys.stderr) if not matches: raise SystemExit("No iPhone simulator destinations found") - selected = max(matches) - print(f"Selected: {selected[2]} - {selected[0][0]}.{selected[0][1]} ({selected[3]})", file=sys.stderr) + selected = max(matches); name=selected[2]; theOS=""+selected[0][0]+"."+selected[0][1] + print(f"Selected Simulator: {name} (iOS {theOS}) [{selected[3]}]", file=sys.stderr) print(selected[3]) ' )" @@ -79,6 +79,39 @@ jobs: CODE_SIGNING_ALLOWED=NO \ test + - name: Export code coverage data + if: always() + env: + SCHEME: ${{ matrix.scheme }} + run: | + set -eo pipefail + + result_bundle="TestResults/${SCHEME}.xcresult" + coverage_json="TestResults/${SCHEME}.xccov.json" + + echo "### Code Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [ ! -d "$result_bundle" ]; then + echo "Coverage report unavailable because \`$result_bundle\` was not created." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + if ! xcrun xccov view --archive --json "$result_bundle" > "$coverage_json"; then + echo "Coverage report unavailable because \`xccov\` could not parse \`$result_bundle\`." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "Exported line-level coverage data for \`$SCHEME\`." >> "$GITHUB_STEP_SUMMARY" + + - name: Upload code coverage data + if: always() + uses: actions/upload-artifact@v6 + with: + name: unit-test-coverage-${{ matrix.scheme }} + path: TestResults/${{ matrix.scheme }}.xccov.json + if-no-files-found: ignore + - name: Upload unit test results if: always() uses: actions/upload-artifact@v6 @@ -86,6 +119,91 @@ jobs: name: project-unit-tests-${{ matrix.scheme }}.xcresult path: TestResults/${{ matrix.scheme }}.xcresult + coverage-summary: + name: Combined Code Coverage + runs-on: ubuntu-latest + needs: unit-tests + if: always() + + steps: + - name: Download code coverage data + continue-on-error: true + uses: actions/download-artifact@v5 + with: + pattern: unit-test-coverage-* + path: CoverageData + merge-multiple: true + + - name: Publish combined coverage summary + run: | + set -eo pipefail + shopt -s nullglob + + coverage_files=(CoverageData/*.xccov.json) + + echo "### Combined Code Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [ ${#coverage_files[@]} -eq 0 ]; then + echo "Combined coverage unavailable because no coverage data artifacts were downloaded." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + python3 - "${coverage_files[@]}" >> "$GITHUB_STEP_SUMMARY" <<'PY' + import json + import os + import sys + + combined = {} + per_scheme = [] + + for path in sys.argv[1:]: + with open(path) as handle: + report = json.load(handle) + + covered_lines = 0 + executable_lines = 0 + for file_path, entries in report.items(): + combined_lines = combined.setdefault(file_path, {}) + for entry in entries: + line_number = entry.get("line") + if line_number is None or not entry.get("isExecutable"): + continue + + is_covered = entry.get("executionCount", 0) > 0 + executable_lines += 1 + if is_covered: + covered_lines += 1 + + previous = combined_lines.get(line_number, False) + combined_lines[line_number] = previous or is_covered + + scheme_name = os.path.basename(path).replace(".xccov.json", "") + coverage_percent = (covered_lines / executable_lines * 100) if executable_lines else 0.0 + per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) + + combined_executable_lines = sum(len(lines) for lines in combined.values()) + combined_covered_lines = sum( + 1 for lines in combined.values() for is_covered in lines.values() if is_covered + ) + combined_coverage_percent = ( + combined_covered_lines / combined_executable_lines * 100 + if combined_executable_lines + else 0.0 + ) + + print("| Scope | Coverage | Covered Lines | Executable Lines |") + print("| --- | ---: | ---: | ---: |") + for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): + print( + f"| {scheme_name} | {coverage_percent:.2f}% | {covered_lines} | {executable_lines} |" + ) + print( + f"| Combined | {combined_coverage_percent:.2f}% | " + f"{combined_covered_lines} | {combined_executable_lines} |" + ) + PY + podspec-lint: name: Podspec Lint (${{ matrix.podspec }}) runs-on: macos-latest diff --git a/.slather.yml b/.slather.yml deleted file mode 100644 index b1c40362..00000000 --- a/.slather.yml +++ /dev/null @@ -1,12 +0,0 @@ -ci_service: travis_ci -coverage_service: coveralls -xcodeproj: libPhoneNumber.xcodeproj -ignore: - - libPhoneNumber/AppDelegate.m - - libPhoneNumber/main.m - - libPhoneNumber/NBPhoneMetaDataGenerator.m - - libPhoneNumber/NBPhoneMetaData.m - - libPhoneNumber/NBPhoneNumberDesc.m - - libPhoneNumber/NBMetadataHelper.m - - libPhoneNumber/NBNumberFormat.m - - libPhoneNumber/NBPhoneNumber.m diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 94d69ebb..00000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: objective-c - -before_install: -- gem install slather -N - -script: -- xcodebuild -project libPhoneNumber.xcodeproj -scheme libPhoneNumberiOS clean -sdk iphonesimulator -- xcodebuild -project libPhoneNumber.xcodeproj -scheme libPhoneNumberiOS -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES build-for-testing -- xctool -project libPhoneNumber.xcodeproj -scheme libPhoneNumberiOS run-tests -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES GCC_GENERATE_TEST_COVERAGE_FILES=YES - -- xcodebuild -project libPhoneNumber.xcodeproj -scheme libPhoneNumberGeocoding -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES build-for-testing -- xctool -project libPhoneNumber.xcodeproj -scheme libPhoneNumberGeocoding run-tests -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES GCC_GENERATE_TEST_COVERAGE_FILES=YES - -- xcodebuild -project libPhoneNumber.xcodeproj -scheme libPhoneNumberShortNumber -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES build-for-testing -- xctool -project libPhoneNumber.xcodeproj -scheme libPhoneNumberShortNumber run-tests -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES GCC_GENERATE_TEST_COVERAGE_FILES=YES - -after_success: slather diff --git a/README.md b/README.md index 3d910320..5e3d5fbc 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ [![CocoaPods](https://img.shields.io/cocoapods/p/libPhoneNumber-iOS.svg?style=flat)](http://cocoapods.org/?q=libPhoneNumber-iOS) [![CocoaPods](https://img.shields.io/cocoapods/v/libPhoneNumber-iOS.svg?style=flat)](http://cocoapods.org/?q=libPhoneNumber-iOS) -[![Travis](https://travis-ci.org/iziz/libPhoneNumber-iOS.svg?branch=master)](https://travis-ci.org/iziz/libPhoneNumber-iOS) -[![Coveralls](https://coveralls.io/repos/iziz/libPhoneNumber-iOS/badge.svg?branch=master&service=github)](https://coveralls.io/github/iziz/libPhoneNumber-iOS?branch=master) +[![Pull Request CI](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/pull-request-ci.yml/badge.svg?branch=master)](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/pull-request-ci.yml) [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) # **libPhoneNumber for iOS** diff --git a/spec_helper.rb b/spec_helper.rb deleted file mode 100644 index 54a6989c..00000000 --- a/spec_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -require 'coveralls' -Coveralls.wear! From cf1558082832ae3787a320501b8d7104860351e3 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 13:07:37 -0500 Subject: [PATCH 17/60] Better string manipulation --- .github/workflows/pull-request-ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 6d6e0c45..f3657eb2 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -50,12 +50,13 @@ jobs: model_match = re.match(r"iPhone\s+(\d+)\b", name) if entry.get("isAvailable") and entry.get("udid") and model_match: matches.append((os_version, int(model_match.group(1)), name, entry["udid"])) - print(f" {name} (iOS {os_version[0]}.{os_version[1]}) [{entry["udid"]}]", file=sys.stderr) + theOS=f"{os_version[0]}.{os_version[1]}" + print(f" {name} (iOS {theOS}) [{entry["udid"]}]", file=sys.stderr) if not matches: raise SystemExit("No iPhone simulator destinations found") - selected = max(matches); name=selected[2]; theOS=""+selected[0][0]+"."+selected[0][1] + selected = max(matches); name=selected[2]; theOS=f"{selected[0][0]}.{selected[0][1]}" print(f"Selected Simulator: {name} (iOS {theOS}) [{selected[3]}]", file=sys.stderr) print(selected[3]) ' From 9d84000fd87272beb21aef0e85d1782184391118 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 13:40:12 -0500 Subject: [PATCH 18/60] Clean up --- .github/workflows/pull-request-ci.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index f3657eb2..b6d26ef3 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -4,6 +4,11 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + issues: write + pull-requests: write + concurrency: group: pull-request-ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true @@ -50,14 +55,14 @@ jobs: model_match = re.match(r"iPhone\s+(\d+)\b", name) if entry.get("isAvailable") and entry.get("udid") and model_match: matches.append((os_version, int(model_match.group(1)), name, entry["udid"])) - theOS=f"{os_version[0]}.{os_version[1]}" - print(f" {name} (iOS {theOS}) [{entry["udid"]}]", file=sys.stderr) + the_os = f"{os_version[0]}.{os_version[1]}" + print(f" {name} (iOS {the_os}) [{entry['udid']}]", file=sys.stderr) if not matches: raise SystemExit("No iPhone simulator destinations found") - selected = max(matches); name=selected[2]; theOS=f"{selected[0][0]}.{selected[0][1]}" - print(f"Selected Simulator: {name} (iOS {theOS}) [{selected[3]}]", file=sys.stderr) + selected = max(matches); name = selected[2]; the_os = f"{selected[0][0]}.{selected[0][1]}" + print(f"Selected Simulator: {name} (iOS {the_os}) [{selected[3]}]", file=sys.stderr) print(selected[3]) ' )" @@ -129,7 +134,7 @@ jobs: steps: - name: Download code coverage data continue-on-error: true - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v7 with: pattern: unit-test-coverage-* path: CoverageData From b7a7f1497931a25d9dfa94846bff3a15e980c501 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 13:40:32 -0500 Subject: [PATCH 19/60] Attempt to publish coverage to the pull request --- .github/workflows/pull-request-ci.yml | 115 +++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index b6d26ef3..53c66f66 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -145,17 +145,29 @@ jobs: set -eo pipefail shopt -s nullglob + mkdir -p CoverageData coverage_files=(CoverageData/*.xccov.json) + summary_file="CoverageData/combined-coverage-summary.md" - echo "### Combined Code Coverage" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Coverage data directory contents:" + ls -la CoverageData || true + echo "Coverage JSON files found: ${#coverage_files[@]}" + for coverage_file in "${coverage_files[@]}"; do + echo "Coverage artifact: $coverage_file" + wc -c "$coverage_file" + done if [ ${#coverage_files[@]} -eq 0 ]; then - echo "Combined coverage unavailable because no coverage data artifacts were downloaded." >> "$GITHUB_STEP_SUMMARY" + { + echo "### Combined Code Coverage" + echo + echo "Combined coverage unavailable because no coverage data artifacts were downloaded." + } > "$summary_file" + cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" exit 0 fi - python3 - "${coverage_files[@]}" >> "$GITHUB_STEP_SUMMARY" <<'PY' + python3 - "${coverage_files[@]}" > "$summary_file" <<'PY' import json import os import sys @@ -164,6 +176,7 @@ jobs: per_scheme = [] for path in sys.argv[1:]: + print(f"Parsing coverage file: {path}", file=sys.stderr) with open(path) as handle: report = json.load(handle) @@ -187,6 +200,10 @@ jobs: scheme_name = os.path.basename(path).replace(".xccov.json", "") coverage_percent = (covered_lines / executable_lines * 100) if executable_lines else 0.0 per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) + print( + f"Scheme {scheme_name}: covered={covered_lines} executable={executable_lines} coverage={coverage_percent:.2f}%", + file=sys.stderr, + ) combined_executable_lines = sum(len(lines) for lines in combined.values()) combined_covered_lines = sum( @@ -198,6 +215,13 @@ jobs: else 0.0 ) + print( + f"Combined coverage: covered={combined_covered_lines} executable={combined_executable_lines} coverage={combined_coverage_percent:.2f}%", + file=sys.stderr, + ) + + print("### Combined Code Coverage") + print() print("| Scope | Coverage | Covered Lines | Executable Lines |") print("| --- | ---: | ---: | ---: |") for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): @@ -210,6 +234,89 @@ jobs: ) PY + echo "Combined coverage summary:" + cat "$summary_file" + cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" + + - name: Publish combined coverage comment to pull request + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + set -eo pipefail + + summary_file="CoverageData/combined-coverage-summary.md" + if [ ! -f "$summary_file" ]; then + echo "No combined coverage summary file was generated; skipping PR comment." + exit 0 + fi + + comment_body_file="$(mktemp)" + { + echo '' + echo + cat "$summary_file" + } > "$comment_body_file" + + comments_json="$(mktemp)" + curl -fsSL \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" \ + > "$comments_json" + + comment_id="$( + python3 - "$comments_json" <<'PY' + import json + import sys + + marker = "" + with open(sys.argv[1]) as handle: + comments = json.load(handle) + + for comment in comments: + if marker in comment.get("body", ""): + print(comment["id"]) + break + PY + )" + + payload_file="$(mktemp)" + python3 - "$comment_body_file" "$payload_file" <<'PY' + import json + import sys + + with open(sys.argv[1]) as handle: + body = handle.read() + + with open(sys.argv[2], "w") as handle: + json.dump({"body": body}, handle) + PY + + if [ -n "$comment_id" ]; then + echo "Updating existing coverage PR comment: $comment_id" + curl -fsSL \ + -X PATCH \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "Content-Type: application/json" \ + --data @"$payload_file" \ + "https://api.github.com/repos/$REPOSITORY/issues/comments/$comment_id" \ + > /dev/null + else + echo "Creating new coverage PR comment" + curl -fsSL \ + -X POST \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "Content-Type: application/json" \ + --data @"$payload_file" \ + "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" \ + > /dev/null + fi + podspec-lint: name: Podspec Lint (${{ matrix.podspec }}) runs-on: macos-latest From dd4443ecd5857a495bbdc30a7c3f52f36c9bb39c Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 13:45:13 -0500 Subject: [PATCH 20/60] more clean up --- .github/workflows/pull-request-ci.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 53c66f66..17bf29b4 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -51,12 +51,13 @@ jobs: os_version = tuple(int(part) for part in runtime_match.groups()) for entry in entries: - name = (entry.get("name") or "").strip() + name = (entry["name"] or "").strip() model_match = re.match(r"iPhone\s+(\d+)\b", name) - if entry.get("isAvailable") and entry.get("udid") and model_match: - matches.append((os_version, int(model_match.group(1)), name, entry["udid"])) - the_os = f"{os_version[0]}.{os_version[1]}" - print(f" {name} (iOS {the_os}) [{entry['udid']}]", file=sys.stderr) + udid = entry["udid"] + if entry["isAvailable"] and udid and model_match: + matches.append((os_version, int(model_match.group(1)), name, udid)) + the_os = f"{os_version[0]}.{os_version[1]}" + print(f" {name} (iOS {the_os}) [{udid}]", file=sys.stderr) if not matches: raise SystemExit("No iPhone simulator destinations found") @@ -185,8 +186,8 @@ jobs: for file_path, entries in report.items(): combined_lines = combined.setdefault(file_path, {}) for entry in entries: - line_number = entry.get("line") - if line_number is None or not entry.get("isExecutable"): + line_number = entry["line"] + if line_number is None or not entry["isExecutable"]: continue is_covered = entry.get("executionCount", 0) > 0 From 565897606c5de936d80e303c49ff3f36c42cb8d1 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 14:31:47 -0500 Subject: [PATCH 21/60] Try to fix the combined code coverage logic --- .github/workflows/pull-request-ci.yml | 65 +++++++++++---------------- 1 file changed, 27 insertions(+), 38 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 17bf29b4..0ab52860 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -254,47 +254,36 @@ jobs: exit 0 fi - comment_body_file="$(mktemp)" - { - echo '' - echo - cat "$summary_file" - } > "$comment_body_file" - - comments_json="$(mktemp)" - curl -fsSL \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" \ - > "$comments_json" - comment_id="$( - python3 - "$comments_json" <<'PY' - import json - import sys - - marker = "" - with open(sys.argv[1]) as handle: - comments = json.load(handle) - - for comment in comments: - if marker in comment.get("body", ""): - print(comment["id"]) - break - PY + curl -fsSL \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | + python3 -c ' + import json + import sys + + marker = "" + comments = json.load(sys.stdin) + + for comment in comments: + if marker in comment.get("body", ""): + print(comment["id"]) + break + ' )" - payload_file="$(mktemp)" - python3 - "$comment_body_file" "$payload_file" <<'PY' - import json - import sys + payload="$( + python3 -c ' + import json + import sys - with open(sys.argv[1]) as handle: - body = handle.read() + with open(sys.argv[1]) as handle: + body = "\n\n" + handle.read() - with open(sys.argv[2], "w") as handle: - json.dump({"body": body}, handle) - PY + print(json.dumps({"body": body})) + ' "$summary_file" + )" if [ -n "$comment_id" ]; then echo "Updating existing coverage PR comment: $comment_id" @@ -303,7 +292,7 @@ jobs: -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "Content-Type: application/json" \ - --data @"$payload_file" \ + --data "$payload" \ "https://api.github.com/repos/$REPOSITORY/issues/comments/$comment_id" \ > /dev/null else @@ -313,7 +302,7 @@ jobs: -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "Content-Type: application/json" \ - --data @"$payload_file" \ + --data "$payload" \ "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" \ > /dev/null fi From 8b41cb59c70972c321f20565cda290952e4e4a7c Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 14:45:19 -0500 Subject: [PATCH 22/60] Muck with the indentation --- .github/workflows/pull-request-ci.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 0ab52860..5f52c833 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -259,18 +259,18 @@ jobs: -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | - python3 -c ' - import json - import sys - - marker = "" - comments = json.load(sys.stdin) - - for comment in comments: - if marker in comment.get("body", ""): - print(comment["id"]) - break - ' + python3 -c ' + import json + import sys + + marker = "" + comments = json.load(sys.stdin) + + for comment in comments: + if marker in comment.get("body", ""): + print(comment["id"]) + break + ' )" payload="$( From 86fb1f092db8e36eefdb44b950a5bfa265cf122e Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 14:45:33 -0500 Subject: [PATCH 23/60] Clean up the code coverage summary handling logic --- .github/workflows/pull-request-ci.yml | 137 +++++++++++--------------- 1 file changed, 60 insertions(+), 77 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 5f52c833..94832721 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -150,14 +150,6 @@ jobs: coverage_files=(CoverageData/*.xccov.json) summary_file="CoverageData/combined-coverage-summary.md" - echo "Coverage data directory contents:" - ls -la CoverageData || true - echo "Coverage JSON files found: ${#coverage_files[@]}" - for coverage_file in "${coverage_files[@]}"; do - echo "Coverage artifact: $coverage_file" - wc -c "$coverage_file" - done - if [ ${#coverage_files[@]} -eq 0 ]; then { echo "### Combined Code Coverage" @@ -168,75 +160,66 @@ jobs: exit 0 fi - python3 - "${coverage_files[@]}" > "$summary_file" <<'PY' - import json - import os - import sys - - combined = {} - per_scheme = [] - - for path in sys.argv[1:]: - print(f"Parsing coverage file: {path}", file=sys.stderr) - with open(path) as handle: - report = json.load(handle) - - covered_lines = 0 - executable_lines = 0 - for file_path, entries in report.items(): - combined_lines = combined.setdefault(file_path, {}) - for entry in entries: - line_number = entry["line"] - if line_number is None or not entry["isExecutable"]: - continue - - is_covered = entry.get("executionCount", 0) > 0 - executable_lines += 1 - if is_covered: - covered_lines += 1 - - previous = combined_lines.get(line_number, False) - combined_lines[line_number] = previous or is_covered - - scheme_name = os.path.basename(path).replace(".xccov.json", "") - coverage_percent = (covered_lines / executable_lines * 100) if executable_lines else 0.0 - per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) - print( - f"Scheme {scheme_name}: covered={covered_lines} executable={executable_lines} coverage={coverage_percent:.2f}%", - file=sys.stderr, - ) - - combined_executable_lines = sum(len(lines) for lines in combined.values()) - combined_covered_lines = sum( - 1 for lines in combined.values() for is_covered in lines.values() if is_covered - ) - combined_coverage_percent = ( - combined_covered_lines / combined_executable_lines * 100 - if combined_executable_lines - else 0.0 - ) - - print( - f"Combined coverage: covered={combined_covered_lines} executable={combined_executable_lines} coverage={combined_coverage_percent:.2f}%", - file=sys.stderr, - ) - - print("### Combined Code Coverage") - print() - print("| Scope | Coverage | Covered Lines | Executable Lines |") - print("| --- | ---: | ---: | ---: |") - for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): - print( - f"| {scheme_name} | {coverage_percent:.2f}% | {covered_lines} | {executable_lines} |" - ) - print( - f"| Combined | {combined_coverage_percent:.2f}% | " - f"{combined_covered_lines} | {combined_executable_lines} |" - ) - PY - - echo "Combined coverage summary:" - cat "$summary_file" + python3 -c ' + import json + import os + import sys + + combined = {} + per_scheme = [] + + for path in sys.argv[1:-1]: + print(f"Processing coverage file: {path}", file=sys.stderr) + with open(path) as handle: + report = json.load(handle) + + covered_lines = 0 + executable_lines = 0 + for file_path, entries in report.items(): + combined_lines = combined.setdefault(file_path, {}) + for entry in entries: + line_number = entry["line"] + if line_number is None or not entry["isExecutable"]: + continue + + is_covered = entry.get("executionCount", 0) > 0 + executable_lines += 1 + if is_covered: + covered_lines += 1 + + combined_lines[line_number] = combined_lines.get(line_number, False) or is_covered + + scheme_name = os.path.basename(path).replace(".xccov.json", "") + coverage_percent = (covered_lines / executable_lines * 100) if executable_lines else 0.0 + per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) + + combined_executable_lines = sum(len(lines) for lines in combined.values()) + combined_covered_lines = sum( + 1 for lines in combined.values() for is_covered in lines.values() if is_covered + ) + combined_coverage_percent = ( + combined_covered_lines / combined_executable_lines * 100 + if combined_executable_lines + else 0.0 + ) + + with open(sys.argv[-1], "w") as handle: + print("### Combined Code Coverage", file=handle) + print(file=handle) + print("| Scope | Coverage | Covered Lines | Executable Lines |", file=handle) + print("| --- | ---: | ---: | ---: |", file=handle) + for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): + print( + f"| {scheme_name} | {coverage_percent:.2f}% | {covered_lines} | {executable_lines} |", + file=handle, + ) + print( + f"| Combined | {combined_coverage_percent:.2f}% | " + f"{combined_covered_lines} | {combined_executable_lines} |", + file=handle, + ) + ' "${coverage_files[@]}" "$summary_file" + cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" - name: Publish combined coverage comment to pull request From 83d71f6b4242c5fec5956819d1caabae5f117ebd Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 14:53:33 -0500 Subject: [PATCH 24/60] Hopefully fix the indentation issue --- .github/workflows/pull-request-ci.yml | 114 +++++++++++++------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 94832721..9915b4f8 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -161,64 +161,64 @@ jobs: fi python3 -c ' - import json - import os - import sys - - combined = {} - per_scheme = [] - - for path in sys.argv[1:-1]: - print(f"Processing coverage file: {path}", file=sys.stderr) - with open(path) as handle: - report = json.load(handle) - - covered_lines = 0 - executable_lines = 0 - for file_path, entries in report.items(): - combined_lines = combined.setdefault(file_path, {}) - for entry in entries: - line_number = entry["line"] - if line_number is None or not entry["isExecutable"]: - continue - - is_covered = entry.get("executionCount", 0) > 0 - executable_lines += 1 - if is_covered: - covered_lines += 1 - - combined_lines[line_number] = combined_lines.get(line_number, False) or is_covered - - scheme_name = os.path.basename(path).replace(".xccov.json", "") - coverage_percent = (covered_lines / executable_lines * 100) if executable_lines else 0.0 - per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) - - combined_executable_lines = sum(len(lines) for lines in combined.values()) - combined_covered_lines = sum( - 1 for lines in combined.values() for is_covered in lines.values() if is_covered - ) - combined_coverage_percent = ( - combined_covered_lines / combined_executable_lines * 100 - if combined_executable_lines - else 0.0 + import json + import os + import sys + + combined = {} + per_scheme = [] + + for path in sys.argv[1:-1]: + print(f"Processing coverage file: {path}", file=sys.stderr) + with open(path) as handle: + report = json.load(handle) + + covered_lines = 0 + executable_lines = 0 + for file_path, entries in report.items(): + combined_lines = combined.setdefault(file_path, {}) + for entry in entries: + line_number = entry["line"] + if line_number is None or not entry["isExecutable"]: + continue + + is_covered = entry.get("executionCount", 0) > 0 + executable_lines += 1 + if is_covered: + covered_lines += 1 + + combined_lines[line_number] = combined_lines.get(line_number, False) or is_covered + + scheme_name = os.path.basename(path).replace(".xccov.json", "") + coverage_percent = (covered_lines / executable_lines * 100) if executable_lines else 0.0 + per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) + + combined_executable_lines = sum(len(lines) for lines in combined.values()) + combined_covered_lines = sum( + 1 for lines in combined.values() for is_covered in lines.values() if is_covered + ) + combined_coverage_percent = ( + combined_covered_lines / combined_executable_lines * 100 + if combined_executable_lines + else 0.0 + ) + + with open(sys.argv[-1], "w") as handle: + print("### Combined Code Coverage", file=handle) + print(file=handle) + print("| Scope | Coverage | Covered Lines | Executable Lines |", file=handle) + print("| --- | ---: | ---: | ---: |", file=handle) + for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): + print( + f"| {scheme_name} | {coverage_percent:.2f}% | {covered_lines} | {executable_lines} |", + file=handle, + ) + print( + f"| Combined | {combined_coverage_percent:.2f}% | " + f"{combined_covered_lines} | {combined_executable_lines} |", + file=handle, ) - - with open(sys.argv[-1], "w") as handle: - print("### Combined Code Coverage", file=handle) - print(file=handle) - print("| Scope | Coverage | Covered Lines | Executable Lines |", file=handle) - print("| --- | ---: | ---: | ---: |", file=handle) - for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): - print( - f"| {scheme_name} | {coverage_percent:.2f}% | {covered_lines} | {executable_lines} |", - file=handle, - ) - print( - f"| Combined | {combined_coverage_percent:.2f}% | " - f"{combined_covered_lines} | {combined_executable_lines} |", - file=handle, - ) - ' "${coverage_files[@]}" "$summary_file" + ' "${coverage_files[@]}" "$summary_file" cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" From a4dc7cc59fba4105271be618ca172822bd345a4e Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 15:02:17 -0500 Subject: [PATCH 25/60] Fixing more indents --- .github/workflows/pull-request-ci.yml | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 9915b4f8..493c2719 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -243,29 +243,29 @@ jobs: -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | python3 -c ' - import json - import sys + import json + import sys - marker = "" - comments = json.load(sys.stdin) + marker = "" + comments = json.load(sys.stdin) - for comment in comments: - if marker in comment.get("body", ""): - print(comment["id"]) - break - ' + for comment in comments: + if marker in comment.get("body", ""): + print(comment["id"]) + break + ' )" payload="$( python3 -c ' - import json - import sys + import json + import sys - with open(sys.argv[1]) as handle: - body = "\n\n" + handle.read() + with open(sys.argv[1]) as handle: + body = "\n\n" + handle.read() - print(json.dumps({"body": body})) - ' "$summary_file" + print(json.dumps({"body": body})) + ' "$summary_file" )" if [ -n "$comment_id" ]; then From dfa08828284b743a9d5e25461ff120da152b37db Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 15:19:06 -0500 Subject: [PATCH 26/60] remove some indents --- .github/workflows/pull-request-ci.yml | 52 +++++++++++++-------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 493c2719..be757724 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -43,24 +43,24 @@ jobs: matches = [] for runtime, entries in json.load(sys.stdin).get("devices", {}).items(): - runtime_match = re.match(r"com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)", runtime) - if not runtime_match: - continue - - print(f"Runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) - - os_version = tuple(int(part) for part in runtime_match.groups()) - for entry in entries: - name = (entry["name"] or "").strip() - model_match = re.match(r"iPhone\s+(\d+)\b", name) - udid = entry["udid"] - if entry["isAvailable"] and udid and model_match: - matches.append((os_version, int(model_match.group(1)), name, udid)) - the_os = f"{os_version[0]}.{os_version[1]}" - print(f" {name} (iOS {the_os}) [{udid}]", file=sys.stderr) + runtime_match = re.match(r"com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)", runtime) + if not runtime_match: + continue + + print(f"Runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) + + os_version = tuple(int(part) for part in runtime_match.groups()) + for entry in entries: + name = (entry["name"] or "").strip() + model_match = re.match(r"iPhone\s+(\d+)\b", name) + udid = entry["udid"] + if entry["isAvailable"] and udid and model_match: + matches.append((os_version, int(model_match.group(1)), name, udid)) + the_os = f"{os_version[0]}.{os_version[1]}" + print(f" {name} (iOS {the_os}) [{udid}]", file=sys.stderr) if not matches: - raise SystemExit("No iPhone simulator destinations found") + raise SystemExit("No iPhone simulator destinations found") selected = max(matches); name = selected[2]; the_os = f"{selected[0][0]}.{selected[0][1]}" print(f"Selected Simulator: {name} (iOS {the_os}) [{selected[3]}]", file=sys.stderr) @@ -242,18 +242,18 @@ jobs: -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | - python3 -c ' - import json - import sys + python3 -c ' + import json + import sys - marker = "" - comments = json.load(sys.stdin) + marker = "" + comments = json.load(sys.stdin) - for comment in comments: - if marker in comment.get("body", ""): - print(comment["id"]) - break - ' + for comment in comments: + if marker in comment.get("body", ""): + print(comment["id"]) + break + ' )" payload="$( From 283d219be99e72a343afecc1ef15310dcfffd719 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 15:23:15 -0500 Subject: [PATCH 27/60] trying again --- .github/workflows/pull-request-ci.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index be757724..95e62298 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -242,18 +242,18 @@ jobs: -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | - python3 -c ' - import json - import sys + python3 -c ' + import sys + import json - marker = "" - comments = json.load(sys.stdin) + marker = "" + comments = json.load(sys.stdin) - for comment in comments: - if marker in comment.get("body", ""): - print(comment["id"]) - break - ' + for comment in comments: + if marker in comment.get("body", ""): + print(comment["id"]) + break + ' )" payload="$( From ec9138bd3fcf0e7d0c1b636219a1525fc72365e2 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 15:31:25 -0500 Subject: [PATCH 28/60] getting desperate --- .github/workflows/pull-request-ci.yml | 36 +++++++++++++++++---------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 95e62298..b5def9e7 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -237,23 +237,33 @@ jobs: exit 0 fi + testing_test="$( + echo "How are you" | + python3 -c ' + import re + import sys + + print(f"I am a test, testing things - {sys.stdin}") + ' + )" + comment_id="$( curl -fsSL \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | - python3 -c ' - import sys - import json + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | + python3 -c ' + import sys + import json - marker = "" - comments = json.load(sys.stdin) + marker = "" + comments = json.load(sys.stdin) - for comment in comments: - if marker in comment.get("body", ""): - print(comment["id"]) - break - ' + for comment in comments: + if marker in comment.get("body", ""): + print(comment["id"]) + break + ' )" payload="$( From 91c2426286e52a1209a9267efcd631b3a7898786 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 15:37:29 -0500 Subject: [PATCH 29/60] WTF?!?!? --- .github/workflows/pull-request-ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index b5def9e7..e43e9d50 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -240,13 +240,15 @@ jobs: testing_test="$( echo "How are you" | python3 -c ' - import re - import sys + import re + import sys - print(f"I am a test, testing things - {sys.stdin}") + print(f"I am a test, testing things - {sys.stdin}", file=sys.stderr) ' )" + echo "$testing_test" + comment_id="$( curl -fsSL \ -H "Authorization: Bearer $GITHUB_TOKEN" \ From ecfc277813d7674ece3028d99d854f929ba9cc48 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 15:48:22 -0500 Subject: [PATCH 30/60] getting ridiculous --- .github/workflows/pull-request-ci.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index e43e9d50..ca9afb09 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -240,14 +240,15 @@ jobs: testing_test="$( echo "How are you" | python3 -c ' - import re - import sys - - print(f"I am a test, testing things - {sys.stdin}", file=sys.stderr) + import re + import sys + value = sys.stdin.read() + print(f"I am a test, testing things - {value}", file=sys.stderr) + print(f"Output testing - {value}") ' )" - echo "$testing_test" + echo "Output: $testing_test" comment_id="$( curl -fsSL \ From 2335b8de21ad5d7b2cde847cb3c4781203434af7 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 15:53:34 -0500 Subject: [PATCH 31/60] Going Plaid! --- .github/workflows/pull-request-ci.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index ca9afb09..f5bfaa84 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -255,18 +255,18 @@ jobs: -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | - python3 -c ' - import sys - import json + python3 -c ' + import sys + import json - marker = "" - comments = json.load(sys.stdin) + marker = "" + comments = json.load(sys.stdin) - for comment in comments: - if marker in comment.get("body", ""): - print(comment["id"]) - break - ' + for comment in comments: + if marker in comment.get("body", ""): + print(comment["id"]) + break + ' )" payload="$( From 505397f29b9bcf52d89cc49de2183d437ca8cf6c Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 16:01:02 -0500 Subject: [PATCH 32/60] All this indenting! --- .github/workflows/pull-request-ci.yml | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index f5bfaa84..66c9a944 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -237,27 +237,14 @@ jobs: exit 0 fi - testing_test="$( - echo "How are you" | - python3 -c ' - import re - import sys - value = sys.stdin.read() - print(f"I am a test, testing things - {value}", file=sys.stderr) - print(f"Output testing - {value}") - ' - )" - - echo "Output: $testing_test" - comment_id="$( curl -fsSL \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | python3 -c ' - import sys import json + import sys marker = "" comments = json.load(sys.stdin) @@ -266,18 +253,18 @@ jobs: if marker in comment.get("body", ""): print(comment["id"]) break - ' + ' )" payload="$( python3 -c ' - import json - import sys + import json + import sys - with open(sys.argv[1]) as handle: - body = "\n\n" + handle.read() + with open(sys.argv[1]) as handle: + body = "\n\n" + handle.read() - print(json.dumps({"body": body})) + print(json.dumps({"body": body})) ' "$summary_file" )" From 9250d5a057cb72698f1a72e65ea36f8d2ddddf06 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 16:16:54 -0500 Subject: [PATCH 33/60] Getting fancy --- .github/workflows/pull-request-ci.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 66c9a944..baf2c1a8 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -203,19 +203,29 @@ jobs: else 0.0 ) + def status_emoji(coverage_percent): + if coverage_percent < 60.0: + return "❌" + if coverage_percent < 75.0: + return "⚠️" + return "✅" + with open(sys.argv[-1], "w") as handle: print("### Combined Code Coverage", file=handle) print(file=handle) - print("| Scope | Coverage | Covered Lines | Executable Lines |", file=handle) - print("| --- | ---: | ---: | ---: |", file=handle) + print("| Scope | Coverage | Status |", file=handle) + print("| --- | ---: | --- |", file=handle) for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): print( - f"| {scheme_name} | {coverage_percent:.2f}% | {covered_lines} | {executable_lines} |", + f"| {scheme_name} | {coverage_percent:.2f}% | {status_emoji(coverage_percent)} |", file=handle, ) print( - f"| Combined | {combined_coverage_percent:.2f}% | " - f"{combined_covered_lines} | {combined_executable_lines} |", + "| **----------** | **----------:** | **---** |", + file=handle, + ) + print( + f"| **Combined** | **{combined_coverage_percent:.2f}%** | **{status_emoji(combined_coverage_percent)}** |", file=handle, ) ' "${coverage_files[@]}" "$summary_file" From 18b8f53711723eda7773eac889dea2f96ce99bb9 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 16:32:43 -0500 Subject: [PATCH 34/60] Clean up code coverage comment --- .github/workflows/pull-request-ci.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index baf2c1a8..4e3fd3a3 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -211,21 +211,20 @@ jobs: return "✅" with open(sys.argv[-1], "w") as handle: - print("### Combined Code Coverage", file=handle) + print("### Code Coverage", file=handle) print(file=handle) print("| Scope | Coverage | Status |", file=handle) - print("| --- | ---: | --- |", file=handle) + print("| --- | :---: | :---: |", file=handle) for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): print( f"| {scheme_name} | {coverage_percent:.2f}% | {status_emoji(coverage_percent)} |", file=handle, ) + indent = "          " + combined_percent = f"combined_coverage_percent:.2f" + combined_emoji = status_emoji(combined_coverage_percent) print( - "| **----------** | **----------:** | **---** |", - file=handle, - ) - print( - f"| **Combined** | **{combined_coverage_percent:.2f}%** | **{status_emoji(combined_coverage_percent)}** |", + f"| {indent} **Combined** | **{combined_percent}%** | **{combined_emoji}** |", file=handle, ) ' "${coverage_files[@]}" "$summary_file" From 5a1c470d147738aa3174601a92717ef2050ddb7e Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 16:43:19 -0500 Subject: [PATCH 35/60] Calculate coverage 1 time --- .github/workflows/pull-request-ci.yml | 70 ++++++++------------------- 1 file changed, 20 insertions(+), 50 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 4e3fd3a3..85c55704 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -86,39 +86,6 @@ jobs: CODE_SIGNING_ALLOWED=NO \ test - - name: Export code coverage data - if: always() - env: - SCHEME: ${{ matrix.scheme }} - run: | - set -eo pipefail - - result_bundle="TestResults/${SCHEME}.xcresult" - coverage_json="TestResults/${SCHEME}.xccov.json" - - echo "### Code Coverage" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - - if [ ! -d "$result_bundle" ]; then - echo "Coverage report unavailable because \`$result_bundle\` was not created." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - if ! xcrun xccov view --archive --json "$result_bundle" > "$coverage_json"; then - echo "Coverage report unavailable because \`xccov\` could not parse \`$result_bundle\`." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - echo "Exported line-level coverage data for \`$SCHEME\`." >> "$GITHUB_STEP_SUMMARY" - - - name: Upload code coverage data - if: always() - uses: actions/upload-artifact@v6 - with: - name: unit-test-coverage-${{ matrix.scheme }} - path: TestResults/${{ matrix.scheme }}.xccov.json - if-no-files-found: ignore - - name: Upload unit test results if: always() uses: actions/upload-artifact@v6 @@ -128,33 +95,31 @@ jobs: coverage-summary: name: Combined Code Coverage - runs-on: ubuntu-latest + runs-on: macos-latest needs: unit-tests if: always() steps: - - name: Download code coverage data + - name: Download unit test results continue-on-error: true uses: actions/download-artifact@v7 with: - pattern: unit-test-coverage-* - path: CoverageData - merge-multiple: true + pattern: project-unit-tests-*.xcresult + path: CoverageResults - name: Publish combined coverage summary run: | set -eo pipefail - shopt -s nullglob + summary_file="CoverageResults/combined-coverage-summary.md" - mkdir -p CoverageData - coverage_files=(CoverageData/*.xccov.json) - summary_file="CoverageData/combined-coverage-summary.md" + mkdir -p CoverageResults + mapfile -d '' result_bundles < <(find CoverageResults -type d -name '*.xcresult' -print0 | sort -z) - if [ ${#coverage_files[@]} -eq 0 ]; then + if [ ${#result_bundles[@]} -eq 0 ]; then { echo "### Combined Code Coverage" echo - echo "Combined coverage unavailable because no coverage data artifacts were downloaded." + echo "Combined coverage unavailable because no unit test result bundles were downloaded." } > "$summary_file" cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" exit 0 @@ -163,15 +128,20 @@ jobs: python3 -c ' import json import os + import subprocess import sys combined = {} per_scheme = [] for path in sys.argv[1:-1]: - print(f"Processing coverage file: {path}", file=sys.stderr) - with open(path) as handle: - report = json.load(handle) + print(f"Processing result bundle: {path}", file=sys.stderr) + report = json.loads( + subprocess.check_output( + ["xcrun", "xccov", "view", "--archive", "--json", path], + text=True, + ) + ) covered_lines = 0 executable_lines = 0 @@ -189,7 +159,7 @@ jobs: combined_lines[line_number] = combined_lines.get(line_number, False) or is_covered - scheme_name = os.path.basename(path).replace(".xccov.json", "") + scheme_name = os.path.basename(path).replace(".xcresult", "") coverage_percent = (covered_lines / executable_lines * 100) if executable_lines else 0.0 per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) @@ -221,7 +191,7 @@ jobs: file=handle, ) indent = "          " - combined_percent = f"combined_coverage_percent:.2f" + combined_percent = f"{combined_coverage_percent:.2f}" combined_emoji = status_emoji(combined_coverage_percent) print( f"| {indent} **Combined** | **{combined_percent}%** | **{combined_emoji}** |", @@ -240,7 +210,7 @@ jobs: run: | set -eo pipefail - summary_file="CoverageData/combined-coverage-summary.md" + summary_file="CoverageResults/combined-coverage-summary.md" if [ ! -f "$summary_file" ]; then echo "No combined coverage summary file was generated; skipping PR comment." exit 0 From 3cc7782672786a3cc09c564afb6093f47320d16c Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 16:54:19 -0500 Subject: [PATCH 36/60] "mapfile" not available --- .github/workflows/pull-request-ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 85c55704..dc2f4395 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -113,7 +113,10 @@ jobs: summary_file="CoverageResults/combined-coverage-summary.md" mkdir -p CoverageResults - mapfile -d '' result_bundles < <(find CoverageResults -type d -name '*.xcresult' -print0 | sort -z) + result_bundles=() + while IFS= read -r -d '' result_bundle; do + result_bundles+=("$result_bundle") + done < <(find CoverageResults -type d -name '*.xcresult' -print0) if [ ${#result_bundles[@]} -eq 0 ]; then { @@ -197,7 +200,7 @@ jobs: f"| {indent} **Combined** | **{combined_percent}%** | **{combined_emoji}** |", file=handle, ) - ' "${coverage_files[@]}" "$summary_file" + ' "${result_bundles[@]}" "$summary_file" cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" From beadc23fdb3543e971cdefa6b418452ccc1573c0 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 17:07:13 -0500 Subject: [PATCH 37/60] Print coverage results to the github action --- .github/workflows/pull-request-ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index dc2f4395..4adad2a8 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -120,7 +120,7 @@ jobs: if [ ${#result_bundles[@]} -eq 0 ]; then { - echo "### Combined Code Coverage" + echo "### Code Coverage" echo echo "Combined coverage unavailable because no unit test result bundles were downloaded." } > "$summary_file" @@ -189,10 +189,12 @@ jobs: print("| Scope | Coverage | Status |", file=handle) print("| --- | :---: | :---: |", file=handle) for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): + emoji = status_emoji(coverage_percent) print( - f"| {scheme_name} | {coverage_percent:.2f}% | {status_emoji(coverage_percent)} |", + f"| {scheme_name} | {coverage_percent:.2f}% | {emoji} |", file=handle, ) + print(f"{scheme_name} - {coverage_percent:.2f}% {emoji}", file=sys.stderr) indent = "          " combined_percent = f"{combined_coverage_percent:.2f}" combined_emoji = status_emoji(combined_coverage_percent) @@ -200,6 +202,7 @@ jobs: f"| {indent} **Combined** | **{combined_percent}%** | **{combined_emoji}** |", file=handle, ) + print(f" Combined - {combined_percent}% {combined_emoji}", file=sys.stderr) ' "${result_bundles[@]}" "$summary_file" cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" From d241e622afb5458ac1d67797f6dc8570f8efedb7 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Wed, 8 Apr 2026 17:37:41 -0500 Subject: [PATCH 38/60] Update to also build main/master and add badges to the readme. Using ci-core.yml to centralize the logic to build and generate code coverage data --- .github/workflows/ci-core.yml | 309 ++++++++++++++++++++++++++ .github/workflows/main-ci.yml | 102 +++++++++ .github/workflows/pull-request-ci.yml | 289 +----------------------- README.md | 3 +- 4 files changed, 418 insertions(+), 285 deletions(-) create mode 100644 .github/workflows/ci-core.yml create mode 100644 .github/workflows/main-ci.yml diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml new file mode 100644 index 00000000..104d75ad --- /dev/null +++ b/.github/workflows/ci-core.yml @@ -0,0 +1,309 @@ +name: CI Core + +on: + workflow_call: + inputs: + publish_pr_comment: + description: Publish the combined coverage summary as a PR comment. + required: false + default: false + type: boolean + outputs: + combined_coverage_percent: + description: Combined unit test coverage percent across all schemes. + value: ${{ jobs.coverage-summary.outputs.combined_coverage_percent }} + +jobs: + unit-tests: + name: Unit Tests (${{ matrix.scheme }}) + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + scheme: + - libPhoneNumber + - libPhoneNumberGeocoding + - libPhoneNumberShortNumber + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Resolve iPhone simulator destination + id: destination + run: | + set -eo pipefail + + destination_id="$( + xcrun simctl list devices --json | + python3 -c ' + import json + import re + import sys + + matches = [] + for runtime, entries in json.load(sys.stdin).get("devices", {}).items(): + runtime_match = re.match(r"com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)", runtime) + if not runtime_match: + continue + + print(f"Runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) + + os_version = tuple(int(part) for part in runtime_match.groups()) + for entry in entries: + name = (entry["name"] or "").strip() + model_match = re.match(r"iPhone\s+(\d+)\b", name) + udid = entry["udid"] + if entry["isAvailable"] and udid and model_match: + matches.append((os_version, int(model_match.group(1)), name, udid)) + the_os = f"{os_version[0]}.{os_version[1]}" + print(f" {name} (iOS {the_os}) [{udid}]", file=sys.stderr) + + if not matches: + raise SystemExit("No iPhone simulator destinations found") + + selected = max(matches); name = selected[2]; the_os = f"{selected[0][0]}.{selected[0][1]}" + print(f"Selected Simulator: {name} (iOS {the_os}) [{selected[3]}]", file=sys.stderr) + print(selected[3]) + ' + )" + + echo "destination=id=$destination_id,arch=arm64" >> "$GITHUB_OUTPUT" + + - name: Run unit tests + env: + SCHEME: ${{ matrix.scheme }} + run: | + set -eo pipefail + mkdir -p TestResults + + xcodebuild \ + -project libPhoneNumber.xcodeproj \ + -scheme "$SCHEME" \ + -destination "${{ steps.destination.outputs.destination }}" \ + -resultBundlePath "TestResults/${SCHEME}.xcresult" \ + -enableCodeCoverage YES \ + CODE_SIGNING_ALLOWED=NO \ + test + + - name: Upload unit test results + if: always() + uses: actions/upload-artifact@v6 + with: + name: project-unit-tests-${{ matrix.scheme }}.xcresult + path: TestResults/${{ matrix.scheme }}.xcresult + + coverage-summary: + name: Combined Code Coverage + runs-on: macos-latest + needs: unit-tests + if: always() + outputs: + combined_coverage_percent: ${{ steps.coverage.outputs.combined_coverage_percent }} + + steps: + - name: Download unit test results + continue-on-error: true + uses: actions/download-artifact@v7 + with: + pattern: project-unit-tests-*.xcresult + path: CoverageResults + + - name: Publish combined coverage summary + id: coverage + run: | + set -eo pipefail + summary_file="CoverageResults/combined-coverage-summary.md" + + mkdir -p CoverageResults + result_bundles=() + while IFS= read -r -d '' result_bundle; do + result_bundles+=("$result_bundle") + done < <(find CoverageResults -type d -name '*.xcresult' -print0) + + if [ ${#result_bundles[@]} -eq 0 ]; then + { + echo "### Combined Code Coverage" + echo + echo "Combined coverage unavailable because no unit test result bundles were downloaded." + } > "$summary_file" + echo "combined_coverage_percent=" >> "$GITHUB_OUTPUT" + cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + python3 -c ' + import json + import os + import subprocess + import sys + + combined = {} + per_scheme = [] + + for path in sys.argv[1:-1]: + print(f"Processing result bundle: {path}", file=sys.stderr) + report = json.loads( + subprocess.check_output( + ["xcrun", "xccov", "view", "--archive", "--json", path], + text=True, + ) + ) + + covered_lines = 0 + executable_lines = 0 + for file_path, entries in report.items(): + combined_lines = combined.setdefault(file_path, {}) + for entry in entries: + line_number = entry["line"] + if line_number is None or not entry["isExecutable"]: + continue + + is_covered = entry.get("executionCount", 0) > 0 + executable_lines += 1 + if is_covered: + covered_lines += 1 + + combined_lines[line_number] = combined_lines.get(line_number, False) or is_covered + + scheme_name = os.path.basename(path).replace(".xcresult", "") + coverage_percent = (covered_lines / executable_lines * 100) if executable_lines else 0.0 + per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) + + combined_executable_lines = sum(len(lines) for lines in combined.values()) + combined_covered_lines = sum( + 1 for lines in combined.values() for is_covered in lines.values() if is_covered + ) + combined_coverage_percent = ( + combined_covered_lines / combined_executable_lines * 100 + if combined_executable_lines + else 0.0 + ) + + def status_emoji(coverage_percent): + if coverage_percent < 60.0: + return "❌" + if coverage_percent < 75.0: + return "⚠️" + return "✅" + + with open(sys.argv[-1], "w") as handle: + print("### Code Coverage", file=handle) + print(file=handle) + print("| Scope | Coverage | Status |", file=handle) + print("| --- | :---: | :---: |", file=handle) + for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): + emoji = status_emoji(coverage_percent) + print( + f"| {scheme_name} | {coverage_percent:.2f}% | {emoji} |", + file=handle, + ) + print(f"{scheme_name} - {coverage_percent:.2f}% {emoji}", file=sys.stderr) + indent = "          " + combined_percent = f"{combined_coverage_percent:.2f}" + combined_emoji = status_emoji(combined_coverage_percent) + print( + f"| {indent} **Combined** | **{combined_percent}%** | **{combined_emoji}** |", + file=handle, + ) + print(f" Combined - {combined_percent}% {combined_emoji}", file=sys.stderr) + + with open(os.environ["GITHUB_OUTPUT"], "a") as handle: + print(f"combined_coverage_percent={combined_coverage_percent:.2f}", file=handle) + ' "${result_bundles[@]}" "$summary_file" + + echo "Combined coverage summary:" + cat "$summary_file" + cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" + + - name: Publish combined coverage comment to pull request + if: inputs.publish_pr_comment + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + run: | + set -eo pipefail + + summary_file="CoverageResults/combined-coverage-summary.md" + if [ ! -f "$summary_file" ]; then + echo "No combined coverage summary file was generated; skipping PR comment." + exit 0 + fi + + comment_id="$( + curl -fsSL \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | + python3 -c ' + import json + import sys + + marker = "" + comments = json.load(sys.stdin) + + for comment in comments: + if marker in comment.get("body", ""): + print(comment["id"]) + break + ' + )" + + payload="$( + python3 -c ' + import json + import sys + + with open(sys.argv[1]) as handle: + body = "\n\n" + handle.read() + + print(json.dumps({"body": body})) + ' "$summary_file" + )" + + if [ -n "$comment_id" ]; then + echo "Updating existing coverage PR comment: $comment_id" + curl -fsSL \ + -X PATCH \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "Content-Type: application/json" \ + --data "$payload" \ + "https://api.github.com/repos/$REPOSITORY/issues/comments/$comment_id" \ + > /dev/null + else + echo "Creating new coverage PR comment" + curl -fsSL \ + -X POST \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "Content-Type: application/json" \ + --data "$payload" \ + "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" \ + > /dev/null + fi + + podspec-lint: + name: Podspec Lint (${{ matrix.podspec }}) + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + podspec: + - libPhoneNumber-iOS.podspec + - libPhoneNumberGeocoding.podspec + - libPhoneNumberShortNumber.podspec + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Ensure CocoaPods is installed + run: | + if ! command -v pod >/dev/null; then + gem install cocoapods + fi + + - name: Lint podspec + run: pod lib lint "${{ matrix.podspec }}" --verbose diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml new file mode 100644 index 00000000..1a95f292 --- /dev/null +++ b/.github/workflows/main-ci.yml @@ -0,0 +1,102 @@ +name: Main CI + +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: main-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + uses: ./.github/workflows/ci-core.yml + with: + publish_pr_comment: false + secrets: inherit + + build-coverage-badge: + name: Build Coverage Badge + runs-on: ubuntu-latest + needs: ci + if: ${{ needs.ci.result == 'success' }} + + steps: + - name: Configure GitHub Pages + uses: actions/configure-pages@v5 + + - name: Build badge site + env: + COMBINED_COVERAGE_PERCENT: ${{ needs.ci.outputs.combined_coverage_percent }} + run: | + set -eo pipefail + mkdir -p badge-site/badges + touch badge-site/.nojekyll + + python3 -c ' + import json + import os + + coverage_text = os.environ.get("COMBINED_COVERAGE_PERCENT", "").strip() + if coverage_text: + coverage_percent = float(coverage_text) + if coverage_percent < 60.0: + color = "red" + elif coverage_percent < 75.0: + color = "yellow" + else: + color = "brightgreen" + message = f"{coverage_percent:.2f}%" + else: + color = "lightgrey" + message = "n/a" + + payload = { + "schemaVersion": 1, + "label": "coverage", + "message": message, + "color": color, + } + + with open("badge-site/badges/coverage.json", "w") as handle: + json.dump(payload, handle) + ' + + cat > badge-site/index.html <<'EOF' + + + + + libPhoneNumber-iOS Badges + + +

Badge assets for libPhoneNumber-iOS.

+ + + EOF + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: badge-site + + deploy-coverage-badge: + name: Deploy Coverage Badge + runs-on: ubuntu-latest + needs: build-coverage-badge + if: ${{ needs.build-coverage-badge.result == 'success' }} + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 4adad2a8..fb80329e 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -14,287 +14,8 @@ concurrency: cancel-in-progress: true jobs: - unit-tests: - name: Unit Tests (${{ matrix.scheme }}) - runs-on: macos-latest - strategy: - fail-fast: false - matrix: - scheme: - - libPhoneNumber - - libPhoneNumberGeocoding - - libPhoneNumberShortNumber - - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Resolve iPhone simulator destination - id: destination - run: | - set -eo pipefail - - destination_id="$( - xcrun simctl list devices --json | - python3 -c ' - import json - import re - import sys - - matches = [] - for runtime, entries in json.load(sys.stdin).get("devices", {}).items(): - runtime_match = re.match(r"com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)", runtime) - if not runtime_match: - continue - - print(f"Runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) - - os_version = tuple(int(part) for part in runtime_match.groups()) - for entry in entries: - name = (entry["name"] or "").strip() - model_match = re.match(r"iPhone\s+(\d+)\b", name) - udid = entry["udid"] - if entry["isAvailable"] and udid and model_match: - matches.append((os_version, int(model_match.group(1)), name, udid)) - the_os = f"{os_version[0]}.{os_version[1]}" - print(f" {name} (iOS {the_os}) [{udid}]", file=sys.stderr) - - if not matches: - raise SystemExit("No iPhone simulator destinations found") - - selected = max(matches); name = selected[2]; the_os = f"{selected[0][0]}.{selected[0][1]}" - print(f"Selected Simulator: {name} (iOS {the_os}) [{selected[3]}]", file=sys.stderr) - print(selected[3]) - ' - )" - - echo "destination=id=$destination_id,arch=arm64" >> "$GITHUB_OUTPUT" - - - name: Run unit tests - env: - SCHEME: ${{ matrix.scheme }} - run: | - set -eo pipefail - mkdir -p TestResults - - xcodebuild \ - -project libPhoneNumber.xcodeproj \ - -scheme "$SCHEME" \ - -destination "${{ steps.destination.outputs.destination }}" \ - -resultBundlePath "TestResults/${SCHEME}.xcresult" \ - -enableCodeCoverage YES \ - CODE_SIGNING_ALLOWED=NO \ - test - - - name: Upload unit test results - if: always() - uses: actions/upload-artifact@v6 - with: - name: project-unit-tests-${{ matrix.scheme }}.xcresult - path: TestResults/${{ matrix.scheme }}.xcresult - - coverage-summary: - name: Combined Code Coverage - runs-on: macos-latest - needs: unit-tests - if: always() - - steps: - - name: Download unit test results - continue-on-error: true - uses: actions/download-artifact@v7 - with: - pattern: project-unit-tests-*.xcresult - path: CoverageResults - - - name: Publish combined coverage summary - run: | - set -eo pipefail - summary_file="CoverageResults/combined-coverage-summary.md" - - mkdir -p CoverageResults - result_bundles=() - while IFS= read -r -d '' result_bundle; do - result_bundles+=("$result_bundle") - done < <(find CoverageResults -type d -name '*.xcresult' -print0) - - if [ ${#result_bundles[@]} -eq 0 ]; then - { - echo "### Code Coverage" - echo - echo "Combined coverage unavailable because no unit test result bundles were downloaded." - } > "$summary_file" - cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - python3 -c ' - import json - import os - import subprocess - import sys - - combined = {} - per_scheme = [] - - for path in sys.argv[1:-1]: - print(f"Processing result bundle: {path}", file=sys.stderr) - report = json.loads( - subprocess.check_output( - ["xcrun", "xccov", "view", "--archive", "--json", path], - text=True, - ) - ) - - covered_lines = 0 - executable_lines = 0 - for file_path, entries in report.items(): - combined_lines = combined.setdefault(file_path, {}) - for entry in entries: - line_number = entry["line"] - if line_number is None or not entry["isExecutable"]: - continue - - is_covered = entry.get("executionCount", 0) > 0 - executable_lines += 1 - if is_covered: - covered_lines += 1 - - combined_lines[line_number] = combined_lines.get(line_number, False) or is_covered - - scheme_name = os.path.basename(path).replace(".xcresult", "") - coverage_percent = (covered_lines / executable_lines * 100) if executable_lines else 0.0 - per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) - - combined_executable_lines = sum(len(lines) for lines in combined.values()) - combined_covered_lines = sum( - 1 for lines in combined.values() for is_covered in lines.values() if is_covered - ) - combined_coverage_percent = ( - combined_covered_lines / combined_executable_lines * 100 - if combined_executable_lines - else 0.0 - ) - - def status_emoji(coverage_percent): - if coverage_percent < 60.0: - return "❌" - if coverage_percent < 75.0: - return "⚠️" - return "✅" - - with open(sys.argv[-1], "w") as handle: - print("### Code Coverage", file=handle) - print(file=handle) - print("| Scope | Coverage | Status |", file=handle) - print("| --- | :---: | :---: |", file=handle) - for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): - emoji = status_emoji(coverage_percent) - print( - f"| {scheme_name} | {coverage_percent:.2f}% | {emoji} |", - file=handle, - ) - print(f"{scheme_name} - {coverage_percent:.2f}% {emoji}", file=sys.stderr) - indent = "          " - combined_percent = f"{combined_coverage_percent:.2f}" - combined_emoji = status_emoji(combined_coverage_percent) - print( - f"| {indent} **Combined** | **{combined_percent}%** | **{combined_emoji}** |", - file=handle, - ) - print(f" Combined - {combined_percent}% {combined_emoji}", file=sys.stderr) - ' "${result_bundles[@]}" "$summary_file" - - cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" - - - name: Publish combined coverage comment to pull request - if: github.event_name == 'pull_request' - env: - GITHUB_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPOSITORY: ${{ github.repository }} - run: | - set -eo pipefail - - summary_file="CoverageResults/combined-coverage-summary.md" - if [ ! -f "$summary_file" ]; then - echo "No combined coverage summary file was generated; skipping PR comment." - exit 0 - fi - - comment_id="$( - curl -fsSL \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | - python3 -c ' - import json - import sys - - marker = "" - comments = json.load(sys.stdin) - - for comment in comments: - if marker in comment.get("body", ""): - print(comment["id"]) - break - ' - )" - - payload="$( - python3 -c ' - import json - import sys - - with open(sys.argv[1]) as handle: - body = "\n\n" + handle.read() - - print(json.dumps({"body": body})) - ' "$summary_file" - )" - - if [ -n "$comment_id" ]; then - echo "Updating existing coverage PR comment: $comment_id" - curl -fsSL \ - -X PATCH \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "Content-Type: application/json" \ - --data "$payload" \ - "https://api.github.com/repos/$REPOSITORY/issues/comments/$comment_id" \ - > /dev/null - else - echo "Creating new coverage PR comment" - curl -fsSL \ - -X POST \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "Content-Type: application/json" \ - --data "$payload" \ - "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" \ - > /dev/null - fi - - podspec-lint: - name: Podspec Lint (${{ matrix.podspec }}) - runs-on: macos-latest - strategy: - fail-fast: false - matrix: - podspec: - - libPhoneNumber-iOS.podspec - - libPhoneNumberGeocoding.podspec - - libPhoneNumberShortNumber.podspec - - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Ensure CocoaPods is installed - run: | - if ! command -v pod >/dev/null; then - gem install cocoapods - fi - - - name: Lint podspec - run: pod lib lint "${{ matrix.podspec }}" --verbose + ci: + uses: ./.github/workflows/ci-core.yml + with: + publish_pr_comment: true + secrets: inherit diff --git a/README.md b/README.md index 5e3d5fbc..42093ad6 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![CocoaPods](https://img.shields.io/cocoapods/p/libPhoneNumber-iOS.svg?style=flat)](http://cocoapods.org/?q=libPhoneNumber-iOS) [![CocoaPods](https://img.shields.io/cocoapods/v/libPhoneNumber-iOS.svg?style=flat)](http://cocoapods.org/?q=libPhoneNumber-iOS) -[![Pull Request CI](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/pull-request-ci.yml/badge.svg?branch=master)](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/pull-request-ci.yml) +[![Main CI](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/main-ci.yml/badge.svg?branch=master)](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/main-ci.yml) +[![Coverage](https://img.shields.io/endpoint?url=https://iziz.github.io/libPhoneNumber-iOS/badges/coverage.json)](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/main-ci.yml) [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) # **libPhoneNumber for iOS** From cf58a5e4926984a4dcbcfbedf4564bab93dc725d Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 10 Apr 2026 12:43:48 -0500 Subject: [PATCH 39/60] Use a random device for testing --- .github/workflows/ci-core.yml | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 104d75ad..2df7b9db 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -39,32 +39,39 @@ jobs: python3 -c ' import json import re + import random import sys - matches = [] + latest_os_version = None + candidates = [] for runtime, entries in json.load(sys.stdin).get("devices", {}).items(): runtime_match = re.match(r"com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)", runtime) if not runtime_match: continue - print(f"Runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) - os_version = tuple(int(part) for part in runtime_match.groups()) + if latest_os_version is None or os_version > latest_os_version: + latest_os_version = os_version + candidates = [] + elif os_version < latest_os_version: + continue + + print(f"Runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) for entry in entries: name = (entry["name"] or "").strip() - model_match = re.match(r"iPhone\s+(\d+)\b", name) udid = entry["udid"] - if entry["isAvailable"] and udid and model_match: - matches.append((os_version, int(model_match.group(1)), name, udid)) + if entry["isAvailable"] and udid and name.startswith("iPhone"): + candidates.append((name, udid)) the_os = f"{os_version[0]}.{os_version[1]}" print(f" {name} (iOS {the_os}) [{udid}]", file=sys.stderr) - if not matches: + if not candidates: raise SystemExit("No iPhone simulator destinations found") - selected = max(matches); name = selected[2]; the_os = f"{selected[0][0]}.{selected[0][1]}" - print(f"Selected Simulator: {name} (iOS {the_os}) [{selected[3]}]", file=sys.stderr) - print(selected[3]) + selected = random.choice(candidates) + the_os = f"{latest_os_version[0]}.{latest_os_version[1]}" + print(f"Selected Simulator: {selected[0]} (iOS {the_os}) [{selected[1]}]", file=sys.stderr) + print(selected[1]) ' )" @@ -212,8 +219,6 @@ jobs: print(f"combined_coverage_percent={combined_coverage_percent:.2f}", file=handle) ' "${result_bundles[@]}" "$summary_file" - echo "Combined coverage summary:" - cat "$summary_file" cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" - name: Publish combined coverage comment to pull request From 692ccbe4ac822977ce60750bdbffd39478115c8a Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 10 Apr 2026 13:32:04 -0500 Subject: [PATCH 40/60] Don't try to publish coverage anywhere. Keep it local for now. --- .github/workflows/ci-core.yml | 4 +- .github/workflows/main-ci.yml | 81 ----------------------------------- README.md | 1 - 3 files changed, 3 insertions(+), 83 deletions(-) diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 2df7b9db..68412b32 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -194,7 +194,9 @@ jobs: return "⚠️" return "✅" - with open(sys.argv[-1], "w") as handle: + summary_path = sys.argv[-1] + + with open(summary_path, "w") as handle: print("### Code Coverage", file=handle) print(file=handle) print("| Scope | Coverage | Status |", file=handle) diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 1a95f292..c47018b0 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -8,8 +8,6 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: main-ci-${{ github.ref }} @@ -21,82 +19,3 @@ jobs: with: publish_pr_comment: false secrets: inherit - - build-coverage-badge: - name: Build Coverage Badge - runs-on: ubuntu-latest - needs: ci - if: ${{ needs.ci.result == 'success' }} - - steps: - - name: Configure GitHub Pages - uses: actions/configure-pages@v5 - - - name: Build badge site - env: - COMBINED_COVERAGE_PERCENT: ${{ needs.ci.outputs.combined_coverage_percent }} - run: | - set -eo pipefail - mkdir -p badge-site/badges - touch badge-site/.nojekyll - - python3 -c ' - import json - import os - - coverage_text = os.environ.get("COMBINED_COVERAGE_PERCENT", "").strip() - if coverage_text: - coverage_percent = float(coverage_text) - if coverage_percent < 60.0: - color = "red" - elif coverage_percent < 75.0: - color = "yellow" - else: - color = "brightgreen" - message = f"{coverage_percent:.2f}%" - else: - color = "lightgrey" - message = "n/a" - - payload = { - "schemaVersion": 1, - "label": "coverage", - "message": message, - "color": color, - } - - with open("badge-site/badges/coverage.json", "w") as handle: - json.dump(payload, handle) - ' - - cat > badge-site/index.html <<'EOF' - - - - - libPhoneNumber-iOS Badges - - -

Badge assets for libPhoneNumber-iOS.

- - - EOF - - - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@v4 - with: - path: badge-site - - deploy-coverage-badge: - name: Deploy Coverage Badge - runs-on: ubuntu-latest - needs: build-coverage-badge - if: ${{ needs.build-coverage-badge.result == 'success' }} - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 42093ad6..69a52336 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ [![CocoaPods](https://img.shields.io/cocoapods/p/libPhoneNumber-iOS.svg?style=flat)](http://cocoapods.org/?q=libPhoneNumber-iOS) [![CocoaPods](https://img.shields.io/cocoapods/v/libPhoneNumber-iOS.svg?style=flat)](http://cocoapods.org/?q=libPhoneNumber-iOS) [![Main CI](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/main-ci.yml/badge.svg?branch=master)](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/main-ci.yml) -[![Coverage](https://img.shields.io/endpoint?url=https://iziz.github.io/libPhoneNumber-iOS/badges/coverage.json)](https://github.com/iziz/libPhoneNumber-iOS/actions/workflows/main-ci.yml) [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) # **libPhoneNumber for iOS** From b14a70ba80daed86c90bbad6460cdac0733b215d Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 09:53:25 -0500 Subject: [PATCH 41/60] Trying some reusable github actions --- .github/workflows/ci-core.yml | 79 +++------------------------ .github/workflows/pull-request-ci.yml | 1 - 2 files changed, 9 insertions(+), 71 deletions(-) diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 68412b32..087e6b78 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -225,71 +225,11 @@ jobs: - name: Publish combined coverage comment to pull request if: inputs.publish_pr_comment - env: - GITHUB_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPOSITORY: ${{ github.repository }} - run: | - set -eo pipefail - - summary_file="CoverageResults/combined-coverage-summary.md" - if [ ! -f "$summary_file" ]; then - echo "No combined coverage summary file was generated; skipping PR comment." - exit 0 - fi - - comment_id="$( - curl -fsSL \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" | - python3 -c ' - import json - import sys - - marker = "" - comments = json.load(sys.stdin) - - for comment in comments: - if marker in comment.get("body", ""): - print(comment["id"]) - break - ' - )" - - payload="$( - python3 -c ' - import json - import sys - - with open(sys.argv[1]) as handle: - body = "\n\n" + handle.read() - - print(json.dumps({"body": body})) - ' "$summary_file" - )" - - if [ -n "$comment_id" ]; then - echo "Updating existing coverage PR comment: $comment_id" - curl -fsSL \ - -X PATCH \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "Content-Type: application/json" \ - --data "$payload" \ - "https://api.github.com/repos/$REPOSITORY/issues/comments/$comment_id" \ - > /dev/null - else - echo "Creating new coverage PR comment" - curl -fsSL \ - -X POST \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "Content-Type: application/json" \ - --data "$payload" \ - "https://api.github.com/repos/$REPOSITORY/issues/$PR_NUMBER/comments" \ - > /dev/null - fi + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: combined-code-coverage + path: CoverageResults/combined-coverage-summary.md + skip_unchanged: true podspec-lint: name: Podspec Lint (${{ matrix.podspec }}) @@ -306,11 +246,10 @@ jobs: - name: Check out repository uses: actions/checkout@v6 - - name: Ensure CocoaPods is installed - run: | - if ! command -v pod >/dev/null; then - gem install cocoapods - fi + - name: Set up CocoaPods + uses: maxim-lobanov/setup-cocoapods@v1 + with: + version: latest - name: Lint podspec run: pod lib lint "${{ matrix.podspec }}" --verbose diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index fb80329e..a80e479d 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -6,7 +6,6 @@ on: permissions: contents: read - issues: write pull-requests: write concurrency: From ee365e023f5452f07e837746e90f74a13b6e5708 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 12:13:20 -0500 Subject: [PATCH 42/60] Attempt to separate out a "pick-my-xcode-tricorder" reusable action. --- .../actions/pick-my-xcode-tricorder/README.md | 71 ++ .../pick-my-xcode-tricorder/action.yml | 61 ++ .../pick-my-xcode-tricorder/pick_simulator.py | 929 ++++++++++++++++++ .github/workflows/ci-core.yml | 110 ++- 4 files changed, 1117 insertions(+), 54 deletions(-) create mode 100644 .github/actions/pick-my-xcode-tricorder/README.md create mode 100644 .github/actions/pick-my-xcode-tricorder/action.yml create mode 100644 .github/actions/pick-my-xcode-tricorder/pick_simulator.py diff --git a/.github/actions/pick-my-xcode-tricorder/README.md b/.github/actions/pick-my-xcode-tricorder/README.md new file mode 100644 index 00000000..dcbedeff --- /dev/null +++ b/.github/actions/pick-my-xcode-tricorder/README.md @@ -0,0 +1,71 @@ +# Pick My Xcode Tricorder + +Local composite action for choosing an installed simulator device from `xcrun simctl list devices --json`. + +## Inputs + +- `device_types` + - Ordered, comma-separated list of device types to return simulators for. + - Supported values: `iphone`, `ipad`, `macos`, `watch`, `vision` +- `selection_mode` + - `random-compatible` + - `random-latest-compatible` + - `model-type` + - `latest-model` +- `model_preferences` + - Semicolon-separated mappings such as `iphone=Pro Max;ipad=Pro;watch=Ultra` +- `iphoneos_version`, `ipados_version`, `macos_version`, `watchos_version`, `visionos_version` + - A specific version like `18`, `18.2`, `15.0` + - Or `latest` + +`device_types` is required. The action returns exactly one simulator destination per requested device type, in the same order the device types were specified. If any requested device type cannot be satisfied, the action fails. + +## Outputs + +- `simulator_jsons` +- `destination_ids` + +`simulator_jsons` returns one object per found simulator with: +- `udid` +- `name` +- `os` +- `modelType` +- `safe_name` + +## Example + +```yaml +- name: Pick simulator + id: simulator + uses: ./.github/actions/pick-my-xcode-tricorder + with: + device_types: iphone,ipad + iphoneos_version: latest + ipados_version: latest + selection_mode: random-latest-compatible + +- name: Run tests + env: + DESTINATION_IDS: ${{ steps.simulator.outputs.destination_ids }} + SIMULATOR_JSONS: ${{ steps.simulator.outputs.simulator_jsons }} + run: | + python3 -c ' + import json + import os + + destination_ids = [ + value.strip() + for value in os.environ["DESTINATION_IDS"].splitlines() + if value.strip() + ] + simulators = json.loads(os.environ["SIMULATOR_JSONS"]) + + for index, destination_id in enumerate(destination_ids): + simulator = simulators[index] + destination = f"id={destination_id},arch=arm64" + safe_name = simulator["safe_name"] + print(f"Run against {simulator['name']} ({simulator['os']})") + print(f"Use safe result-bundle name: {safe_name}") + print(f"xcodebuild -destination {destination} test") + ' +``` diff --git a/.github/actions/pick-my-xcode-tricorder/action.yml b/.github/actions/pick-my-xcode-tricorder/action.yml new file mode 100644 index 00000000..84b2d273 --- /dev/null +++ b/.github/actions/pick-my-xcode-tricorder/action.yml @@ -0,0 +1,61 @@ +name: Pick My Xcode Tricorder +description: Select a compatible Apple simulator device from the installed simctl inventory. + +inputs: + device_types: + description: "Ordered, comma-separated list of device types to consider. Supported values: iphone, ipad, macos, watch, vision." + required: true + selection_mode: + description: "One of random-compatible, random-latest-compatible, model-type, latest-model." + required: false + default: random-compatible + model_preferences: + description: 'Semicolon-separated device type mappings such as "iphone=Pro Max;ipad=Pro;watch=Ultra;vision=Pro".' + required: false + default: "" + iphoneos_version: + description: "Specific iOS version for iPhone devices, or latest." + required: false + default: latest + ipados_version: + description: "Specific iOS version for iPad devices, or latest." + required: false + default: latest + macos_version: + description: "Specific macOS version for macOS simulator devices, or latest." + required: false + default: latest + watchos_version: + description: "Specific watchOS version for watch devices, or latest." + required: false + default: latest + visionos_version: + description: "Specific visionOS version for Vision devices, or latest." + required: false + default: latest + +outputs: + simulator_jsons: + description: "The selected simulators as a JSON array of objects with udid, name, os, and modelType." + value: ${{ steps.pick.outputs.simulator_jsons }} + destination_ids: + description: "The selected simulator UDIDs as a newline-separated list." + value: ${{ steps.pick.outputs.destination_ids }} + +runs: + using: composite + steps: + - name: Pick simulator + id: pick + shell: bash + run: | + set -eo pipefail + python3 "${{ github.action_path }}/pick_simulator.py" \ + --device-types "${{ inputs.device_types }}" \ + --selection-mode "${{ inputs.selection_mode }}" \ + --model-preferences "${{ inputs.model_preferences }}" \ + --iphoneos-version "${{ inputs.iphoneos_version }}" \ + --ipados-version "${{ inputs.ipados_version }}" \ + --macos-version "${{ inputs.macos_version }}" \ + --watchos-version "${{ inputs.watchos_version }}" \ + --visionos-version "${{ inputs.visionos_version }}" diff --git a/.github/actions/pick-my-xcode-tricorder/pick_simulator.py b/.github/actions/pick-my-xcode-tricorder/pick_simulator.py new file mode 100644 index 00000000..6f379ee8 --- /dev/null +++ b/.github/actions/pick-my-xcode-tricorder/pick_simulator.py @@ -0,0 +1,929 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# pick_simulator.py +# pick-my-xcode-tricorder +# +# Created by Kodex on 4/17/26. +# +# This script inspects the locally installed Xcode simulator inventory and chooses one +# compatible destination per requested device type for downstream use in GitHub Actions +# or local automation. + +import argparse +import json +import os +import random +import re +import subprocess +import sys +from dataclasses import dataclass +from typing import Optional + + +SCRIPT_VERSION: str = "0.2.0" +"""The current version of the script""" + + +SUPPORTED_DEVICE_TYPES: tuple[str, ...] = ("iphone", "ipad", "macos", "watch", "vision") +"""Supported Apple simulator device types""" + + +SUPPORTED_SELECTION_MODES: tuple[str, ...] = ( + "random-compatible", + "random-latest-compatible", + "model-type", + "latest-model", +) +"""Supported simulator selection modes""" + + +RUNTIME_FAMILY_MAP: dict[str, tuple[str, ...]] = { + "iOS": ("iphone", "ipad"), + "macOS": ("macos",), + "watchOS": ("watch",), + "visionOS": ("vision",), + "xrOS": ("vision",), +} +"""Maps simctl runtime families to the supported device types""" + + +VARIANT_SCORES: tuple[tuple[str, int], ...] = ( + ("ultra", 80), + ("pro max", 70), + ("max", 60), + ("plus", 50), + ("pro", 40), + ("air", 30), + ("mini", 20), + ("se", 10), +) +"""Relative ranking for Apple simulator model variants""" + + +@dataclass(frozen=True) +class Candidate: + """Represents a single compatible simulator candidate""" + + deviceType: str + """The normalized device type""" + + name: str + """The simulator display name""" + + udid: str + """The simulator UDID""" + + runtimeIdentifier: str + """The CoreSimulator runtime identifier""" + + runtimeFamily: str + """The runtime family, such as iOS or watchOS""" + + osVersion: tuple[int, ...] + """The parsed OS version tuple""" + + +def setupArgumentParser() -> argparse.ArgumentParser: + """ + Sets up the Arugment Parser + + Returns + ------- + ArgumentParser + The created argument parser for this script + """ + + parser: argparse.ArgumentParser = argparse.ArgumentParser(description=""" + This script inspects the available Xcode simulators and selects one + compatible Apple simulator destination per requested device type.""") + + parser.add_argument("--version", "-v", action="version", + version="%(prog)s " + SCRIPT_VERSION) + parser.add_argument("-?", action="help", + help="show this help message and exit") + parser.add_argument("--device-types", metavar="iphone,ipad", required=True, + help="Ordered, comma-separated list of device types to consider - iphone, ipad, macos, watch, vision", + dest='deviceTypes') + parser.add_argument("--selection-mode", metavar="random-compatible", + help="How to choose the simulator(s) - random-compatible, random-latest-compatible, model-type, latest-model", + dest='selectionMode', default="random-compatible") + parser.add_argument("--model-preferences", metavar="iphone=Pro Max;ipad=Pro", + help="Semicolon-separated model keywords per device type", + dest='modelPreferences', default="") + parser.add_argument("--iphoneos-version", metavar="latest", + help="Specific iOS version for iPhone simulators, or latest", + dest='iphoneosVersion', default="latest") + parser.add_argument("--ipados-version", metavar="latest", + help="Specific iOS version for iPad simulators, or latest", + dest='ipadosVersion', default="latest") + parser.add_argument("--macos-version", metavar="latest", + help="Specific macOS version for macOS simulators, or latest", + dest='macosVersion', default="latest") + parser.add_argument("--watchos-version", metavar="latest", + help="Specific watchOS version for watch simulators, or latest", + dest='watchosVersion', default="latest") + parser.add_argument("--visionos-version", metavar="latest", + help="Specific visionOS version for Vision simulators, or latest", + dest='visionosVersion', default="latest") + parser.add_argument("--devices-json-file", metavar="devices.json", + help="Optional path to a simctl devices JSON file for local testing", + dest='devicesJsonFile', default=None) + + return parser + + +def printScriptStart(): + """Prints the info for the start of the script""" + + print(f"Starting {os.path.basename(__file__)} v{SCRIPT_VERSION}", file=sys.stderr) + + +def parseCommaSeparatedList(value: str) -> list[str]: + """ + Parses a comma-separated string into a normalized list of values + + Parameters + ---------- + value + The comma-separated string to parse + + Returns + ------- + list[str] + The normalized list of values + """ + + return [part.strip().lower() for part in value.split(",") if part.strip()] + + +def parseRequestedVersion(value: str) -> Optional[tuple[int, ...]]: + """ + Parses the requested OS version string + + Parameters + ---------- + value + The requested version string + + Returns + ------- + tuple[int, ...] | None + The parsed version tuple, or None when the latest available version should be used + """ + + normalized = value.strip().lower() + if normalized == "latest": + return None + + if not re.fullmatch(r"\d+(?:\.\d+)*", normalized): + raise ValueError(f"Unsupported version value specified: {value}") + + return tuple(int(part) for part in normalized.split(".")) + + +def parseModelPreferences(value: str) -> dict[str, list[str]]: + """ + Parses the model preference string into a dictionary keyed by device type + + Parameters + ---------- + value + The raw model preference string + + Returns + ------- + dict[str, list[str]] + The parsed model preferences by device type + """ + + preferences: dict[str, list[str]] = {} + if not value or len(value.strip()) <= 0: + return preferences + + for segment in value.split(";"): + trimmedSegment = segment.strip() + if len(trimmedSegment) <= 0: + continue + + if "=" not in trimmedSegment: + raise ValueError( + "Model preferences must use the format " + '"device=keyword1,keyword2;other-device=keyword"' + ) + + deviceType, keywordsString = trimmedSegment.split("=", 1) + normalizedDeviceType = deviceType.strip().lower() + + if normalizedDeviceType not in SUPPORTED_DEVICE_TYPES: + raise ValueError(f"Unsupported device type in model preferences: {deviceType}") + + preferences[normalizedDeviceType] = [ + keyword.strip().lower() for keyword in keywordsString.split(",") if keyword.strip() + ] + + return preferences + + +def readDevicesJson(devicesJsonFile: Optional[str]=None) -> dict[str, object]: + """ + Reads the simulator device inventory as JSON + + Parameters + ---------- + devicesJsonFile + Optional path to a pre-generated simctl devices JSON file + + Returns + ------- + dict[str, object] + The simctl device inventory payload + """ + + if devicesJsonFile is not None and len(devicesJsonFile.strip()) > 0: + with open(devicesJsonFile, "r", encoding="utf-8") as file: + return json.load(file) + + result = subprocess.check_output( + ["xcrun", "simctl", "list", "devices", "--json"], + text=True, + ) + + return json.loads(result) + + +def parseRuntimeIdentifier(runtimeIdentifier: str) -> Optional[tuple[str, tuple[int, ...]]]: + """ + Parses a CoreSimulator runtime identifier + + Parameters + ---------- + runtimeIdentifier + The CoreSimulator runtime identifier + + Returns + ------- + tuple[str, tuple[int, ...]] | None + The runtime family and parsed version tuple, or None if unsupported + """ + + runtimeMatch = re.match( + r"^com\.apple\.CoreSimulator\.SimRuntime\.([A-Za-z]+)-(\d+(?:-\d+)*)$", + runtimeIdentifier, + ) + if runtimeMatch is None: + return None + + runtimeFamily = runtimeMatch.group(1) + if runtimeFamily not in RUNTIME_FAMILY_MAP: + return None + + runtimeVersion = tuple(int(part) for part in runtimeMatch.group(2).split("-")) + return (runtimeFamily, runtimeVersion) + + +def classifyDeviceType(name: str) -> Optional[str]: + """ + Determines the normalized device type from the simulator display name + + Parameters + ---------- + name + The simulator display name + + Returns + ------- + str | None + The normalized device type, or None if unsupported + """ + + normalizedName = name.strip() + + if normalizedName.startswith("iPhone"): + return "iphone" + if normalizedName.startswith("iPad"): + return "ipad" + if normalizedName.startswith("Apple Watch"): + return "watch" + if "Vision" in normalizedName: + return "vision" + if normalizedName.startswith("Mac") or normalizedName == "My Mac": + return "macos" + + return None + + +def versionToString(version: tuple[int, ...]) -> str: + """ + Converts a version tuple into a human-readable string + + Parameters + ---------- + version + The version tuple to convert + + Returns + ------- + str + The version string + """ + + return ".".join(str(part) for part in version) + + +def matchesRequestedVersion(candidateVersion: tuple[int, ...], requestedVersion: Optional[tuple[int, ...]]) -> bool: + """ + Determines whether a simulator OS version matches the requested version + + Parameters + ---------- + candidateVersion + The simulator candidate OS version + requestedVersion + The requested OS version, or None when any/latest version is acceptable + + Returns + ------- + bool + Whether the candidate version matches the requested version + """ + + if requestedVersion is None: + return True + + return candidateVersion[:len(requestedVersion)] == requestedVersion + + +def determineVariantScore(name: str) -> int: + """ + Determines the relative ranking score for a simulator model variant + + Parameters + ---------- + name + The simulator display name + + Returns + ------- + int + The relative model variant score + """ + + normalizedName = name.lower() + + for keyword, score in VARIANT_SCORES: + if keyword in normalizedName: + return score + + return 25 + + +def determineModelType(name: str) -> str: + """ + Determines the simulator model type from the simulator display name + + Parameters + ---------- + name + The simulator display name + + Returns + ------- + str + The detected model type, or an empty string if none is present + """ + + normalizedName = name.lower() + + for keyword, _score in VARIANT_SCORES: + if keyword in normalizedName: + return keyword.title() + + return "" + + +def createSafeName(name: str, osVersion: str) -> str: + """ + Creates a filesystem-safe simulator identifier string + + Parameters + ---------- + name + The simulator display name + osVersion + The simulator OS version string + + Returns + ------- + str + The filesystem-safe simulator identifier + """ + + return re.sub(r"[^A-Za-z0-9._-]+", "-", f"{name}-{osVersion}").strip("-") + + +def determineModelRank(candidate: Candidate) -> tuple[tuple[int, ...], int, tuple[int, ...], str]: + """ + Determines the rank tuple for a simulator candidate + + Parameters + ---------- + candidate + The simulator candidate to rank + + Returns + ------- + tuple[tuple[int, ...], int, tuple[int, ...], str] + The rank tuple used for deterministic sorting and comparisons + """ + + numericParts = tuple(int(part) for part in re.findall(r"\d+", candidate.name)) + return ( + candidate.osVersion, + determineVariantScore(candidate.name), + numericParts, + candidate.name, + ) + + +def filterToLatestModel(candidates: list[Candidate]) -> list[Candidate]: + """ + Filters the candidate list down to the highest-ranked model(s) + + Parameters + ---------- + candidates + The candidate simulators to filter + + Returns + ------- + list[Candidate] + The highest-ranked simulator candidate(s) + """ + + if len(candidates) <= 0: + return [] + + bestRank = max(determineModelRank(candidate) for candidate in candidates) + return [candidate for candidate in candidates if determineModelRank(candidate) == bestRank] + + +def filterToModelPreferences(candidates: list[Candidate], modelPreferences: dict[str, list[str]]) -> list[Candidate]: + """ + Filters the candidate list using the requested model preference keywords + + Parameters + ---------- + candidates + The candidate simulators to filter + modelPreferences + The requested model preferences by device type + + Returns + ------- + list[Candidate] + The filtered candidate list + """ + + if len(candidates) <= 0: + return [] + + keywords = modelPreferences.get(candidates[0].deviceType, []) + if len(keywords) <= 0: + return candidates + + return [ + candidate + for candidate in candidates + if all(keyword in candidate.name.lower() for keyword in keywords) + ] + + +def enumerateCandidates(devicePayload: dict[str, object], requestedDeviceTypes: list[str]) -> dict[str, list[Candidate]]: + """ + Enumerates all compatible simulator candidates from the simctl payload + + Parameters + ---------- + devicePayload + The simctl device payload + requestedDeviceTypes + The normalized device types to consider + + Returns + ------- + dict[str, list[Candidate]] + The compatible candidates grouped by device type + """ + + requestedDeviceTypesSet = set(requestedDeviceTypes) + candidatesByType: dict[str, list[Candidate]] = { + deviceType: [] for deviceType in requestedDeviceTypes + } + + devicesByRuntime = devicePayload.get("devices", {}) + if not isinstance(devicesByRuntime, dict): + raise ValueError("Unexpected simctl JSON format: missing devices map") + + for runtimeIdentifier, entries in devicesByRuntime.items(): + parsedRuntime = parseRuntimeIdentifier(runtimeIdentifier) + if parsedRuntime is None: + continue + + runtimeFamily, runtimeVersion = parsedRuntime + if not isinstance(entries, list): + continue + + print(f"Runtime: {runtimeIdentifier} ({len(entries)} devices)", file=sys.stderr) + + for entry in entries: + if not isinstance(entry, dict): + continue + + name = str(entry.get("name") or "").strip() + udid = str(entry.get("udid") or "").strip() + isAvailable = bool(entry.get("isAvailable")) + + if not isAvailable or len(name) <= 0 or len(udid) <= 0: + continue + + deviceType = classifyDeviceType(name) + if deviceType is None: + continue + if deviceType not in requestedDeviceTypesSet: + continue + if deviceType not in RUNTIME_FAMILY_MAP.get(runtimeFamily, ()): + continue + + candidate = Candidate( + deviceType=deviceType, + name=name, + udid=udid, + runtimeIdentifier=runtimeIdentifier, + runtimeFamily=runtimeFamily, + osVersion=runtimeVersion, + ) + + candidatesByType[deviceType].append(candidate) + print( + f" {name} ({runtimeFamily} {versionToString(runtimeVersion)}) [{udid}]", + file=sys.stderr, + ) + + return candidatesByType + + +def filterCandidatesForRequestedOs(candidates: list[Candidate], requestedVersion: Optional[tuple[int, ...]], deviceType: str) -> list[Candidate]: + """ + Filters candidates to the requested OS version, or the latest available version when unspecified + + Parameters + ---------- + candidates + The candidate simulators to filter + requestedVersion + The requested OS version, or None for latest + deviceType + The device type being filtered + + Returns + ------- + list[Candidate] + The filtered candidates for the requested or latest OS version + """ + + matchingCandidates = [ + candidate + for candidate in candidates + if matchesRequestedVersion(candidate.osVersion, requestedVersion) + ] + + if requestedVersion is not None: + print( + f"{deviceType}: requested OS {versionToString(requestedVersion)}", + file=sys.stderr, + ) + return matchingCandidates + + if len(matchingCandidates) <= 0: + return matchingCandidates + + latestVersion = max(candidate.osVersion for candidate in matchingCandidates) + print(f"{deviceType}: using latest OS {versionToString(latestVersion)}", file=sys.stderr) + + return [ + candidate for candidate in matchingCandidates if candidate.osVersion == latestVersion + ] + + +def buildCandidatePool(deviceType: str, + candidates: list[Candidate], + requestedVersion: Optional[tuple[int, ...]], + selectionMode: str, + modelPreferences: dict[str, list[str]]) -> list[Candidate]: + """ + Builds the final candidate pool for the specified device type and selection mode + + Parameters + ---------- + deviceType + The device type being evaluated + candidates + The compatible candidates for this device type + requestedVersion + The requested OS version, or None for latest + selectionMode + The simulator selection mode + modelPreferences + The requested model preferences by device type + + Returns + ------- + list[Candidate] + The narrowed candidate pool for final selection + """ + + versionFilteredCandidates = filterCandidatesForRequestedOs( + candidates=candidates, + requestedVersion=requestedVersion, + deviceType=deviceType, + ) + + if selectionMode == "random-compatible": + return versionFilteredCandidates + + if selectionMode == "random-latest-compatible": + return filterToLatestModel(versionFilteredCandidates) + + if selectionMode == "model-type": + return filterToLatestModel( + filterToModelPreferences(versionFilteredCandidates, modelPreferences) + ) + + if selectionMode == "latest-model": + return filterToLatestModel(versionFilteredCandidates) + + raise ValueError(f"Unsupported selection mode: {selectionMode}") + + +def selectDestinationFromPool(candidates: list[Candidate], + selectionMode: str) -> Optional[Candidate]: + """ + Selects a single destination from a prepared candidate pool + + Parameters + ---------- + candidates + The candidate pool to select from + selectionMode + The simulator selection mode + + Returns + ------- + Candidate | None + The selected candidate destination + """ + + if len(candidates) <= 0: + return None + + if selectionMode.startswith("random-"): + return random.choice(candidates) + + rankedCandidates = sorted(candidates, key=determineModelRank, reverse=True) + return rankedCandidates[0] + + +def determineRequestedVersions(scriptArgs: argparse.Namespace) -> dict[str, Optional[tuple[int, ...]]]: + """ + Determines the requested OS versions for all supported device types + + Parameters + ---------- + scriptArgs + The parsed script arguments + + Returns + ------- + dict[str, tuple[int, ...] | None] + The requested OS versions by device type + """ + + return { + "iphone": parseRequestedVersion(scriptArgs.iphoneosVersion), + "ipad": parseRequestedVersion(scriptArgs.ipadosVersion), + "macos": parseRequestedVersion(scriptArgs.macosVersion), + "watch": parseRequestedVersion(scriptArgs.watchosVersion), + "vision": parseRequestedVersion(scriptArgs.visionosVersion), + } + + +def validateScriptArguments(scriptArgs: argparse.Namespace) -> list[str]: + """ + Validates the parsed script arguments + + Parameters + ---------- + scriptArgs + The parsed script arguments + + Returns + ------- + list[str] + The normalized requested device types + """ + + requestedDeviceTypes = parseCommaSeparatedList(scriptArgs.deviceTypes) + if len(requestedDeviceTypes) <= 0: + raise ValueError("At least one device type must be provided") + + for deviceType in requestedDeviceTypes: + if deviceType not in SUPPORTED_DEVICE_TYPES: + raise ValueError(f"Unsupported device type specified: {deviceType}") + + if scriptArgs.selectionMode not in SUPPORTED_SELECTION_MODES: + raise ValueError( + f"Unsupported selection mode specified: {scriptArgs.selectionMode}. " + f"Expected one of {', '.join(SUPPORTED_SELECTION_MODES)}" + ) + + return requestedDeviceTypes + + +def determineSelectedDestinations(requestedDeviceTypes: list[str], + candidatesByType: dict[str, list[Candidate]], + requestedVersions: dict[str, Optional[tuple[int, ...]]], + selectionMode: str, + modelPreferences: dict[str, list[str]]) -> list[Candidate]: + """ + Determines the final selected destinations across the requested device types + + Parameters + ---------- + requestedDeviceTypes + The ordered requested device types + candidatesByType + The compatible candidates grouped by device type + requestedVersions + The requested OS versions by device type + selectionMode + The simulator selection mode + modelPreferences + The requested model preferences by device type + + Returns + ------- + list[Candidate] + The selected destinations + """ + + selectedCandidates: list[Candidate] = [] + missingDeviceTypes: list[str] = [] + + for deviceType in requestedDeviceTypes: + compatibleCandidates = candidatesByType.get(deviceType, []) + if len(compatibleCandidates) <= 0: + print(f"{deviceType}: no compatible devices found", file=sys.stderr) + missingDeviceTypes.append(deviceType) + continue + + candidatePool = buildCandidatePool( + deviceType=deviceType, + candidates=compatibleCandidates, + requestedVersion=requestedVersions[deviceType], + selectionMode=selectionMode, + modelPreferences=modelPreferences, + ) + + if len(candidatePool) <= 0: + print(f"{deviceType}: no devices matched the requested filters", file=sys.stderr) + missingDeviceTypes.append(deviceType) + continue + + selectedCandidate = selectDestinationFromPool( + candidates=candidatePool, + selectionMode=selectionMode, + ) + if selectedCandidate is None: + missingDeviceTypes.append(deviceType) + continue + + selectedCandidates.append(selectedCandidate) + + if len(missingDeviceTypes) > 0: + missingDeviceTypesLabel = ", ".join(missingDeviceTypes) + raise SystemExit( + f"Unable to determine simulator destinations for the requested device types: " + f"{missingDeviceTypesLabel}" + ) + + return selectedCandidates + + +def writeGithubOutput(name: str, value: str): + """ + Writes a single GitHub Actions output value + + Parameters + ---------- + name + The output name + value + The output value + """ + + outputFile = os.environ.get("GITHUB_OUTPUT") + if outputFile is None or len(outputFile.strip()) <= 0: + return + + with open(outputFile, "a", encoding="utf-8") as file: + print(f"{name}={value}", file=file) + + +def writeGithubMultilineOutput(name: str, values: list[str]): + """ + Writes a multiline GitHub Actions output value + + Parameters + ---------- + name + The output name + values + The list of values to write + """ + + outputFile = os.environ.get("GITHUB_OUTPUT") + if outputFile is None or len(outputFile.strip()) <= 0: + return + + with open(outputFile, "a", encoding="utf-8") as file: + print(f"{name}<<__PICK_MY_XCODE_TRICORDER__", file=file) + for value in values: + print(value, file=file) + print("__PICK_MY_XCODE_TRICORDER__", file=file) + + +def publishOutputs(selectedCandidates: list[Candidate]): + """ + Publishes the selected simulator outputs for GitHub Actions + + Parameters + ---------- + selectedCandidates + The selected simulator candidates + """ + + if len(selectedCandidates) <= 0: + return + + destinationIds = [candidate.udid for candidate in selectedCandidates] + simulators = [ + { + "udid": candidate.udid, + "name": candidate.name, + "os": versionToString(candidate.osVersion), + "modelType": determineModelType(candidate.name), + "safe_name": createSafeName(candidate.name, versionToString(candidate.osVersion)), + } + for candidate in selectedCandidates + ] + + writeGithubOutput("simulator_jsons", json.dumps(simulators)) + writeGithubMultilineOutput("destination_ids", destinationIds) + + +def main(): + """Runs the simulator picker script""" + + parser = setupArgumentParser() + scriptArgs = parser.parse_args() + + printScriptStart() + + requestedDeviceTypes = validateScriptArguments(scriptArgs) + requestedVersions = determineRequestedVersions(scriptArgs) + modelPreferences = parseModelPreferences(scriptArgs.modelPreferences) + devicePayload = readDevicesJson(scriptArgs.devicesJsonFile) + candidatesByType = enumerateCandidates(devicePayload, requestedDeviceTypes) + + selectedCandidates = determineSelectedDestinations( + requestedDeviceTypes=requestedDeviceTypes, + candidatesByType=candidatesByType, + requestedVersions=requestedVersions, + selectionMode=scriptArgs.selectionMode, + modelPreferences=modelPreferences, + ) + + print("Selected Simulators:", file=sys.stderr) + for candidate in selectedCandidates: + print( + f" {candidate.name} ({candidate.runtimeFamily} {versionToString(candidate.osVersion)}) " + f"[{candidate.udid}]", + file=sys.stderr, + ) + + publishOutputs(selectedCandidates) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 087e6b78..72fbb776 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -31,74 +31,76 @@ jobs: - name: Resolve iPhone simulator destination id: destination - run: | - set -eo pipefail - - destination_id="$( - xcrun simctl list devices --json | - python3 -c ' - import json - import re - import random - import sys - - latest_os_version = None - candidates = [] - for runtime, entries in json.load(sys.stdin).get("devices", {}).items(): - runtime_match = re.match(r"com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)-(\d+)", runtime) - if not runtime_match: - continue - - os_version = tuple(int(part) for part in runtime_match.groups()) - if latest_os_version is None or os_version > latest_os_version: - latest_os_version = os_version - candidates = [] - elif os_version < latest_os_version: - continue - - print(f"Runtime: {runtime} ({len(entries)} devices)", file=sys.stderr) - for entry in entries: - name = (entry["name"] or "").strip() - udid = entry["udid"] - if entry["isAvailable"] and udid and name.startswith("iPhone"): - candidates.append((name, udid)) - the_os = f"{os_version[0]}.{os_version[1]}" - print(f" {name} (iOS {the_os}) [{udid}]", file=sys.stderr) - - if not candidates: - raise SystemExit("No iPhone simulator destinations found") - - selected = random.choice(candidates) - the_os = f"{latest_os_version[0]}.{latest_os_version[1]}" - print(f"Selected Simulator: {selected[0]} (iOS {the_os}) [{selected[1]}]", file=sys.stderr) - print(selected[1]) - ' - )" - - echo "destination=id=$destination_id,arch=arm64" >> "$GITHUB_OUTPUT" + uses: ./.github/actions/pick-my-xcode-tricorder + with: + device_types: iphone + iphoneos_version: latest + selection_mode: random-compatible - name: Run unit tests env: SCHEME: ${{ matrix.scheme }} + DESTINATION_IDS: ${{ steps.destination.outputs.destination_ids }} + SIMULATOR_JSONS: ${{ steps.destination.outputs.simulator_jsons }} run: | set -eo pipefail mkdir -p TestResults - xcodebuild \ - -project libPhoneNumber.xcodeproj \ - -scheme "$SCHEME" \ - -destination "${{ steps.destination.outputs.destination }}" \ - -resultBundlePath "TestResults/${SCHEME}.xcresult" \ - -enableCodeCoverage YES \ - CODE_SIGNING_ALLOWED=NO \ - test + python3 -c ' + import json + import os + import subprocess + import sys + + scheme = os.environ["SCHEME"] + destination_ids = [ + value.strip() + for value in os.environ["DESTINATION_IDS"].splitlines() + if value.strip() + ] + simulators = json.loads(os.environ["SIMULATOR_JSONS"]) + + if len(destination_ids) != len(simulators): + raise SystemExit("Destination ID and simulator output counts do not match") + + for index, destination_id in enumerate(destination_ids): + simulator = simulators[index] + simulator_name = simulator["name"] + simulator_os = simulator["os"] + destination = f"id={destination_id},arch=arm64" + safe_name = simulator["safe_name"] + result_bundle_path = os.path.join("TestResults", f"{scheme}-{safe_name}.xcresult") + + print( + f"Running {scheme} on {simulator_name} ({simulator_os}) -> {result_bundle_path}", + file=sys.stderr, + ) + + subprocess.check_call( + [ + "xcodebuild", + "-project", + "libPhoneNumber.xcodeproj", + "-scheme", + scheme, + "-destination", + destination, + "-resultBundlePath", + result_bundle_path, + "-enableCodeCoverage", + "YES", + "CODE_SIGNING_ALLOWED=NO", + "test", + ] + ) + ' - name: Upload unit test results if: always() uses: actions/upload-artifact@v6 with: name: project-unit-tests-${{ matrix.scheme }}.xcresult - path: TestResults/${{ matrix.scheme }}.xcresult + path: TestResults/*.xcresult coverage-summary: name: Combined Code Coverage From c957494ff05c0e129ca053a01224f410c1b0a418 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 13:19:48 -0500 Subject: [PATCH 43/60] enhance coverage calculation logic to properly handle multiple xcresults if they exist --- .github/workflows/ci-core.yml | 139 +++++++++++++++++++++------------- 1 file changed, 88 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 72fbb776..e7a07433 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -99,7 +99,7 @@ jobs: if: always() uses: actions/upload-artifact@v6 with: - name: project-unit-tests-${{ matrix.scheme }}.xcresult + name: project-unit-tests-${{ matrix.scheme }} path: TestResults/*.xcresult coverage-summary: @@ -115,8 +115,8 @@ jobs: continue-on-error: true uses: actions/download-artifact@v7 with: - pattern: project-unit-tests-*.xcresult - path: CoverageResults + pattern: project-unit-tests-* + path: CoverageResults/xcresults - name: Publish combined coverage summary id: coverage @@ -125,69 +125,109 @@ jobs: summary_file="CoverageResults/combined-coverage-summary.md" mkdir -p CoverageResults - result_bundles=() - while IFS= read -r -d '' result_bundle; do - result_bundles+=("$result_bundle") - done < <(find CoverageResults -type d -name '*.xcresult' -print0) - - if [ ${#result_bundles[@]} -eq 0 ]; then - { - echo "### Combined Code Coverage" - echo - echo "Combined coverage unavailable because no unit test result bundles were downloaded." - } > "$summary_file" - echo "combined_coverage_percent=" >> "$GITHUB_OUTPUT" - cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - python3 -c ' import json import os import subprocess import sys - combined = {} - per_scheme = [] + ARTIFACT_PREFIX = "project-unit-tests-" + XCRESULT_SUFFIX = ".xcresult" + + artifacts_root = sys.argv[1] + summary_path = sys.argv[2] + + def append_output(coverage_percent): + with open(os.environ["GITHUB_OUTPUT"], "a") as handle: + print(f"combined_coverage_percent={coverage_percent}", file=handle) + + def write_unavailable_summary(message): + with open(summary_path, "w") as handle: + print("### Combined Code Coverage", file=handle) + print(file=handle) + print(message, file=handle) + + def find_scheme_result_bundles(search_root): + scheme_result_bundles = {} - for path in sys.argv[1:-1]: - print(f"Processing result bundle: {path}", file=sys.stderr) - report = json.loads( - subprocess.check_output( - ["xcrun", "xccov", "view", "--archive", "--json", path], - text=True, + if not os.path.isdir(search_root): + return scheme_result_bundles + + for artifact_name in sorted(os.listdir(search_root)): + artifact_path = os.path.join(search_root, artifact_name) + if not os.path.isdir(artifact_path): + continue + if not artifact_name.startswith(ARTIFACT_PREFIX): + continue + + scheme_name = artifact_name[len(ARTIFACT_PREFIX):] + result_bundles = sorted( + os.path.join(artifact_path, entry_name) + for entry_name in os.listdir(artifact_path) + if entry_name.endswith(XCRESULT_SUFFIX) ) - ) - covered_lines = 0 - executable_lines = 0 + if len(result_bundles) <= 0: + print(f"{scheme_name}: no downloaded .xcresult bundles found", file=sys.stderr) + continue + + scheme_result_bundles[scheme_name] = result_bundles + + return scheme_result_bundles + + def merge_report(target, report): for file_path, entries in report.items(): - combined_lines = combined.setdefault(file_path, {}) + combined_lines = target.setdefault(file_path, {}) for entry in entries: line_number = entry["line"] if line_number is None or not entry["isExecutable"]: continue is_covered = entry.get("executionCount", 0) > 0 - executable_lines += 1 - if is_covered: - covered_lines += 1 - combined_lines[line_number] = combined_lines.get(line_number, False) or is_covered - scheme_name = os.path.basename(path).replace(".xcresult", "") - coverage_percent = (covered_lines / executable_lines * 100) if executable_lines else 0.0 + def summarize_lines(line_map): + executable_lines = sum(len(lines) for lines in line_map.values()) + covered_lines = sum( + 1 for lines in line_map.values() for is_covered in lines.values() if is_covered + ) + coverage_percent = ( + covered_lines / executable_lines * 100 + if executable_lines + else 0.0 + ) + return covered_lines, executable_lines, coverage_percent + + scheme_result_bundles = find_scheme_result_bundles(artifacts_root) + combined = {} + per_scheme = [] + + if len(scheme_result_bundles) <= 0: + write_unavailable_summary( + "Combined coverage unavailable because no unit test result bundles were downloaded." + ) + append_output("") + raise SystemExit(0) + + for scheme_name, scheme_bundle_paths in sorted(scheme_result_bundles.items()): + scheme_lines = {} + + for path in scheme_bundle_paths: + print(f"Processing result bundle for {scheme_name}: {path}", file=sys.stderr) + report = json.loads( + subprocess.check_output( + ["xcrun", "xccov", "view", "--archive", "--json", path], + text=True, + ) + ) + + merge_report(scheme_lines, report) + merge_report(combined, report) + + covered_lines, executable_lines, coverage_percent = summarize_lines(scheme_lines) per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) - combined_executable_lines = sum(len(lines) for lines in combined.values()) - combined_covered_lines = sum( - 1 for lines in combined.values() for is_covered in lines.values() if is_covered - ) - combined_coverage_percent = ( - combined_covered_lines / combined_executable_lines * 100 - if combined_executable_lines - else 0.0 - ) + combined_covered_lines, combined_executable_lines, combined_coverage_percent = summarize_lines(combined) def status_emoji(coverage_percent): if coverage_percent < 60.0: @@ -196,8 +236,6 @@ jobs: return "⚠️" return "✅" - summary_path = sys.argv[-1] - with open(summary_path, "w") as handle: print("### Code Coverage", file=handle) print(file=handle) @@ -219,9 +257,8 @@ jobs: ) print(f" Combined - {combined_percent}% {combined_emoji}", file=sys.stderr) - with open(os.environ["GITHUB_OUTPUT"], "a") as handle: - print(f"combined_coverage_percent={combined_coverage_percent:.2f}", file=handle) - ' "${result_bundles[@]}" "$summary_file" + append_output(f"{combined_coverage_percent:.2f}") + ' "CoverageResults/xcresults" "$summary_file" cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" From a530865937f269878f5aff69ccf004720bb3ebfa Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 14:02:46 -0500 Subject: [PATCH 44/60] Making a github action to run unit tests --- .../README.md | 33 +- .../action.yml | 2 +- .../pick_simulator.py | 2 +- .../xcode-test-the-tricorders/README.md | 62 +++ .../xcode-test-the-tricorders/action.yml | 63 +++ .../run_xcode_tests.py | 369 ++++++++++++++++++ .github/workflows/ci-core.yml | 67 +--- 7 files changed, 513 insertions(+), 85 deletions(-) rename .github/actions/{pick-my-xcode-tricorder => simctl-pick-a-tricorder}/README.md (60%) rename .github/actions/{pick-my-xcode-tricorder => simctl-pick-a-tricorder}/action.yml (98%) rename .github/actions/{pick-my-xcode-tricorder => simctl-pick-a-tricorder}/pick_simulator.py (99%) create mode 100644 .github/actions/xcode-test-the-tricorders/README.md create mode 100644 .github/actions/xcode-test-the-tricorders/action.yml create mode 100644 .github/actions/xcode-test-the-tricorders/run_xcode_tests.py diff --git a/.github/actions/pick-my-xcode-tricorder/README.md b/.github/actions/simctl-pick-a-tricorder/README.md similarity index 60% rename from .github/actions/pick-my-xcode-tricorder/README.md rename to .github/actions/simctl-pick-a-tricorder/README.md index dcbedeff..d0d0b607 100644 --- a/.github/actions/pick-my-xcode-tricorder/README.md +++ b/.github/actions/simctl-pick-a-tricorder/README.md @@ -1,4 +1,4 @@ -# Pick My Xcode Tricorder +# Simctl Pick A Tricorder Local composite action for choosing an installed simulator device from `xcrun simctl list devices --json`. @@ -37,7 +37,7 @@ Local composite action for choosing an installed simulator device from `xcrun si ```yaml - name: Pick simulator id: simulator - uses: ./.github/actions/pick-my-xcode-tricorder + uses: ./.github/actions/simctl-pick-a-tricorder with: device_types: iphone,ipad iphoneos_version: latest @@ -45,27 +45,10 @@ Local composite action for choosing an installed simulator device from `xcrun si selection_mode: random-latest-compatible - name: Run tests - env: - DESTINATION_IDS: ${{ steps.simulator.outputs.destination_ids }} - SIMULATOR_JSONS: ${{ steps.simulator.outputs.simulator_jsons }} - run: | - python3 -c ' - import json - import os - - destination_ids = [ - value.strip() - for value in os.environ["DESTINATION_IDS"].splitlines() - if value.strip() - ] - simulators = json.loads(os.environ["SIMULATOR_JSONS"]) - - for index, destination_id in enumerate(destination_ids): - simulator = simulators[index] - destination = f"id={destination_id},arch=arm64" - safe_name = simulator["safe_name"] - print(f"Run against {simulator['name']} ({simulator['os']})") - print(f"Use safe result-bundle name: {safe_name}") - print(f"xcodebuild -destination {destination} test") - ' + uses: ./.github/actions/xcode-test-the-tricorders + with: + scheme: libPhoneNumber + xcode_container: libPhoneNumber.xcodeproj + destination_ids: ${{ steps.simulator.outputs.destination_ids }} + simulator_jsons: ${{ steps.simulator.outputs.simulator_jsons }} ``` diff --git a/.github/actions/pick-my-xcode-tricorder/action.yml b/.github/actions/simctl-pick-a-tricorder/action.yml similarity index 98% rename from .github/actions/pick-my-xcode-tricorder/action.yml rename to .github/actions/simctl-pick-a-tricorder/action.yml index 84b2d273..ef10678a 100644 --- a/.github/actions/pick-my-xcode-tricorder/action.yml +++ b/.github/actions/simctl-pick-a-tricorder/action.yml @@ -1,4 +1,4 @@ -name: Pick My Xcode Tricorder +name: Simctl Pick A Tricorder description: Select a compatible Apple simulator device from the installed simctl inventory. inputs: diff --git a/.github/actions/pick-my-xcode-tricorder/pick_simulator.py b/.github/actions/simctl-pick-a-tricorder/pick_simulator.py similarity index 99% rename from .github/actions/pick-my-xcode-tricorder/pick_simulator.py rename to .github/actions/simctl-pick-a-tricorder/pick_simulator.py index 6f379ee8..40af9746 100644 --- a/.github/actions/pick-my-xcode-tricorder/pick_simulator.py +++ b/.github/actions/simctl-pick-a-tricorder/pick_simulator.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # pick_simulator.py -# pick-my-xcode-tricorder +# simctl-pick-a-tricorder # # Created by Kodex on 4/17/26. # diff --git a/.github/actions/xcode-test-the-tricorders/README.md b/.github/actions/xcode-test-the-tricorders/README.md new file mode 100644 index 00000000..0266524e --- /dev/null +++ b/.github/actions/xcode-test-the-tricorders/README.md @@ -0,0 +1,62 @@ +# Xcode Test The Tricorders + +Local composite action for running `xcodebuild test` across simulator destinations selected by `simctl-pick-a-tricorder`. + +## Inputs + +- `scheme` + - Xcode scheme to test +- `xcode_container` + - Path to the Xcode project or workspace + - The action infers the type from the file extension + - Supported values end in `.xcodeproj` or `.xcworkspace` +- `destination_ids` + - Newline-separated simulator UDIDs from `simctl-pick-a-tricorder` +- `simulator_jsons` + - JSON array from `simctl-pick-a-tricorder` +- `result_bundle_directory` + - Directory where `.xcresult` bundles should be created + - Default: `TestResults` +- `destination_arch` + - Architecture used in each `xcodebuild -destination` + - Default: `arm64` +- `enable_code_coverage` + - Value passed to `-enableCodeCoverage` + - Default: `YES` +- `code_signing_allowed` + - Value passed through `CODE_SIGNING_ALLOWED` + - Default: `NO` +- `xcodebuild_extra_args` + - Optional extra `xcodebuild` arguments + +## Outputs + +- `result_bundle_directory` +- `result_bundle_paths` + +## Example + +```yaml +- name: Pick simulator + id: simulator + uses: ./.github/actions/simctl-pick-a-tricorder + with: + device_types: iphone + iphoneos_version: latest + selection_mode: random-compatible + +- name: Run unit tests + id: tests + uses: ./.github/actions/xcode-test-the-tricorders + with: + scheme: libPhoneNumber + xcode_container: libPhoneNumber.xcodeproj + destination_ids: ${{ steps.simulator.outputs.destination_ids }} + simulator_jsons: ${{ steps.simulator.outputs.simulator_jsons }} + +- name: Upload unit test results + uses: actions/upload-artifact@v6 + with: + name: project-unit-tests-libPhoneNumber + path: ${{ steps.tests.outputs.result_bundle_paths }} +``` diff --git a/.github/actions/xcode-test-the-tricorders/action.yml b/.github/actions/xcode-test-the-tricorders/action.yml new file mode 100644 index 00000000..1ead5a26 --- /dev/null +++ b/.github/actions/xcode-test-the-tricorders/action.yml @@ -0,0 +1,63 @@ +name: Xcode Test The Tricorders +description: Run xcodebuild tests against simulator destinations selected by simctl-pick-a-tricorder. + +inputs: + scheme: + description: Xcode scheme to test. + required: true + xcode_container: + description: Path to the Xcode project or workspace. + required: true + destination_ids: + description: Newline-separated list of simulator destination UDIDs. + required: true + simulator_jsons: + description: JSON array of simulator objects returned by simctl-pick-a-tricorder. + required: true + result_bundle_directory: + description: Directory where generated xcresult bundles should be written. + required: false + default: TestResults + destination_arch: + description: Destination architecture to use with xcodebuild. + required: false + default: arm64 + enable_code_coverage: + description: Value to pass to -enableCodeCoverage. + required: false + default: "YES" + code_signing_allowed: + description: Value to pass through CODE_SIGNING_ALLOWED. + required: false + default: "NO" + xcodebuild_extra_args: + description: Optional extra xcodebuild arguments. + required: false + default: "" + +outputs: + result_bundle_directory: + description: Directory containing the generated xcresult bundles. + value: ${{ steps.run.outputs.result_bundle_directory }} + result_bundle_paths: + description: Newline-separated list of generated xcresult bundle paths. + value: ${{ steps.run.outputs.result_bundle_paths }} + +runs: + using: composite + steps: + - name: Run xcodebuild tests + id: run + shell: bash + run: | + set -eo pipefail + python3 "${{ github.action_path }}/run_xcode_tests.py" \ + --scheme "${{ inputs.scheme }}" \ + --xcode-container "${{ inputs.xcode_container }}" \ + --destination-ids "${{ inputs.destination_ids }}" \ + --simulator-jsons "${{ inputs.simulator_jsons }}" \ + --result-bundle-directory "${{ inputs.result_bundle_directory }}" \ + --destination-arch "${{ inputs.destination_arch }}" \ + --enable-code-coverage "${{ inputs.enable_code_coverage }}" \ + --code-signing-allowed "${{ inputs.code_signing_allowed }}" \ + --xcodebuild-extra-args "${{ inputs.xcodebuild_extra_args }}" diff --git a/.github/actions/xcode-test-the-tricorders/run_xcode_tests.py b/.github/actions/xcode-test-the-tricorders/run_xcode_tests.py new file mode 100644 index 00000000..f105781b --- /dev/null +++ b/.github/actions/xcode-test-the-tricorders/run_xcode_tests.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# run_xcode_tests.py +# xcode-test-the-tricorders +# +# Created by Kodex on 4/17/26. +# +# This script runs xcodebuild tests against one or more simulator destinations +# selected by simctl-pick-a-tricorder and publishes the generated xcresult +# bundle paths for downstream GitHub Actions steps. + +import argparse +import json +import os +import shlex +import subprocess +import sys + + +SCRIPT_VERSION: str = "0.1.0" +"""The current version of the script""" + + +SUPPORTED_XCODE_CONTAINERS: dict[str, str] = { + ".xcodeproj": "project", + ".xcworkspace": "workspace", +} +"""Supported Xcode container extensions mapped to xcodebuild argument types""" + + +def setupArgumentParser() -> argparse.ArgumentParser: + """ + Sets up the Arugment Parser + + Returns + ------- + ArgumentParser + The created argument parser for this script + """ + + parser: argparse.ArgumentParser = argparse.ArgumentParser(description=""" + This script runs xcodebuild tests against simulator destinations + selected by simctl-pick-a-tricorder.""") + + parser.add_argument("--version", "-v", action="version", + version="%(prog)s " + SCRIPT_VERSION) + parser.add_argument("-?", action="help", + help="show this help message and exit") + parser.add_argument("--scheme", metavar="SchemeName", required=True, + help="The Xcode scheme to run tests for", + dest='scheme') + parser.add_argument("--xcode-container", metavar="Project.xcodeproj", required=True, + help="The path to the Xcode project or workspace", + dest='xcodeContainer') + parser.add_argument("--destination-ids", metavar="DESTINATION_IDS", required=True, + help="Newline-separated simulator destination UDIDs", + dest='destinationIds') + parser.add_argument("--simulator-jsons", metavar="SIMULATOR_JSONS", required=True, + help="JSON array of simulator objects from simctl-pick-a-tricorder", + dest='simulatorJsons') + parser.add_argument("--result-bundle-directory", metavar="TestResults", + help="The directory where xcresult bundles should be written", + dest='resultBundleDirectory', default="TestResults") + parser.add_argument("--destination-arch", metavar="arm64", required=True, + help="The destination architecture to use with xcodebuild", + dest='destinationArch') + parser.add_argument("--enable-code-coverage", metavar="YES", required=True, + help="The value to pass to -enableCodeCoverage", + dest='enableCodeCoverage') + parser.add_argument("--code-signing-allowed", metavar="NO", required=True, + help="The value to pass through CODE_SIGNING_ALLOWED", + dest='codeSigningAllowed') + parser.add_argument("--xcodebuild-extra-args", metavar="--test-iterations 2", + help="Optional extra xcodebuild arguments", + dest='xcodebuildExtraArgs', default="") + + return parser + + +def printScriptStart(): + """Prints the info for the start of the script""" + + print(f"Starting {os.path.basename(__file__)} v{SCRIPT_VERSION}", file=sys.stderr) + + +def validateScriptArguments(scriptArgs: argparse.Namespace): + """ + Validates the parsed script arguments + + Parameters + ---------- + scriptArgs + The parsed script arguments + """ + + if len(scriptArgs.scheme.strip()) <= 0: + raise ValueError("An Xcode scheme must be provided") + + if len(scriptArgs.xcodeContainer.strip()) <= 0: + raise ValueError("An Xcode container path must be provided") + + if len(scriptArgs.resultBundleDirectory.strip()) <= 0: + raise ValueError("A result bundle directory must be provided") + + determineXcodeContainerType(scriptArgs.xcodeContainer) + + +def determineXcodeContainerType(xcodeContainer: str) -> str: + """ + Determines the Xcode container type from the specified path + + Parameters + ---------- + xcodeContainer + The path to the Xcode project or workspace + + Returns + ------- + str + The Xcode container type to pass to xcodebuild + """ + + _root, extension = os.path.splitext(xcodeContainer.strip()) + containerType = SUPPORTED_XCODE_CONTAINERS.get(extension.lower()) + + if containerType is None: + raise ValueError( + f"Unsupported Xcode container specified: {xcodeContainer}. " + f"Expected a path ending in {', '.join(SUPPORTED_XCODE_CONTAINERS.keys())}" + ) + + return containerType + + +def parseDestinationIds(value: str) -> list[str]: + """ + Parses the destination ID input into a list of UDIDs + + Parameters + ---------- + value + The raw newline-separated destination ID string + + Returns + ------- + list[str] + The parsed destination IDs + """ + + return [part.strip() for part in value.splitlines() if part.strip()] + + +def parseSimulatorJsons(value: str) -> list[dict[str, str]]: + """ + Parses the simulator JSON payload + + Parameters + ---------- + value + The raw simulator JSON string + + Returns + ------- + list[dict[str, str]] + The parsed simulator objects + """ + + simulators = json.loads(value) + if not isinstance(simulators, list): + raise ValueError("Simulator JSON payload must be a list") + + normalizedSimulators: list[dict[str, str]] = [] + for simulator in simulators: + if not isinstance(simulator, dict): + raise ValueError("Simulator JSON payload entries must be objects") + + normalizedSimulators.append({ + "name": str(simulator.get("name") or "").strip(), + "os": str(simulator.get("os") or "").strip(), + "safe_name": str(simulator.get("safe_name") or "").strip(), + }) + + return normalizedSimulators + + +def determineResultBundlePath(resultBundleDirectory: str, + scheme: str, + safeName: str) -> str: + """ + Determines the xcresult bundle path for the specified simulator + + Parameters + ---------- + resultBundleDirectory + The directory where xcresult bundles should be written + scheme + The Xcode scheme being tested + safeName + The filesystem-safe simulator identifier + + Returns + ------- + str + The full xcresult bundle path + """ + + return os.path.join(resultBundleDirectory, f"{scheme}-{safeName}.xcresult") + + +def writeGithubOutput(name: str, value: str): + """ + Writes a single GitHub Actions output value + + Parameters + ---------- + name + The output name + value + The output value + """ + + outputFile = os.environ.get("GITHUB_OUTPUT") + if outputFile is None or len(outputFile.strip()) <= 0: + return + + with open(outputFile, "a", encoding="utf-8") as file: + print(f"{name}={value}", file=file) + + +def writeGithubMultilineOutput(name: str, values: list[str]): + """ + Writes a multiline GitHub Actions output value + + Parameters + ---------- + name + The output name + values + The list of values to write + """ + + outputFile = os.environ.get("GITHUB_OUTPUT") + if outputFile is None or len(outputFile.strip()) <= 0: + return + + with open(outputFile, "a", encoding="utf-8") as file: + print(f"{name}<<__XCODE_TEST_THE_TRICORDERS__", file=file) + for value in values: + print(value, file=file) + print("__XCODE_TEST_THE_TRICORDERS__", file=file) + + +def publishOutputs(resultBundleDirectory: str, resultBundlePaths: list[str]): + """ + Publishes the generated test result bundle outputs for GitHub Actions + + Parameters + ---------- + resultBundleDirectory + The directory containing the generated xcresult bundles + resultBundlePaths + The generated xcresult bundle paths + """ + + writeGithubOutput("result_bundle_directory", resultBundleDirectory) + writeGithubMultilineOutput("result_bundle_paths", resultBundlePaths) + + +def runTests(scriptArgs: argparse.Namespace, + destinationIds: list[str], + simulators: list[dict[str, str]]) -> list[str]: + """ + Runs xcodebuild tests for all selected simulator destinations + + Parameters + ---------- + scriptArgs + The parsed script arguments + destinationIds + The simulator destination IDs + simulators + The parsed simulator metadata + + Returns + ------- + list[str] + The generated xcresult bundle paths + """ + + if len(destinationIds) != len(simulators): + raise ValueError("Destination ID and simulator output counts do not match") + + os.makedirs(scriptArgs.resultBundleDirectory, exist_ok=True) + + resultBundlePaths: list[str] = [] + extraArgs = shlex.split(scriptArgs.xcodebuildExtraArgs) + xcodeContainerType = determineXcodeContainerType(scriptArgs.xcodeContainer) + + for index, destinationId in enumerate(destinationIds): + simulator = simulators[index] + simulatorName = simulator["name"] + simulatorOs = simulator["os"] + safeName = simulator["safe_name"] + + if len(safeName) <= 0: + raise ValueError("Simulator output is missing a safe_name value") + + destination = f"id={destinationId},arch={scriptArgs.destinationArch}" + resultBundlePath = determineResultBundlePath( + resultBundleDirectory=scriptArgs.resultBundleDirectory, + scheme=scriptArgs.scheme, + safeName=safeName, + ) + + print( + f"Running {scriptArgs.scheme} on {simulatorName} ({simulatorOs}) -> {resultBundlePath}", + file=sys.stderr, + ) + + subprocess.check_call( + [ + "xcodebuild", + f"-{xcodeContainerType}", + scriptArgs.xcodeContainer, + "-scheme", + scriptArgs.scheme, + "-destination", + destination, + "-resultBundlePath", + resultBundlePath, + "-enableCodeCoverage", + scriptArgs.enableCodeCoverage, + "CODE_SIGNING_ALLOWED=" + scriptArgs.codeSigningAllowed, + *extraArgs, + "test", + ] + ) + + resultBundlePaths.append(resultBundlePath) + + return resultBundlePaths + + +def main(): + """Runs the Xcode test execution script""" + + parser = setupArgumentParser() + scriptArgs = parser.parse_args() + + printScriptStart() + + validateScriptArguments(scriptArgs) + destinationIds = parseDestinationIds(scriptArgs.destinationIds) + simulators = parseSimulatorJsons(scriptArgs.simulatorJsons) + resultBundlePaths = runTests( + scriptArgs=scriptArgs, + destinationIds=destinationIds, + simulators=simulators, + ) + + publishOutputs( + resultBundleDirectory=scriptArgs.resultBundleDirectory, + resultBundlePaths=resultBundlePaths, + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index e7a07433..44d7745b 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -31,76 +31,27 @@ jobs: - name: Resolve iPhone simulator destination id: destination - uses: ./.github/actions/pick-my-xcode-tricorder + uses: ./.github/actions/simctl-pick-a-tricorder with: device_types: iphone iphoneos_version: latest selection_mode: random-compatible - name: Run unit tests - env: - SCHEME: ${{ matrix.scheme }} - DESTINATION_IDS: ${{ steps.destination.outputs.destination_ids }} - SIMULATOR_JSONS: ${{ steps.destination.outputs.simulator_jsons }} - run: | - set -eo pipefail - mkdir -p TestResults - - python3 -c ' - import json - import os - import subprocess - import sys - - scheme = os.environ["SCHEME"] - destination_ids = [ - value.strip() - for value in os.environ["DESTINATION_IDS"].splitlines() - if value.strip() - ] - simulators = json.loads(os.environ["SIMULATOR_JSONS"]) - - if len(destination_ids) != len(simulators): - raise SystemExit("Destination ID and simulator output counts do not match") - - for index, destination_id in enumerate(destination_ids): - simulator = simulators[index] - simulator_name = simulator["name"] - simulator_os = simulator["os"] - destination = f"id={destination_id},arch=arm64" - safe_name = simulator["safe_name"] - result_bundle_path = os.path.join("TestResults", f"{scheme}-{safe_name}.xcresult") - - print( - f"Running {scheme} on {simulator_name} ({simulator_os}) -> {result_bundle_path}", - file=sys.stderr, - ) - - subprocess.check_call( - [ - "xcodebuild", - "-project", - "libPhoneNumber.xcodeproj", - "-scheme", - scheme, - "-destination", - destination, - "-resultBundlePath", - result_bundle_path, - "-enableCodeCoverage", - "YES", - "CODE_SIGNING_ALLOWED=NO", - "test", - ] - ) - ' + id: run-tests + uses: ./.github/actions/xcode-test-the-tricorders + with: + scheme: ${{ matrix.scheme }} + xcode_container: libPhoneNumber.xcodeproj + destination_ids: ${{ steps.destination.outputs.destination_ids }} + simulator_jsons: ${{ steps.destination.outputs.simulator_jsons }} - name: Upload unit test results if: always() uses: actions/upload-artifact@v6 with: name: project-unit-tests-${{ matrix.scheme }} - path: TestResults/*.xcresult + path: ${{ steps.run-tests.outputs.result_bundle_paths }} coverage-summary: name: Combined Code Coverage From 0c7eec70379901ca6ba02d3c90c1b8b9a3cfce6e Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 14:15:38 -0500 Subject: [PATCH 45/60] environment variables? --- .../xcode-test-the-tricorders/action.yml | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/actions/xcode-test-the-tricorders/action.yml b/.github/actions/xcode-test-the-tricorders/action.yml index 1ead5a26..1b3571c0 100644 --- a/.github/actions/xcode-test-the-tricorders/action.yml +++ b/.github/actions/xcode-test-the-tricorders/action.yml @@ -49,15 +49,25 @@ runs: - name: Run xcodebuild tests id: run shell: bash + env: + INPUT_SCHEME: ${{ inputs.scheme }} + INPUT_XCODE_CONTAINER: ${{ inputs.xcode_container }} + INPUT_DESTINATION_IDS: ${{ inputs.destination_ids }} + INPUT_SIMULATOR_JSONS: ${{ inputs.simulator_jsons }} + INPUT_RESULT_BUNDLE_DIRECTORY: ${{ inputs.result_bundle_directory }} + INPUT_DESTINATION_ARCH: ${{ inputs.destination_arch }} + INPUT_ENABLE_CODE_COVERAGE: ${{ inputs.enable_code_coverage }} + INPUT_CODE_SIGNING_ALLOWED: ${{ inputs.code_signing_allowed }} + INPUT_XCODEBUILD_EXTRA_ARGS: ${{ inputs.xcodebuild_extra_args }} run: | set -eo pipefail python3 "${{ github.action_path }}/run_xcode_tests.py" \ - --scheme "${{ inputs.scheme }}" \ - --xcode-container "${{ inputs.xcode_container }}" \ - --destination-ids "${{ inputs.destination_ids }}" \ - --simulator-jsons "${{ inputs.simulator_jsons }}" \ - --result-bundle-directory "${{ inputs.result_bundle_directory }}" \ - --destination-arch "${{ inputs.destination_arch }}" \ - --enable-code-coverage "${{ inputs.enable_code_coverage }}" \ - --code-signing-allowed "${{ inputs.code_signing_allowed }}" \ - --xcodebuild-extra-args "${{ inputs.xcodebuild_extra_args }}" + --scheme "$INPUT_SCHEME" \ + --xcode-container "$INPUT_XCODE_CONTAINER" \ + --destination-ids "$INPUT_DESTINATION_IDS" \ + --simulator-jsons "$INPUT_SIMULATOR_JSONS" \ + --result-bundle-directory "$INPUT_RESULT_BUNDLE_DIRECTORY" \ + --destination-arch "$INPUT_DESTINATION_ARCH" \ + --enable-code-coverage "$INPUT_ENABLE_CODE_COVERAGE" \ + --code-signing-allowed "$INPUT_CODE_SIGNING_ALLOWED" \ + --xcodebuild-extra-args "$INPUT_XCODEBUILD_EXTRA_ARGS" From fad3db2d1b6358f5c36d2442ae72665fe4398480 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 14:31:24 -0500 Subject: [PATCH 46/60] hopefully upload the directory of xcresults now... --- .github/actions/simctl-pick-a-tricorder/README.md | 7 +++++++ .github/actions/xcode-test-the-tricorders/README.md | 2 +- .github/workflows/ci-core.yml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/actions/simctl-pick-a-tricorder/README.md b/.github/actions/simctl-pick-a-tricorder/README.md index d0d0b607..df490443 100644 --- a/.github/actions/simctl-pick-a-tricorder/README.md +++ b/.github/actions/simctl-pick-a-tricorder/README.md @@ -45,10 +45,17 @@ Local composite action for choosing an installed simulator device from `xcrun si selection_mode: random-latest-compatible - name: Run tests + id: tests uses: ./.github/actions/xcode-test-the-tricorders with: scheme: libPhoneNumber xcode_container: libPhoneNumber.xcodeproj destination_ids: ${{ steps.simulator.outputs.destination_ids }} simulator_jsons: ${{ steps.simulator.outputs.simulator_jsons }} + +- name: Upload unit test results + uses: actions/upload-artifact@v6 + with: + name: project-unit-tests-libPhoneNumber + path: ${{ steps.tests.outputs.result_bundle_directory }} ``` diff --git a/.github/actions/xcode-test-the-tricorders/README.md b/.github/actions/xcode-test-the-tricorders/README.md index 0266524e..fe2877b0 100644 --- a/.github/actions/xcode-test-the-tricorders/README.md +++ b/.github/actions/xcode-test-the-tricorders/README.md @@ -58,5 +58,5 @@ Local composite action for running `xcodebuild test` across simulator destinatio uses: actions/upload-artifact@v6 with: name: project-unit-tests-libPhoneNumber - path: ${{ steps.tests.outputs.result_bundle_paths }} + path: ${{ steps.tests.outputs.result_bundle_directory }} ``` diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 44d7745b..723a6c84 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -51,7 +51,7 @@ jobs: uses: actions/upload-artifact@v6 with: name: project-unit-tests-${{ matrix.scheme }} - path: ${{ steps.run-tests.outputs.result_bundle_paths }} + path: ${{ steps.run-tests.outputs.result_bundle_directory }} coverage-summary: name: Combined Code Coverage From 88ade5b6a4b9dac908300a10809e4e28b53760ba Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 15:01:20 -0500 Subject: [PATCH 47/60] An action just to combine code coverage info --- .github/actions/xccov-warp-bubble/README.md | 49 ++ .github/actions/xccov-warp-bubble/action.yml | 52 ++ .../generate_coverage_summary.py | 567 ++++++++++++++++++ .github/workflows/ci-core.yml | 151 +---- 4 files changed, 675 insertions(+), 144 deletions(-) create mode 100644 .github/actions/xccov-warp-bubble/README.md create mode 100644 .github/actions/xccov-warp-bubble/action.yml create mode 100644 .github/actions/xccov-warp-bubble/generate_coverage_summary.py diff --git a/.github/actions/xccov-warp-bubble/README.md b/.github/actions/xccov-warp-bubble/README.md new file mode 100644 index 00000000..94352ae4 --- /dev/null +++ b/.github/actions/xccov-warp-bubble/README.md @@ -0,0 +1,49 @@ +# Xccov Warp Bubble + +Local composite action for generating a code coverage summary from downloaded `.xcresult` bundles. + +## Inputs + +- `xcresults_directory` + - Root directory containing downloaded artifact folders with `.xcresult` bundles + - Default: `CoverageResults/xcresults` +- `summary_file` + - Markdown file path where the coverage summary should be written + - Default: `CoverageResults/code-coverage-summary.md` +- `failing_coverage_threshold` + - Coverage percent below which the status is marked with a red X + - Default: `60` +- `passing_coverage_threshold` + - Coverage percent at or above which the status is marked with a green checkmark + - Default: `75` + +If only one coverage scope is found, the action reports coverage for that scope only. If multiple scopes are found, the action also computes and reports combined coverage across all scopes. + +## Outputs + +- `coverage_percent` +- `summary_file` +- `scope_count` + +## Example + +```yaml +- name: Download unit test results + uses: actions/download-artifact@v7 + with: + pattern: project-unit-tests-* + path: CoverageResults/xcresults + +- name: Generate code coverage summary + id: coverage + uses: ./.github/actions/xccov-warp-bubble + with: + xcresults_directory: CoverageResults/xcresults + summary_file: CoverageResults/code-coverage-summary.md + +- name: Publish coverage comment to pull request + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: combined-code-coverage + path: ${{ steps.coverage.outputs.summary_file }} +``` diff --git a/.github/actions/xccov-warp-bubble/action.yml b/.github/actions/xccov-warp-bubble/action.yml new file mode 100644 index 00000000..bdebdafe --- /dev/null +++ b/.github/actions/xccov-warp-bubble/action.yml @@ -0,0 +1,52 @@ +name: Xccov Warp Bubble +description: Generate a code coverage summary from downloaded xcresult bundles. + +inputs: + xcresults_directory: + description: Root directory containing downloaded artifact folders with xcresult bundles. + required: false + default: CoverageResults/xcresults + summary_file: + description: Markdown file path where the coverage summary should be written. + required: false + default: CoverageResults/code-coverage-summary.md + failing_coverage_threshold: + description: Coverage percent below which the status is marked as failing. + required: false + default: "60" + passing_coverage_threshold: + description: Coverage percent at or above which the status is marked as passing. + required: false + default: "75" + +outputs: + coverage_percent: + description: The overall coverage percent. For one scope this is that scope coverage; for multiple scopes this is the combined coverage. + value: ${{ steps.summary.outputs.coverage_percent }} + summary_file: + description: Markdown summary file path. + value: ${{ steps.summary.outputs.summary_file }} + scope_count: + description: The number of coverage scopes that were summarized. + value: ${{ steps.summary.outputs.scope_count }} + +runs: + using: composite + steps: + - name: Generate code coverage summary + id: summary + shell: bash + env: + INPUT_XCRESULTS_DIRECTORY: ${{ inputs.xcresults_directory }} + INPUT_SUMMARY_FILE: ${{ inputs.summary_file }} + INPUT_FAILING_COVERAGE_THRESHOLD: ${{ inputs.failing_coverage_threshold }} + INPUT_PASSING_COVERAGE_THRESHOLD: ${{ inputs.passing_coverage_threshold }} + run: | + set -eo pipefail + python3 "${{ github.action_path }}/generate_coverage_summary.py" \ + --xcresults-directory "$INPUT_XCRESULTS_DIRECTORY" \ + --summary-file "$INPUT_SUMMARY_FILE" \ + --failing-coverage-threshold "$INPUT_FAILING_COVERAGE_THRESHOLD" \ + --passing-coverage-threshold "$INPUT_PASSING_COVERAGE_THRESHOLD" + + cat "$INPUT_SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/xccov-warp-bubble/generate_coverage_summary.py b/.github/actions/xccov-warp-bubble/generate_coverage_summary.py new file mode 100644 index 00000000..b57d4f2a --- /dev/null +++ b/.github/actions/xccov-warp-bubble/generate_coverage_summary.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# generate_coverage_summary.py +# xccov-warp-bubble +# +# Created by Kodex on 4/17/26. +# +# This script reads downloaded xcresult bundles, calculates per-scope coverage, +# optionally calculates combined coverage across multiple scopes, prints the +# results to the GitHub Actions log, and writes a markdown coverage summary file. + +import argparse +import json +import os +import subprocess +import sys +from dataclasses import dataclass + + +SCRIPT_VERSION: str = "0.1.0" +"""The current version of the script""" + + +XCRESULT_SUFFIX: str = ".xcresult" +"""The filesystem suffix used for Xcode result bundles""" + + +DEFAULT_SCOPE_PREFIXES_TO_TRIM: tuple[str, ...] = ("project-unit-tests-",) +"""Common artifact name prefixes that should be trimmed from scope labels""" + + +@dataclass(frozen=True) +class ScopeCoverage: + """Represents coverage summary details for a single scope""" + + name: str + """The display name of the scope""" + + coveredLines: int + """The number of covered executable lines""" + + executableLines: int + """The total number of executable lines""" + + coveragePercent: float + """The coverage percent for this scope""" + + +def setupArgumentParser() -> argparse.ArgumentParser: + """ + Sets up the Arugment Parser + + Returns + ------- + ArgumentParser + The created argument parser for this script + """ + + parser: argparse.ArgumentParser = argparse.ArgumentParser(description=""" + This script generates a code coverage summary from downloaded + xcresult bundles.""") + + parser.add_argument("--version", "-v", action="version", + version="%(prog)s " + SCRIPT_VERSION) + parser.add_argument("-?", action="help", + help="show this help message and exit") + parser.add_argument("--xcresults-directory", metavar="CoverageResults/xcresults", + help="The root directory containing downloaded xcresult artifacts", + dest='xcresultsDirectory', required=True) + parser.add_argument("--summary-file", metavar="CoverageResults/code-coverage-summary.md", + help="The markdown file path where the coverage summary should be written", + dest='summaryFile', required=True) + parser.add_argument("--failing-coverage-threshold", metavar="60", + help="Coverage percent below which the status is marked as failing", + dest='failingCoverageThreshold', required=True) + parser.add_argument("--passing-coverage-threshold", metavar="75", + help="Coverage percent at or above which the status is marked as passing", + dest='passingCoverageThreshold', required=True) + + return parser + + +def printScriptStart(): + """Prints the info for the start of the script""" + + print(f"Starting {os.path.basename(__file__)} v{SCRIPT_VERSION}", file=sys.stderr) + + +def parseCoverageThreshold(value: str, label: str) -> float: + """ + Parses a coverage threshold value + + Parameters + ---------- + value + The raw threshold string + label + The threshold label for error reporting + + Returns + ------- + float + The parsed threshold value + """ + + try: + threshold = float(value.strip()) + except ValueError as error: + raise ValueError(f"Unsupported {label} value specified: {value}") from error + + if threshold < 0.0 or threshold > 100.0: + raise ValueError(f"{label} must be between 0 and 100: {value}") + + return threshold + + +def validateScriptArguments(scriptArgs: argparse.Namespace) -> tuple[float, float]: + """ + Validates the parsed script arguments + + Parameters + ---------- + scriptArgs + The parsed script arguments + + Returns + ------- + tuple[float, float] + The parsed failing and passing coverage thresholds + """ + + if len(scriptArgs.xcresultsDirectory.strip()) <= 0: + raise ValueError("An xcresults directory must be provided") + + if len(scriptArgs.summaryFile.strip()) <= 0: + raise ValueError("A summary file path must be provided") + + failingCoverageThreshold = parseCoverageThreshold( + scriptArgs.failingCoverageThreshold, + "failing coverage threshold", + ) + passingCoverageThreshold = parseCoverageThreshold( + scriptArgs.passingCoverageThreshold, + "passing coverage threshold", + ) + + if failingCoverageThreshold >= passingCoverageThreshold: + raise ValueError( + "The failing coverage threshold must be less than the passing coverage threshold" + ) + + return (failingCoverageThreshold, passingCoverageThreshold) + + +def normalizeScopeName(scopeName: str) -> str: + """ + Normalizes a downloaded artifact directory name into a scope label + + Parameters + ---------- + scopeName + The raw artifact directory name + + Returns + ------- + str + The normalized scope label + """ + + normalizedScopeName = scopeName.strip() + + for prefix in DEFAULT_SCOPE_PREFIXES_TO_TRIM: + if normalizedScopeName.startswith(prefix): + normalizedScopeName = normalizedScopeName[len(prefix):] + break + + if normalizedScopeName.endswith(XCRESULT_SUFFIX): + normalizedScopeName = normalizedScopeName[:-len(XCRESULT_SUFFIX)] + + return normalizedScopeName or scopeName + + +def findResultBundles(searchRoot: str) -> list[str]: + """ + Finds all xcresult bundles under the specified directory + + Parameters + ---------- + searchRoot + The directory to search for xcresult bundles + + Returns + ------- + list[str] + The discovered xcresult bundle paths + """ + + resultBundles: list[str] = [] + + if not os.path.isdir(searchRoot): + return resultBundles + + for root, dirnames, _filenames in os.walk(searchRoot): + remainingDirnames: list[str] = [] + for dirname in dirnames: + fullPath = os.path.join(root, dirname) + if dirname.endswith(XCRESULT_SUFFIX): + resultBundles.append(fullPath) + else: + remainingDirnames.append(dirname) + dirnames[:] = remainingDirnames + + return sorted(resultBundles) + + +def determineScopeBundles(searchRoot: str) -> dict[str, list[str]]: + """ + Determines the downloaded coverage scopes and their xcresult bundles + + Parameters + ---------- + searchRoot + The root directory containing downloaded coverage artifacts + + Returns + ------- + dict[str, list[str]] + The discovered coverage scopes and their xcresult bundle paths + """ + + scopeBundles: dict[str, list[str]] = {} + + if not os.path.isdir(searchRoot): + return scopeBundles + + for entryName in sorted(os.listdir(searchRoot)): + entryPath = os.path.join(searchRoot, entryName) + if not os.path.isdir(entryPath): + continue + + resultBundles = findResultBundles(entryPath) + if len(resultBundles) <= 0: + print(f"{entryName}: no downloaded .xcresult bundles found", file=sys.stderr) + continue + + scopeBundles[normalizeScopeName(entryName)] = resultBundles + + if len(scopeBundles) > 0: + return scopeBundles + + rootResultBundles = findResultBundles(searchRoot) + if len(rootResultBundles) > 0: + scopeBundles[normalizeScopeName(os.path.basename(os.path.normpath(searchRoot)) or "Coverage")] = rootResultBundles + + return scopeBundles + + +def readCoverageReport(resultBundlePath: str) -> dict[str, object]: + """ + Reads the xccov JSON coverage report for an xcresult bundle + + Parameters + ---------- + resultBundlePath + The xcresult bundle path + + Returns + ------- + dict[str, object] + The parsed xccov JSON report + """ + + report = subprocess.check_output( + ["xcrun", "xccov", "view", "--archive", "--json", resultBundlePath], + text=True, + ) + + return json.loads(report) + + +def mergeCoverageReport(target: dict[str, dict[int, bool]], report: dict[str, object]): + """ + Merges an xccov JSON report into an aggregated line coverage map + + Parameters + ---------- + target + The target aggregated line coverage map + report + The xccov JSON report to merge + """ + + for filePath, entries in report.items(): + if not isinstance(entries, list): + continue + + combinedLines = target.setdefault(filePath, {}) + for entry in entries: + if not isinstance(entry, dict): + continue + + lineNumber = entry.get("line") + isExecutable = bool(entry.get("isExecutable")) + if lineNumber is None or not isExecutable: + continue + + isCovered = int(entry.get("executionCount", 0) or 0) > 0 + combinedLines[int(lineNumber)] = combinedLines.get(int(lineNumber), False) or isCovered + + +def summarizeLineCoverage(lineCoverageMap: dict[str, dict[int, bool]]) -> tuple[int, int, float]: + """ + Summarizes an aggregated line coverage map + + Parameters + ---------- + lineCoverageMap + The aggregated line coverage map + + Returns + ------- + tuple[int, int, float] + The covered line count, executable line count, and coverage percent + """ + + executableLines = sum(len(lines) for lines in lineCoverageMap.values()) + coveredLines = sum( + 1 for lines in lineCoverageMap.values() for isCovered in lines.values() if isCovered + ) + coveragePercent = ( + coveredLines / executableLines * 100.0 + if executableLines > 0 + else 0.0 + ) + + return (coveredLines, executableLines, coveragePercent) + + +def determineStatusEmoji(coveragePercent: float, + failingCoverageThreshold: float, + passingCoverageThreshold: float) -> str: + """ + Determines the coverage status emoji for a coverage percent + + Parameters + ---------- + coveragePercent + The coverage percent to evaluate + failingCoverageThreshold + The failing coverage threshold + passingCoverageThreshold + The passing coverage threshold + + Returns + ------- + str + The status emoji + """ + + if coveragePercent < failingCoverageThreshold: + return "❌" + if coveragePercent < passingCoverageThreshold: + return "⚠️" + return "✅" + + +def writeGithubOutput(name: str, value: str): + """ + Writes a single GitHub Actions output value + + Parameters + ---------- + name + The output name + value + The output value + """ + + outputFile = os.environ.get("GITHUB_OUTPUT") + if outputFile is None or len(outputFile.strip()) <= 0: + return + + with open(outputFile, "a", encoding="utf-8") as file: + print(f"{name}={value}", file=file) + + +def publishOutputs(summaryFile: str, coveragePercent: str, scopeCount: int): + """ + Publishes the generated coverage summary outputs for GitHub Actions + + Parameters + ---------- + summaryFile + The markdown summary file path + coveragePercent + The overall coverage percent string + scopeCount + The number of summarized scopes + """ + + writeGithubOutput("summary_file", summaryFile) + writeGithubOutput("coverage_percent", coveragePercent) + writeGithubOutput("scope_count", str(scopeCount)) + + +def writeUnavailableSummary(summaryFile: str, message: str): + """ + Writes a markdown summary file for an unavailable coverage result + + Parameters + ---------- + summaryFile + The markdown summary file path + message + The message to write + """ + + summaryDirectory = os.path.dirname(summaryFile) + if len(summaryDirectory) > 0: + os.makedirs(summaryDirectory, exist_ok=True) + + with open(summaryFile, "w", encoding="utf-8") as file: + print("### Code Coverage", file=file) + print(file=file) + print(message, file=file) + + +def writeSummaryFile(summaryFile: str, + scopeCoverageSummaries: list[ScopeCoverage], + overallCoverage: ScopeCoverage | None, + failingCoverageThreshold: float, + passingCoverageThreshold: float): + """ + Writes the markdown coverage summary file + + Parameters + ---------- + summaryFile + The markdown summary file path + scopeCoverageSummaries + The per-scope coverage summaries + overallCoverage + The optional combined coverage summary + failingCoverageThreshold + The failing coverage threshold + passingCoverageThreshold + The passing coverage threshold + """ + + summaryDirectory = os.path.dirname(summaryFile) + if len(summaryDirectory) > 0: + os.makedirs(summaryDirectory, exist_ok=True) + + with open(summaryFile, "w", encoding="utf-8") as file: + print("### Code Coverage", file=file) + print(file=file) + print("| Scope | Coverage | Status |", file=file) + print("| --- | :---: | :---: |", file=file) + + for scopeCoverage in scopeCoverageSummaries: + emoji = determineStatusEmoji( + coveragePercent=scopeCoverage.coveragePercent, + failingCoverageThreshold=failingCoverageThreshold, + passingCoverageThreshold=passingCoverageThreshold, + ) + print( + f"| {scopeCoverage.name} | {scopeCoverage.coveragePercent:.2f}% | {emoji} |", + file=file, + ) + + if overallCoverage is not None: + combinedEmoji = determineStatusEmoji( + coveragePercent=overallCoverage.coveragePercent, + failingCoverageThreshold=failingCoverageThreshold, + passingCoverageThreshold=passingCoverageThreshold, + ) + indent = "          " + print( + f"| {indent} **Combined** | **{overallCoverage.coveragePercent:.2f}%** | **{combinedEmoji}** |", + file=file, + ) + + +def main(): + """Runs the coverage summary generation script""" + + parser = setupArgumentParser() + scriptArgs = parser.parse_args() + + printScriptStart() + + failingCoverageThreshold, passingCoverageThreshold = validateScriptArguments(scriptArgs) + scopeBundles = determineScopeBundles(scriptArgs.xcresultsDirectory) + + if len(scopeBundles) <= 0: + writeUnavailableSummary( + scriptArgs.summaryFile, + "Code coverage unavailable because no unit test result bundles were downloaded.", + ) + publishOutputs( + summaryFile=scriptArgs.summaryFile, + coveragePercent="", + scopeCount=0, + ) + return + + combinedCoverageMap: dict[str, dict[int, bool]] = {} + scopeCoverageSummaries: list[ScopeCoverage] = [] + + for scopeName, resultBundles in sorted(scopeBundles.items()): + scopeCoverageMap: dict[str, dict[int, bool]] = {} + + for resultBundle in resultBundles: + print(f"Processing result bundle for {scopeName}: {resultBundle}") + report = readCoverageReport(resultBundle) + mergeCoverageReport(scopeCoverageMap, report) + mergeCoverageReport(combinedCoverageMap, report) + + coveredLines, executableLines, coveragePercent = summarizeLineCoverage(scopeCoverageMap) + scopeCoverage = ScopeCoverage( + name=scopeName, + coveredLines=coveredLines, + executableLines=executableLines, + coveragePercent=coveragePercent, + ) + scopeCoverageSummaries.append(scopeCoverage) + print( + f"{scopeCoverage.name} - {scopeCoverage.coveragePercent:.2f}% " + f"{determineStatusEmoji(scopeCoverage.coveragePercent, failingCoverageThreshold, passingCoverageThreshold)}" + ) + + overallCoverage: ScopeCoverage | None = None + overallCoveragePercent = "" + + if len(scopeCoverageSummaries) == 1: + overallCoveragePercent = f"{scopeCoverageSummaries[0].coveragePercent:.2f}" + else: + coveredLines, executableLines, coveragePercent = summarizeLineCoverage(combinedCoverageMap) + overallCoverage = ScopeCoverage( + name="Combined", + coveredLines=coveredLines, + executableLines=executableLines, + coveragePercent=coveragePercent, + ) + overallCoveragePercent = f"{overallCoverage.coveragePercent:.2f}" + print( + f"Combined - {overallCoverage.coveragePercent:.2f}% " + f"{determineStatusEmoji(overallCoverage.coveragePercent, failingCoverageThreshold, passingCoverageThreshold)}" + ) + + writeSummaryFile( + summaryFile=scriptArgs.summaryFile, + scopeCoverageSummaries=scopeCoverageSummaries, + overallCoverage=overallCoverage, + failingCoverageThreshold=failingCoverageThreshold, + passingCoverageThreshold=passingCoverageThreshold, + ) + publishOutputs( + summaryFile=scriptArgs.summaryFile, + coveragePercent=overallCoveragePercent, + scopeCount=len(scopeCoverageSummaries), + ) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 723a6c84..9a283ffe 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -59,7 +59,7 @@ jobs: needs: unit-tests if: always() outputs: - combined_coverage_percent: ${{ steps.coverage.outputs.combined_coverage_percent }} + combined_coverage_percent: ${{ steps.coverage.outputs.coverage_percent }} steps: - name: Download unit test results @@ -69,156 +69,19 @@ jobs: pattern: project-unit-tests-* path: CoverageResults/xcresults - - name: Publish combined coverage summary + - name: Generate code coverage summary id: coverage - run: | - set -eo pipefail - summary_file="CoverageResults/combined-coverage-summary.md" - - mkdir -p CoverageResults - python3 -c ' - import json - import os - import subprocess - import sys - - ARTIFACT_PREFIX = "project-unit-tests-" - XCRESULT_SUFFIX = ".xcresult" - - artifacts_root = sys.argv[1] - summary_path = sys.argv[2] - - def append_output(coverage_percent): - with open(os.environ["GITHUB_OUTPUT"], "a") as handle: - print(f"combined_coverage_percent={coverage_percent}", file=handle) - - def write_unavailable_summary(message): - with open(summary_path, "w") as handle: - print("### Combined Code Coverage", file=handle) - print(file=handle) - print(message, file=handle) - - def find_scheme_result_bundles(search_root): - scheme_result_bundles = {} - - if not os.path.isdir(search_root): - return scheme_result_bundles - - for artifact_name in sorted(os.listdir(search_root)): - artifact_path = os.path.join(search_root, artifact_name) - if not os.path.isdir(artifact_path): - continue - if not artifact_name.startswith(ARTIFACT_PREFIX): - continue - - scheme_name = artifact_name[len(ARTIFACT_PREFIX):] - result_bundles = sorted( - os.path.join(artifact_path, entry_name) - for entry_name in os.listdir(artifact_path) - if entry_name.endswith(XCRESULT_SUFFIX) - ) - - if len(result_bundles) <= 0: - print(f"{scheme_name}: no downloaded .xcresult bundles found", file=sys.stderr) - continue - - scheme_result_bundles[scheme_name] = result_bundles - - return scheme_result_bundles - - def merge_report(target, report): - for file_path, entries in report.items(): - combined_lines = target.setdefault(file_path, {}) - for entry in entries: - line_number = entry["line"] - if line_number is None or not entry["isExecutable"]: - continue - - is_covered = entry.get("executionCount", 0) > 0 - combined_lines[line_number] = combined_lines.get(line_number, False) or is_covered - - def summarize_lines(line_map): - executable_lines = sum(len(lines) for lines in line_map.values()) - covered_lines = sum( - 1 for lines in line_map.values() for is_covered in lines.values() if is_covered - ) - coverage_percent = ( - covered_lines / executable_lines * 100 - if executable_lines - else 0.0 - ) - return covered_lines, executable_lines, coverage_percent - - scheme_result_bundles = find_scheme_result_bundles(artifacts_root) - combined = {} - per_scheme = [] - - if len(scheme_result_bundles) <= 0: - write_unavailable_summary( - "Combined coverage unavailable because no unit test result bundles were downloaded." - ) - append_output("") - raise SystemExit(0) - - for scheme_name, scheme_bundle_paths in sorted(scheme_result_bundles.items()): - scheme_lines = {} - - for path in scheme_bundle_paths: - print(f"Processing result bundle for {scheme_name}: {path}", file=sys.stderr) - report = json.loads( - subprocess.check_output( - ["xcrun", "xccov", "view", "--archive", "--json", path], - text=True, - ) - ) - - merge_report(scheme_lines, report) - merge_report(combined, report) - - covered_lines, executable_lines, coverage_percent = summarize_lines(scheme_lines) - per_scheme.append((scheme_name, coverage_percent, covered_lines, executable_lines)) - - combined_covered_lines, combined_executable_lines, combined_coverage_percent = summarize_lines(combined) - - def status_emoji(coverage_percent): - if coverage_percent < 60.0: - return "❌" - if coverage_percent < 75.0: - return "⚠️" - return "✅" - - with open(summary_path, "w") as handle: - print("### Code Coverage", file=handle) - print(file=handle) - print("| Scope | Coverage | Status |", file=handle) - print("| --- | :---: | :---: |", file=handle) - for scheme_name, coverage_percent, covered_lines, executable_lines in sorted(per_scheme): - emoji = status_emoji(coverage_percent) - print( - f"| {scheme_name} | {coverage_percent:.2f}% | {emoji} |", - file=handle, - ) - print(f"{scheme_name} - {coverage_percent:.2f}% {emoji}", file=sys.stderr) - indent = "          " - combined_percent = f"{combined_coverage_percent:.2f}" - combined_emoji = status_emoji(combined_coverage_percent) - print( - f"| {indent} **Combined** | **{combined_percent}%** | **{combined_emoji}** |", - file=handle, - ) - print(f" Combined - {combined_percent}% {combined_emoji}", file=sys.stderr) - - append_output(f"{combined_coverage_percent:.2f}") - ' "CoverageResults/xcresults" "$summary_file" - - cat "$summary_file" >> "$GITHUB_STEP_SUMMARY" + uses: ./.github/actions/xccov-warp-bubble + with: + xcresults_directory: CoverageResults/xcresults + summary_file: CoverageResults/code-coverage-summary.md - name: Publish combined coverage comment to pull request if: inputs.publish_pr_comment uses: marocchino/sticky-pull-request-comment@v2 with: header: combined-code-coverage - path: CoverageResults/combined-coverage-summary.md + path: ${{ steps.coverage.outputs.summary_file }} skip_unchanged: true podspec-lint: From 5afede00f61de86eeec19bd3f9663d65a8f106ce Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 15:31:59 -0500 Subject: [PATCH 48/60] Create a json file to be more re-usable --- .github/actions/xccov-warp-bubble/README.md | 5 + .github/actions/xccov-warp-bubble/action.yml | 9 + .../generate_coverage_summary.py | 540 +++++++++++++----- 3 files changed, 424 insertions(+), 130 deletions(-) diff --git a/.github/actions/xccov-warp-bubble/README.md b/.github/actions/xccov-warp-bubble/README.md index 94352ae4..168d0204 100644 --- a/.github/actions/xccov-warp-bubble/README.md +++ b/.github/actions/xccov-warp-bubble/README.md @@ -10,6 +10,9 @@ Local composite action for generating a code coverage summary from downloaded `. - `summary_file` - Markdown file path where the coverage summary should be written - Default: `CoverageResults/code-coverage-summary.md` +- `summary_json_file` + - JSON file path where the coverage summary should be written + - Default: `CoverageResults/code-coverage-summary.json` - `failing_coverage_threshold` - Coverage percent below which the status is marked with a red X - Default: `60` @@ -23,6 +26,7 @@ If only one coverage scope is found, the action reports coverage for that scope - `coverage_percent` - `summary_file` +- `summary_json_file` - `scope_count` ## Example @@ -40,6 +44,7 @@ If only one coverage scope is found, the action reports coverage for that scope with: xcresults_directory: CoverageResults/xcresults summary_file: CoverageResults/code-coverage-summary.md + summary_json_file: CoverageResults/code-coverage-summary.json - name: Publish coverage comment to pull request uses: marocchino/sticky-pull-request-comment@v2 diff --git a/.github/actions/xccov-warp-bubble/action.yml b/.github/actions/xccov-warp-bubble/action.yml index bdebdafe..b5490d4e 100644 --- a/.github/actions/xccov-warp-bubble/action.yml +++ b/.github/actions/xccov-warp-bubble/action.yml @@ -10,6 +10,10 @@ inputs: description: Markdown file path where the coverage summary should be written. required: false default: CoverageResults/code-coverage-summary.md + summary_json_file: + description: JSON file path where the coverage summary should be written. + required: false + default: CoverageResults/code-coverage-summary.json failing_coverage_threshold: description: Coverage percent below which the status is marked as failing. required: false @@ -26,6 +30,9 @@ outputs: summary_file: description: Markdown summary file path. value: ${{ steps.summary.outputs.summary_file }} + summary_json_file: + description: JSON summary file path. + value: ${{ steps.summary.outputs.summary_json_file }} scope_count: description: The number of coverage scopes that were summarized. value: ${{ steps.summary.outputs.scope_count }} @@ -39,6 +46,7 @@ runs: env: INPUT_XCRESULTS_DIRECTORY: ${{ inputs.xcresults_directory }} INPUT_SUMMARY_FILE: ${{ inputs.summary_file }} + INPUT_SUMMARY_JSON_FILE: ${{ inputs.summary_json_file }} INPUT_FAILING_COVERAGE_THRESHOLD: ${{ inputs.failing_coverage_threshold }} INPUT_PASSING_COVERAGE_THRESHOLD: ${{ inputs.passing_coverage_threshold }} run: | @@ -46,6 +54,7 @@ runs: python3 "${{ github.action_path }}/generate_coverage_summary.py" \ --xcresults-directory "$INPUT_XCRESULTS_DIRECTORY" \ --summary-file "$INPUT_SUMMARY_FILE" \ + --summary-json-file "$INPUT_SUMMARY_JSON_FILE" \ --failing-coverage-threshold "$INPUT_FAILING_COVERAGE_THRESHOLD" \ --passing-coverage-threshold "$INPUT_PASSING_COVERAGE_THRESHOLD" diff --git a/.github/actions/xccov-warp-bubble/generate_coverage_summary.py b/.github/actions/xccov-warp-bubble/generate_coverage_summary.py index b57d4f2a..bf31e1c5 100644 --- a/.github/actions/xccov-warp-bubble/generate_coverage_summary.py +++ b/.github/actions/xccov-warp-bubble/generate_coverage_summary.py @@ -8,7 +8,7 @@ # # This script reads downloaded xcresult bundles, calculates per-scope coverage, # optionally calculates combined coverage across multiple scopes, prints the -# results to the GitHub Actions log, and writes a markdown coverage summary file. +# results to the GitHub Actions log, and writes markdown and JSON summary files. import argparse import json @@ -18,7 +18,7 @@ from dataclasses import dataclass -SCRIPT_VERSION: str = "0.1.0" +SCRIPT_VERSION: str = "0.2.0" """The current version of the script""" @@ -30,6 +30,17 @@ """Common artifact name prefixes that should be trimmed from scope labels""" +@dataclass(frozen=True) +class CoverageThresholds: + """Represents the configured coverage thresholds""" + + failingCoverageThreshold: float + """Coverage percent below which the status is considered failing""" + + passingCoverageThreshold: float + """Coverage percent at or above which the status is considered passing""" + + @dataclass(frozen=True) class ScopeCoverage: """Represents coverage summary details for a single scope""" @@ -71,6 +82,9 @@ def setupArgumentParser() -> argparse.ArgumentParser: parser.add_argument("--summary-file", metavar="CoverageResults/code-coverage-summary.md", help="The markdown file path where the coverage summary should be written", dest='summaryFile', required=True) + parser.add_argument("--summary-json-file", metavar="CoverageResults/code-coverage-summary.json", + help="The JSON file path where the coverage summary should be written", + dest='summaryJsonFile', required=True) parser.add_argument("--failing-coverage-threshold", metavar="60", help="Coverage percent below which the status is marked as failing", dest='failingCoverageThreshold', required=True) @@ -115,7 +129,7 @@ def parseCoverageThreshold(value: str, label: str) -> float: return threshold -def validateScriptArguments(scriptArgs: argparse.Namespace) -> tuple[float, float]: +def validateScriptArguments(scriptArgs: argparse.Namespace) -> CoverageThresholds: """ Validates the parsed script arguments @@ -126,8 +140,8 @@ def validateScriptArguments(scriptArgs: argparse.Namespace) -> tuple[float, floa Returns ------- - tuple[float, float] - The parsed failing and passing coverage thresholds + CoverageThresholds + The parsed coverage thresholds """ if len(scriptArgs.xcresultsDirectory.strip()) <= 0: @@ -136,6 +150,9 @@ def validateScriptArguments(scriptArgs: argparse.Namespace) -> tuple[float, floa if len(scriptArgs.summaryFile.strip()) <= 0: raise ValueError("A summary file path must be provided") + if len(scriptArgs.summaryJsonFile.strip()) <= 0: + raise ValueError("A summary JSON file path must be provided") + failingCoverageThreshold = parseCoverageThreshold( scriptArgs.failingCoverageThreshold, "failing coverage threshold", @@ -150,7 +167,10 @@ def validateScriptArguments(scriptArgs: argparse.Namespace) -> tuple[float, floa "The failing coverage threshold must be less than the passing coverage threshold" ) - return (failingCoverageThreshold, passingCoverageThreshold) + return CoverageThresholds( + failingCoverageThreshold=failingCoverageThreshold, + passingCoverageThreshold=passingCoverageThreshold, + ) def normalizeScopeName(scopeName: str) -> str: @@ -214,9 +234,9 @@ def findResultBundles(searchRoot: str) -> list[str]: return sorted(resultBundles) -def determineScopeBundles(searchRoot: str) -> dict[str, list[str]]: +def discoverCoverageScopes(searchRoot: str) -> dict[str, list[str]]: """ - Determines the downloaded coverage scopes and their xcresult bundles + Discovers the downloaded coverage scopes and their xcresult bundles Parameters ---------- @@ -251,7 +271,8 @@ def determineScopeBundles(searchRoot: str) -> dict[str, list[str]]: rootResultBundles = findResultBundles(searchRoot) if len(rootResultBundles) > 0: - scopeBundles[normalizeScopeName(os.path.basename(os.path.normpath(searchRoot)) or "Coverage")] = rootResultBundles + fallbackScopeName = os.path.basename(os.path.normpath(searchRoot)) or "Coverage" + scopeBundles[normalizeScopeName(fallbackScopeName)] = rootResultBundles return scopeBundles @@ -337,20 +358,130 @@ def summarizeLineCoverage(lineCoverageMap: dict[str, dict[int, bool]]) -> tuple[ return (coveredLines, executableLines, coveragePercent) -def determineStatusEmoji(coveragePercent: float, - failingCoverageThreshold: float, - passingCoverageThreshold: float) -> str: +def createScopeCoverage(name: str, + lineCoverageMap: dict[str, dict[int, bool]]) -> ScopeCoverage: + """ + Creates a scope coverage summary from an aggregated line coverage map + + Parameters + ---------- + name + The scope display name + lineCoverageMap + The aggregated line coverage map + + Returns + ------- + ScopeCoverage + The created scope coverage summary + """ + + coveredLines, executableLines, coveragePercent = summarizeLineCoverage(lineCoverageMap) + return ScopeCoverage( + name=name, + coveredLines=coveredLines, + executableLines=executableLines, + coveragePercent=coveragePercent, + ) + + +def calculateScopeCoverages(discoveredScopes: dict[str, list[str]]) -> tuple[list[ScopeCoverage], dict[str, dict[int, bool]]]: """ - Determines the coverage status emoji for a coverage percent + Calculates per-scope coverages and the aggregated combined line coverage map + + Parameters + ---------- + discoveredScopes + The discovered scopes and their xcresult bundle paths + + Returns + ------- + tuple[list[ScopeCoverage], dict[str, dict[int, bool]]] + The per-scope coverage summaries and the combined line coverage map + """ + + combinedCoverageMap: dict[str, dict[int, bool]] = {} + scopeCoverageSummaries: list[ScopeCoverage] = [] + + for scopeName, resultBundles in sorted(discoveredScopes.items()): + scopeCoverageMap: dict[str, dict[int, bool]] = {} + + for resultBundle in resultBundles: + print(f"Processing result bundle for {scopeName}: {resultBundle}") + report = readCoverageReport(resultBundle) + mergeCoverageReport(scopeCoverageMap, report) + mergeCoverageReport(combinedCoverageMap, report) + + scopeCoverageSummaries.append( + createScopeCoverage( + name=scopeName, + lineCoverageMap=scopeCoverageMap, + ) + ) + + return (scopeCoverageSummaries, combinedCoverageMap) + + +def calculateOverallCoverage(scopeCoverageSummaries: list[ScopeCoverage], + combinedCoverageMap: dict[str, dict[int, bool]]) -> ScopeCoverage | None: + """ + Calculates the overall combined coverage when multiple scopes are present + + Parameters + ---------- + scopeCoverageSummaries + The per-scope coverage summaries + combinedCoverageMap + The combined line coverage map + + Returns + ------- + ScopeCoverage | None + The combined coverage summary, or None when only one scope is present + """ + + if len(scopeCoverageSummaries) <= 1: + return None + + return createScopeCoverage( + name="Combined", + lineCoverageMap=combinedCoverageMap, + ) + + +def determineCoverageStatus(coveragePercent: float, + thresholds: CoverageThresholds) -> str: + """ + Determines the normalized status for a coverage percent Parameters ---------- coveragePercent The coverage percent to evaluate - failingCoverageThreshold - The failing coverage threshold - passingCoverageThreshold - The passing coverage threshold + thresholds + The configured coverage thresholds + + Returns + ------- + str + The normalized coverage status + """ + + if coveragePercent < thresholds.failingCoverageThreshold: + return "fail" + if coveragePercent < thresholds.passingCoverageThreshold: + return "warn" + return "pass" + + +def determineStatusEmoji(status: str) -> str: + """ + Determines the coverage status emoji for a normalized status + + Parameters + ---------- + status + The normalized coverage status Returns ------- @@ -358,13 +489,188 @@ def determineStatusEmoji(coveragePercent: float, The status emoji """ - if coveragePercent < failingCoverageThreshold: + if status == "fail": return "❌" - if coveragePercent < passingCoverageThreshold: + if status == "warn": return "⚠️" return "✅" +def printCoverageSummary(scopeCoverageSummaries: list[ScopeCoverage], + overallCoverage: ScopeCoverage | None, + thresholds: CoverageThresholds): + """ + Prints the coverage summary details to the GitHub Actions log + + Parameters + ---------- + scopeCoverageSummaries + The per-scope coverage summaries + overallCoverage + The optional combined coverage summary + thresholds + The configured coverage thresholds + """ + + for scopeCoverage in scopeCoverageSummaries: + status = determineCoverageStatus(scopeCoverage.coveragePercent, thresholds) + emoji = determineStatusEmoji(status) + print(f"{scopeCoverage.name} - {scopeCoverage.coveragePercent:.2f}% {emoji}") + + if overallCoverage is not None: + status = determineCoverageStatus(overallCoverage.coveragePercent, thresholds) + emoji = determineStatusEmoji(status) + print(f"Combined - {overallCoverage.coveragePercent:.2f}% {emoji}") + + +def ensureParentDirectory(filePath: str): + """ + Ensures that the parent directory exists for a file path + + Parameters + ---------- + filePath + The file path whose parent directory should exist + """ + + parentDirectory = os.path.dirname(filePath) + if len(parentDirectory) > 0: + os.makedirs(parentDirectory, exist_ok=True) + + +def renderMarkdownSummary(scopeCoverageSummaries: list[ScopeCoverage], + overallCoverage: ScopeCoverage | None, + thresholds: CoverageThresholds) -> str: + """ + Renders the markdown coverage summary text + + Parameters + ---------- + scopeCoverageSummaries + The per-scope coverage summaries + overallCoverage + The optional combined coverage summary + thresholds + The configured coverage thresholds + + Returns + ------- + str + The rendered markdown summary + """ + + lines: list[str] = [ + "### Code Coverage", + "", + "| Scope | Coverage | Status |", + "| --- | :---: | :---: |", + ] + + for scopeCoverage in scopeCoverageSummaries: + status = determineCoverageStatus(scopeCoverage.coveragePercent, thresholds) + emoji = determineStatusEmoji(status) + lines.append(f"| {scopeCoverage.name} | {scopeCoverage.coveragePercent:.2f}% | {emoji} |") + + if overallCoverage is not None: + status = determineCoverageStatus(overallCoverage.coveragePercent, thresholds) + emoji = determineStatusEmoji(status) + indent = "          " + lines.append( + f"| {indent} **Combined** | **{overallCoverage.coveragePercent:.2f}%** | **{emoji}** |" + ) + + return "\n".join(lines) + "\n" + + +def renderJsonSummary(scopeCoverageSummaries: list[ScopeCoverage], + overallCoverage: ScopeCoverage | None, + thresholds: CoverageThresholds) -> dict[str, object]: + """ + Renders the JSON coverage summary payload + + Parameters + ---------- + scopeCoverageSummaries + The per-scope coverage summaries + overallCoverage + The optional combined coverage summary + thresholds + The configured coverage thresholds + + Returns + ------- + dict[str, object] + The JSON coverage summary payload + """ + + def serializeScope(scopeCoverage: ScopeCoverage) -> dict[str, object]: + status = determineCoverageStatus(scopeCoverage.coveragePercent, thresholds) + return { + "name": scopeCoverage.name, + "covered_lines": scopeCoverage.coveredLines, + "executable_lines": scopeCoverage.executableLines, + "coverage_percent": round(scopeCoverage.coveragePercent, 2), + "status": status, + "status_emoji": determineStatusEmoji(status), + } + + payload: dict[str, object] = { + "scope_count": len(scopeCoverageSummaries), + "thresholds": { + "failing_coverage_threshold": thresholds.failingCoverageThreshold, + "passing_coverage_threshold": thresholds.passingCoverageThreshold, + }, + "scopes": [serializeScope(scopeCoverage) for scopeCoverage in scopeCoverageSummaries], + "overall_coverage_percent": "", + } + + if len(scopeCoverageSummaries) == 1: + payload["overall_coverage_percent"] = round(scopeCoverageSummaries[0].coveragePercent, 2) + + if overallCoverage is not None: + payload["combined"] = serializeScope(overallCoverage) + payload["overall_coverage_percent"] = round(overallCoverage.coveragePercent, 2) + + return payload + + +def writeTextFile(filePath: str, contents: str): + """ + Writes text contents to a file path + + Parameters + ---------- + filePath + The file path to write + contents + The text contents to write + """ + + ensureParentDirectory(filePath) + + with open(filePath, "w", encoding="utf-8") as file: + file.write(contents) + + +def writeJsonFile(filePath: str, payload: dict[str, object]): + """ + Writes a JSON payload to a file path + + Parameters + ---------- + filePath + The file path to write + payload + The JSON payload to write + """ + + ensureParentDirectory(filePath) + + with open(filePath, "w", encoding="utf-8") as file: + json.dump(payload, file, indent=2, sort_keys=True) + file.write("\n") + + def writeGithubOutput(name: str, value: str): """ Writes a single GitHub Actions output value @@ -385,7 +691,10 @@ def writeGithubOutput(name: str, value: str): print(f"{name}={value}", file=file) -def publishOutputs(summaryFile: str, coveragePercent: str, scopeCount: int): +def publishOutputs(summaryFile: str, + summaryJsonFile: str, + coveragePercent: str, + scopeCount: int): """ Publishes the generated coverage summary outputs for GitHub Actions @@ -393,6 +702,8 @@ def publishOutputs(summaryFile: str, coveragePercent: str, scopeCount: int): ---------- summaryFile The markdown summary file path + summaryJsonFile + The JSON summary file path coveragePercent The overall coverage percent string scopeCount @@ -400,86 +711,76 @@ def publishOutputs(summaryFile: str, coveragePercent: str, scopeCount: int): """ writeGithubOutput("summary_file", summaryFile) + writeGithubOutput("summary_json_file", summaryJsonFile) writeGithubOutput("coverage_percent", coveragePercent) writeGithubOutput("scope_count", str(scopeCount)) -def writeUnavailableSummary(summaryFile: str, message: str): +def writeUnavailableSummaries(summaryFile: str, + summaryJsonFile: str, + message: str, + thresholds: CoverageThresholds): """ - Writes a markdown summary file for an unavailable coverage result + Writes markdown and JSON summary files for an unavailable coverage result Parameters ---------- summaryFile The markdown summary file path + summaryJsonFile + The JSON summary file path message The message to write + thresholds + The configured coverage thresholds """ - summaryDirectory = os.path.dirname(summaryFile) - if len(summaryDirectory) > 0: - os.makedirs(summaryDirectory, exist_ok=True) - - with open(summaryFile, "w", encoding="utf-8") as file: - print("### Code Coverage", file=file) - print(file=file) - print(message, file=file) - - -def writeSummaryFile(summaryFile: str, - scopeCoverageSummaries: list[ScopeCoverage], - overallCoverage: ScopeCoverage | None, - failingCoverageThreshold: float, - passingCoverageThreshold: float): + markdownSummary = "\n".join([ + "### Code Coverage", + "", + message, + "", + ]) + jsonSummary = { + "message": message, + "overall_coverage_percent": "", + "scope_count": 0, + "scopes": [], + "thresholds": { + "failing_coverage_threshold": thresholds.failingCoverageThreshold, + "passing_coverage_threshold": thresholds.passingCoverageThreshold, + }, + } + + writeTextFile(summaryFile, markdownSummary) + writeJsonFile(summaryJsonFile, jsonSummary) + + +def determineOverallCoveragePercent(scopeCoverageSummaries: list[ScopeCoverage], + overallCoverage: ScopeCoverage | None) -> str: """ - Writes the markdown coverage summary file + Determines the overall coverage percent string for action outputs Parameters ---------- - summaryFile - The markdown summary file path scopeCoverageSummaries The per-scope coverage summaries overallCoverage The optional combined coverage summary - failingCoverageThreshold - The failing coverage threshold - passingCoverageThreshold - The passing coverage threshold - """ - - summaryDirectory = os.path.dirname(summaryFile) - if len(summaryDirectory) > 0: - os.makedirs(summaryDirectory, exist_ok=True) - - with open(summaryFile, "w", encoding="utf-8") as file: - print("### Code Coverage", file=file) - print(file=file) - print("| Scope | Coverage | Status |", file=file) - print("| --- | :---: | :---: |", file=file) - - for scopeCoverage in scopeCoverageSummaries: - emoji = determineStatusEmoji( - coveragePercent=scopeCoverage.coveragePercent, - failingCoverageThreshold=failingCoverageThreshold, - passingCoverageThreshold=passingCoverageThreshold, - ) - print( - f"| {scopeCoverage.name} | {scopeCoverage.coveragePercent:.2f}% | {emoji} |", - file=file, - ) - if overallCoverage is not None: - combinedEmoji = determineStatusEmoji( - coveragePercent=overallCoverage.coveragePercent, - failingCoverageThreshold=failingCoverageThreshold, - passingCoverageThreshold=passingCoverageThreshold, - ) - indent = "          " - print( - f"| {indent} **Combined** | **{overallCoverage.coveragePercent:.2f}%** | **{combinedEmoji}** |", - file=file, - ) + Returns + ------- + str + The overall coverage percent string + """ + + if overallCoverage is not None: + return f"{overallCoverage.coveragePercent:.2f}" + + if len(scopeCoverageSummaries) == 1: + return f"{scopeCoverageSummaries[0].coveragePercent:.2f}" + + return "" def main(): @@ -490,75 +791,54 @@ def main(): printScriptStart() - failingCoverageThreshold, passingCoverageThreshold = validateScriptArguments(scriptArgs) - scopeBundles = determineScopeBundles(scriptArgs.xcresultsDirectory) + thresholds = validateScriptArguments(scriptArgs) + discoveredScopes = discoverCoverageScopes(scriptArgs.xcresultsDirectory) - if len(scopeBundles) <= 0: - writeUnavailableSummary( - scriptArgs.summaryFile, - "Code coverage unavailable because no unit test result bundles were downloaded.", + if len(discoveredScopes) <= 0: + writeUnavailableSummaries( + summaryFile=scriptArgs.summaryFile, + summaryJsonFile=scriptArgs.summaryJsonFile, + message="Code coverage unavailable because no unit test result bundles were downloaded.", + thresholds=thresholds, ) publishOutputs( summaryFile=scriptArgs.summaryFile, + summaryJsonFile=scriptArgs.summaryJsonFile, coveragePercent="", scopeCount=0, ) return - combinedCoverageMap: dict[str, dict[int, bool]] = {} - scopeCoverageSummaries: list[ScopeCoverage] = [] - - for scopeName, resultBundles in sorted(scopeBundles.items()): - scopeCoverageMap: dict[str, dict[int, bool]] = {} - - for resultBundle in resultBundles: - print(f"Processing result bundle for {scopeName}: {resultBundle}") - report = readCoverageReport(resultBundle) - mergeCoverageReport(scopeCoverageMap, report) - mergeCoverageReport(combinedCoverageMap, report) - - coveredLines, executableLines, coveragePercent = summarizeLineCoverage(scopeCoverageMap) - scopeCoverage = ScopeCoverage( - name=scopeName, - coveredLines=coveredLines, - executableLines=executableLines, - coveragePercent=coveragePercent, - ) - scopeCoverageSummaries.append(scopeCoverage) - print( - f"{scopeCoverage.name} - {scopeCoverage.coveragePercent:.2f}% " - f"{determineStatusEmoji(scopeCoverage.coveragePercent, failingCoverageThreshold, passingCoverageThreshold)}" - ) - - overallCoverage: ScopeCoverage | None = None - overallCoveragePercent = "" + scopeCoverageSummaries, combinedCoverageMap = calculateScopeCoverages(discoveredScopes) + overallCoverage = calculateOverallCoverage( + scopeCoverageSummaries=scopeCoverageSummaries, + combinedCoverageMap=combinedCoverageMap, + ) - if len(scopeCoverageSummaries) == 1: - overallCoveragePercent = f"{scopeCoverageSummaries[0].coveragePercent:.2f}" - else: - coveredLines, executableLines, coveragePercent = summarizeLineCoverage(combinedCoverageMap) - overallCoverage = ScopeCoverage( - name="Combined", - coveredLines=coveredLines, - executableLines=executableLines, - coveragePercent=coveragePercent, - ) - overallCoveragePercent = f"{overallCoverage.coveragePercent:.2f}" - print( - f"Combined - {overallCoverage.coveragePercent:.2f}% " - f"{determineStatusEmoji(overallCoverage.coveragePercent, failingCoverageThreshold, passingCoverageThreshold)}" - ) + printCoverageSummary( + scopeCoverageSummaries=scopeCoverageSummaries, + overallCoverage=overallCoverage, + thresholds=thresholds, + ) - writeSummaryFile( - summaryFile=scriptArgs.summaryFile, + markdownSummary = renderMarkdownSummary( scopeCoverageSummaries=scopeCoverageSummaries, overallCoverage=overallCoverage, - failingCoverageThreshold=failingCoverageThreshold, - passingCoverageThreshold=passingCoverageThreshold, + thresholds=thresholds, + ) + jsonSummary = renderJsonSummary( + scopeCoverageSummaries=scopeCoverageSummaries, + overallCoverage=overallCoverage, + thresholds=thresholds, ) + + writeTextFile(scriptArgs.summaryFile, markdownSummary) + writeJsonFile(scriptArgs.summaryJsonFile, jsonSummary) + publishOutputs( summaryFile=scriptArgs.summaryFile, - coveragePercent=overallCoveragePercent, + summaryJsonFile=scriptArgs.summaryJsonFile, + coveragePercent=determineOverallCoveragePercent(scopeCoverageSummaries, overallCoverage), scopeCount=len(scopeCoverageSummaries), ) From c86e03566fa692f53edf44a486324cc9d4811d0c Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 15:48:52 -0500 Subject: [PATCH 49/60] Attempting a little clean up --- .../simctl-pick-a-tricorder/pick_simulator.py | 72 ++- .../generate_coverage_summary.py | 500 ++++++++---------- .../run_xcode_tests.py | 187 ++++--- 3 files changed, 358 insertions(+), 401 deletions(-) diff --git a/.github/actions/simctl-pick-a-tricorder/pick_simulator.py b/.github/actions/simctl-pick-a-tricorder/pick_simulator.py index 40af9746..d3e3a58d 100644 --- a/.github/actions/simctl-pick-a-tricorder/pick_simulator.py +++ b/.github/actions/simctl-pick-a-tricorder/pick_simulator.py @@ -21,7 +21,7 @@ from typing import Optional -SCRIPT_VERSION: str = "0.2.0" +SCRIPT_VERSION: str = "0.3.0" """The current version of the script""" @@ -61,6 +61,10 @@ """Relative ranking for Apple simulator model variants""" +OUTPUT_MARKER: str = "__SIMCTL_PICK_A_TRICORDER__" +"""The multiline GitHub Actions output marker""" + + @dataclass(frozen=True) class Candidate: """Represents a single compatible simulator candidate""" @@ -107,7 +111,8 @@ def setupArgumentParser() -> argparse.ArgumentParser: dest='deviceTypes') parser.add_argument("--selection-mode", metavar="random-compatible", help="How to choose the simulator(s) - random-compatible, random-latest-compatible, model-type, latest-model", - dest='selectionMode', default="random-compatible") + dest='selectionMode', default="random-compatible", + choices=SUPPORTED_SELECTION_MODES) parser.add_argument("--model-preferences", metavar="iphone=Pro Max;ipad=Pro", help="Semicolon-separated model keywords per device type", dest='modelPreferences', default="") @@ -154,7 +159,7 @@ def parseCommaSeparatedList(value: str) -> list[str]: The normalized list of values """ - return [part.strip().lower() for part in value.split(",") if part.strip()] + return [part.strip().lower() for part in value.split(",") if len(part.strip()) > 0] def parseRequestedVersion(value: str) -> Optional[tuple[int, ...]]: @@ -198,7 +203,7 @@ def parseModelPreferences(value: str) -> dict[str, list[str]]: """ preferences: dict[str, list[str]] = {} - if not value or len(value.strip()) <= 0: + if value is None or len(value.strip()) <= 0: return preferences for segment in value.split(";"): @@ -219,7 +224,7 @@ def parseModelPreferences(value: str) -> dict[str, list[str]]: raise ValueError(f"Unsupported device type in model preferences: {deviceType}") preferences[normalizedDeviceType] = [ - keyword.strip().lower() for keyword in keywordsString.split(",") if keyword.strip() + keyword.strip().lower() for keyword in keywordsString.split(",") if len(keyword.strip()) > 0 ] return preferences @@ -354,9 +359,9 @@ def matchesRequestedVersion(candidateVersion: tuple[int, ...], requestedVersion: return candidateVersion[:len(requestedVersion)] == requestedVersion -def determineVariantScore(name: str) -> int: +def determineVariantDetails(name: str) -> tuple[str, int] | None: """ - Determines the relative ranking score for a simulator model variant + Determines the detected model variant keyword and score Parameters ---------- @@ -365,17 +370,17 @@ def determineVariantScore(name: str) -> int: Returns ------- - int - The relative model variant score + tuple[str, int] | None + The detected variant keyword and score, or None if no variant matched """ normalizedName = name.lower() for keyword, score in VARIANT_SCORES: if keyword in normalizedName: - return score + return (keyword, score) - return 25 + return None def determineModelType(name: str) -> str: @@ -393,13 +398,8 @@ def determineModelType(name: str) -> str: The detected model type, or an empty string if none is present """ - normalizedName = name.lower() - - for keyword, _score in VARIANT_SCORES: - if keyword in normalizedName: - return keyword.title() - - return "" + variantDetails = determineVariantDetails(name) + return variantDetails[0].title() if variantDetails is not None else "" def createSafeName(name: str, osVersion: str) -> str: @@ -437,10 +437,11 @@ def determineModelRank(candidate: Candidate) -> tuple[tuple[int, ...], int, tupl The rank tuple used for deterministic sorting and comparisons """ + variantDetails = determineVariantDetails(candidate.name) numericParts = tuple(int(part) for part in re.findall(r"\d+", candidate.name)) return ( candidate.osVersion, - determineVariantScore(candidate.name), + variantDetails[1] if variantDetails is not None else 25, numericParts, candidate.name, ) @@ -592,11 +593,7 @@ def filterCandidatesForRequestedOs(candidates: list[Candidate], requestedVersion The filtered candidates for the requested or latest OS version """ - matchingCandidates = [ - candidate - for candidate in candidates - if matchesRequestedVersion(candidate.osVersion, requestedVersion) - ] + matchingCandidates = [candidate for candidate in candidates if matchesRequestedVersion(candidate.osVersion, requestedVersion)] if requestedVersion is not None: print( @@ -652,15 +649,13 @@ def buildCandidatePool(deviceType: str, if selectionMode == "random-compatible": return versionFilteredCandidates - if selectionMode == "random-latest-compatible": - return filterToLatestModel(versionFilteredCandidates) - if selectionMode == "model-type": - return filterToLatestModel( - filterToModelPreferences(versionFilteredCandidates, modelPreferences) + versionFilteredCandidates = filterToModelPreferences( + versionFilteredCandidates, + modelPreferences, ) - if selectionMode == "latest-model": + if selectionMode in {"random-latest-compatible", "model-type", "latest-model"}: return filterToLatestModel(versionFilteredCandidates) raise ValueError(f"Unsupported selection mode: {selectionMode}") @@ -737,14 +732,13 @@ def validateScriptArguments(scriptArgs: argparse.Namespace) -> list[str]: if len(requestedDeviceTypes) <= 0: raise ValueError("At least one device type must be provided") - for deviceType in requestedDeviceTypes: - if deviceType not in SUPPORTED_DEVICE_TYPES: - raise ValueError(f"Unsupported device type specified: {deviceType}") - - if scriptArgs.selectionMode not in SUPPORTED_SELECTION_MODES: + unsupportedDeviceTypes = [ + deviceType for deviceType in requestedDeviceTypes + if deviceType not in SUPPORTED_DEVICE_TYPES + ] + if unsupportedDeviceTypes: raise ValueError( - f"Unsupported selection mode specified: {scriptArgs.selectionMode}. " - f"Expected one of {', '.join(SUPPORTED_SELECTION_MODES)}" + f"Unsupported device type specified: {', '.join(unsupportedDeviceTypes)}" ) return requestedDeviceTypes @@ -857,10 +851,10 @@ def writeGithubMultilineOutput(name: str, values: list[str]): return with open(outputFile, "a", encoding="utf-8") as file: - print(f"{name}<<__PICK_MY_XCODE_TRICORDER__", file=file) + print(f"{name}<<{OUTPUT_MARKER}", file=file) for value in values: print(value, file=file) - print("__PICK_MY_XCODE_TRICORDER__", file=file) + print(OUTPUT_MARKER, file=file) def publishOutputs(selectedCandidates: list[Candidate]): diff --git a/.github/actions/xccov-warp-bubble/generate_coverage_summary.py b/.github/actions/xccov-warp-bubble/generate_coverage_summary.py index bf31e1c5..5370b7e4 100644 --- a/.github/actions/xccov-warp-bubble/generate_coverage_summary.py +++ b/.github/actions/xccov-warp-bubble/generate_coverage_summary.py @@ -18,7 +18,7 @@ from dataclasses import dataclass -SCRIPT_VERSION: str = "0.2.0" +SCRIPT_VERSION: str = "0.3.1" """The current version of the script""" @@ -34,10 +34,10 @@ class CoverageThresholds: """Represents the configured coverage thresholds""" - failingCoverageThreshold: float + failing: float """Coverage percent below which the status is considered failing""" - passingCoverageThreshold: float + passing: float """Coverage percent at or above which the status is considered passing""" @@ -60,7 +60,7 @@ class ScopeCoverage: def setupArgumentParser() -> argparse.ArgumentParser: """ - Sets up the Arugment Parser + Sets up the argument parser Returns ------- @@ -78,19 +78,24 @@ def setupArgumentParser() -> argparse.ArgumentParser: help="show this help message and exit") parser.add_argument("--xcresults-directory", metavar="CoverageResults/xcresults", help="The root directory containing downloaded xcresult artifacts", - dest='xcresultsDirectory', required=True) + dest='xcresultsDirectory', required=True, + type=parseNonEmptyArgument) parser.add_argument("--summary-file", metavar="CoverageResults/code-coverage-summary.md", help="The markdown file path where the coverage summary should be written", - dest='summaryFile', required=True) + dest='summaryFile', required=True, + type=parseNonEmptyArgument) parser.add_argument("--summary-json-file", metavar="CoverageResults/code-coverage-summary.json", help="The JSON file path where the coverage summary should be written", - dest='summaryJsonFile', required=True) + dest='summaryJsonFile', required=True, + type=parseNonEmptyArgument) parser.add_argument("--failing-coverage-threshold", metavar="60", help="Coverage percent below which the status is marked as failing", - dest='failingCoverageThreshold', required=True) + dest='failingCoverageThreshold', required=True, + type=parseCoverageThresholdArgument) parser.add_argument("--passing-coverage-threshold", metavar="75", help="Coverage percent at or above which the status is marked as passing", - dest='passingCoverageThreshold', required=True) + dest='passingCoverageThreshold', required=True, + type=parseCoverageThresholdArgument) return parser @@ -101,7 +106,29 @@ def printScriptStart(): print(f"Starting {os.path.basename(__file__)} v{SCRIPT_VERSION}", file=sys.stderr) -def parseCoverageThreshold(value: str, label: str) -> float: +def parseNonEmptyArgument(value: str) -> str: + """ + Parses and validates a non-empty string argument + + Parameters + ---------- + value + The raw argument value + + Returns + ------- + str + The normalized non-empty argument value + """ + + normalizedValue = value.strip() + if len(normalizedValue) <= 0: + raise ValueError("Argument value must not be empty") + + return normalizedValue + + +def parseThreshold(value: str, label: str) -> float: """ Parses a coverage threshold value @@ -129,9 +156,27 @@ def parseCoverageThreshold(value: str, label: str) -> float: return threshold -def validateScriptArguments(scriptArgs: argparse.Namespace) -> CoverageThresholds: +def parseCoverageThresholdArgument(value: str) -> float: """ - Validates the parsed script arguments + Parses and validates a coverage threshold argument + + Parameters + ---------- + value + The raw threshold argument value + + Returns + ------- + float + The parsed threshold value + """ + + return parseThreshold(value, "coverage threshold") + + +def parseThresholds(scriptArgs: argparse.Namespace) -> CoverageThresholds: + """ + Parses and validates the configured coverage thresholds Parameters ---------- @@ -144,33 +189,34 @@ def validateScriptArguments(scriptArgs: argparse.Namespace) -> CoverageThreshold The parsed coverage thresholds """ - if len(scriptArgs.xcresultsDirectory.strip()) <= 0: - raise ValueError("An xcresults directory must be provided") - - if len(scriptArgs.summaryFile.strip()) <= 0: - raise ValueError("A summary file path must be provided") - - if len(scriptArgs.summaryJsonFile.strip()) <= 0: - raise ValueError("A summary JSON file path must be provided") - - failingCoverageThreshold = parseCoverageThreshold( - scriptArgs.failingCoverageThreshold, - "failing coverage threshold", - ) - passingCoverageThreshold = parseCoverageThreshold( - scriptArgs.passingCoverageThreshold, - "passing coverage threshold", + thresholds = CoverageThresholds( + failing=float(scriptArgs.failingCoverageThreshold), + passing=float(scriptArgs.passingCoverageThreshold), ) - - if failingCoverageThreshold >= passingCoverageThreshold: + if thresholds.failing >= thresholds.passing: raise ValueError( "The failing coverage threshold must be less than the passing coverage threshold" ) - return CoverageThresholds( - failingCoverageThreshold=failingCoverageThreshold, - passingCoverageThreshold=passingCoverageThreshold, - ) + return thresholds + + +def validateScriptArguments(scriptArgs: argparse.Namespace) -> CoverageThresholds: + """ + Validates the parsed script arguments + + Parameters + ---------- + scriptArgs + The parsed script arguments + + Returns + ------- + CoverageThresholds + The parsed coverage thresholds + """ + + return parseThresholds(scriptArgs) def normalizeScopeName(scopeName: str) -> str: @@ -217,7 +263,6 @@ def findResultBundles(searchRoot: str) -> list[str]: """ resultBundles: list[str] = [] - if not os.path.isdir(searchRoot): return resultBundles @@ -249,11 +294,10 @@ def discoverCoverageScopes(searchRoot: str) -> dict[str, list[str]]: The discovered coverage scopes and their xcresult bundle paths """ - scopeBundles: dict[str, list[str]] = {} - if not os.path.isdir(searchRoot): - return scopeBundles + return {} + scopeBundles: dict[str, list[str]] = {} for entryName in sorted(os.listdir(searchRoot)): entryPath = os.path.join(searchRoot, entryName) if not os.path.isdir(entryPath): @@ -270,48 +314,32 @@ def discoverCoverageScopes(searchRoot: str) -> dict[str, list[str]]: return scopeBundles rootResultBundles = findResultBundles(searchRoot) - if len(rootResultBundles) > 0: - fallbackScopeName = os.path.basename(os.path.normpath(searchRoot)) or "Coverage" - scopeBundles[normalizeScopeName(fallbackScopeName)] = rootResultBundles + if len(rootResultBundles) <= 0: + return {} - return scopeBundles + fallbackScopeName = os.path.basename(os.path.normpath(searchRoot)) or "Coverage" + return {normalizeScopeName(fallbackScopeName): rootResultBundles} -def readCoverageReport(resultBundlePath: str) -> dict[str, object]: +def mergeCoverageReport(target: dict[str, dict[int, bool]], resultBundlePath: str): """ - Reads the xccov JSON coverage report for an xcresult bundle + Merges an xcresult coverage report into an aggregated line coverage map Parameters ---------- + target + The target aggregated line coverage map resultBundlePath - The xcresult bundle path - - Returns - ------- - dict[str, object] - The parsed xccov JSON report + The xcresult bundle path to process """ - report = subprocess.check_output( - ["xcrun", "xccov", "view", "--archive", "--json", resultBundlePath], - text=True, + report = json.loads( + subprocess.check_output( + ["xcrun", "xccov", "view", "--archive", "--json", resultBundlePath], + text=True, + ) ) - return json.loads(report) - - -def mergeCoverageReport(target: dict[str, dict[int, bool]], report: dict[str, object]): - """ - Merges an xccov JSON report into an aggregated line coverage map - - Parameters - ---------- - target - The target aggregated line coverage map - report - The xccov JSON report to merge - """ - for filePath, entries in report.items(): if not isinstance(entries, list): continue @@ -322,15 +350,14 @@ def mergeCoverageReport(target: dict[str, dict[int, bool]], report: dict[str, ob continue lineNumber = entry.get("line") - isExecutable = bool(entry.get("isExecutable")) - if lineNumber is None or not isExecutable: + if lineNumber is None or not entry.get("isExecutable"): continue isCovered = int(entry.get("executionCount", 0) or 0) > 0 combinedLines[int(lineNumber)] = combinedLines.get(int(lineNumber), False) or isCovered -def summarizeLineCoverage(lineCoverageMap: dict[str, dict[int, bool]]) -> tuple[int, int, float]: +def summarizeLineCoverage(lineCoverageMap: dict[str, dict[int, bool]]) -> ScopeCoverage: """ Summarizes an aggregated line coverage map @@ -341,8 +368,8 @@ def summarizeLineCoverage(lineCoverageMap: dict[str, dict[int, bool]]) -> tuple[ Returns ------- - tuple[int, int, float] - The covered line count, executable line count, and coverage percent + ScopeCoverage + The summarized coverage details """ executableLines = sum(len(lines) for lines in lineCoverageMap.values()) @@ -355,39 +382,42 @@ def summarizeLineCoverage(lineCoverageMap: dict[str, dict[int, bool]]) -> tuple[ else 0.0 ) - return (coveredLines, executableLines, coveragePercent) + return ScopeCoverage( + name="", + coveredLines=coveredLines, + executableLines=executableLines, + coveragePercent=coveragePercent, + ) -def createScopeCoverage(name: str, - lineCoverageMap: dict[str, dict[int, bool]]) -> ScopeCoverage: +def withName(scopeCoverage: ScopeCoverage, name: str) -> ScopeCoverage: """ - Creates a scope coverage summary from an aggregated line coverage map + Applies a display name to a scope coverage summary Parameters ---------- + scopeCoverage + The coverage summary to rename name - The scope display name - lineCoverageMap - The aggregated line coverage map + The scope name to apply Returns ------- ScopeCoverage - The created scope coverage summary + The renamed coverage summary """ - coveredLines, executableLines, coveragePercent = summarizeLineCoverage(lineCoverageMap) return ScopeCoverage( name=name, - coveredLines=coveredLines, - executableLines=executableLines, - coveragePercent=coveragePercent, + coveredLines=scopeCoverage.coveredLines, + executableLines=scopeCoverage.executableLines, + coveragePercent=scopeCoverage.coveragePercent, ) -def calculateScopeCoverages(discoveredScopes: dict[str, list[str]]) -> tuple[list[ScopeCoverage], dict[str, dict[int, bool]]]: +def calculateScopeCoverages(discoveredScopes: dict[str, list[str]]) -> tuple[list[ScopeCoverage], ScopeCoverage | None]: """ - Calculates per-scope coverages and the aggregated combined line coverage map + Calculates coverage summaries for each discovered scope Parameters ---------- @@ -396,63 +426,31 @@ def calculateScopeCoverages(discoveredScopes: dict[str, list[str]]) -> tuple[lis Returns ------- - tuple[list[ScopeCoverage], dict[str, dict[int, bool]]] - The per-scope coverage summaries and the combined line coverage map + tuple[list[ScopeCoverage], ScopeCoverage | None] + The per-scope summaries and optional combined summary """ combinedCoverageMap: dict[str, dict[int, bool]] = {} - scopeCoverageSummaries: list[ScopeCoverage] = [] + scopeCoverages: list[ScopeCoverage] = [] for scopeName, resultBundles in sorted(discoveredScopes.items()): scopeCoverageMap: dict[str, dict[int, bool]] = {} - for resultBundle in resultBundles: print(f"Processing result bundle for {scopeName}: {resultBundle}") - report = readCoverageReport(resultBundle) - mergeCoverageReport(scopeCoverageMap, report) - mergeCoverageReport(combinedCoverageMap, report) - - scopeCoverageSummaries.append( - createScopeCoverage( - name=scopeName, - lineCoverageMap=scopeCoverageMap, - ) - ) + mergeCoverageReport(scopeCoverageMap, resultBundle) + mergeCoverageReport(combinedCoverageMap, resultBundle) - return (scopeCoverageSummaries, combinedCoverageMap) + scopeCoverages.append(withName(summarizeLineCoverage(scopeCoverageMap), scopeName)) + if len(scopeCoverages) <= 1: + return (scopeCoverages, None) -def calculateOverallCoverage(scopeCoverageSummaries: list[ScopeCoverage], - combinedCoverageMap: dict[str, dict[int, bool]]) -> ScopeCoverage | None: - """ - Calculates the overall combined coverage when multiple scopes are present + return (scopeCoverages, withName(summarizeLineCoverage(combinedCoverageMap), "Combined")) - Parameters - ---------- - scopeCoverageSummaries - The per-scope coverage summaries - combinedCoverageMap - The combined line coverage map - Returns - ------- - ScopeCoverage | None - The combined coverage summary, or None when only one scope is present - """ - - if len(scopeCoverageSummaries) <= 1: - return None - - return createScopeCoverage( - name="Combined", - lineCoverageMap=combinedCoverageMap, - ) - - -def determineCoverageStatus(coveragePercent: float, - thresholds: CoverageThresholds) -> str: +def determineCoverageStatus(coveragePercent: float, thresholds: CoverageThresholds) -> str: """ - Determines the normalized status for a coverage percent + Determines the coverage status label for a coverage percent Parameters ---------- @@ -464,47 +462,72 @@ def determineCoverageStatus(coveragePercent: float, Returns ------- str - The normalized coverage status + The coverage status label """ - if coveragePercent < thresholds.failingCoverageThreshold: + if coveragePercent < thresholds.failing: return "fail" - if coveragePercent < thresholds.passingCoverageThreshold: + if coveragePercent < thresholds.passing: return "warn" + return "pass" def determineStatusEmoji(status: str) -> str: """ - Determines the coverage status emoji for a normalized status + Determines the emoji for a coverage status label Parameters ---------- status - The normalized coverage status + The coverage status label Returns ------- str - The status emoji + The corresponding emoji """ - if status == "fail": - return "❌" - if status == "warn": - return "⚠️" - return "✅" + return {"fail": "❌", "warn": "⚠️", "pass": "✅"}[status] -def printCoverageSummary(scopeCoverageSummaries: list[ScopeCoverage], +def serializeScope(scopeCoverage: ScopeCoverage, thresholds: CoverageThresholds) -> dict[str, object]: + """ + Serializes a scope coverage summary for JSON output + + Parameters + ---------- + scopeCoverage + The scope coverage summary + thresholds + The configured coverage thresholds + + Returns + ------- + dict[str, object] + The serialized scope coverage payload + """ + + status = determineCoverageStatus(scopeCoverage.coveragePercent, thresholds) + return { + "name": scopeCoverage.name, + "covered_lines": scopeCoverage.coveredLines, + "executable_lines": scopeCoverage.executableLines, + "coverage_percent": round(scopeCoverage.coveragePercent, 2), + "status": status, + "status_emoji": determineStatusEmoji(status), + } + + +def printCoverageSummary(scopeCoverages: list[ScopeCoverage], overallCoverage: ScopeCoverage | None, thresholds: CoverageThresholds): """ - Prints the coverage summary details to the GitHub Actions log + Prints a concise coverage summary to the GitHub Actions log Parameters ---------- - scopeCoverageSummaries + scopeCoverages The per-scope coverage summaries overallCoverage The optional combined coverage summary @@ -512,41 +535,24 @@ def printCoverageSummary(scopeCoverageSummaries: list[ScopeCoverage], The configured coverage thresholds """ - for scopeCoverage in scopeCoverageSummaries: + for scopeCoverage in scopeCoverages: status = determineCoverageStatus(scopeCoverage.coveragePercent, thresholds) - emoji = determineStatusEmoji(status) - print(f"{scopeCoverage.name} - {scopeCoverage.coveragePercent:.2f}% {emoji}") + print(f"{scopeCoverage.name} - {scopeCoverage.coveragePercent:.2f}% {determineStatusEmoji(status)}") if overallCoverage is not None: status = determineCoverageStatus(overallCoverage.coveragePercent, thresholds) - emoji = determineStatusEmoji(status) - print(f"Combined - {overallCoverage.coveragePercent:.2f}% {emoji}") + print(f"Combined - {overallCoverage.coveragePercent:.2f}% {determineStatusEmoji(status)}") -def ensureParentDirectory(filePath: str): - """ - Ensures that the parent directory exists for a file path - - Parameters - ---------- - filePath - The file path whose parent directory should exist - """ - - parentDirectory = os.path.dirname(filePath) - if len(parentDirectory) > 0: - os.makedirs(parentDirectory, exist_ok=True) - - -def renderMarkdownSummary(scopeCoverageSummaries: list[ScopeCoverage], +def renderMarkdownSummary(scopeCoverages: list[ScopeCoverage], overallCoverage: ScopeCoverage | None, thresholds: CoverageThresholds) -> str: """ - Renders the markdown coverage summary text + Renders the markdown coverage summary Parameters ---------- - scopeCoverageSummaries + scopeCoverages The per-scope coverage summaries overallCoverage The optional combined coverage summary @@ -556,33 +562,29 @@ def renderMarkdownSummary(scopeCoverageSummaries: list[ScopeCoverage], Returns ------- str - The rendered markdown summary + The markdown summary contents """ - lines: list[str] = [ + lines = [ "### Code Coverage", "", "| Scope | Coverage | Status |", "| --- | :---: | :---: |", ] - for scopeCoverage in scopeCoverageSummaries: + for scopeCoverage in scopeCoverages: status = determineCoverageStatus(scopeCoverage.coveragePercent, thresholds) - emoji = determineStatusEmoji(status) - lines.append(f"| {scopeCoverage.name} | {scopeCoverage.coveragePercent:.2f}% | {emoji} |") + lines.append(f"| {scopeCoverage.name} | {scopeCoverage.coveragePercent:.2f}% | {determineStatusEmoji(status)} |") if overallCoverage is not None: status = determineCoverageStatus(overallCoverage.coveragePercent, thresholds) - emoji = determineStatusEmoji(status) indent = "          " - lines.append( - f"| {indent} **Combined** | **{overallCoverage.coveragePercent:.2f}%** | **{emoji}** |" - ) + lines.append(f"| {indent} **Combined** | **{overallCoverage.coveragePercent:.2f}%** | **{determineStatusEmoji(status)}** |") return "\n".join(lines) + "\n" -def renderJsonSummary(scopeCoverageSummaries: list[ScopeCoverage], +def renderJsonSummary(scopeCoverages: list[ScopeCoverage], overallCoverage: ScopeCoverage | None, thresholds: CoverageThresholds) -> dict[str, object]: """ @@ -590,7 +592,7 @@ def renderJsonSummary(scopeCoverageSummaries: list[ScopeCoverage], Parameters ---------- - scopeCoverageSummaries + scopeCoverages The per-scope coverage summaries overallCoverage The optional combined coverage summary @@ -603,37 +605,40 @@ def renderJsonSummary(scopeCoverageSummaries: list[ScopeCoverage], The JSON coverage summary payload """ - def serializeScope(scopeCoverage: ScopeCoverage) -> dict[str, object]: - status = determineCoverageStatus(scopeCoverage.coveragePercent, thresholds) - return { - "name": scopeCoverage.name, - "covered_lines": scopeCoverage.coveredLines, - "executable_lines": scopeCoverage.executableLines, - "coverage_percent": round(scopeCoverage.coveragePercent, 2), - "status": status, - "status_emoji": determineStatusEmoji(status), - } - payload: dict[str, object] = { - "scope_count": len(scopeCoverageSummaries), + "scope_count": len(scopeCoverages), "thresholds": { - "failing_coverage_threshold": thresholds.failingCoverageThreshold, - "passing_coverage_threshold": thresholds.passingCoverageThreshold, + "failing_coverage_threshold": thresholds.failing, + "passing_coverage_threshold": thresholds.passing, }, - "scopes": [serializeScope(scopeCoverage) for scopeCoverage in scopeCoverageSummaries], + "scopes": [serializeScope(scopeCoverage, thresholds) for scopeCoverage in scopeCoverages], "overall_coverage_percent": "", } - if len(scopeCoverageSummaries) == 1: - payload["overall_coverage_percent"] = round(scopeCoverageSummaries[0].coveragePercent, 2) - if overallCoverage is not None: - payload["combined"] = serializeScope(overallCoverage) + payload["combined"] = serializeScope(overallCoverage, thresholds) payload["overall_coverage_percent"] = round(overallCoverage.coveragePercent, 2) + elif len(scopeCoverages) == 1: + payload["overall_coverage_percent"] = round(scopeCoverages[0].coveragePercent, 2) return payload +def ensureParentDirectory(filePath: str): + """ + Ensures the parent directory for a file path exists + + Parameters + ---------- + filePath + The file path whose parent directory should be created + """ + + parentDirectory = os.path.dirname(filePath) + if len(parentDirectory) > 0: + os.makedirs(parentDirectory, exist_ok=True) + + def writeTextFile(filePath: str, contents: str): """ Writes text contents to a file path @@ -647,7 +652,6 @@ def writeTextFile(filePath: str, contents: str): """ ensureParentDirectory(filePath) - with open(filePath, "w", encoding="utf-8") as file: file.write(contents) @@ -665,7 +669,6 @@ def writeJsonFile(filePath: str, payload: dict[str, object]): """ ensureParentDirectory(filePath) - with open(filePath, "w", encoding="utf-8") as file: json.dump(payload, file, indent=2, sort_keys=True) file.write("\n") @@ -691,10 +694,7 @@ def writeGithubOutput(name: str, value: str): print(f"{name}={value}", file=file) -def publishOutputs(summaryFile: str, - summaryJsonFile: str, - coveragePercent: str, - scopeCount: int): +def publishOutputs(summaryFile: str, summaryJsonFile: str, coveragePercent: str, scopeCount: int): """ Publishes the generated coverage summary outputs for GitHub Actions @@ -735,35 +735,29 @@ def writeUnavailableSummaries(summaryFile: str, The configured coverage thresholds """ - markdownSummary = "\n".join([ - "### Code Coverage", - "", - message, - "", - ]) - jsonSummary = { - "message": message, - "overall_coverage_percent": "", - "scope_count": 0, - "scopes": [], - "thresholds": { - "failing_coverage_threshold": thresholds.failingCoverageThreshold, - "passing_coverage_threshold": thresholds.passingCoverageThreshold, + writeTextFile(summaryFile, f"### Code Coverage\n\n{message}\n") + writeJsonFile( + summaryJsonFile, + { + "message": message, + "overall_coverage_percent": "", + "scope_count": 0, + "scopes": [], + "thresholds": { + "failing_coverage_threshold": thresholds.failing, + "passing_coverage_threshold": thresholds.passing, + }, }, - } - - writeTextFile(summaryFile, markdownSummary) - writeJsonFile(summaryJsonFile, jsonSummary) + ) -def determineOverallCoveragePercent(scopeCoverageSummaries: list[ScopeCoverage], - overallCoverage: ScopeCoverage | None) -> str: +def determineOverallCoveragePercent(scopeCoverages: list[ScopeCoverage], overallCoverage: ScopeCoverage | None) -> str: """ Determines the overall coverage percent string for action outputs Parameters ---------- - scopeCoverageSummaries + scopeCoverages The per-scope coverage summaries overallCoverage The optional combined coverage summary @@ -776,9 +770,8 @@ def determineOverallCoveragePercent(scopeCoverageSummaries: list[ScopeCoverage], if overallCoverage is not None: return f"{overallCoverage.coveragePercent:.2f}" - - if len(scopeCoverageSummaries) == 1: - return f"{scopeCoverageSummaries[0].coveragePercent:.2f}" + if len(scopeCoverages) == 1: + return f"{scopeCoverages[0].coveragePercent:.2f}" return "" @@ -795,51 +788,28 @@ def main(): discoveredScopes = discoverCoverageScopes(scriptArgs.xcresultsDirectory) if len(discoveredScopes) <= 0: - writeUnavailableSummaries( - summaryFile=scriptArgs.summaryFile, - summaryJsonFile=scriptArgs.summaryJsonFile, - message="Code coverage unavailable because no unit test result bundles were downloaded.", - thresholds=thresholds, - ) - publishOutputs( - summaryFile=scriptArgs.summaryFile, - summaryJsonFile=scriptArgs.summaryJsonFile, - coveragePercent="", - scopeCount=0, - ) + message = "Code coverage unavailable because no unit test result bundles were downloaded." + writeUnavailableSummaries(scriptArgs.summaryFile, scriptArgs.summaryJsonFile, message, thresholds) + publishOutputs(scriptArgs.summaryFile, scriptArgs.summaryJsonFile, "", 0) return - scopeCoverageSummaries, combinedCoverageMap = calculateScopeCoverages(discoveredScopes) - overallCoverage = calculateOverallCoverage( - scopeCoverageSummaries=scopeCoverageSummaries, - combinedCoverageMap=combinedCoverageMap, - ) + scopeCoverages, overallCoverage = calculateScopeCoverages(discoveredScopes) + printCoverageSummary(scopeCoverages, overallCoverage, thresholds) - printCoverageSummary( - scopeCoverageSummaries=scopeCoverageSummaries, - overallCoverage=overallCoverage, - thresholds=thresholds, + writeTextFile( + scriptArgs.summaryFile, + renderMarkdownSummary(scopeCoverages, overallCoverage, thresholds), ) - - markdownSummary = renderMarkdownSummary( - scopeCoverageSummaries=scopeCoverageSummaries, - overallCoverage=overallCoverage, - thresholds=thresholds, + writeJsonFile( + scriptArgs.summaryJsonFile, + renderJsonSummary(scopeCoverages, overallCoverage, thresholds), ) - jsonSummary = renderJsonSummary( - scopeCoverageSummaries=scopeCoverageSummaries, - overallCoverage=overallCoverage, - thresholds=thresholds, - ) - - writeTextFile(scriptArgs.summaryFile, markdownSummary) - writeJsonFile(scriptArgs.summaryJsonFile, jsonSummary) publishOutputs( - summaryFile=scriptArgs.summaryFile, - summaryJsonFile=scriptArgs.summaryJsonFile, - coveragePercent=determineOverallCoveragePercent(scopeCoverageSummaries, overallCoverage), - scopeCount=len(scopeCoverageSummaries), + scriptArgs.summaryFile, + scriptArgs.summaryJsonFile, + determineOverallCoveragePercent(scopeCoverages, overallCoverage), + len(scopeCoverages), ) diff --git a/.github/actions/xcode-test-the-tricorders/run_xcode_tests.py b/.github/actions/xcode-test-the-tricorders/run_xcode_tests.py index f105781b..4d303b3a 100644 --- a/.github/actions/xcode-test-the-tricorders/run_xcode_tests.py +++ b/.github/actions/xcode-test-the-tricorders/run_xcode_tests.py @@ -7,8 +7,8 @@ # Created by Kodex on 4/17/26. # # This script runs xcodebuild tests against one or more simulator destinations -# selected by simctl-pick-a-tricorder and publishes the generated xcresult -# bundle paths for downstream GitHub Actions steps. +# selected upstream and publishes the generated xcresult bundle paths for +# downstream GitHub Actions steps. import argparse import json @@ -18,7 +18,7 @@ import sys -SCRIPT_VERSION: str = "0.1.0" +SCRIPT_VERSION: str = "0.2.1" """The current version of the script""" @@ -29,9 +29,13 @@ """Supported Xcode container extensions mapped to xcodebuild argument types""" +OUTPUT_MARKER: str = "__XCODE_TEST_THE_TRICORDERS__" +"""The multiline GitHub Actions output marker""" + + def setupArgumentParser() -> argparse.ArgumentParser: """ - Sets up the Arugment Parser + Sets up the argument parser Returns ------- @@ -41,7 +45,7 @@ def setupArgumentParser() -> argparse.ArgumentParser: parser: argparse.ArgumentParser = argparse.ArgumentParser(description=""" This script runs xcodebuild tests against simulator destinations - selected by simctl-pick-a-tricorder.""") + selected by an upstream simulator-selection step.""") parser.add_argument("--version", "-v", action="version", version="%(prog)s " + SCRIPT_VERSION) @@ -49,28 +53,29 @@ def setupArgumentParser() -> argparse.ArgumentParser: help="show this help message and exit") parser.add_argument("--scheme", metavar="SchemeName", required=True, help="The Xcode scheme to run tests for", - dest='scheme') + dest='scheme', type=parseNonEmptyArgument) parser.add_argument("--xcode-container", metavar="Project.xcodeproj", required=True, help="The path to the Xcode project or workspace", - dest='xcodeContainer') + dest='xcodeContainer', type=parseNonEmptyArgument) parser.add_argument("--destination-ids", metavar="DESTINATION_IDS", required=True, help="Newline-separated simulator destination UDIDs", - dest='destinationIds') + dest='destinationIds', type=parseNonEmptyArgument) parser.add_argument("--simulator-jsons", metavar="SIMULATOR_JSONS", required=True, - help="JSON array of simulator objects from simctl-pick-a-tricorder", - dest='simulatorJsons') + help="JSON array of simulator objects from the picker action", + dest='simulatorJsons', type=parseNonEmptyArgument) parser.add_argument("--result-bundle-directory", metavar="TestResults", help="The directory where xcresult bundles should be written", - dest='resultBundleDirectory', default="TestResults") + dest='resultBundleDirectory', default="TestResults", + type=parseNonEmptyArgument) parser.add_argument("--destination-arch", metavar="arm64", required=True, help="The destination architecture to use with xcodebuild", - dest='destinationArch') + dest='destinationArch', type=parseNonEmptyArgument) parser.add_argument("--enable-code-coverage", metavar="YES", required=True, help="The value to pass to -enableCodeCoverage", - dest='enableCodeCoverage') + dest='enableCodeCoverage', type=parseYesNoArgument) parser.add_argument("--code-signing-allowed", metavar="NO", required=True, help="The value to pass through CODE_SIGNING_ALLOWED", - dest='codeSigningAllowed') + dest='codeSigningAllowed', type=parseYesNoArgument) parser.add_argument("--xcodebuild-extra-args", metavar="--test-iterations 2", help="Optional extra xcodebuild arguments", dest='xcodebuildExtraArgs', default="") @@ -84,26 +89,48 @@ def printScriptStart(): print(f"Starting {os.path.basename(__file__)} v{SCRIPT_VERSION}", file=sys.stderr) -def validateScriptArguments(scriptArgs: argparse.Namespace): +def parseNonEmptyArgument(value: str) -> str: """ - Validates the parsed script arguments + Parses and validates a non-empty string argument Parameters ---------- - scriptArgs - The parsed script arguments + value + The raw argument value + + Returns + ------- + str + The normalized non-empty argument value """ - if len(scriptArgs.scheme.strip()) <= 0: - raise ValueError("An Xcode scheme must be provided") + normalizedValue = value.strip() + if len(normalizedValue) <= 0: + raise ValueError("Argument value must not be empty") - if len(scriptArgs.xcodeContainer.strip()) <= 0: - raise ValueError("An Xcode container path must be provided") + return normalizedValue - if len(scriptArgs.resultBundleDirectory.strip()) <= 0: - raise ValueError("A result bundle directory must be provided") - determineXcodeContainerType(scriptArgs.xcodeContainer) +def parseYesNoArgument(value: str) -> str: + """ + Parses and validates a YES or NO argument value + + Parameters + ---------- + value + The raw argument value + + Returns + ------- + str + The normalized YES or NO value + """ + + normalizedValue = parseNonEmptyArgument(value).upper() + if normalizedValue not in {"YES", "NO"}: + raise ValueError(f"Unsupported YES/NO value specified: {value}") + + return normalizedValue def determineXcodeContainerType(xcodeContainer: str) -> str: @@ -123,7 +150,6 @@ def determineXcodeContainerType(xcodeContainer: str) -> str: _root, extension = os.path.splitext(xcodeContainer.strip()) containerType = SUPPORTED_XCODE_CONTAINERS.get(extension.lower()) - if containerType is None: raise ValueError( f"Unsupported Xcode container specified: {xcodeContainer}. " @@ -148,7 +174,7 @@ def parseDestinationIds(value: str) -> list[str]: The parsed destination IDs """ - return [part.strip() for part in value.splitlines() if part.strip()] + return [part.strip() for part in value.splitlines() if len(part.strip()) > 0] def parseSimulatorJsons(value: str) -> list[dict[str, str]]: @@ -175,37 +201,42 @@ def parseSimulatorJsons(value: str) -> list[dict[str, str]]: if not isinstance(simulator, dict): raise ValueError("Simulator JSON payload entries must be objects") + safeName = str(simulator.get("safe_name") or "").strip() + if len(safeName) <= 0: + raise ValueError("Simulator output is missing a safe_name value") + normalizedSimulators.append({ "name": str(simulator.get("name") or "").strip(), "os": str(simulator.get("os") or "").strip(), - "safe_name": str(simulator.get("safe_name") or "").strip(), + "safe_name": safeName, }) return normalizedSimulators -def determineResultBundlePath(resultBundleDirectory: str, - scheme: str, - safeName: str) -> str: +def validateScriptArguments(scriptArgs: argparse.Namespace) -> tuple[list[str], list[dict[str, str]]]: """ - Determines the xcresult bundle path for the specified simulator + Validates the parsed script arguments Parameters ---------- - resultBundleDirectory - The directory where xcresult bundles should be written - scheme - The Xcode scheme being tested - safeName - The filesystem-safe simulator identifier + scriptArgs + The parsed script arguments Returns ------- - str - The full xcresult bundle path + tuple[list[str], list[dict[str, str]]] + The parsed destination IDs and simulator objects """ - return os.path.join(resultBundleDirectory, f"{scheme}-{safeName}.xcresult") + determineXcodeContainerType(scriptArgs.xcodeContainer) + + destinationIds = parseDestinationIds(scriptArgs.destinationIds) + simulators = parseSimulatorJsons(scriptArgs.simulatorJsons) + if len(destinationIds) != len(simulators): + raise ValueError("Destination ID and simulator output counts do not match") + + return (destinationIds, simulators) def writeGithubOutput(name: str, value: str): @@ -245,31 +276,15 @@ def writeGithubMultilineOutput(name: str, values: list[str]): return with open(outputFile, "a", encoding="utf-8") as file: - print(f"{name}<<__XCODE_TEST_THE_TRICORDERS__", file=file) + print(f"{name}<<{OUTPUT_MARKER}", file=file) for value in values: print(value, file=file) - print("__XCODE_TEST_THE_TRICORDERS__", file=file) - - -def publishOutputs(resultBundleDirectory: str, resultBundlePaths: list[str]): - """ - Publishes the generated test result bundle outputs for GitHub Actions - - Parameters - ---------- - resultBundleDirectory - The directory containing the generated xcresult bundles - resultBundlePaths - The generated xcresult bundle paths - """ - - writeGithubOutput("result_bundle_directory", resultBundleDirectory) - writeGithubMultilineOutput("result_bundle_paths", resultBundlePaths) + print(OUTPUT_MARKER, file=file) def runTests(scriptArgs: argparse.Namespace, destinationIds: list[str], - simulators: list[dict[str, str]]) -> list[str]: + simulators: list[dict[str, str]]) -> tuple[str, list[str]]: """ Runs xcodebuild tests for all selected simulator destinations @@ -277,44 +292,28 @@ def runTests(scriptArgs: argparse.Namespace, ---------- scriptArgs The parsed script arguments - destinationIds - The simulator destination IDs - simulators - The parsed simulator metadata Returns ------- - list[str] - The generated xcresult bundle paths + tuple[str, list[str]] + The result bundle directory and generated xcresult bundle paths """ - if len(destinationIds) != len(simulators): - raise ValueError("Destination ID and simulator output counts do not match") + xcodeContainerType = determineXcodeContainerType(scriptArgs.xcodeContainer) + extraArgs = shlex.split(scriptArgs.xcodebuildExtraArgs) os.makedirs(scriptArgs.resultBundleDirectory, exist_ok=True) resultBundlePaths: list[str] = [] - extraArgs = shlex.split(scriptArgs.xcodebuildExtraArgs) - xcodeContainerType = determineXcodeContainerType(scriptArgs.xcodeContainer) - - for index, destinationId in enumerate(destinationIds): - simulator = simulators[index] - simulatorName = simulator["name"] - simulatorOs = simulator["os"] - safeName = simulator["safe_name"] - - if len(safeName) <= 0: - raise ValueError("Simulator output is missing a safe_name value") - - destination = f"id={destinationId},arch={scriptArgs.destinationArch}" - resultBundlePath = determineResultBundlePath( - resultBundleDirectory=scriptArgs.resultBundleDirectory, - scheme=scriptArgs.scheme, - safeName=safeName, + for destinationId, simulator in zip(destinationIds, simulators): + resultBundlePath = os.path.join( + scriptArgs.resultBundleDirectory, + f"{scriptArgs.scheme}-{simulator['safe_name']}.xcresult", ) + destination = f"id={destinationId},arch={scriptArgs.destinationArch}" print( - f"Running {scriptArgs.scheme} on {simulatorName} ({simulatorOs}) -> {resultBundlePath}", + f"Running {scriptArgs.scheme} on {simulator['name']} ({simulator['os']}) -> {resultBundlePath}", file=sys.stderr, ) @@ -336,10 +335,9 @@ def runTests(scriptArgs: argparse.Namespace, "test", ] ) - resultBundlePaths.append(resultBundlePath) - return resultBundlePaths + return (scriptArgs.resultBundleDirectory, resultBundlePaths) def main(): @@ -350,19 +348,14 @@ def main(): printScriptStart() - validateScriptArguments(scriptArgs) - destinationIds = parseDestinationIds(scriptArgs.destinationIds) - simulators = parseSimulatorJsons(scriptArgs.simulatorJsons) - resultBundlePaths = runTests( + destinationIds, simulators = validateScriptArguments(scriptArgs) + resultBundleDirectory, resultBundlePaths = runTests( scriptArgs=scriptArgs, destinationIds=destinationIds, simulators=simulators, ) - - publishOutputs( - resultBundleDirectory=scriptArgs.resultBundleDirectory, - resultBundlePaths=resultBundlePaths, - ) + writeGithubOutput("result_bundle_directory", resultBundleDirectory) + writeGithubMultilineOutput("result_bundle_paths", resultBundlePaths) if __name__ == "__main__": From 3b93b1ba7daa99af8a12914695f2be85653c8361 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 15:54:52 -0500 Subject: [PATCH 50/60] Maybe checkout... --- .github/workflows/ci-core.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 9a283ffe..3ef23955 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -62,6 +62,9 @@ jobs: combined_coverage_percent: ${{ steps.coverage.outputs.coverage_percent }} steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Download unit test results continue-on-error: true uses: actions/download-artifact@v7 From 31f15a1a3feb8fe4eea95d6cd85717e6884ab5a5 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 16:50:03 -0500 Subject: [PATCH 51/60] Setting up to do unit testing of the shareable github actions --- .github/actions/python-action-tests/README.md | 41 ++ .../python-action-tests/pyproject.toml | 22 + .../python-action-tests/requirements.txt | 2 + .../actions/python-action-tests/run_tests.sh | 63 +++ .../python-action-tests/setup_tests.sh | 17 + .../python-action-tests/tests/conftest.py | 92 ++++ .../tests/test_generate_coverage_summary.py | 374 ++++++++++++++++ .../tests/test_pick_simulator.py | 402 ++++++++++++++++++ .../tests/test_run_xcode_tests.py | 239 +++++++++++ .../tests/sample-devices.json | 74 ++++ .gitignore | 14 + 11 files changed, 1340 insertions(+) create mode 100644 .github/actions/python-action-tests/README.md create mode 100644 .github/actions/python-action-tests/pyproject.toml create mode 100644 .github/actions/python-action-tests/requirements.txt create mode 100755 .github/actions/python-action-tests/run_tests.sh create mode 100755 .github/actions/python-action-tests/setup_tests.sh create mode 100644 .github/actions/python-action-tests/tests/conftest.py create mode 100644 .github/actions/python-action-tests/tests/test_generate_coverage_summary.py create mode 100644 .github/actions/python-action-tests/tests/test_pick_simulator.py create mode 100644 .github/actions/python-action-tests/tests/test_run_xcode_tests.py create mode 100644 .github/actions/simctl-pick-a-tricorder/tests/sample-devices.json diff --git a/.github/actions/python-action-tests/README.md b/.github/actions/python-action-tests/README.md new file mode 100644 index 00000000..326401fb --- /dev/null +++ b/.github/actions/python-action-tests/README.md @@ -0,0 +1,41 @@ +# Python Action Tests + +This folder contains the Python unit-test harness for the reusable GitHub Actions under `.github/actions`. + +## Local setup + +1. Run the setup script: + + ```bash + ./.github/actions/python-action-tests/setup_tests.sh + ``` + +2. Run the tests and generate coverage artifacts: + + ```bash + ./.github/actions/python-action-tests/run_tests.sh + ``` + +If `.github/actions/python-action-tests/.venv` exists, the test runner will automatically use it. + +To use a specific Python interpreter during setup, set `PYTHON_ACTION_TEST_SETUP_PYTHON_BIN`: + +```bash +PYTHON_ACTION_TEST_SETUP_PYTHON_BIN=python3.13 ./.github/actions/python-action-tests/setup_tests.sh +``` + +## Generated artifacts + +By default, test artifacts are written under `.github/actions/python-action-tests/build/python-action-test-results/`: + +- `junit.xml` +- `coverage.xml` +- `htmlcov/` + +## Optional coverage threshold + +To fail the test run if total coverage drops below a minimum percentage: + +```bash +PYTHON_ACTION_TEST_COVERAGE_FAIL_UNDER=90 ./.github/actions/python-action-tests/run_tests.sh +``` diff --git a/.github/actions/python-action-tests/pyproject.toml b/.github/actions/python-action-tests/pyproject.toml new file mode 100644 index 00000000..ba239988 --- /dev/null +++ b/.github/actions/python-action-tests/pyproject.toml @@ -0,0 +1,22 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +junit_family = "xunit2" +addopts = ["-ra"] + +[tool.coverage.run] +branch = true +relative_files = true +patch = ["subprocess"] +source = [ + ".github/actions/simctl-pick-a-tricorder", + ".github/actions/xccov-warp-bubble", + ".github/actions/xcode-test-the-tricorders", +] +omit = [".github/actions/python-action-tests/*"] + +[tool.coverage.report] +show_missing = true +skip_empty = true +precision = 2 +omit = [".github/actions/python-action-tests/*"] diff --git a/.github/actions/python-action-tests/requirements.txt b/.github/actions/python-action-tests/requirements.txt new file mode 100644 index 00000000..09ab3511 --- /dev/null +++ b/.github/actions/python-action-tests/requirements.txt @@ -0,0 +1,2 @@ +pytest>=8.0,<9 +pytest-cov>=5.0,<7 diff --git a/.github/actions/python-action-tests/run_tests.sh b/.github/actions/python-action-tests/run_tests.sh new file mode 100755 index 00000000..4de66fbe --- /dev/null +++ b/.github/actions/python-action-tests/run_tests.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../../.." && pwd)" + +results_dir="${PYTHON_ACTION_TEST_RESULTS_DIR:-$script_dir/build/python-action-test-results}" +coverage_fail_under="${PYTHON_ACTION_TEST_COVERAGE_FAIL_UNDER:-}" +python_bin="${PYTHON_ACTION_TEST_PYTHON_BIN:-}" + +if [[ -z "$python_bin" && -x "$script_dir/.venv/bin/python" ]]; then + python_bin="$script_dir/.venv/bin/python" +fi + +if [[ -z "$python_bin" ]]; then + python_bin="python3" +fi + +mkdir -p "$results_dir" +cd "$repo_root" +export COVERAGE_FILE="$script_dir/.coverage" +rm -f "$script_dir"/.coverage "$script_dir"/.coverage.* + +cleanup_python_caches() { + find "$repo_root" -type d \( -name "__pycache__" -o -name ".pytest_cache" \) -prune -exec rm -rf {} + +} + +trap cleanup_python_caches EXIT + +pytest_args=( + "-c" "$script_dir/pyproject.toml" + "$script_dir/tests" + "--junitxml=$results_dir/junit.xml" + "--cov=.github/actions/simctl-pick-a-tricorder" + "--cov=.github/actions/xcode-test-the-tricorders" + "--cov=.github/actions/xccov-warp-bubble" + "--cov-branch" + "--cov-report=" +) + +if [[ -n "$coverage_fail_under" ]]; then + pytest_args+=("--cov-fail-under=$coverage_fail_under") +fi + +set +e +"$python_bin" -m pytest "${pytest_args[@]}" +pytest_exit_code=$? +set -e + +shopt -s nullglob +coverage_shards=( "$script_dir"/.coverage.* ) +shopt -u nullglob + +if [[ ${#coverage_shards[@]} -gt 0 ]]; then + "$python_bin" -m coverage combine "$script_dir" +fi + +"$python_bin" -m coverage report --skip-covered --show-missing +"$python_bin" -m coverage xml -o "$results_dir/coverage.xml" +"$python_bin" -m coverage html -d "$results_dir/htmlcov" + +exit "$pytest_exit_code" diff --git a/.github/actions/python-action-tests/setup_tests.sh b/.github/actions/python-action-tests/setup_tests.sh new file mode 100755 index 00000000..af1be3ca --- /dev/null +++ b/.github/actions/python-action-tests/setup_tests.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +python_bin="${PYTHON_ACTION_TEST_SETUP_PYTHON_BIN:-python3}" +venv_dir="${PYTHON_ACTION_TEST_VENV_DIR:-$script_dir/.venv}" +venv_python="$venv_dir/bin/python" + +if [[ ! -d "$venv_dir" ]]; then + "$python_bin" -m venv "$venv_dir" +fi + +"$venv_python" -m pip install -r "$script_dir/requirements.txt" + +echo "Python action test environment is ready: $venv_dir" diff --git a/.github/actions/python-action-tests/tests/conftest.py b/.github/actions/python-action-tests/tests/conftest.py new file mode 100644 index 00000000..6a0e4546 --- /dev/null +++ b/.github/actions/python-action-tests/tests/conftest.py @@ -0,0 +1,92 @@ +import importlib.util +import sys +from pathlib import Path + +import pytest + + +def determine_repo_root() -> Path: + current_path = Path(__file__).resolve() + for candidate in current_path.parents: + if (candidate / ".git").exists(): + return candidate + + raise RuntimeError("Unable to determine repository root for Python action tests") + + +REPO_ROOT = determine_repo_root() + + +@pytest.fixture(scope="session") +def repo_root() -> Path: + return REPO_ROOT + + +@pytest.fixture(scope="session") +def python_executable() -> str: + return sys.executable + + +def load_module(module_name: str, relative_path: str): + module_path = REPO_ROOT / relative_path + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load module: {module_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="session") +def pick_simulator_module(): + return load_module( + "test_pick_simulator_module", + ".github/actions/simctl-pick-a-tricorder/pick_simulator.py", + ) + + +@pytest.fixture(scope="session") +def run_xcode_tests_module(): + return load_module( + "test_run_xcode_tests_module", + ".github/actions/xcode-test-the-tricorders/run_xcode_tests.py", + ) + + +@pytest.fixture(scope="session") +def coverage_summary_module(): + return load_module( + "test_generate_coverage_summary_module", + ".github/actions/xccov-warp-bubble/generate_coverage_summary.py", + ) + + +@pytest.fixture() +def sample_devices_payload(): + return { + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-18-0": [ + {"name": "iPhone 16", "udid": "IPHONE16", "isAvailable": True}, + {"name": "iPhone 16 Pro Max", "udid": "IPHONE16PM", "isAvailable": True}, + {"name": "iPad Pro 13-inch (M4)", "udid": "IPADPRO", "isAvailable": True}, + {"name": "iPhone 14", "udid": "IPHONE14", "isAvailable": False}, + ], + "com.apple.CoreSimulator.SimRuntime.iOS-17-5": [ + {"name": "iPhone SE (3rd generation)", "udid": "IPHONESE", "isAvailable": True}, + ], + "com.apple.CoreSimulator.SimRuntime.macOS-15-0": [ + {"name": "My Mac", "udid": "MYMAC", "isAvailable": True}, + ], + "com.apple.CoreSimulator.SimRuntime.watchOS-11-0": [ + {"name": "Apple Watch Series 10 (42mm)", "udid": "WATCH10", "isAvailable": True}, + ], + "com.apple.CoreSimulator.SimRuntime.visionOS-2-0": [ + {"name": "Apple Vision Pro", "udid": "VISIONPRO", "isAvailable": True}, + ], + "com.apple.CoreSimulator.SimRuntime.tvOS-18-0": [ + {"name": "Apple TV", "udid": "TV", "isAvailable": True}, + ], + } + } diff --git a/.github/actions/python-action-tests/tests/test_generate_coverage_summary.py b/.github/actions/python-action-tests/tests/test_generate_coverage_summary.py new file mode 100644 index 00000000..5b62691d --- /dev/null +++ b/.github/actions/python-action-tests/tests/test_generate_coverage_summary.py @@ -0,0 +1,374 @@ +import argparse +import json +import os +import subprocess +import textwrap + +import pytest + + +def build_script_args(tmp_path, **overrides): + values = { + "xcresultsDirectory": str(tmp_path / "CoverageResults" / "xcresults"), + "summaryFile": str(tmp_path / "CoverageResults" / "summary.md"), + "summaryJsonFile": str(tmp_path / "CoverageResults" / "summary.json"), + "failingCoverageThreshold": 60.0, + "passingCoverageThreshold": 75.0, + } + values.update(overrides) + return argparse.Namespace(**values) + + +def test_setup_argument_parser_parses_valid_values(coverage_summary_module): + parser = coverage_summary_module.setupArgumentParser() + script_args = parser.parse_args([ + "--xcresults-directory", "CoverageResults/xcresults", + "--summary-file", "CoverageResults/summary.md", + "--summary-json-file", "CoverageResults/summary.json", + "--failing-coverage-threshold", "60", + "--passing-coverage-threshold", "75", + ]) + + assert script_args.xcresultsDirectory == "CoverageResults/xcresults" + assert script_args.failingCoverageThreshold == 60.0 + assert script_args.passingCoverageThreshold == 75.0 + + +def test_argument_parsing_helpers(coverage_summary_module): + assert coverage_summary_module.parseNonEmptyArgument(" summary.md ") == "summary.md" + assert coverage_summary_module.parseThreshold("75", "passing coverage threshold") == 75.0 + assert coverage_summary_module.parseCoverageThresholdArgument("60") == 60.0 + + with pytest.raises(ValueError): + coverage_summary_module.parseNonEmptyArgument(" ") + + with pytest.raises(ValueError): + coverage_summary_module.parseThreshold("abc", "coverage threshold") + + with pytest.raises(ValueError): + coverage_summary_module.parseThreshold("120", "coverage threshold") + + +def test_validate_script_arguments_rejects_invalid_threshold_order(coverage_summary_module, tmp_path): + script_args = build_script_args(tmp_path, failingCoverageThreshold=80.0, passingCoverageThreshold=75.0) + + with pytest.raises(ValueError): + coverage_summary_module.validateScriptArguments(script_args) + + +def test_normalize_scope_name_and_find_result_bundles(coverage_summary_module, tmp_path): + assert coverage_summary_module.normalizeScopeName("project-unit-tests-libPhoneNumber") == "libPhoneNumber" + assert coverage_summary_module.normalizeScopeName("libPhoneNumber.xcresult") == "libPhoneNumber" + + root = tmp_path / "xcresults" + nested_bundle = root / "project-unit-tests-libPhoneNumber" / "libPhoneNumber-iPhone-16.xcresult" + nested_bundle.mkdir(parents=True) + + bundles = coverage_summary_module.findResultBundles(str(root)) + assert bundles == [str(nested_bundle)] + + +def test_discover_coverage_scopes_prefers_scope_directories(coverage_summary_module, tmp_path, capsys): + root = tmp_path / "xcresults" + (root / "project-unit-tests-libPhoneNumber" / "libPhoneNumber-iPhone-16.xcresult").mkdir(parents=True) + (root / "project-unit-tests-libPhoneNumberGeocoding" / "libPhoneNumberGeocoding-iPhone-16.xcresult").mkdir(parents=True) + (root / "empty-scope").mkdir(parents=True) + + scopes = coverage_summary_module.discoverCoverageScopes(str(root)) + assert sorted(scopes.keys()) == ["libPhoneNumber", "libPhoneNumberGeocoding"] + + error_output = capsys.readouterr().err + assert "empty-scope: no downloaded .xcresult bundles found" in error_output + + +def test_discover_coverage_scopes_falls_back_to_root_bundles(coverage_summary_module, tmp_path): + root = tmp_path / "xcresults" + bundle = root / "libPhoneNumber-iPhone-16.xcresult" + bundle.mkdir(parents=True) + + scopes = coverage_summary_module.discoverCoverageScopes(str(root)) + assert scopes == {"xcresults": [str(bundle)]} + + +def test_merge_coverage_report_and_scope_calculation(coverage_summary_module, tmp_path, monkeypatch): + scope_a_bundle = tmp_path / "scope-a.xcresult" + scope_b_bundle = tmp_path / "scope-b.xcresult" + scope_a_bundle.mkdir() + scope_b_bundle.mkdir() + + reports = { + str(scope_a_bundle): { + "/tmp/FileA.swift": [ + {"line": 1, "isExecutable": True, "executionCount": 1}, + {"line": 2, "isExecutable": True, "executionCount": 0}, + {"line": 3, "isExecutable": False, "executionCount": 0}, + ], + }, + str(scope_b_bundle): { + "/tmp/FileA.swift": [ + {"line": 2, "isExecutable": True, "executionCount": 1}, + ], + "/tmp/FileB.swift": [ + {"line": 10, "isExecutable": True, "executionCount": 1}, + ], + }, + } + + def fake_check_output(command, text): + assert text is True + bundle_path = command[-1] + return json.dumps(reports[bundle_path]) + + monkeypatch.setattr(coverage_summary_module.subprocess, "check_output", fake_check_output) + + merged = {} + coverage_summary_module.mergeCoverageReport(merged, str(scope_a_bundle)) + assert merged["/tmp/FileA.swift"] == {1: True, 2: False} + + scope_coverages, overall_coverage = coverage_summary_module.calculateScopeCoverages( + { + "ScopeA": [str(scope_a_bundle)], + "ScopeB": [str(scope_b_bundle)], + } + ) + + assert [(scope.name, round(scope.coveragePercent, 2)) for scope in scope_coverages] == [ + ("ScopeA", 50.0), + ("ScopeB", 100.0), + ] + assert overall_coverage is not None + assert overall_coverage.name == "Combined" + assert round(overall_coverage.coveragePercent, 2) == 100.0 + + +def test_rendering_and_serialization_helpers(coverage_summary_module, capsys): + thresholds = coverage_summary_module.CoverageThresholds(failing=60.0, passing=75.0) + scope_a = coverage_summary_module.ScopeCoverage( + name="ScopeA", + coveredLines=3, + executableLines=5, + coveragePercent=60.0, + ) + scope_b = coverage_summary_module.ScopeCoverage( + name="ScopeB", + coveredLines=5, + executableLines=5, + coveragePercent=100.0, + ) + combined = coverage_summary_module.ScopeCoverage( + name="Combined", + coveredLines=8, + executableLines=10, + coveragePercent=80.0, + ) + + assert coverage_summary_module.determineCoverageStatus(50.0, thresholds) == "fail" + assert coverage_summary_module.determineCoverageStatus(65.0, thresholds) == "warn" + assert coverage_summary_module.determineCoverageStatus(80.0, thresholds) == "pass" + assert coverage_summary_module.determineStatusEmoji("pass") == "✅" + assert coverage_summary_module.serializeScope(scope_a, thresholds)["status"] == "warn" + assert coverage_summary_module.determineOverallCoveragePercent([scope_a], None) == "60.00" + assert coverage_summary_module.determineOverallCoveragePercent([scope_a, scope_b], combined) == "80.00" + + coverage_summary_module.printCoverageSummary([scope_a, scope_b], combined, thresholds) + output = capsys.readouterr().out + assert "ScopeA - 60.00% ⚠️" in output + assert "Combined - 80.00% ✅" in output + + markdown = coverage_summary_module.renderMarkdownSummary([scope_a, scope_b], combined, thresholds) + assert "| ScopeA | 60.00% | ⚠️ |" in markdown + assert "**Combined**" in markdown + + json_payload = coverage_summary_module.renderJsonSummary([scope_a, scope_b], combined, thresholds) + assert json_payload["scope_count"] == 2 + assert json_payload["combined"]["status"] == "pass" + + +def test_file_output_helpers(coverage_summary_module, tmp_path, monkeypatch): + thresholds = coverage_summary_module.CoverageThresholds(failing=60.0, passing=75.0) + summary_file = tmp_path / "results" / "summary.md" + summary_json_file = tmp_path / "results" / "summary.json" + output_file = tmp_path / "github-output.txt" + + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + + coverage_summary_module.writeTextFile(str(summary_file), "hello\n") + coverage_summary_module.writeJsonFile(str(summary_json_file), {"ok": True}) + coverage_summary_module.publishOutputs(str(summary_file), str(summary_json_file), "88.00", 2) + + assert summary_file.read_text(encoding="utf-8") == "hello\n" + assert json.loads(summary_json_file.read_text(encoding="utf-8")) == {"ok": True} + + output_contents = output_file.read_text(encoding="utf-8") + assert f"summary_file={summary_file}" in output_contents + assert "coverage_percent=88.00" in output_contents + assert "scope_count=2" in output_contents + + coverage_summary_module.writeUnavailableSummaries( + str(summary_file), + str(summary_json_file), + "No coverage available.", + thresholds, + ) + assert "No coverage available." in summary_file.read_text(encoding="utf-8") + assert json.loads(summary_json_file.read_text(encoding="utf-8"))["scope_count"] == 0 + + +def test_main_writes_unavailable_summaries(coverage_summary_module, tmp_path, monkeypatch): + script_args = build_script_args(tmp_path) + output_file = tmp_path / "github-output.txt" + + class FakeParser: + def parse_args(self): + return script_args + + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setattr(coverage_summary_module, "setupArgumentParser", lambda: FakeParser()) + + coverage_summary_module.main() + + summary_contents = (tmp_path / "CoverageResults" / "summary.md").read_text(encoding="utf-8") + json_payload = json.loads((tmp_path / "CoverageResults" / "summary.json").read_text(encoding="utf-8")) + output_contents = output_file.read_text(encoding="utf-8") + + assert "Code coverage unavailable because no unit test result bundles were downloaded." in summary_contents + assert json_payload["scope_count"] == 0 + assert "coverage_percent=" in output_contents + + +def test_main_writes_summary_for_downloaded_results(coverage_summary_module, tmp_path, monkeypatch): + xcresults_root = tmp_path / "CoverageResults" / "xcresults" + bundle = xcresults_root / "project-unit-tests-libPhoneNumber" / "libPhoneNumber-iPhone-16.xcresult" + bundle.mkdir(parents=True) + + script_args = build_script_args(tmp_path) + output_file = tmp_path / "github-output.txt" + + class FakeParser: + def parse_args(self): + return script_args + + def fake_check_output(command, text): + assert command[-1] == str(bundle) + return json.dumps( + { + "/tmp/FileA.swift": [ + {"line": 1, "isExecutable": True, "executionCount": 1}, + {"line": 2, "isExecutable": True, "executionCount": 0}, + ], + } + ) + + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setattr(coverage_summary_module, "setupArgumentParser", lambda: FakeParser()) + monkeypatch.setattr(coverage_summary_module.subprocess, "check_output", fake_check_output) + + coverage_summary_module.main() + + summary_contents = (tmp_path / "CoverageResults" / "summary.md").read_text(encoding="utf-8") + json_payload = json.loads((tmp_path / "CoverageResults" / "summary.json").read_text(encoding="utf-8")) + output_contents = output_file.read_text(encoding="utf-8") + + assert "| libPhoneNumber | 50.00% | ❌ |" in summary_contents + assert json_payload["overall_coverage_percent"] == 50.0 + assert "coverage_percent=50.00" in output_contents + + +def test_generate_coverage_summary_script_runs_as_black_box(repo_root, python_executable, tmp_path): + script_path = repo_root / ".github/actions/xccov-warp-bubble/generate_coverage_summary.py" + output_file = tmp_path / "github-output.txt" + fake_bin_dir = tmp_path / "bin" + fake_bin_dir.mkdir() + fake_xcrun_path = fake_bin_dir / "xcrun" + report_mapping = { + "libPhoneNumber-iPhone-16.xcresult": { + "/tmp/FileA.swift": [ + {"line": 1, "isExecutable": True, "executionCount": 1}, + {"line": 2, "isExecutable": True, "executionCount": 0}, + ] + }, + "libPhoneNumberGeocoding-iPhone-16.xcresult": { + "/tmp/FileA.swift": [ + {"line": 2, "isExecutable": True, "executionCount": 1}, + ], + "/tmp/FileB.swift": [ + {"line": 10, "isExecutable": True, "executionCount": 1}, + ], + }, + } + mapping_file = tmp_path / "xccov-reports.json" + mapping_file.write_text(json.dumps(report_mapping), encoding="utf-8") + + fake_xcrun_path.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + import sys + from pathlib import Path + + args = sys.argv[1:] + with open(os.environ["XCRUN_LOG_FILE"], "a", encoding="utf-8") as handle: + handle.write(" ".join(args) + "\\n") + + if args[:3] != ["xccov", "view", "--archive"] or args[3] != "--json": + raise SystemExit(f"Unexpected xcrun arguments: {args}") + + report_key = Path(args[4]).name + with open(os.environ["XCCOV_REPORTS_FILE"], "r", encoding="utf-8") as handle: + reports = json.load(handle) + + sys.stdout.write(json.dumps(reports[report_key])) + raise SystemExit(0) + """ + ), + encoding="utf-8", + ) + fake_xcrun_path.chmod(0o755) + + xcresults_root = tmp_path / "CoverageResults" / "xcresults" + (xcresults_root / "project-unit-tests-libPhoneNumber" / "libPhoneNumber-iPhone-16.xcresult").mkdir(parents=True) + (xcresults_root / "project-unit-tests-libPhoneNumberGeocoding" / "libPhoneNumberGeocoding-iPhone-16.xcresult").mkdir(parents=True) + summary_file = tmp_path / "CoverageResults" / "summary.md" + summary_json_file = tmp_path / "CoverageResults" / "summary.json" + + command = [ + python_executable, + str(script_path), + "--xcresults-directory", str(xcresults_root), + "--summary-file", str(summary_file), + "--summary-json-file", str(summary_json_file), + "--failing-coverage-threshold", "60", + "--passing-coverage-threshold", "75", + ] + environment = os.environ.copy() + environment["GITHUB_OUTPUT"] = str(output_file) + environment["XCCOV_REPORTS_FILE"] = str(mapping_file) + environment["XCRUN_LOG_FILE"] = str(tmp_path / "xcrun.log") + environment["PATH"] = str(fake_bin_dir) + os.pathsep + environment["PATH"] + + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env=environment, + ) + + summary_contents = summary_file.read_text(encoding="utf-8") + summary_json = json.loads(summary_json_file.read_text(encoding="utf-8")) + output_contents = output_file.read_text(encoding="utf-8") + xcrun_log_lines = (tmp_path / "xcrun.log").read_text(encoding="utf-8").splitlines() + + assert "| libPhoneNumber | 50.00% | ❌ |" in summary_contents + assert "| libPhoneNumberGeocoding | 100.00% | ✅ |" in summary_contents + assert "**Combined**" in summary_contents + assert summary_json["combined"]["coverage_percent"] == 100.0 + assert "coverage_percent=100.00" in output_contents + assert "scope_count=2" in output_contents + assert "Processing result bundle for libPhoneNumber:" in result.stdout + assert "Combined - 100.00% ✅" in result.stdout + assert len(xcrun_log_lines) == 4 + assert sum("libPhoneNumber-iPhone-16.xcresult" in line for line in xcrun_log_lines) == 2 + assert sum("libPhoneNumberGeocoding-iPhone-16.xcresult" in line for line in xcrun_log_lines) == 2 diff --git a/.github/actions/python-action-tests/tests/test_pick_simulator.py b/.github/actions/python-action-tests/tests/test_pick_simulator.py new file mode 100644 index 00000000..3baf89e2 --- /dev/null +++ b/.github/actions/python-action-tests/tests/test_pick_simulator.py @@ -0,0 +1,402 @@ +import argparse +import json +import os +import subprocess +import textwrap + +import pytest + + +def make_candidate(module, *, device_type: str, name: str, udid: str, runtime: str, family: str, version: tuple[int, ...]): + return module.Candidate( + deviceType=device_type, + name=name, + udid=udid, + runtimeIdentifier=runtime, + runtimeFamily=family, + osVersion=version, + ) + + +def test_setup_argument_parser_parses_valid_values(pick_simulator_module): + parser = pick_simulator_module.setupArgumentParser() + script_args = parser.parse_args([ + "--device-types", "iphone,ipad", + "--selection-mode", "latest-model", + "--iphoneos-version", "18.0", + ]) + + assert script_args.deviceTypes == "iphone,ipad" + assert script_args.selectionMode == "latest-model" + assert script_args.iphoneosVersion == "18.0" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("iphone, ipad", ["iphone", "ipad"]), + (" iphone ", ["iphone"]), + ("iphone,,watch", ["iphone", "watch"]), + ], +) +def test_parse_comma_separated_list(pick_simulator_module, value, expected): + assert pick_simulator_module.parseCommaSeparatedList(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("latest", None), + ("18", (18,)), + ("18.2", (18, 2)), + ], +) +def test_parse_requested_version_valid_values(pick_simulator_module, value, expected): + assert pick_simulator_module.parseRequestedVersion(value) == expected + + +def test_parse_requested_version_invalid_value_raises(pick_simulator_module): + with pytest.raises(ValueError): + pick_simulator_module.parseRequestedVersion("18.x") + + +def test_parse_model_preferences_parses_expected_values(pick_simulator_module): + preferences = pick_simulator_module.parseModelPreferences("iphone=Pro Max,Plus;ipad=Pro") + assert preferences == {"iphone": ["pro max", "plus"], "ipad": ["pro"]} + + +def test_parse_model_preferences_rejects_invalid_segment(pick_simulator_module): + with pytest.raises(ValueError): + pick_simulator_module.parseModelPreferences("iphone") + + +def test_read_devices_json_reads_from_file(pick_simulator_module, tmp_path, sample_devices_payload): + json_file = tmp_path / "devices.json" + json_file.write_text(json.dumps(sample_devices_payload), encoding="utf-8") + + loaded_payload = pick_simulator_module.readDevicesJson(str(json_file)) + assert loaded_payload == sample_devices_payload + + +def test_parse_runtime_identifier_and_classification_helpers(pick_simulator_module): + assert pick_simulator_module.parseRuntimeIdentifier( + "com.apple.CoreSimulator.SimRuntime.iOS-18-2" + ) == ("iOS", (18, 2)) + assert pick_simulator_module.parseRuntimeIdentifier("com.apple.CoreSimulator.SimRuntime.tvOS-18-0") is None + assert pick_simulator_module.classifyDeviceType("iPhone 16 Pro") == "iphone" + assert pick_simulator_module.classifyDeviceType("iPad Air 13-inch") == "ipad" + assert pick_simulator_module.classifyDeviceType("Apple Watch Ultra 2") == "watch" + assert pick_simulator_module.classifyDeviceType("Apple Vision Pro") == "vision" + assert pick_simulator_module.classifyDeviceType("My Mac") == "macos" + assert pick_simulator_module.versionToString((18, 2, 1)) == "18.2.1" + + +def test_variant_and_model_helpers(pick_simulator_module): + pro_max = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16 Pro Max", + udid="A", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + base = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16", + udid="B", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + + assert pick_simulator_module.determineVariantDetails(pro_max.name) == ("pro max", 70) + assert pick_simulator_module.determineModelType(pro_max.name) == "Pro Max" + assert pick_simulator_module.determineModelType(base.name) == "" + assert pick_simulator_module.createSafeName("iPhone 16 Pro Max", "18.0") == "iPhone-16-Pro-Max-18.0" + assert pick_simulator_module.filterToLatestModel([base, pro_max]) == [pro_max] + + +def test_filter_to_model_preferences(pick_simulator_module): + pro = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16 Pro", + udid="PRO", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + plus = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16 Plus", + udid="PLUS", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + + filtered = pick_simulator_module.filterToModelPreferences( + [pro, plus], + {"iphone": ["plus"]}, + ) + assert filtered == [plus] + + +def test_enumerate_candidates_groups_by_device_type(pick_simulator_module, sample_devices_payload, capsys): + candidates_by_type = pick_simulator_module.enumerateCandidates( + sample_devices_payload, + ["iphone", "ipad", "macos", "watch", "vision"], + ) + + assert [candidate.name for candidate in candidates_by_type["iphone"]] == [ + "iPhone 16", + "iPhone 16 Pro Max", + "iPhone SE (3rd generation)", + ] + assert [candidate.name for candidate in candidates_by_type["ipad"]] == ["iPad Pro 13-inch (M4)"] + assert [candidate.name for candidate in candidates_by_type["macos"]] == ["My Mac"] + assert [candidate.name for candidate in candidates_by_type["watch"]] == ["Apple Watch Series 10 (42mm)"] + assert [candidate.name for candidate in candidates_by_type["vision"]] == ["Apple Vision Pro"] + + error_output = capsys.readouterr().err + assert "Runtime: com.apple.CoreSimulator.SimRuntime.iOS-18-0" in error_output + assert "Apple TV" not in error_output + + +def test_enumerate_candidates_rejects_invalid_devices_map(pick_simulator_module): + with pytest.raises(ValueError): + pick_simulator_module.enumerateCandidates({"devices": []}, ["iphone"]) + + +def test_filter_candidates_for_requested_os_and_candidate_pool(pick_simulator_module, sample_devices_payload, capsys): + candidates = pick_simulator_module.enumerateCandidates(sample_devices_payload, ["iphone"])["iphone"] + + latest_candidates = pick_simulator_module.filterCandidatesForRequestedOs( + candidates, + None, + "iphone", + ) + assert [candidate.name for candidate in latest_candidates] == ["iPhone 16", "iPhone 16 Pro Max"] + + specific_candidates = pick_simulator_module.filterCandidatesForRequestedOs( + candidates, + (17, 5), + "iphone", + ) + assert [candidate.name for candidate in specific_candidates] == ["iPhone SE (3rd generation)"] + + pool = pick_simulator_module.buildCandidatePool( + deviceType="iphone", + candidates=candidates, + requestedVersion=None, + selectionMode="latest-model", + modelPreferences={}, + ) + assert [candidate.name for candidate in pool] == ["iPhone 16 Pro Max"] + + error_output = capsys.readouterr().err + assert "iphone: using latest OS 18.0" in error_output + assert "iphone: requested OS 17.5" in error_output + + +def test_select_destination_from_pool_and_requested_versions(pick_simulator_module, monkeypatch): + first = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16", + udid="FIRST", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + second = make_candidate( + pick_simulator_module, + device_type="iphone", + name="iPhone 16 Pro Max", + udid="SECOND", + runtime="com.apple.CoreSimulator.SimRuntime.iOS-18-0", + family="iOS", + version=(18, 0), + ) + + monkeypatch.setattr(pick_simulator_module.random, "choice", lambda values: values[0]) + assert pick_simulator_module.selectDestinationFromPool([first, second], "random-compatible") == first + assert pick_simulator_module.selectDestinationFromPool([first, second], "latest-model") == second + assert pick_simulator_module.selectDestinationFromPool([], "latest-model") is None + + script_args = argparse.Namespace( + iphoneosVersion="18.0", + ipadosVersion="latest", + macosVersion="15.0", + watchosVersion="latest", + visionosVersion="2.0", + ) + assert pick_simulator_module.determineRequestedVersions(script_args) == { + "iphone": (18, 0), + "ipad": None, + "macos": (15, 0), + "watch": None, + "vision": (2, 0), + } + + +def test_validate_script_arguments_and_selection_failures(pick_simulator_module): + valid_args = argparse.Namespace(deviceTypes="iphone,watch", selectionMode="latest-model") + assert pick_simulator_module.validateScriptArguments(valid_args) == ["iphone", "watch"] + + with pytest.raises(ValueError): + pick_simulator_module.validateScriptArguments( + argparse.Namespace(deviceTypes="iphone,tvos", selectionMode="latest-model") + ) + + with pytest.raises(SystemExit): + pick_simulator_module.determineSelectedDestinations( + requestedDeviceTypes=["iphone"], + candidatesByType={"iphone": []}, + requestedVersions={"iphone": None}, + selectionMode="latest-model", + modelPreferences={}, + ) + + +def test_determine_selected_destinations_success_and_publish_outputs( + pick_simulator_module, + tmp_path, + sample_devices_payload, + monkeypatch, +): + candidates_by_type = pick_simulator_module.enumerateCandidates(sample_devices_payload, ["iphone", "watch"]) + monkeypatch.setattr(pick_simulator_module.random, "choice", lambda values: values[0]) + + selected_candidates = pick_simulator_module.determineSelectedDestinations( + requestedDeviceTypes=["iphone", "watch"], + candidatesByType=candidates_by_type, + requestedVersions={"iphone": None, "watch": None}, + selectionMode="random-compatible", + modelPreferences={}, + ) + + assert [candidate.udid for candidate in selected_candidates] == ["IPHONE16", "WATCH10"] + + output_file = tmp_path / "github-output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + pick_simulator_module.publishOutputs(selected_candidates) + + output_contents = output_file.read_text(encoding="utf-8") + assert "simulator_jsons=" in output_contents + assert "destination_ids<<__SIMCTL_PICK_A_TRICORDER__" in output_contents + assert "IPHONE16" in output_contents + assert "WATCH10" in output_contents + + +def test_main_runs_end_to_end(pick_simulator_module, tmp_path, sample_devices_payload, monkeypatch, capsys): + class FakeParser: + def parse_args(self): + return argparse.Namespace( + deviceTypes="iphone,watch", + selectionMode="random-compatible", + modelPreferences="", + iphoneosVersion="latest", + ipadosVersion="latest", + macosVersion="latest", + watchosVersion="latest", + visionosVersion="latest", + devicesJsonFile=None, + ) + + output_file = tmp_path / "github-output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setattr(pick_simulator_module, "setupArgumentParser", lambda: FakeParser()) + monkeypatch.setattr(pick_simulator_module, "readDevicesJson", lambda *_args, **_kwargs: sample_devices_payload) + monkeypatch.setattr(pick_simulator_module.random, "choice", lambda values: values[0]) + + pick_simulator_module.main() + + output_contents = output_file.read_text(encoding="utf-8") + assert "simulator_jsons=" in output_contents + assert "IPHONE16" in output_contents + assert "WATCH10" in output_contents + + error_output = capsys.readouterr().err + assert "Selected Simulators:" in error_output + + +def test_pick_simulator_script_runs_as_black_box(repo_root, python_executable, tmp_path, sample_devices_payload): + script_path = repo_root / ".github/actions/simctl-pick-a-tricorder/pick_simulator.py" + output_file = tmp_path / "github-output.txt" + fake_bin_dir = tmp_path / "bin" + fake_bin_dir.mkdir() + fake_xcrun_path = fake_bin_dir / "xcrun" + devices_json_path = tmp_path / "devices.json" + devices_json_path.write_text(json.dumps(sample_devices_payload), encoding="utf-8") + + fake_xcrun_path.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import os + import sys + + with open(os.environ["SIMCTL_LOG_FILE"], "a", encoding="utf-8") as handle: + handle.write(" ".join(sys.argv[1:]) + "\\n") + + if sys.argv[1:] == ["simctl", "list", "devices", "--json"]: + with open(os.environ["SIMCTL_DEVICES_JSON_FILE"], "r", encoding="utf-8") as handle: + sys.stdout.write(handle.read()) + raise SystemExit(0) + + raise SystemExit(f"Unexpected xcrun arguments: {sys.argv[1:]}") + """ + ), + encoding="utf-8", + ) + fake_xcrun_path.chmod(0o755) + + command = [ + python_executable, + str(script_path), + "--device-types", "iphone,watch", + "--selection-mode", "latest-model", + "--iphoneos-version", "latest", + "--watchos-version", "latest", + ] + environment = os.environ.copy() + environment["GITHUB_OUTPUT"] = str(output_file) + environment["SIMCTL_DEVICES_JSON_FILE"] = str(devices_json_path) + environment["SIMCTL_LOG_FILE"] = str(tmp_path / "xcrun.log") + environment["PATH"] = str(fake_bin_dir) + os.pathsep + environment["PATH"] + + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env=environment, + ) + + output_contents = output_file.read_text(encoding="utf-8") + assert "simulator_jsons=" in output_contents + assert "destination_ids<<__SIMCTL_PICK_A_TRICORDER__" in output_contents + assert "IPHONE16PM" in output_contents + assert "WATCH10" in output_contents + + simulator_payload = json.loads(output_contents.split("simulator_jsons=", 1)[1].splitlines()[0]) + assert simulator_payload[0] == { + "udid": "IPHONE16PM", + "name": "iPhone 16 Pro Max", + "os": "18.0", + "modelType": "Pro Max", + "safe_name": "iPhone-16-Pro-Max-18.0", + } + assert simulator_payload[1]["udid"] == "WATCH10" + assert simulator_payload[1]["name"] == "Apple Watch Series 10 (42mm)" + assert simulator_payload[1]["os"] == "11.0" + assert simulator_payload[1]["safe_name"].startswith("Apple-Watch-Series-10-42mm") + + assert "Selected Simulators:" in result.stderr + assert "iPhone 16 Pro Max" in result.stderr + assert "Apple Watch Series 10 (42mm)" in result.stderr + assert (tmp_path / "xcrun.log").read_text(encoding="utf-8").strip() == "simctl list devices --json" diff --git a/.github/actions/python-action-tests/tests/test_run_xcode_tests.py b/.github/actions/python-action-tests/tests/test_run_xcode_tests.py new file mode 100644 index 00000000..d5d8a99a --- /dev/null +++ b/.github/actions/python-action-tests/tests/test_run_xcode_tests.py @@ -0,0 +1,239 @@ +import argparse +import json +import os +import subprocess +import textwrap + +import pytest + + +def build_script_args(tmp_path, **overrides): + values = { + "scheme": "libPhoneNumber", + "xcodeContainer": "libPhoneNumber.xcodeproj", + "destinationIds": "SIM-001\nSIM-002\n", + "simulatorJsons": json.dumps([ + {"name": "iPhone 16", "os": "18.0", "safe_name": "iPhone-16-18.0"}, + {"name": "iPhone 16 Pro Max", "os": "18.0", "safe_name": "iPhone-16-Pro-Max-18.0"}, + ]), + "resultBundleDirectory": str(tmp_path / "TestResults"), + "destinationArch": "arm64", + "enableCodeCoverage": "YES", + "codeSigningAllowed": "NO", + "xcodebuildExtraArgs": "--test-iterations 2", + } + values.update(overrides) + return argparse.Namespace(**values) + + +def test_setup_argument_parser_parses_valid_values(run_xcode_tests_module): + parser = run_xcode_tests_module.setupArgumentParser() + script_args = parser.parse_args([ + "--scheme", "libPhoneNumber", + "--xcode-container", "libPhoneNumber.xcodeproj", + "--destination-ids", "SIM-001", + "--simulator-jsons", '[{"name":"iPhone 16","os":"18.0","safe_name":"iphone-16"}]', + "--destination-arch", "arm64", + "--enable-code-coverage", "YES", + "--code-signing-allowed", "NO", + ]) + + assert script_args.scheme == "libPhoneNumber" + assert script_args.enableCodeCoverage == "YES" + assert script_args.codeSigningAllowed == "NO" + + +def test_argument_parsing_helpers(run_xcode_tests_module): + assert run_xcode_tests_module.parseNonEmptyArgument(" libPhoneNumber ") == "libPhoneNumber" + assert run_xcode_tests_module.parseYesNoArgument("yes") == "YES" + assert run_xcode_tests_module.parseDestinationIds("A\nB\n\n") == ["A", "B"] + + with pytest.raises(ValueError): + run_xcode_tests_module.parseNonEmptyArgument(" ") + + with pytest.raises(ValueError): + run_xcode_tests_module.parseYesNoArgument("maybe") + + +def test_determine_xcode_container_type(run_xcode_tests_module): + assert run_xcode_tests_module.determineXcodeContainerType("App.xcodeproj") == "project" + assert run_xcode_tests_module.determineXcodeContainerType("App.xcworkspace") == "workspace" + + with pytest.raises(ValueError): + run_xcode_tests_module.determineXcodeContainerType("App.swift") + + +def test_parse_simulator_jsons_validation(run_xcode_tests_module): + simulators = run_xcode_tests_module.parseSimulatorJsons( + '[{"name":"iPhone 16","os":"18.0","safe_name":"iphone-16"}]' + ) + assert simulators == [{"name": "iPhone 16", "os": "18.0", "safe_name": "iphone-16"}] + + with pytest.raises(ValueError): + run_xcode_tests_module.parseSimulatorJsons('{"name":"iPhone 16"}') + + with pytest.raises(ValueError): + run_xcode_tests_module.parseSimulatorJsons('[{"name":"iPhone 16","os":"18.0"}]') + + +def test_validate_script_arguments(run_xcode_tests_module, tmp_path): + script_args = build_script_args(tmp_path) + destination_ids, simulators = run_xcode_tests_module.validateScriptArguments(script_args) + + assert destination_ids == ["SIM-001", "SIM-002"] + assert len(simulators) == 2 + + with pytest.raises(ValueError): + run_xcode_tests_module.validateScriptArguments( + build_script_args(tmp_path, destinationIds="SIM-001") + ) + + +def test_write_github_outputs(run_xcode_tests_module, tmp_path, monkeypatch): + output_file = tmp_path / "github-output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + + run_xcode_tests_module.writeGithubOutput("result_bundle_directory", "TestResults") + run_xcode_tests_module.writeGithubMultilineOutput("result_bundle_paths", ["A.xcresult", "B.xcresult"]) + + contents = output_file.read_text(encoding="utf-8") + assert "result_bundle_directory=TestResults" in contents + assert "result_bundle_paths<<__XCODE_TEST_THE_TRICORDERS__" in contents + assert "A.xcresult" in contents + assert "B.xcresult" in contents + + +def test_run_tests_executes_xcodebuild_for_each_destination(run_xcode_tests_module, tmp_path, monkeypatch): + script_args = build_script_args(tmp_path) + destination_ids = ["SIM-001", "SIM-002"] + simulators = run_xcode_tests_module.parseSimulatorJsons(script_args.simulatorJsons) + recorded_commands = [] + + def fake_check_call(command): + recorded_commands.append(command) + + monkeypatch.setattr(run_xcode_tests_module.subprocess, "check_call", fake_check_call) + + result_bundle_directory, result_bundle_paths = run_xcode_tests_module.runTests( + scriptArgs=script_args, + destinationIds=destination_ids, + simulators=simulators, + ) + + assert result_bundle_directory == script_args.resultBundleDirectory + assert result_bundle_paths == [ + str(tmp_path / "TestResults" / "libPhoneNumber-iPhone-16-18.0.xcresult"), + str(tmp_path / "TestResults" / "libPhoneNumber-iPhone-16-Pro-Max-18.0.xcresult"), + ] + assert len(recorded_commands) == 2 + assert recorded_commands[0][:4] == ["xcodebuild", "-project", "libPhoneNumber.xcodeproj", "-scheme"] + assert "--test-iterations" in recorded_commands[0] + assert "2" in recorded_commands[0] + assert "CODE_SIGNING_ALLOWED=NO" in recorded_commands[0] + assert recorded_commands[0][-1] == "test" + + +def test_main_runs_end_to_end(run_xcode_tests_module, tmp_path, monkeypatch): + script_args = build_script_args(tmp_path) + output_file = tmp_path / "github-output.txt" + recorded_commands = [] + + class FakeParser: + def parse_args(self): + return script_args + + monkeypatch.setenv("GITHUB_OUTPUT", str(output_file)) + monkeypatch.setattr(run_xcode_tests_module, "setupArgumentParser", lambda: FakeParser()) + monkeypatch.setattr(run_xcode_tests_module.subprocess, "check_call", lambda command: recorded_commands.append(command)) + + run_xcode_tests_module.main() + + contents = output_file.read_text(encoding="utf-8") + assert "result_bundle_directory=" in contents + assert "result_bundle_paths<<__XCODE_TEST_THE_TRICORDERS__" in contents + assert len(recorded_commands) == 2 + + +def test_run_xcode_tests_script_runs_as_black_box(repo_root, python_executable, tmp_path): + script_path = repo_root / ".github/actions/xcode-test-the-tricorders/run_xcode_tests.py" + output_file = tmp_path / "github-output.txt" + fake_bin_dir = tmp_path / "bin" + fake_bin_dir.mkdir() + fake_xcodebuild_path = fake_bin_dir / "xcodebuild" + fake_xcodebuild_path.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + import sys + from pathlib import Path + + args = sys.argv[1:] + with open(os.environ["XCODEBUILD_LOG_FILE"], "a", encoding="utf-8") as handle: + handle.write(json.dumps(args) + "\\n") + + if "-resultBundlePath" in args: + result_bundle_path = args[args.index("-resultBundlePath") + 1] + Path(result_bundle_path).mkdir(parents=True, exist_ok=True) + + raise SystemExit(0) + """ + ), + encoding="utf-8", + ) + fake_xcodebuild_path.chmod(0o755) + + result_bundle_directory = tmp_path / "TestResults" + command = [ + python_executable, + str(script_path), + "--scheme", "libPhoneNumber", + "--xcode-container", "libPhoneNumber.xcodeproj", + "--destination-ids", "SIM-001\nSIM-002", + "--simulator-jsons", json.dumps([ + {"name": "iPhone 16", "os": "18.0", "safe_name": "iPhone-16-18.0"}, + {"name": "iPhone 16 Pro Max", "os": "18.0", "safe_name": "iPhone-16-Pro-Max-18.0"}, + ]), + "--result-bundle-directory", str(result_bundle_directory), + "--destination-arch", "arm64", + "--enable-code-coverage", "YES", + "--code-signing-allowed", "NO", + "--xcodebuild-extra-args", "--test-iterations 2", + ] + environment = os.environ.copy() + environment["GITHUB_OUTPUT"] = str(output_file) + environment["XCODEBUILD_LOG_FILE"] = str(tmp_path / "xcodebuild.log") + environment["PATH"] = str(fake_bin_dir) + os.pathsep + environment["PATH"] + + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env=environment, + ) + + output_contents = output_file.read_text(encoding="utf-8") + assert f"result_bundle_directory={result_bundle_directory}" in output_contents + assert "result_bundle_paths<<__XCODE_TEST_THE_TRICORDERS__" in output_contents + assert "libPhoneNumber-iPhone-16-18.0.xcresult" in output_contents + assert "libPhoneNumber-iPhone-16-Pro-Max-18.0.xcresult" in output_contents + + logged_commands = [ + json.loads(line) + for line in (tmp_path / "xcodebuild.log").read_text(encoding="utf-8").splitlines() + ] + assert len(logged_commands) == 2 + assert logged_commands[0][:4] == ["-project", "libPhoneNumber.xcodeproj", "-scheme", "libPhoneNumber"] + assert "--test-iterations" in logged_commands[0] + assert "2" in logged_commands[0] + assert any( + path.name == "libPhoneNumber-iPhone-16-18.0.xcresult" + for path in result_bundle_directory.iterdir() + ) + assert any( + path.name == "libPhoneNumber-iPhone-16-Pro-Max-18.0.xcresult" + for path in result_bundle_directory.iterdir() + ) + assert "Running libPhoneNumber on iPhone 16 (18.0)" in result.stderr diff --git a/.github/actions/simctl-pick-a-tricorder/tests/sample-devices.json b/.github/actions/simctl-pick-a-tricorder/tests/sample-devices.json new file mode 100644 index 00000000..fa6b5dbe --- /dev/null +++ b/.github/actions/simctl-pick-a-tricorder/tests/sample-devices.json @@ -0,0 +1,74 @@ +{ + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-18-1": [ + { + "name": "iPhone 16", + "udid": "AAA11111-1111-1111-1111-111111111111", + "isAvailable": true + }, + { + "name": "iPhone 16 Pro", + "udid": "AAA22222-2222-2222-2222-222222222222", + "isAvailable": true + }, + { + "name": "iPad Pro 13-inch (M4)", + "udid": "AAA33333-3333-3333-3333-333333333333", + "isAvailable": true + } + ], + "com.apple.CoreSimulator.SimRuntime.iOS-18-2": [ + { + "name": "iPhone 17", + "udid": "BBB11111-1111-1111-1111-111111111111", + "isAvailable": true + }, + { + "name": "iPhone 17 Pro", + "udid": "BBB22222-2222-2222-2222-222222222222", + "isAvailable": true + }, + { + "name": "iPhone 17 Pro Max", + "udid": "BBB33333-3333-3333-3333-333333333333", + "isAvailable": true + }, + { + "name": "iPad Air 13-inch (M3)", + "udid": "BBB44444-4444-4444-4444-444444444444", + "isAvailable": true + } + ], + "com.apple.CoreSimulator.SimRuntime.watchOS-11-2": [ + { + "name": "Apple Watch SE (2nd generation) (44mm)", + "udid": "CCC11111-1111-1111-1111-111111111111", + "isAvailable": true + }, + { + "name": "Apple Watch Ultra 2 (49mm)", + "udid": "CCC22222-2222-2222-2222-222222222222", + "isAvailable": true + } + ], + "com.apple.CoreSimulator.SimRuntime.macOS-15-0": [ + { + "name": "Mac", + "udid": "DDD11111-1111-1111-1111-111111111111", + "isAvailable": true + }, + { + "name": "MacBook Pro (16-inch)", + "udid": "DDD22222-2222-2222-2222-222222222222", + "isAvailable": true + } + ], + "com.apple.CoreSimulator.SimRuntime.xrOS-2-0": [ + { + "name": "Apple Vision Pro", + "udid": "EEE11111-1111-1111-1111-111111111111", + "isAvailable": true + } + ] + } +} diff --git a/.gitignore b/.gitignore index 549e675f..3c35cbed 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,17 @@ ObjectiveC.gcda # SPM .build .swiftpm + +# Python +*.py[cod] +**/__pycache__/ +**/.pytest_cache/ + +# Python action tests +.coverage +.coverage.* +.github/actions/python-action-tests/.coverage +.github/actions/python-action-tests/.coverage.* +.github/actions/python-action-tests/.venv/ +.github/actions/python-action-tests/build/ +.venv-python-action-tests/ From 5f4e06bcd76f93f87ff4e349c941c9f4804d01df Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Fri, 17 Apr 2026 17:09:04 -0500 Subject: [PATCH 52/60] Moving around the python unit testing smarts --- .../README.md | 15 ++-- .../tests => actions-tests}/conftest.py | 0 .../pyproject.toml | 12 ++- .../requirements.txt | 0 .../run_tests.sh | 6 +- .../setup_tests.sh | 0 .../pick_simulator_tests.py} | 0 .../generate_coverage_summary_tests.py} | 0 .../run_xcode_tests_tests.py} | 0 .../tests/sample-devices.json | 74 ------------------- .gitignore | 8 +- 11 files changed, 24 insertions(+), 91 deletions(-) rename .github/{actions/python-action-tests => actions-tests}/README.md (53%) rename .github/{actions/python-action-tests/tests => actions-tests}/conftest.py (100%) rename .github/{actions/python-action-tests => actions-tests}/pyproject.toml (64%) rename .github/{actions/python-action-tests => actions-tests}/requirements.txt (100%) rename .github/{actions/python-action-tests => actions-tests}/run_tests.sh (91%) rename .github/{actions/python-action-tests => actions-tests}/setup_tests.sh (100%) rename .github/{actions/python-action-tests/tests/test_pick_simulator.py => actions-tests/simctl-pick-a-tricorder/pick_simulator_tests.py} (100%) rename .github/{actions/python-action-tests/tests/test_generate_coverage_summary.py => actions-tests/xccov-warp-bubble/generate_coverage_summary_tests.py} (100%) rename .github/{actions/python-action-tests/tests/test_run_xcode_tests.py => actions-tests/xcode-test-the-tricorders/run_xcode_tests_tests.py} (100%) delete mode 100644 .github/actions/simctl-pick-a-tricorder/tests/sample-devices.json diff --git a/.github/actions/python-action-tests/README.md b/.github/actions-tests/README.md similarity index 53% rename from .github/actions/python-action-tests/README.md rename to .github/actions-tests/README.md index 326401fb..f8f4628a 100644 --- a/.github/actions/python-action-tests/README.md +++ b/.github/actions-tests/README.md @@ -1,32 +1,33 @@ -# Python Action Tests +# Actions Tests This folder contains the Python unit-test harness for the reusable GitHub Actions under `.github/actions`. +The subfolder layout mirrors the action names so each action's tests live beside the corresponding action name under `.github/actions-tests`. ## Local setup 1. Run the setup script: ```bash - ./.github/actions/python-action-tests/setup_tests.sh + ./.github/actions-tests/setup_tests.sh ``` 2. Run the tests and generate coverage artifacts: ```bash - ./.github/actions/python-action-tests/run_tests.sh + ./.github/actions-tests/run_tests.sh ``` -If `.github/actions/python-action-tests/.venv` exists, the test runner will automatically use it. +If `.github/actions-tests/.venv` exists, the test runner will automatically use it. To use a specific Python interpreter during setup, set `PYTHON_ACTION_TEST_SETUP_PYTHON_BIN`: ```bash -PYTHON_ACTION_TEST_SETUP_PYTHON_BIN=python3.13 ./.github/actions/python-action-tests/setup_tests.sh +PYTHON_ACTION_TEST_SETUP_PYTHON_BIN=python3.13 ./.github/actions-tests/setup_tests.sh ``` ## Generated artifacts -By default, test artifacts are written under `.github/actions/python-action-tests/build/python-action-test-results/`: +By default, test artifacts are written under `.github/actions-tests/build/python-action-test-results/`: - `junit.xml` - `coverage.xml` @@ -37,5 +38,5 @@ By default, test artifacts are written under `.github/actions/python-action-test To fail the test run if total coverage drops below a minimum percentage: ```bash -PYTHON_ACTION_TEST_COVERAGE_FAIL_UNDER=90 ./.github/actions/python-action-tests/run_tests.sh +PYTHON_ACTION_TEST_COVERAGE_FAIL_UNDER=90 ./.github/actions-tests/run_tests.sh ``` diff --git a/.github/actions/python-action-tests/tests/conftest.py b/.github/actions-tests/conftest.py similarity index 100% rename from .github/actions/python-action-tests/tests/conftest.py rename to .github/actions-tests/conftest.py diff --git a/.github/actions/python-action-tests/pyproject.toml b/.github/actions-tests/pyproject.toml similarity index 64% rename from .github/actions/python-action-tests/pyproject.toml rename to .github/actions-tests/pyproject.toml index ba239988..50e2c6d7 100644 --- a/.github/actions/python-action-tests/pyproject.toml +++ b/.github/actions-tests/pyproject.toml @@ -1,6 +1,10 @@ [tool.pytest.ini_options] -testpaths = ["tests"] -python_files = ["test_*.py"] +testpaths = [ + "simctl-pick-a-tricorder", + "xcode-test-the-tricorders", + "xccov-warp-bubble", +] +python_files = ["*_tests.py"] junit_family = "xunit2" addopts = ["-ra"] @@ -13,10 +17,10 @@ source = [ ".github/actions/xccov-warp-bubble", ".github/actions/xcode-test-the-tricorders", ] -omit = [".github/actions/python-action-tests/*"] +omit = [".github/actions-tests/*"] [tool.coverage.report] show_missing = true skip_empty = true precision = 2 -omit = [".github/actions/python-action-tests/*"] +omit = [".github/actions-tests/*"] diff --git a/.github/actions/python-action-tests/requirements.txt b/.github/actions-tests/requirements.txt similarity index 100% rename from .github/actions/python-action-tests/requirements.txt rename to .github/actions-tests/requirements.txt diff --git a/.github/actions/python-action-tests/run_tests.sh b/.github/actions-tests/run_tests.sh similarity index 91% rename from .github/actions/python-action-tests/run_tests.sh rename to .github/actions-tests/run_tests.sh index 4de66fbe..be0a3d3a 100755 --- a/.github/actions/python-action-tests/run_tests.sh +++ b/.github/actions-tests/run_tests.sh @@ -3,7 +3,7 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/../../.." && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" results_dir="${PYTHON_ACTION_TEST_RESULTS_DIR:-$script_dir/build/python-action-test-results}" coverage_fail_under="${PYTHON_ACTION_TEST_COVERAGE_FAIL_UNDER:-}" @@ -30,7 +30,9 @@ trap cleanup_python_caches EXIT pytest_args=( "-c" "$script_dir/pyproject.toml" - "$script_dir/tests" + "$script_dir/simctl-pick-a-tricorder" + "$script_dir/xcode-test-the-tricorders" + "$script_dir/xccov-warp-bubble" "--junitxml=$results_dir/junit.xml" "--cov=.github/actions/simctl-pick-a-tricorder" "--cov=.github/actions/xcode-test-the-tricorders" diff --git a/.github/actions/python-action-tests/setup_tests.sh b/.github/actions-tests/setup_tests.sh similarity index 100% rename from .github/actions/python-action-tests/setup_tests.sh rename to .github/actions-tests/setup_tests.sh diff --git a/.github/actions/python-action-tests/tests/test_pick_simulator.py b/.github/actions-tests/simctl-pick-a-tricorder/pick_simulator_tests.py similarity index 100% rename from .github/actions/python-action-tests/tests/test_pick_simulator.py rename to .github/actions-tests/simctl-pick-a-tricorder/pick_simulator_tests.py diff --git a/.github/actions/python-action-tests/tests/test_generate_coverage_summary.py b/.github/actions-tests/xccov-warp-bubble/generate_coverage_summary_tests.py similarity index 100% rename from .github/actions/python-action-tests/tests/test_generate_coverage_summary.py rename to .github/actions-tests/xccov-warp-bubble/generate_coverage_summary_tests.py diff --git a/.github/actions/python-action-tests/tests/test_run_xcode_tests.py b/.github/actions-tests/xcode-test-the-tricorders/run_xcode_tests_tests.py similarity index 100% rename from .github/actions/python-action-tests/tests/test_run_xcode_tests.py rename to .github/actions-tests/xcode-test-the-tricorders/run_xcode_tests_tests.py diff --git a/.github/actions/simctl-pick-a-tricorder/tests/sample-devices.json b/.github/actions/simctl-pick-a-tricorder/tests/sample-devices.json deleted file mode 100644 index fa6b5dbe..00000000 --- a/.github/actions/simctl-pick-a-tricorder/tests/sample-devices.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "devices": { - "com.apple.CoreSimulator.SimRuntime.iOS-18-1": [ - { - "name": "iPhone 16", - "udid": "AAA11111-1111-1111-1111-111111111111", - "isAvailable": true - }, - { - "name": "iPhone 16 Pro", - "udid": "AAA22222-2222-2222-2222-222222222222", - "isAvailable": true - }, - { - "name": "iPad Pro 13-inch (M4)", - "udid": "AAA33333-3333-3333-3333-333333333333", - "isAvailable": true - } - ], - "com.apple.CoreSimulator.SimRuntime.iOS-18-2": [ - { - "name": "iPhone 17", - "udid": "BBB11111-1111-1111-1111-111111111111", - "isAvailable": true - }, - { - "name": "iPhone 17 Pro", - "udid": "BBB22222-2222-2222-2222-222222222222", - "isAvailable": true - }, - { - "name": "iPhone 17 Pro Max", - "udid": "BBB33333-3333-3333-3333-333333333333", - "isAvailable": true - }, - { - "name": "iPad Air 13-inch (M3)", - "udid": "BBB44444-4444-4444-4444-444444444444", - "isAvailable": true - } - ], - "com.apple.CoreSimulator.SimRuntime.watchOS-11-2": [ - { - "name": "Apple Watch SE (2nd generation) (44mm)", - "udid": "CCC11111-1111-1111-1111-111111111111", - "isAvailable": true - }, - { - "name": "Apple Watch Ultra 2 (49mm)", - "udid": "CCC22222-2222-2222-2222-222222222222", - "isAvailable": true - } - ], - "com.apple.CoreSimulator.SimRuntime.macOS-15-0": [ - { - "name": "Mac", - "udid": "DDD11111-1111-1111-1111-111111111111", - "isAvailable": true - }, - { - "name": "MacBook Pro (16-inch)", - "udid": "DDD22222-2222-2222-2222-222222222222", - "isAvailable": true - } - ], - "com.apple.CoreSimulator.SimRuntime.xrOS-2-0": [ - { - "name": "Apple Vision Pro", - "udid": "EEE11111-1111-1111-1111-111111111111", - "isAvailable": true - } - ] - } -} diff --git a/.gitignore b/.gitignore index 3c35cbed..2e40e972 100644 --- a/.gitignore +++ b/.gitignore @@ -38,8 +38,8 @@ ObjectiveC.gcda # Python action tests .coverage .coverage.* -.github/actions/python-action-tests/.coverage -.github/actions/python-action-tests/.coverage.* -.github/actions/python-action-tests/.venv/ -.github/actions/python-action-tests/build/ +.github/actions-tests/.coverage +.github/actions-tests/.coverage.* +.github/actions-tests/.venv/ +.github/actions-tests/build/ .venv-python-action-tests/ From 3c78794da393e3e5f37731be6855277de4b1cc0a Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Tue, 21 Apr 2026 11:47:25 -0500 Subject: [PATCH 53/60] Renaming actions...yes again --- .github/actions-tests/conftest.py | 4 ++-- .github/actions-tests/pyproject.toml | 8 ++++---- .github/actions-tests/run_tests.sh | 8 ++++---- .../pick_simulator_tests.py | 2 +- .../run_xcode_tests_tests.py | 2 +- .../README.md | 4 ++-- .../action.yml | 2 +- .../pick_simulator.py | 2 +- .../README.md | 10 +++++----- .../action.yml | 6 +++--- .../run_xcode_tests.py | 2 +- .github/workflows/ci-core.yml | 4 ++-- 12 files changed, 27 insertions(+), 27 deletions(-) rename .github/actions-tests/{simctl-pick-a-tricorder => simctl-tricorder-selector}/pick_simulator_tests.py (99%) rename .github/actions-tests/{xcode-test-the-tricorders => xcode-tricorder-tester}/run_xcode_tests_tests.py (99%) rename .github/actions/{simctl-pick-a-tricorder => simctl-tricorder-selector}/README.md (94%) rename .github/actions/{simctl-pick-a-tricorder => simctl-tricorder-selector}/action.yml (98%) rename .github/actions/{simctl-pick-a-tricorder => simctl-tricorder-selector}/pick_simulator.py (99%) rename .github/actions/{xcode-test-the-tricorders => xcode-tricorder-tester}/README.md (84%) rename .github/actions/{xcode-test-the-tricorders => xcode-tricorder-tester}/action.yml (96%) rename .github/actions/{xcode-test-the-tricorders => xcode-tricorder-tester}/run_xcode_tests.py (99%) diff --git a/.github/actions-tests/conftest.py b/.github/actions-tests/conftest.py index 6a0e4546..b0852c6c 100644 --- a/.github/actions-tests/conftest.py +++ b/.github/actions-tests/conftest.py @@ -43,7 +43,7 @@ def load_module(module_name: str, relative_path: str): def pick_simulator_module(): return load_module( "test_pick_simulator_module", - ".github/actions/simctl-pick-a-tricorder/pick_simulator.py", + ".github/actions/simctl-tricorder-selector/pick_simulator.py", ) @@ -51,7 +51,7 @@ def pick_simulator_module(): def run_xcode_tests_module(): return load_module( "test_run_xcode_tests_module", - ".github/actions/xcode-test-the-tricorders/run_xcode_tests.py", + ".github/actions/xcode-tricorder-tester/run_xcode_tests.py", ) diff --git a/.github/actions-tests/pyproject.toml b/.github/actions-tests/pyproject.toml index 50e2c6d7..02edb3ef 100644 --- a/.github/actions-tests/pyproject.toml +++ b/.github/actions-tests/pyproject.toml @@ -1,7 +1,7 @@ [tool.pytest.ini_options] testpaths = [ - "simctl-pick-a-tricorder", - "xcode-test-the-tricorders", + "simctl-tricorder-selector", + "xcode-tricorder-tester", "xccov-warp-bubble", ] python_files = ["*_tests.py"] @@ -13,9 +13,9 @@ branch = true relative_files = true patch = ["subprocess"] source = [ - ".github/actions/simctl-pick-a-tricorder", + ".github/actions/simctl-tricorder-selector", ".github/actions/xccov-warp-bubble", - ".github/actions/xcode-test-the-tricorders", + ".github/actions/xcode-tricorder-tester", ] omit = [".github/actions-tests/*"] diff --git a/.github/actions-tests/run_tests.sh b/.github/actions-tests/run_tests.sh index be0a3d3a..1d6f5d15 100755 --- a/.github/actions-tests/run_tests.sh +++ b/.github/actions-tests/run_tests.sh @@ -30,12 +30,12 @@ trap cleanup_python_caches EXIT pytest_args=( "-c" "$script_dir/pyproject.toml" - "$script_dir/simctl-pick-a-tricorder" - "$script_dir/xcode-test-the-tricorders" + "$script_dir/simctl-tricorder-selector" + "$script_dir/xcode-tricorder-tester" "$script_dir/xccov-warp-bubble" "--junitxml=$results_dir/junit.xml" - "--cov=.github/actions/simctl-pick-a-tricorder" - "--cov=.github/actions/xcode-test-the-tricorders" + "--cov=.github/actions/simctl-tricorder-selector" + "--cov=.github/actions/xcode-tricorder-tester" "--cov=.github/actions/xccov-warp-bubble" "--cov-branch" "--cov-report=" diff --git a/.github/actions-tests/simctl-pick-a-tricorder/pick_simulator_tests.py b/.github/actions-tests/simctl-tricorder-selector/pick_simulator_tests.py similarity index 99% rename from .github/actions-tests/simctl-pick-a-tricorder/pick_simulator_tests.py rename to .github/actions-tests/simctl-tricorder-selector/pick_simulator_tests.py index 3baf89e2..dc6ce782 100644 --- a/.github/actions-tests/simctl-pick-a-tricorder/pick_simulator_tests.py +++ b/.github/actions-tests/simctl-tricorder-selector/pick_simulator_tests.py @@ -325,7 +325,7 @@ def parse_args(self): def test_pick_simulator_script_runs_as_black_box(repo_root, python_executable, tmp_path, sample_devices_payload): - script_path = repo_root / ".github/actions/simctl-pick-a-tricorder/pick_simulator.py" + script_path = repo_root / ".github/actions/simctl-tricorder-selector/pick_simulator.py" output_file = tmp_path / "github-output.txt" fake_bin_dir = tmp_path / "bin" fake_bin_dir.mkdir() diff --git a/.github/actions-tests/xcode-test-the-tricorders/run_xcode_tests_tests.py b/.github/actions-tests/xcode-tricorder-tester/run_xcode_tests_tests.py similarity index 99% rename from .github/actions-tests/xcode-test-the-tricorders/run_xcode_tests_tests.py rename to .github/actions-tests/xcode-tricorder-tester/run_xcode_tests_tests.py index d5d8a99a..935876f7 100644 --- a/.github/actions-tests/xcode-test-the-tricorders/run_xcode_tests_tests.py +++ b/.github/actions-tests/xcode-tricorder-tester/run_xcode_tests_tests.py @@ -155,7 +155,7 @@ def parse_args(self): def test_run_xcode_tests_script_runs_as_black_box(repo_root, python_executable, tmp_path): - script_path = repo_root / ".github/actions/xcode-test-the-tricorders/run_xcode_tests.py" + script_path = repo_root / ".github/actions/xcode-tricorder-tester/run_xcode_tests.py" output_file = tmp_path / "github-output.txt" fake_bin_dir = tmp_path / "bin" fake_bin_dir.mkdir() diff --git a/.github/actions/simctl-pick-a-tricorder/README.md b/.github/actions/simctl-tricorder-selector/README.md similarity index 94% rename from .github/actions/simctl-pick-a-tricorder/README.md rename to .github/actions/simctl-tricorder-selector/README.md index df490443..175136e9 100644 --- a/.github/actions/simctl-pick-a-tricorder/README.md +++ b/.github/actions/simctl-tricorder-selector/README.md @@ -37,7 +37,7 @@ Local composite action for choosing an installed simulator device from `xcrun si ```yaml - name: Pick simulator id: simulator - uses: ./.github/actions/simctl-pick-a-tricorder + uses: ./.github/actions/simctl-tricorder-selector with: device_types: iphone,ipad iphoneos_version: latest @@ -46,7 +46,7 @@ Local composite action for choosing an installed simulator device from `xcrun si - name: Run tests id: tests - uses: ./.github/actions/xcode-test-the-tricorders + uses: ./.github/actions/xcode-tricorder-tester with: scheme: libPhoneNumber xcode_container: libPhoneNumber.xcodeproj diff --git a/.github/actions/simctl-pick-a-tricorder/action.yml b/.github/actions/simctl-tricorder-selector/action.yml similarity index 98% rename from .github/actions/simctl-pick-a-tricorder/action.yml rename to .github/actions/simctl-tricorder-selector/action.yml index ef10678a..c8f8e559 100644 --- a/.github/actions/simctl-pick-a-tricorder/action.yml +++ b/.github/actions/simctl-tricorder-selector/action.yml @@ -1,4 +1,4 @@ -name: Simctl Pick A Tricorder +name: Simctl Tricorder Selector description: Select a compatible Apple simulator device from the installed simctl inventory. inputs: diff --git a/.github/actions/simctl-pick-a-tricorder/pick_simulator.py b/.github/actions/simctl-tricorder-selector/pick_simulator.py similarity index 99% rename from .github/actions/simctl-pick-a-tricorder/pick_simulator.py rename to .github/actions/simctl-tricorder-selector/pick_simulator.py index d3e3a58d..11fc128e 100644 --- a/.github/actions/simctl-pick-a-tricorder/pick_simulator.py +++ b/.github/actions/simctl-tricorder-selector/pick_simulator.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # pick_simulator.py -# simctl-pick-a-tricorder +# simctl-tricorder-selector # # Created by Kodex on 4/17/26. # diff --git a/.github/actions/xcode-test-the-tricorders/README.md b/.github/actions/xcode-tricorder-tester/README.md similarity index 84% rename from .github/actions/xcode-test-the-tricorders/README.md rename to .github/actions/xcode-tricorder-tester/README.md index fe2877b0..b7783909 100644 --- a/.github/actions/xcode-test-the-tricorders/README.md +++ b/.github/actions/xcode-tricorder-tester/README.md @@ -1,6 +1,6 @@ # Xcode Test The Tricorders -Local composite action for running `xcodebuild test` across simulator destinations selected by `simctl-pick-a-tricorder`. +Local composite action for running `xcodebuild test` across simulator destinations selected by `simctl-tricorder-selector`. ## Inputs @@ -11,9 +11,9 @@ Local composite action for running `xcodebuild test` across simulator destinatio - The action infers the type from the file extension - Supported values end in `.xcodeproj` or `.xcworkspace` - `destination_ids` - - Newline-separated simulator UDIDs from `simctl-pick-a-tricorder` + - Newline-separated simulator UDIDs from `simctl-tricorder-selector` - `simulator_jsons` - - JSON array from `simctl-pick-a-tricorder` + - JSON array from `simctl-tricorder-selector` - `result_bundle_directory` - Directory where `.xcresult` bundles should be created - Default: `TestResults` @@ -39,7 +39,7 @@ Local composite action for running `xcodebuild test` across simulator destinatio ```yaml - name: Pick simulator id: simulator - uses: ./.github/actions/simctl-pick-a-tricorder + uses: ./.github/actions/simctl-tricorder-selector with: device_types: iphone iphoneos_version: latest @@ -47,7 +47,7 @@ Local composite action for running `xcodebuild test` across simulator destinatio - name: Run unit tests id: tests - uses: ./.github/actions/xcode-test-the-tricorders + uses: ./.github/actions/xcode-tricorder-tester with: scheme: libPhoneNumber xcode_container: libPhoneNumber.xcodeproj diff --git a/.github/actions/xcode-test-the-tricorders/action.yml b/.github/actions/xcode-tricorder-tester/action.yml similarity index 96% rename from .github/actions/xcode-test-the-tricorders/action.yml rename to .github/actions/xcode-tricorder-tester/action.yml index 1b3571c0..4059a21e 100644 --- a/.github/actions/xcode-test-the-tricorders/action.yml +++ b/.github/actions/xcode-tricorder-tester/action.yml @@ -1,5 +1,5 @@ -name: Xcode Test The Tricorders -description: Run xcodebuild tests against simulator destinations selected by simctl-pick-a-tricorder. +name: Xcode Tricorder Tester +description: Run xcodebuild tests against simulator destinations selected by simctl-tricorder-selector. inputs: scheme: @@ -12,7 +12,7 @@ inputs: description: Newline-separated list of simulator destination UDIDs. required: true simulator_jsons: - description: JSON array of simulator objects returned by simctl-pick-a-tricorder. + description: JSON array of simulator objects returned by simctl-tricorder-selector. required: true result_bundle_directory: description: Directory where generated xcresult bundles should be written. diff --git a/.github/actions/xcode-test-the-tricorders/run_xcode_tests.py b/.github/actions/xcode-tricorder-tester/run_xcode_tests.py similarity index 99% rename from .github/actions/xcode-test-the-tricorders/run_xcode_tests.py rename to .github/actions/xcode-tricorder-tester/run_xcode_tests.py index 4d303b3a..c05d21c8 100644 --- a/.github/actions/xcode-test-the-tricorders/run_xcode_tests.py +++ b/.github/actions/xcode-tricorder-tester/run_xcode_tests.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # run_xcode_tests.py -# xcode-test-the-tricorders +# xcode-tricorder-tester # # Created by Kodex on 4/17/26. # diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 3ef23955..2df9ce04 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -31,7 +31,7 @@ jobs: - name: Resolve iPhone simulator destination id: destination - uses: ./.github/actions/simctl-pick-a-tricorder + uses: ./.github/actions/simctl-tricorder-selector with: device_types: iphone iphoneos_version: latest @@ -39,7 +39,7 @@ jobs: - name: Run unit tests id: run-tests - uses: ./.github/actions/xcode-test-the-tricorders + uses: ./.github/actions/xcode-tricorder-tester with: scheme: ${{ matrix.scheme }} xcode_container: libPhoneNumber.xcodeproj From fbd1d45f24cf8724d3ca4add21dad2116dd16f26 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Tue, 21 Apr 2026 12:13:09 -0500 Subject: [PATCH 54/60] Update published github action versions --- .github/actions/simctl-tricorder-selector/README.md | 2 +- .github/actions/xccov-warp-bubble/README.md | 4 ++-- .github/actions/xcode-tricorder-tester/README.md | 2 +- .github/workflows/ci-core.yml | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/actions/simctl-tricorder-selector/README.md b/.github/actions/simctl-tricorder-selector/README.md index 175136e9..c51200b9 100644 --- a/.github/actions/simctl-tricorder-selector/README.md +++ b/.github/actions/simctl-tricorder-selector/README.md @@ -54,7 +54,7 @@ Local composite action for choosing an installed simulator device from `xcrun si simulator_jsons: ${{ steps.simulator.outputs.simulator_jsons }} - name: Upload unit test results - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: project-unit-tests-libPhoneNumber path: ${{ steps.tests.outputs.result_bundle_directory }} diff --git a/.github/actions/xccov-warp-bubble/README.md b/.github/actions/xccov-warp-bubble/README.md index 168d0204..03d48bd3 100644 --- a/.github/actions/xccov-warp-bubble/README.md +++ b/.github/actions/xccov-warp-bubble/README.md @@ -33,7 +33,7 @@ If only one coverage scope is found, the action reports coverage for that scope ```yaml - name: Download unit test results - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: project-unit-tests-* path: CoverageResults/xcresults @@ -47,7 +47,7 @@ If only one coverage scope is found, the action reports coverage for that scope summary_json_file: CoverageResults/code-coverage-summary.json - name: Publish coverage comment to pull request - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@v3 with: header: combined-code-coverage path: ${{ steps.coverage.outputs.summary_file }} diff --git a/.github/actions/xcode-tricorder-tester/README.md b/.github/actions/xcode-tricorder-tester/README.md index b7783909..24c28932 100644 --- a/.github/actions/xcode-tricorder-tester/README.md +++ b/.github/actions/xcode-tricorder-tester/README.md @@ -55,7 +55,7 @@ Local composite action for running `xcodebuild test` across simulator destinatio simulator_jsons: ${{ steps.simulator.outputs.simulator_jsons }} - name: Upload unit test results - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: project-unit-tests-libPhoneNumber path: ${{ steps.tests.outputs.result_bundle_directory }} diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 2df9ce04..f6ef0bfb 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -48,7 +48,7 @@ jobs: - name: Upload unit test results if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: project-unit-tests-${{ matrix.scheme }} path: ${{ steps.run-tests.outputs.result_bundle_directory }} @@ -67,7 +67,7 @@ jobs: - name: Download unit test results continue-on-error: true - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: pattern: project-unit-tests-* path: CoverageResults/xcresults @@ -81,7 +81,7 @@ jobs: - name: Publish combined coverage comment to pull request if: inputs.publish_pr_comment - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@v3 with: header: combined-code-coverage path: ${{ steps.coverage.outputs.summary_file }} From a3713c8c6efcc714a314eebf661d88cea41fdab6 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Tue, 21 Apr 2026 14:02:41 -0500 Subject: [PATCH 55/60] Making more reusable workflows. --- .../run_xcode_tests_tests.py | 13 +- .../actions/xcode-tricorder-tester/README.md | 8 +- .../actions/xcode-tricorder-tester/action.yml | 12 - .../xcode-tricorder-tester/run_xcode_tests.py | 34 +-- .github/workflows/ci-core.yml | 23 -- .github/workflows/github-holodeck-trials.yml | 243 ++++++++++++++++++ .../workflows/github-podspec-sensor-sweep.yml | 94 +++++++ .github/workflows/main-ci.yml | 10 + .github/workflows/pull-request-ci.yml | 21 +- 9 files changed, 371 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/github-holodeck-trials.yml create mode 100644 .github/workflows/github-podspec-sensor-sweep.yml diff --git a/.github/actions-tests/xcode-tricorder-tester/run_xcode_tests_tests.py b/.github/actions-tests/xcode-tricorder-tester/run_xcode_tests_tests.py index 935876f7..c007276c 100644 --- a/.github/actions-tests/xcode-tricorder-tester/run_xcode_tests_tests.py +++ b/.github/actions-tests/xcode-tricorder-tester/run_xcode_tests_tests.py @@ -18,8 +18,6 @@ def build_script_args(tmp_path, **overrides): ]), "resultBundleDirectory": str(tmp_path / "TestResults"), "destinationArch": "arm64", - "enableCodeCoverage": "YES", - "codeSigningAllowed": "NO", "xcodebuildExtraArgs": "--test-iterations 2", } values.update(overrides) @@ -34,26 +32,19 @@ def test_setup_argument_parser_parses_valid_values(run_xcode_tests_module): "--destination-ids", "SIM-001", "--simulator-jsons", '[{"name":"iPhone 16","os":"18.0","safe_name":"iphone-16"}]', "--destination-arch", "arm64", - "--enable-code-coverage", "YES", - "--code-signing-allowed", "NO", ]) assert script_args.scheme == "libPhoneNumber" - assert script_args.enableCodeCoverage == "YES" - assert script_args.codeSigningAllowed == "NO" + assert script_args.destinationArch == "arm64" def test_argument_parsing_helpers(run_xcode_tests_module): assert run_xcode_tests_module.parseNonEmptyArgument(" libPhoneNumber ") == "libPhoneNumber" - assert run_xcode_tests_module.parseYesNoArgument("yes") == "YES" assert run_xcode_tests_module.parseDestinationIds("A\nB\n\n") == ["A", "B"] with pytest.raises(ValueError): run_xcode_tests_module.parseNonEmptyArgument(" ") - with pytest.raises(ValueError): - run_xcode_tests_module.parseYesNoArgument("maybe") - def test_determine_xcode_container_type(run_xcode_tests_module): assert run_xcode_tests_module.determineXcodeContainerType("App.xcodeproj") == "project" @@ -197,8 +188,6 @@ def test_run_xcode_tests_script_runs_as_black_box(repo_root, python_executable, ]), "--result-bundle-directory", str(result_bundle_directory), "--destination-arch", "arm64", - "--enable-code-coverage", "YES", - "--code-signing-allowed", "NO", "--xcodebuild-extra-args", "--test-iterations 2", ] environment = os.environ.copy() diff --git a/.github/actions/xcode-tricorder-tester/README.md b/.github/actions/xcode-tricorder-tester/README.md index 24c28932..6734ab35 100644 --- a/.github/actions/xcode-tricorder-tester/README.md +++ b/.github/actions/xcode-tricorder-tester/README.md @@ -20,15 +20,11 @@ Local composite action for running `xcodebuild test` across simulator destinatio - `destination_arch` - Architecture used in each `xcodebuild -destination` - Default: `arm64` -- `enable_code_coverage` - - Value passed to `-enableCodeCoverage` - - Default: `YES` -- `code_signing_allowed` - - Value passed through `CODE_SIGNING_ALLOWED` - - Default: `NO` - `xcodebuild_extra_args` - Optional extra `xcodebuild` arguments +This action always runs with `-enableCodeCoverage YES` and `CODE_SIGNING_ALLOWED=NO`. + ## Outputs - `result_bundle_directory` diff --git a/.github/actions/xcode-tricorder-tester/action.yml b/.github/actions/xcode-tricorder-tester/action.yml index 4059a21e..0e30e08c 100644 --- a/.github/actions/xcode-tricorder-tester/action.yml +++ b/.github/actions/xcode-tricorder-tester/action.yml @@ -22,14 +22,6 @@ inputs: description: Destination architecture to use with xcodebuild. required: false default: arm64 - enable_code_coverage: - description: Value to pass to -enableCodeCoverage. - required: false - default: "YES" - code_signing_allowed: - description: Value to pass through CODE_SIGNING_ALLOWED. - required: false - default: "NO" xcodebuild_extra_args: description: Optional extra xcodebuild arguments. required: false @@ -56,8 +48,6 @@ runs: INPUT_SIMULATOR_JSONS: ${{ inputs.simulator_jsons }} INPUT_RESULT_BUNDLE_DIRECTORY: ${{ inputs.result_bundle_directory }} INPUT_DESTINATION_ARCH: ${{ inputs.destination_arch }} - INPUT_ENABLE_CODE_COVERAGE: ${{ inputs.enable_code_coverage }} - INPUT_CODE_SIGNING_ALLOWED: ${{ inputs.code_signing_allowed }} INPUT_XCODEBUILD_EXTRA_ARGS: ${{ inputs.xcodebuild_extra_args }} run: | set -eo pipefail @@ -68,6 +58,4 @@ runs: --simulator-jsons "$INPUT_SIMULATOR_JSONS" \ --result-bundle-directory "$INPUT_RESULT_BUNDLE_DIRECTORY" \ --destination-arch "$INPUT_DESTINATION_ARCH" \ - --enable-code-coverage "$INPUT_ENABLE_CODE_COVERAGE" \ - --code-signing-allowed "$INPUT_CODE_SIGNING_ALLOWED" \ --xcodebuild-extra-args "$INPUT_XCODEBUILD_EXTRA_ARGS" diff --git a/.github/actions/xcode-tricorder-tester/run_xcode_tests.py b/.github/actions/xcode-tricorder-tester/run_xcode_tests.py index c05d21c8..4895bc8f 100644 --- a/.github/actions/xcode-tricorder-tester/run_xcode_tests.py +++ b/.github/actions/xcode-tricorder-tester/run_xcode_tests.py @@ -18,7 +18,7 @@ import sys -SCRIPT_VERSION: str = "0.2.1" +SCRIPT_VERSION: str = "0.2.2" """The current version of the script""" @@ -70,12 +70,6 @@ def setupArgumentParser() -> argparse.ArgumentParser: parser.add_argument("--destination-arch", metavar="arm64", required=True, help="The destination architecture to use with xcodebuild", dest='destinationArch', type=parseNonEmptyArgument) - parser.add_argument("--enable-code-coverage", metavar="YES", required=True, - help="The value to pass to -enableCodeCoverage", - dest='enableCodeCoverage', type=parseYesNoArgument) - parser.add_argument("--code-signing-allowed", metavar="NO", required=True, - help="The value to pass through CODE_SIGNING_ALLOWED", - dest='codeSigningAllowed', type=parseYesNoArgument) parser.add_argument("--xcodebuild-extra-args", metavar="--test-iterations 2", help="Optional extra xcodebuild arguments", dest='xcodebuildExtraArgs', default="") @@ -111,28 +105,6 @@ def parseNonEmptyArgument(value: str) -> str: return normalizedValue -def parseYesNoArgument(value: str) -> str: - """ - Parses and validates a YES or NO argument value - - Parameters - ---------- - value - The raw argument value - - Returns - ------- - str - The normalized YES or NO value - """ - - normalizedValue = parseNonEmptyArgument(value).upper() - if normalizedValue not in {"YES", "NO"}: - raise ValueError(f"Unsupported YES/NO value specified: {value}") - - return normalizedValue - - def determineXcodeContainerType(xcodeContainer: str) -> str: """ Determines the Xcode container type from the specified path @@ -329,8 +301,8 @@ def runTests(scriptArgs: argparse.Namespace, "-resultBundlePath", resultBundlePath, "-enableCodeCoverage", - scriptArgs.enableCodeCoverage, - "CODE_SIGNING_ALLOWED=" + scriptArgs.codeSigningAllowed, + "YES", + "CODE_SIGNING_ALLOWED=NO", *extraArgs, "test", ] diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index f6ef0bfb..aadd6912 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -86,26 +86,3 @@ jobs: header: combined-code-coverage path: ${{ steps.coverage.outputs.summary_file }} skip_unchanged: true - - podspec-lint: - name: Podspec Lint (${{ matrix.podspec }}) - runs-on: macos-latest - strategy: - fail-fast: false - matrix: - podspec: - - libPhoneNumber-iOS.podspec - - libPhoneNumberGeocoding.podspec - - libPhoneNumberShortNumber.podspec - - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Set up CocoaPods - uses: maxim-lobanov/setup-cocoapods@v1 - with: - version: latest - - - name: Lint podspec - run: pod lib lint "${{ matrix.podspec }}" --verbose diff --git a/.github/workflows/github-holodeck-trials.yml b/.github/workflows/github-holodeck-trials.yml new file mode 100644 index 00000000..0967b263 --- /dev/null +++ b/.github/workflows/github-holodeck-trials.yml @@ -0,0 +1,243 @@ +name: GitHub Holodeck Trials + +on: + workflow_call: + inputs: + schemes: + description: | + YAML list of Xcode schemes to test. + Example: + - libPhoneNumber + - libPhoneNumberGeocoding + required: true + type: string + xcode_container: + description: Path to the Xcode project or workspace to test. + required: true + type: string + device_types: + description: | + YAML list of simulator device types to resolve. + Supported values include iphone, ipad, macos, watch, and vision. + Example: + - iphone + required: true + type: string + selection_mode: + description: | + Simulator selection mode to pass to simctl-tricorder-selector. + Supported values: + - random-compatible + - random-latest-compatible + - model-type + - latest-model + required: false + default: random-compatible + type: string + model_preferences: + description: Optional model preferences such as iphone=Pro Max;ipad=Pro. + required: false + default: "" + type: string + iphoneos_version: + description: Specific iOS version for iPhone devices, or latest. + required: false + default: latest + type: string + ipados_version: + description: Specific iPadOS version for iPad devices, or latest. + required: false + default: latest + type: string + macos_version: + description: Specific macOS version for macOS simulator devices, or latest. + required: false + default: latest + type: string + watchos_version: + description: Specific watchOS version for watch devices, or latest. + required: false + default: latest + type: string + visionos_version: + description: Specific visionOS version for Vision devices, or latest. + required: false + default: latest + type: string + destination_arch: + description: Destination architecture to use with xcodebuild. + required: false + default: arm64 + type: string + xcodebuild_extra_args: + description: Optional extra xcodebuild arguments. + required: false + default: "" + type: string + failing_coverage_threshold: + description: Coverage percent below which the status is marked as failing. + required: false + default: "60" + type: string + passing_coverage_threshold: + description: Coverage percent at or above which the status is marked as passing. + required: false + default: "75" + type: string + outputs: + combined_coverage_percent: + description: Combined unit test coverage percent across all schemes. + value: ${{ jobs.coverage-summary.outputs.combined_coverage_percent }} + +jobs: + prepare-inputs: + name: Prepare Workflow Inputs + runs-on: ubuntu-latest + outputs: + schemes_json: ${{ steps.parse.outputs.schemes_json }} + device_types_csv: ${{ steps.parse.outputs.device_types_csv }} + + steps: + - name: Parse workflow inputs + id: parse + shell: bash + env: + INPUT_SCHEMES: ${{ inputs.schemes }} + INPUT_DEVICE_TYPES: ${{ inputs.device_types }} + run: | + set -eo pipefail + python3 -c "$(cat <<'PYTHON' + import json + import os + + + def parse_yaml_list(value, input_name): + \"\"\"Parse a simple YAML list made up of \"- value\" lines.\"\"\" + + items = [] + for raw_line in value.splitlines(): + line = raw_line.strip() + if len(line) <= 0 or line.startswith(\"#\"): + continue + if not line.startswith(\"-\"): + raise SystemExit( + f\"{input_name} must be provided as a YAML list using lines that start with \\\"- \\\": {raw_line}\" + ) + + item = line[1:].strip() + if len(item) <= 0: + raise SystemExit(f\"{input_name} contains an empty item: {raw_line}\") + + items.append(item) + + if len(items) <= 0: + raise SystemExit(f\"{input_name} must contain at least one item\") + + return items + + + schemes = parse_yaml_list(os.environ[\"INPUT_SCHEMES\"], \"schemes\") + device_types = parse_yaml_list(os.environ[\"INPUT_DEVICE_TYPES\"], \"device_types\") + + with open(os.environ[\"GITHUB_OUTPUT\"], \"a\", encoding=\"utf-8\") as handle: + print(\"schemes_json< Date: Tue, 21 Apr 2026 14:11:24 -0500 Subject: [PATCH 56/60] Fixing list inputs --- .github/workflows/github-holodeck-trials.yml | 41 ++++++++----------- .../workflows/github-podspec-sensor-sweep.yml | 33 ++++++--------- .github/workflows/main-ci.yml | 6 +-- .github/workflows/pull-request-ci.yml | 14 +++---- 4 files changed, 38 insertions(+), 56 deletions(-) diff --git a/.github/workflows/github-holodeck-trials.yml b/.github/workflows/github-holodeck-trials.yml index 0967b263..1be2e6cf 100644 --- a/.github/workflows/github-holodeck-trials.yml +++ b/.github/workflows/github-holodeck-trials.yml @@ -5,10 +5,10 @@ on: inputs: schemes: description: | - YAML list of Xcode schemes to test. + Newline-separated list of Xcode schemes to test. Example: - - libPhoneNumber - - libPhoneNumberGeocoding + libPhoneNumber + libPhoneNumberGeocoding required: true type: string xcode_container: @@ -17,10 +17,10 @@ on: type: string device_types: description: | - YAML list of simulator device types to resolve. + Newline-separated list of simulator device types to resolve. Supported values include iphone, ipad, macos, watch, and vision. Example: - - iphone + iphone required: true type: string selection_mode: @@ -111,39 +111,30 @@ jobs: import os - def parse_yaml_list(value, input_name): - \"\"\"Parse a simple YAML list made up of \"- value\" lines.\"\"\" + def parse_list_input(value, input_name): + """Parse a newline-separated list input.""" items = [] for raw_line in value.splitlines(): line = raw_line.strip() - if len(line) <= 0 or line.startswith(\"#\"): + if len(line) <= 0 or line.startswith("#"): continue - if not line.startswith(\"-\"): - raise SystemExit( - f\"{input_name} must be provided as a YAML list using lines that start with \\\"- \\\": {raw_line}\" - ) - - item = line[1:].strip() - if len(item) <= 0: - raise SystemExit(f\"{input_name} contains an empty item: {raw_line}\") - - items.append(item) + items.append(line) if len(items) <= 0: - raise SystemExit(f\"{input_name} must contain at least one item\") + raise SystemExit(f"{input_name} must contain at least one item") return items - schemes = parse_yaml_list(os.environ[\"INPUT_SCHEMES\"], \"schemes\") - device_types = parse_yaml_list(os.environ[\"INPUT_DEVICE_TYPES\"], \"device_types\") + schemes = parse_list_input(os.environ["INPUT_SCHEMES"], "schemes") + device_types = parse_list_input(os.environ["INPUT_DEVICE_TYPES"], "device_types") - with open(os.environ[\"GITHUB_OUTPUT\"], \"a\", encoding=\"utf-8\") as handle: - print(\"schemes_json< Date: Tue, 21 Apr 2026 14:19:16 -0500 Subject: [PATCH 57/60] More clean up --- .github/workflows/ci-core.yml | 14 -------------- .github/workflows/github-podspec-sensor-sweep.yml | 2 +- .github/workflows/main-ci.yml | 4 +--- .github/workflows/pull-request-ci.yml | 2 +- 4 files changed, 3 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index aadd6912..27062d36 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -2,12 +2,6 @@ name: CI Core on: workflow_call: - inputs: - publish_pr_comment: - description: Publish the combined coverage summary as a PR comment. - required: false - default: false - type: boolean outputs: combined_coverage_percent: description: Combined unit test coverage percent across all schemes. @@ -78,11 +72,3 @@ jobs: with: xcresults_directory: CoverageResults/xcresults summary_file: CoverageResults/code-coverage-summary.md - - - name: Publish combined coverage comment to pull request - if: inputs.publish_pr_comment - uses: marocchino/sticky-pull-request-comment@v3 - with: - header: combined-code-coverage - path: ${{ steps.coverage.outputs.summary_file }} - skip_unchanged: true diff --git a/.github/workflows/github-podspec-sensor-sweep.yml b/.github/workflows/github-podspec-sensor-sweep.yml index 9129d3b8..452f642b 100644 --- a/.github/workflows/github-podspec-sensor-sweep.yml +++ b/.github/workflows/github-podspec-sensor-sweep.yml @@ -63,7 +63,7 @@ jobs: )" podspec-lint: - name: Podspec Sensor Sweep (${{ matrix.podspec }}) + name: Podspec Linting (${{ matrix.podspec }}) runs-on: macos-latest needs: prepare-inputs strategy: diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index ab2ddd88..644a649c 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -16,12 +16,10 @@ concurrency: jobs: ci: uses: ./.github/workflows/ci-core.yml - with: - publish_pr_comment: false secrets: inherit podspec-lint: - name: Podspec Sensor Sweep + name: Podspec Linting uses: ./.github/workflows/github-podspec-sensor-sweep.yml with: podspecs: | diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index dc392e0e..35ad0ffa 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -26,7 +26,7 @@ jobs: secrets: inherit podspec-lint: - name: Podspec Sensor Sweep + name: Podspec Linting uses: ./.github/workflows/github-podspec-sensor-sweep.yml with: podspecs: | From 5e091c9c058da021e6583c06997e4a3115a3c581 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Tue, 21 Apr 2026 14:27:54 -0500 Subject: [PATCH 58/60] Lists as JSON objects --- .github/workflows/github-holodeck-trials.yml | 65 ++----------------- .../workflows/github-podspec-sensor-sweep.yml | 53 +-------------- .github/workflows/main-ci.yml | 10 +-- .github/workflows/pull-request-ci.yml | 23 ++++--- 4 files changed, 28 insertions(+), 123 deletions(-) diff --git a/.github/workflows/github-holodeck-trials.yml b/.github/workflows/github-holodeck-trials.yml index 1be2e6cf..e3122b71 100644 --- a/.github/workflows/github-holodeck-trials.yml +++ b/.github/workflows/github-holodeck-trials.yml @@ -5,10 +5,8 @@ on: inputs: schemes: description: | - Newline-separated list of Xcode schemes to test. - Example: - libPhoneNumber - libPhoneNumberGeocoding + JSON array of Xcode schemes to test. + Example: ["libPhoneNumber", "libPhoneNumberGeocoding"] required: true type: string xcode_container: @@ -17,10 +15,9 @@ on: type: string device_types: description: | - Newline-separated list of simulator device types to resolve. + Comma-separated list of simulator device types to resolve. Supported values include iphone, ipad, macos, watch, and vision. - Example: - iphone + Example: iphone,ipad required: true type: string selection_mode: @@ -90,58 +87,9 @@ on: value: ${{ jobs.coverage-summary.outputs.combined_coverage_percent }} jobs: - prepare-inputs: - name: Prepare Workflow Inputs - runs-on: ubuntu-latest - outputs: - schemes_json: ${{ steps.parse.outputs.schemes_json }} - device_types_csv: ${{ steps.parse.outputs.device_types_csv }} - - steps: - - name: Parse workflow inputs - id: parse - shell: bash - env: - INPUT_SCHEMES: ${{ inputs.schemes }} - INPUT_DEVICE_TYPES: ${{ inputs.device_types }} - run: | - set -eo pipefail - python3 -c "$(cat <<'PYTHON' - import json - import os - - - def parse_list_input(value, input_name): - """Parse a newline-separated list input.""" - - items = [] - for raw_line in value.splitlines(): - line = raw_line.strip() - if len(line) <= 0 or line.startswith("#"): - continue - items.append(line) - - if len(items) <= 0: - raise SystemExit(f"{input_name} must contain at least one item") - - return items - - - schemes = parse_list_input(os.environ["INPUT_SCHEMES"], "schemes") - device_types = parse_list_input(os.environ["INPUT_DEVICE_TYPES"], "device_types") - - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle: - print("schemes_json<- + [ + "libPhoneNumber-iOS.podspec", + "libPhoneNumberGeocoding.podspec", + "libPhoneNumberShortNumber.podspec" + ] secrets: inherit diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 35ad0ffa..e20e179b 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -16,20 +16,23 @@ jobs: unit-tests: uses: ./.github/workflows/github-holodeck-trials.yml with: - schemes: | - libPhoneNumber - libPhoneNumberGeocoding - libPhoneNumberShortNumber + schemes: >- + [ + "libPhoneNumber", + "libPhoneNumberGeocoding", + "libPhoneNumberShortNumber" + ] xcode_container: libPhoneNumber.xcodeproj - device_types: | - iphone + device_types: iphone secrets: inherit podspec-lint: name: Podspec Linting uses: ./.github/workflows/github-podspec-sensor-sweep.yml with: - podspecs: | - libPhoneNumber-iOS.podspec - libPhoneNumberGeocoding.podspec - libPhoneNumberShortNumber.podspec + podspecs: >- + [ + "libPhoneNumber-iOS.podspec", + "libPhoneNumberGeocoding.podspec", + "libPhoneNumberShortNumber.podspec" + ] From 5d6e31321073cc992aa5f2b5948a3a8948390b60 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Tue, 21 Apr 2026 14:32:22 -0500 Subject: [PATCH 59/60] Better naming --- .github/workflows/pull-request-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index e20e179b..ebec1a01 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -14,6 +14,7 @@ concurrency: jobs: unit-tests: + name: Unit Testing uses: ./.github/workflows/github-holodeck-trials.yml with: schemes: >- From 007d4cb8140e070c16d0914cf32e9323276809b1 Mon Sep 17 00:00:00 2001 From: Kris Kline Date: Tue, 21 Apr 2026 14:38:19 -0500 Subject: [PATCH 60/60] Resolve simulator destinations on the same server that is going to run unit tests. --- .github/workflows/github-holodeck-trials.yml | 29 ++++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/.github/workflows/github-holodeck-trials.yml b/.github/workflows/github-holodeck-trials.yml index e3122b71..aef5afbf 100644 --- a/.github/workflows/github-holodeck-trials.yml +++ b/.github/workflows/github-holodeck-trials.yml @@ -87,12 +87,13 @@ on: value: ${{ jobs.coverage-summary.outputs.combined_coverage_percent }} jobs: - resolve-destinations: - name: Resolve Simulator Destinations + unit-tests: + name: Unit Tests (${{ matrix.scheme }}) runs-on: macos-latest - outputs: - destination_ids: ${{ steps.destination.outputs.destination_ids }} - simulator_jsons: ${{ steps.destination.outputs.simulator_jsons }} + strategy: + fail-fast: false + matrix: + scheme: ${{ fromJson(inputs.schemes) }} steps: - name: Check out repository @@ -111,28 +112,14 @@ jobs: watchos_version: ${{ inputs.watchos_version }} visionos_version: ${{ inputs.visionos_version }} - unit-tests: - name: Unit Tests (${{ matrix.scheme }}) - runs-on: macos-latest - needs: - - resolve-destinations - strategy: - fail-fast: false - matrix: - scheme: ${{ fromJson(inputs.schemes) }} - - steps: - - name: Check out repository - uses: actions/checkout@v6 - - name: Run unit tests id: run-tests uses: ./.github/actions/xcode-tricorder-tester with: scheme: ${{ matrix.scheme }} xcode_container: ${{ inputs.xcode_container }} - destination_ids: ${{ needs.resolve-destinations.outputs.destination_ids }} - simulator_jsons: ${{ needs.resolve-destinations.outputs.simulator_jsons }} + destination_ids: ${{ steps.destination.outputs.destination_ids }} + simulator_jsons: ${{ steps.destination.outputs.simulator_jsons }} destination_arch: ${{ inputs.destination_arch }} xcodebuild_extra_args: ${{ inputs.xcodebuild_extra_args }}