From 0ee3753de78b45171b249184c142141e2486551b Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Tue, 20 Oct 2026 14:40:03 -0400 Subject: [PATCH 1/2] fix(ios): unlink kits dropped from $RNMParticleSPMKits pod install added the listed kits to the app target but never removed one. Taking a kit off $RNMParticleSPMKits left it linked, and switching to another major version of a kit, such as mParticle-Braze-14 to mParticle-Braze-17, kept the old repository, because both share the product name mParticle-Braze. Now each pod install points a listed product at its configured package, unlinks any product from ios/mparticle_spm_kits.json that is not listed, and removes those package references once no target links them. It prints each change. Packages from other URLs, such as hand-added ones or kits given by url:, are never removed or replaced: if one already provides a listed kit's product, pod install stops and says so. Listing two kits with the same product also stops pod install, instead of switching between them on each run; a kit listed twice is linked once. scripts/ios/mparticle_spm_test.rb covers removal, switching, repeat installs, hand-added packages and other targets' references, and runs in the SPM CI leg. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/pull-request.yml | 5 ++ README.md | 4 +- ios/mparticle_spm.rb | 63 +++++++++++++- scripts/ios/mparticle_spm_test.rb | 128 +++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 scripts/ios/mparticle_spm_test.rb diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2762cea..01daaf3 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -179,6 +179,11 @@ jobs: working-directory: sample run: bundle install + - name: Test how pod install links the Swift packages + if: matrix.ios_deps == 'spm' + working-directory: sample + run: bundle exec ruby ../scripts/ios/mparticle_spm_test.rb + # A tvOS app must get the opt-out error, not silently lose this pod (see the podspec's platforms). - name: Check that a tvOS Podfile stops with the iOS-only error if: matrix.ios_deps == 'spm' diff --git a/README.md b/README.md index 17f7c01..cef3652 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ $RNMParticleSPMCoreVersion = '9.6.1' - `$RNMParticleSPMKits` lists kits by CocoaPods name. [`ios/mparticle_spm_kits.json`](./ios/mparticle_spm_kits.json) maps every kit of the mParticle Apple SDK to its Swift package, including `mParticle-Kochava-9` and `mParticle-Kochava-No-Tracking-9`, which ship only as Swift packages, and `RoktSDKPlus`, which already includes `mParticle-Rokt` and is supported only from Swift Package Manager. For any other kit, give `{ url: '…', product: '…', version: '…' }`. - `$RNMParticleSPMCoreVersion` pins the core SDK. It defaults to the version this release was tested with. A kit without a version gets the core's, since mParticle kits are released with the core. -**What `pod install` changes.** On each `pod install`, this package edits your app's `.xcodeproj`: it adds a Swift package reference for the core SDK and each kit, pinned to the exact version, and links each package's product into every iOS application target that uses this package. It prints every change (`[mParticle] MyApp <- mParticle-Rokt 9.6.1`), and running it again changes nothing. It stops `pod install` with an error if a pod would add a second copy of the SDKs. Commit the `.xcodeproj` change and `ios/.xcworkspace/xcshareddata/swiftpm/Package.resolved`, so every build resolves the same versions. +**What `pod install` changes.** On each `pod install`, this package edits your app's `.xcodeproj`: it adds a Swift package reference for the core SDK and each kit, pinned to the exact version, and links each package's product into every iOS application target that uses this package. It unlinks every kit from `ios/mparticle_spm_kits.json` that `$RNMParticleSPMKits` does not list, including one you added by hand or switched to another major version, and removes its package reference once no target links it. It never removes or replaces a package from any other URL, such as a kit you gave by `url:`: remove that in Xcode. It prints every change (`[mParticle] MyApp <- mParticle-Rokt 9.6.1`), and running it again changes nothing. It stops `pod install` with an error if a pod would add a second copy of the SDKs. Commit the `.xcodeproj` change and `ios/.xcworkspace/xcshareddata/swiftpm/Package.resolved`, so every build resolves the same versions. **Linkage.** This package's pod is always built as a static framework, so it works with each CocoaPods linkage: @@ -362,6 +362,8 @@ $RNMParticleSPMCoreVersion = '9.6.1' - `pod install` fails with `[mParticle] This package takes the mParticle SDKs from Swift Package Manager, but these pods would add a second copy`: remove the kit pods you declared, such as `pod 'mParticle-Rokt'`, and their entries in any `pre_install` hook, then list the kits in `$RNMParticleSPMKits`. The error also lists the pods those kits pull in. To stay on CocoaPods for now, set `$RNMParticleDisableSPM = true` instead. - `pod install` fails with `[mParticle] $RNMParticleSPMKits: unknown kit`: use a name from `ios/mparticle_spm_kits.json`, or give the kit's `url:` and `product:`. +- `pod install` fails with `[mParticle] MyApp already links`: the app target links that kit's product from a package this package does not manage, such as a fork. Remove it from the target in Xcode, or list that package in `$RNMParticleSPMKits` by `url:`. +- `pod install` fails with `[mParticle] $RNMParticleSPMKits lists more than one kit with the product`: list one major version of each kit, such as `mParticle-Braze-17` and not also `mParticle-Braze-14`. - `pod install` fails with `[mParticle] Swift Package Manager mode is iOS only`: a tvOS target uses this package, as in a `react-native-tvos` app whose Podfile sets `platform :tvos`. Set `$RNMParticleDisableSPM = true` at the top of the Podfile, or `iosDependencyManager: 'cocoapods'` with Expo. - `pod install` fails inside `MParticleSPM::InstallerHooks` after a CocoaPods upgrade: this package hooks CocoaPods' private `resolve_dependencies`, `validate_targets` and `run_podfile_post_install_hooks` methods, tested with CocoaPods 1.15.2 and 1.16.2. Open an issue with your CocoaPods version. - The build fails with `[mParticle] Swift Package Manager mode is on, but the mParticle-Apple-SDK Swift package is not linked into the app target`: the package was removed from the app target after `pod install`. Run `pod install` again. diff --git a/ios/mparticle_spm.rb b/ios/mparticle_spm.rb index e7e4073..95c1dfd 100644 --- a/ios/mparticle_spm.rb +++ b/ios/mparticle_spm.rb @@ -16,7 +16,8 @@ # crashes at runtime. # 2. After the Podfile's post_install hook, it adds the core SDK and each kit as Swift packages, # pinned to an exact version, to every iOS application target that uses this pod. The app target -# owns the only copy of each SDK. +# owns the only copy of each SDK. It unlinks the kits from ios/mparticle_spm_kits.json that the +# Podfile does not list. # With the opt-out, it only warns when an app target still links the mParticle Swift packages. require 'json' require 'xcodeproj' @@ -30,6 +31,9 @@ module MParticleSPM DEFAULT_CORE_VERSION = '9.6.1'.freeze # Kits by their CocoaPods name. The Expo config plugin reads the same file. KITS = JSON.parse(File.read(File.join(__dir__, 'mparticle_spm_kits.json')))['kits'].freeze + # Packages this file unlinks when the Podfile does not list them. A package from any other URL, such + # as a kit given by url:, may have been added by hand, so it is never removed. + MANAGED_URLS = [CORE_URL, *KITS.values.map { |kit| kit['url'] }].freeze CONFLICTING_PODS = /\A(mParticle-.*|Rokt-Widget|RoktContracts|RoktUXHelper|DcuiSchema|RoktSDKPlus|RoktPaymentExtension)\z/.freeze @@ -54,7 +58,15 @@ def self.kits "[mParticle] $RNMParticleSPMKits lists #{name} and #{included.join(', ')}, but #{name} " \ "already includes #{included.join(', ')}. List only #{name}." end - entries.map { |entry| resolve_kit(entry) } + resolved = entries.map { |entry| resolve_kit(entry) }.uniq + resolved.group_by { |kit| kit[:product] }.each do |product, same| + next if same.size == 1 + + raise Pod::Informative, + "[mParticle] $RNMParticleSPMKits lists more than one kit with the product #{product}. " \ + 'List only one of them.' + end + resolved end def self.resolve_kit(entry) @@ -132,7 +144,21 @@ def self.add_package(project, target, url:, product:, version:) changed = ref.requirement != requirement ref.requirement = requirement - return changed if target.package_product_dependencies.any? { |d| d.product_name == product } + # A kit switched to another major version keeps its product name, so point the product at the + # new package. A product linked from a package this file does not manage, such as a fork, stays. + dep = target.package_product_dependencies.find { |d| d.product_name == product } + if dep + linked_url = dep.package.respond_to?(:repositoryURL) ? dep.package.repositoryURL : 'a local package' + unless dep.package == ref || MANAGED_URLS.include?(linked_url) + raise Pod::Informative, + "[mParticle] #{target.name} already links #{product} from #{linked_url}, but " \ + "$RNMParticleSPMKits takes it from #{url}. Remove it from the target in Xcode, or list that " \ + 'package in $RNMParticleSPMKits by url:.' + end + changed ||= dep.package != ref + dep.package = ref + return changed + end dep = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) dep.package = ref @@ -144,6 +170,31 @@ def self.add_package(project, target, url:, product:, version:) true end + def self.unlink_unlisted(target, products) + unlisted = target.package_product_dependencies.select do |dep| + dep.package.respond_to?(:repositoryURL) && MANAGED_URLS.include?(dep.package.repositoryURL) && + !products.include?(dep.product_name) + end + unlisted.each do |dep| + target.build_phases.each do |phase| + phase.files.select { |file| file.product_ref == dep }.each(&:remove_from_project) + end + dep.remove_from_project + Pod::UI.puts "[mParticle] #{target.name} -x #{dep.product_name} (not in $RNMParticleSPMKits)".yellow + end + end + + # Keeps a package another target still links. + def self.remove_unused_references(project) + used = project.objects.grep(Xcodeproj::Project::Object::XCSwiftPackageProductDependency).map(&:package) + project.root_object.package_references.select do |ref| + ref.respond_to?(:repositoryURL) && MANAGED_URLS.include?(ref.repositoryURL) && !used.include?(ref) + end.each do |ref| + Pod::UI.puts "[mParticle] Removed the unused Swift package #{ref.repositoryURL}".yellow + ref.remove_from_project + end + end + def self.link_packages(installer) packages = [{ url: CORE_URL, product: CORE_PRODUCT, version: core_version }] + kits touched = {} @@ -152,9 +203,13 @@ def self.link_packages(installer) added = add_package(project, target, **pkg.slice(:url, :product, :version)) Pod::UI.puts "[mParticle] #{target.name} <- #{pkg[:product]} #{pkg[:version]}".green if added end + unlink_unlisted(target, packages.map { |pkg| pkg[:product] }) touched[project.path.to_s] = project end - touched.each_value(&:save) + touched.each_value do |project| + remove_unused_references(project) + project.save + end end # With the opt-out, the CocoaPods SDK and any leftover Swift package would both be linked. Kits diff --git a/scripts/ios/mparticle_spm_test.rb b/scripts/ios/mparticle_spm_test.rb new file mode 100644 index 0000000..fd91bb8 --- /dev/null +++ b/scripts/ios/mparticle_spm_test.rb @@ -0,0 +1,128 @@ +# Tests how ios/mparticle_spm.rb links the mParticle Swift packages into an app's .xcodeproj. +# Run from sample/, which has the CocoaPods Gemfile: bundle exec ruby ../scripts/ios/mparticle_spm_test.rb +require 'cocoapods' +require 'minitest/autorun' +require 'tmpdir' +require_relative '../../ios/mparticle_spm' + +class MParticleSPMTest < Minitest::Test + Installer = Struct.new(:aggregate_targets) + Aggregate = Struct.new(:platform, :pod_targets, :user_targets, :user_project) + Platform = Struct.new(:name) + PodTarget = Struct.new(:pod_name) + + CORE = [MParticleSPM::CORE_URL, MParticleSPM::CORE_PRODUCT].freeze + ROKT = [MParticleSPM::KITS['mParticle-Rokt']['url'], 'mParticle-Rokt'].freeze + BRAZE_14 = [MParticleSPM::KITS['mParticle-Braze-14']['url'], 'mParticle-Braze'].freeze + BRAZE_17 = [MParticleSPM::KITS['mParticle-Braze-17']['url'], 'mParticle-Braze'].freeze + HAND_ADDED = ['https://github.com/example/other-package', 'Other'].freeze + FORK = ['https://github.com/example/rokt-kit-fork', 'mParticle-Rokt'].freeze + + def setup + Pod::Config.instance.silent = true + @dir = Dir.mktmpdir + @path = File.join(@dir, 'App.xcodeproj') + project = Xcodeproj::Project.new(@path) + project.new_target(:application, 'App', :ios) + # An app target that does not use react-native-mparticle. + project.new_target(:application, 'Other', :ios) + project.save + end + + def teardown + FileUtils.remove_entry(@dir) + $RNMParticleSPMKits = nil + end + + # Runs the helper as `pod install` would, then reopens the saved project. + def install(kits) + $RNMParticleSPMKits = kits + project = Xcodeproj::Project.open(@path) + app = project.targets.find { |target| target.name == 'App' } + aggregate = Aggregate.new(Platform.new(:ios), [PodTarget.new('react-native-mparticle')], [app], project) + MParticleSPM.link_packages(Installer.new([aggregate])) + Xcodeproj::Project.open(@path) + end + + def link_by_hand(target_name, (url, product)) + project = Xcodeproj::Project.open(@path) + target = project.targets.find { |t| t.name == target_name } + MParticleSPM.add_package(project, target, url: url, product: product, version: '1.0.0') + project.save + end + + def linked(project, target_name) + target = project.targets.find { |t| t.name == target_name } + deps = target.package_product_dependencies.map { |dep| [dep.package.repositoryURL, dep.product_name] } + frameworks = target.frameworks_build_phase.files.map(&:product_ref).compact.map(&:product_name) + assert_equal deps.map(&:last).sort, frameworks.sort, 'each linked product has one framework build file' + deps.sort + end + + def references(project) + project.root_object.package_references.map(&:repositoryURL).sort + end + + def pbxproj + File.read(File.join(@path, 'project.pbxproj')) + end + + def test_installing_again_changes_nothing + install(['mParticle-Rokt']) + before = pbxproj + project = install(['mParticle-Rokt']) + assert_equal before, pbxproj + assert_equal [CORE, ROKT].sort, linked(project, 'App') + end + + def test_removing_a_kit_unlinks_it + install(['mParticle-Rokt']) + project = install([]) + assert_equal [CORE], linked(project, 'App') + assert_equal [CORE.first], references(project) + + before = pbxproj + install([]) + assert_equal before, pbxproj + end + + def test_switching_a_kit_to_another_repository_relinks_its_product + install(['mParticle-Braze-14']) + project = install(['mParticle-Braze-17']) + assert_equal [CORE, BRAZE_17].sort, linked(project, 'App') + assert_equal [CORE.first, BRAZE_17.first].sort, references(project) + + before = pbxproj + install(['mParticle-Braze-17']) + assert_equal before, pbxproj + end + + def test_keeps_hand_added_packages_and_references_other_targets_use + link_by_hand('App', HAND_ADDED) + link_by_hand('Other', ROKT) + install(['mParticle-Rokt']) + project = install([]) + assert_equal [CORE, HAND_ADDED].sort, linked(project, 'App') + assert_equal [ROKT], linked(project, 'Other') + assert_equal [CORE.first, ROKT.first, HAND_ADDED.first].sort, references(project) + end + + def test_stops_instead_of_replacing_a_hand_added_package_with_the_same_product + link_by_hand('App', FORK) + before = pbxproj + error = assert_raises(Pod::Informative) { install(['mParticle-Rokt']) } + assert_includes error.message, FORK.first + assert_equal before, pbxproj + end + + def test_a_kit_listed_twice_is_linked_once + $RNMParticleSPMKits = ['mParticle-Rokt', 'mParticle-Rokt'] + assert_equal 1, MParticleSPM.kits.size + end + + def test_kits_with_the_same_product_are_rejected + $RNMParticleSPMKits = ['mParticle-Braze-14', 'mParticle-Braze-17'] + error = assert_raises(Pod::Informative) { MParticleSPM.kits } + assert_includes error.message, 'mParticle-Braze' + end +end From 204cabb7a3d9d690c75904e5278de013610f9309 Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Tue, 20 Oct 2026 14:40:53 -0400 Subject: [PATCH 2/2] ci: run the Rokt placeholder tests on iOS RNMPRoktPlaceholderTests is compiled into the sample's test target but was never in the -only-testing list, so neither iOS leg ran it, though the next major release changes how placeholders resolve. Both legs now run it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/pull-request.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 01daaf3..584bebd 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -215,6 +215,7 @@ jobs: -only-testing:MParticleSampleTests/RNMParticleDuplicateSDKTests \ -only-testing:MParticleSampleTests/RNMPRoktEventMapperTests \ -only-testing:MParticleSampleTests/RNMPRoktConfigFactoryTests \ + -only-testing:MParticleSampleTests/RNMPRoktPlaceholderTests \ -only-testing:MParticleSampleTests/RNMPRoktSwiftTests \ | bundle exec xcpretty -k