Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
contents: read
pull-requests: read
checks: write
id-token: write

Check failure on line 15 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

excessive-permissions

pull-request.yml:15: overly broad permissions: id-token: write is overly broad at the workflow level

jobs:
trunk-check:
Expand All @@ -23,9 +23,9 @@
contents: read
pull-requests: read
steps:
- name: Checkout

Check warning on line 26 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

artipacked

pull-request.yml:26: credential persistence through GitHub Actions artifacts: does not set persist-credentials: false
uses: actions/checkout@v7

Check failure on line 27 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

unpinned-uses

pull-request.yml:27: unpinned action reference: action is not pinned to a hash (required by blanket policy)
- uses: actions/setup-node@v7.1.0

Check failure on line 28 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

unpinned-uses

pull-request.yml:28: unpinned action reference: action is not pinned to a hash (required by blanket policy)
with:
node-version: 24
cache: yarn
Expand All @@ -33,15 +33,15 @@
- name: Install node modules
run: yarn install --frozen-lockfile
- name: Trunk Check
uses: trunk-io/trunk-action@v1

Check failure on line 36 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

unpinned-uses

pull-request.yml:36: unpinned action reference: action is not pinned to a hash (required by blanket policy)

js-tests:
name: 'JS Tests'
runs-on: ubuntu-latest
steps:
- name: 'Checkout'

Check warning on line 42 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

artipacked

pull-request.yml:42: credential persistence through GitHub Actions artifacts: does not set persist-credentials: false
uses: actions/checkout@v7

Check failure on line 43 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

unpinned-uses

pull-request.yml:43: unpinned action reference: action is not pinned to a hash (required by blanket policy)
- uses: actions/setup-node@v7.1.0

Check failure on line 44 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

unpinned-uses

pull-request.yml:44: unpinned action reference: action is not pinned to a hash (required by blanket policy)
with:
node-version: 24
cache: yarn
Expand All @@ -59,8 +59,8 @@
name: 'Android Unit Tests'
runs-on: ubuntu-latest
steps:
- name: 'Checkout'

Check warning on line 62 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

artipacked

pull-request.yml:62: credential persistence through GitHub Actions artifacts: does not set persist-credentials: false
uses: actions/checkout@v7

Check failure on line 63 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

unpinned-uses

pull-request.yml:63: unpinned action reference: action is not pinned to a hash (required by blanket policy)
- name: 'Run Android Unit Tests'
working-directory: android
run: ./gradlew test
Expand All @@ -69,8 +69,8 @@
name: 'Android Lint'
runs-on: ubuntu-latest
steps:
- name: 'Checkout'

Check warning on line 72 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

artipacked

pull-request.yml:72: credential persistence through GitHub Actions artifacts: does not set persist-credentials: false
uses: actions/checkout@v7

Check failure on line 73 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

unpinned-uses

pull-request.yml:73: unpinned action reference: action is not pinned to a hash (required by blanket policy)
- name: 'Run Android lint'
working-directory: android
run: ./gradlew lint
Expand All @@ -79,7 +79,7 @@
name: 'Android kotlin lint'
runs-on: ubuntu-latest
steps:
- name: 'Checkout'

Check warning on line 82 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

artipacked

pull-request.yml:82: credential persistence through GitHub Actions artifacts: does not set persist-credentials: false
uses: actions/checkout@v7
- name: 'Run Android kotlin lint'
working-directory: android
Expand All @@ -89,7 +89,7 @@
name: Android Sample App
runs-on: ubuntu-latest
steps:
- name: Checkout

Check warning on line 92 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

artipacked

pull-request.yml:92: credential persistence through GitHub Actions artifacts: does not set persist-credentials: false
uses: actions/checkout@v7
- uses: actions/setup-node@v7.1.0
with:
Expand Down Expand Up @@ -132,7 +132,7 @@
# sample/ios/Podfile takes the mParticle SDKs from Swift Package Manager unless this is 1.
MP_USE_COCOAPODS: ${{ matrix.ios_deps == 'cocoapods' && '1' || '0' }}
steps:
- name: Checkout

Check warning on line 135 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

artipacked

pull-request.yml:135: credential persistence through GitHub Actions artifacts: does not set persist-credentials: false
uses: actions/checkout@v7
- uses: actions/setup-node@v7.1.0
with:
Expand Down Expand Up @@ -179,6 +179,11 @@
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'
Expand All @@ -200,7 +205,7 @@
set -o pipefail && xcodebuild -workspace MParticleSample.xcworkspace \
-configuration Debug \
-scheme MParticleSample \
-destination 'id=${{ steps.simulator.outputs.udid }}' \

Check notice on line 208 in .github/workflows/pull-request.yml

View workflow job for this annotation

GitHub Actions / Scan GitHub Actions workflows

template-injection

pull-request.yml:208: code injection via template expansion: may expand into attacker-controllable code
-derivedDataPath ios/build \
-UseModernBuildSystem=YES \
test \
Expand All @@ -210,6 +215,7 @@
-only-testing:MParticleSampleTests/RNMParticleDuplicateSDKTests \
-only-testing:MParticleSampleTests/RNMPRoktEventMapperTests \
-only-testing:MParticleSampleTests/RNMPRoktConfigFactoryTests \
-only-testing:MParticleSampleTests/RNMPRoktPlaceholderTests \
-only-testing:MParticleSampleTests/RNMPRoktSwiftTests \
| bundle exec xcpretty -k

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<App>.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/<App>.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:

Expand All @@ -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.
Expand Down
63 changes: 59 additions & 4 deletions ios/mparticle_spm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 = {}
Expand All @@ -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
Expand Down
128 changes: 128 additions & 0 deletions scripts/ios/mparticle_spm_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading