Skip to content

feat(ios): add opt-in Swift Package Manager mode for the mParticle SDKs - #423

Open
thomson-t wants to merge 1 commit into
thomson-t/spm-02-sdk-import-headerfrom
thomson-t/spm-03-spm-core-mode
Open

thomson-t wants to merge 1 commit into
thomson-t/spm-02-sdk-import-headerfrom
thomson-t/spm-03-spm-core-mode

Conversation

@thomson-t

@thomson-t thomson-t commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Why

Today an iOS app using this package must take the mParticle SDK and its kits from CocoaPods, whose central repository becomes read-only on 2026-12-02: after that, new SDK releases can no longer reach apps through it. Apps that add kits with Swift Package Manager also end up with two copies of the SDK and a runtime crash. Once this lands, an app can opt in, with two lines in its Podfile, to taking the mParticle core SDK and kits from Swift Package Manager, with exactly one copy of each in the app. Apps that do nothing see no change.

Programme

Part of the plan to support Swift Package Manager in this package before the CocoaPods central repository becomes read-only (CocoaPods announcement). This is the third of eight pull requests, and the main deliverable; all eight merge into the workstation/spm-migration branch, which merges into main once the series is complete, and ship together in one release. The plan record is internal and cannot be linked here.

What changes

Before: this package's pod always depends on the mParticle-Apple-SDK-ObjC and RoktContracts pods, and apps add kits as pods.

After, with $RNMParticleUseSPM = true in the Podfile:

  • The pod drops those two dependencies and compiles only against the SDK headers. A build phase finds the headers from the path Xcode records for each package, so no DerivedData layout is assumed.
  • The pod is always a static framework in this mode, so all three CocoaPods linkages work.
  • A new Podfile helper, mparticle_spm_post_install(installer, kits: [...]) in ios/mparticle_spm.rb, adds the mParticle core and each kit as Swift packages, pinned to exact versions, to each iOS app target that uses this package.
  • pod install fails with a clear message if a pod would add a second copy of the SDK.
  • Without the flag, the podspec resolves exactly as before.

The helper edits the app's .xcodeproj on every pod install. It prints each change and is idempotent; the README says so.

Also changed:

  • Sample app: MP_USE_SPM=1 switches sample/ios/Podfile to this mode. The sample unit tests now import the SDK through the library's import header, so they compile in both modes.
  • CI: the iOS job runs both modes. The CocoaPods leg keeps the check name "iOS Sample App", which the main ruleset requires; the new leg is "iOS Sample App (SPM)". Each leg now also archives a Release build and fails if the app contains more than one copy of any SDK class (scripts/ios/check-single-copy.sh).
  • Docs: a README section "Swift Package Manager (opt-in)" (setup, what the helper changes, linkage, troubleshooting), and a MIGRATING section "Moving an iOS app to Swift Package Manager mode". The changelog is generated by the release-draft workflow.

Start reading at the use_spm block in react-native-mparticle.podspec, then ios/mparticle_spm.rb, then the sample Podfile. Left alone on purpose: tvOS. $RNMParticleUseSPM applies to the whole Podfile, so a Podfile with a tvOS target that uses this package stays on CocoaPods (the Rokt kit package is iOS-only); the README says so. The Expo plugin gets this option in the next pull request.

Linked work

Depends on: the previous two pull requests in this series (branches thomson-t/spm-01-duplicate-sdk-warning and thomson-t/spm-02-sdk-import-header); this one needs the private import header from the second.
Unblocks: the Expo config plugin option (branch thomson-t/spm-04-expo-spm), which calls the same helper.
Related: #429, later in this series, makes this mode the default, with $RNMParticleDisableSPM = true as the opt-out, and replaces the mparticle_spm_post_install call with hooks that run on their own.

Rollout

Path: this merges into workstation/spm-migration, not main, so nothing reaches main or a release until the whole series has merged there and that branch is merged into main. It then ships in the next release. The mode is off unless an app sets $RNMParticleUseSPM.
Feature flags: the Podfile global $RNMParticleUseSPM, off by default. An app's developer turns it on and should first check that a Debug build shows no duplicate-SDK red box.
Turning it off: an app removes the flag and the helper call, restores its kit pods and runs pod install (MIGRATING lists the steps). For the package, reverting this pull request and releasing again removes the mode in the next version.
What we watch: both CI legs on every pull request, including the single-copy check. After the release, this repository's issues for build failures in this mode.

Risks

  • Apps that don't opt in could resolve different pods; prevented because the podspec is unchanged without the flag, and the sample's Podfile.lock in default mode matched the previous one apart from the Podfile's own checksum; we would see changed pod versions after upgrading.
  • An app mixes CocoaPods and Swift Package Manager copies of the SDK; prevented because pod install fails in this mode if any mParticle or Rokt pod remains (the helper's own message, or CocoaPods' "does not define modules" error when no dynamic-framework hook is present; both are in the README), and the previous pull request's Debug red box covers setups outside this mode; we would see a report of the runtime crash.
  • The header-finding build phase breaks on a different Xcode or package location; contained because it reads the path Xcode itself records for each package, and was tested with a custom -clonedSourcePackagesDirPath; the build then fails with a message naming the fix; we would see the SPM CI leg fail, and CI runs Xcode 16, which the local runs did not use.
  • The package is missing from the app target, for example after someone edits the project by hand; contained because the build phase fails with a message telling the developer to call the helper; we would see that message in a build log.
  • Transitive package versions float, because the Rokt SDK package is resolved "up to next major"; contained because direct packages are pinned exactly and the README tells apps to commit Package.resolved; we would see different Rokt versions between builds of the same app.
  • ENABLE_USER_SCRIPT_SANDBOXING in an app blocks the build phase; not addressed beyond declaring its inputs and outputs, because the sample does not enable sandboxing for pod targets; we would see a sandbox denial in the build log.

Risk class: higher — this adds a second way to link the SDKs into partner apps and changes CI. Default CocoaPods behaviour is unchanged and verified.

Who

Written by: an automated coding agent (Claude Code), at an engineer's request, following the internal Swift Package Manager migration plan and its proof of concept.
Code reviewed before opening: an independent review agent reviewed the change before it was committed.
Design reviewed before opening: the requesting engineer approved the migration plan, including this opt-in design and its rejected alternatives. Two alternatives failed in the proof of concept: a package dependency declared inside the pod (duplicate symbols) and a DerivedData-relative header path (archive fails).
Decision this implements: the requesting engineer's approval of the migration plan on 2026-09-28; the record is internal and cannot be linked.
Checked: on 2026-09-28, with Xcode 27.0 on an iOS 26.5 simulator, the sample app (React Native 0.84, New Architecture), SDK and Rokt kit 9.6.1:

  • Swift Package Manager mode, static libraries: Release build and unsigned archive succeeded; the archived app holds exactly one copy of each SDK class, all in the app binary; embedded placements by name and by legacy tag, and an overlay placement, rendered; Debug unit tests passed (38 run, 0 failures).
  • Swift Package Manager mode with use_frameworks!: both :dynamic and :static passed the same build, archive, single-copy and runtime checks.
  • A kit pod left in: pod install failed, as intended.
  • Package removed from the app target: the build failed with the build-phase message.
  • Custom -clonedSourcePackagesDirPath: build and archive succeeded, with a single copy.
  • Default CocoaPods mode: Podfile.lock unchanged apart from the Podfile checksum; build, archive, single copy, runtime and unit tests passed.
    Not checked: the Old Architecture; physical devices; an app with user-script sandboxing enabled; a real Podfile with several app targets, where only a script with stand-in targets checked that the helper skips apps that do not use this package, tvOS apps and test targets. CI, which builds with Xcode 16, passed on this pull request.

Size

Hand-written: about 280 lines added and 21 removed in 9 files (93 of them the Podfile helper, 58 docs).
Generated: none.

Notes for reviewers

How headers are found. For every package, Xcode writes $(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)/<module>.modulemap, whose umbrella line is the absolute path of the package's public headers, for build and archive alike. The pod's before_compile script phase reads mParticle_Apple_SDK_ObjC.modulemap and symlinks that directory to $(DERIVED_FILE_DIR)/mParticleSPMInclude, which is on the pod's header search path, so RNMPSDKImports.h takes its flat-header branch. RoktContracts-Swift.h is also in that directory, and is declared as an input so the phase runs after that package builds.

Why not a package dependency in the podspec (React Native's spm_dependency): in the proof of concept, Xcode merged the package objects into the pod's static library, and the app linked them again (12,100 duplicate symbols).

The sample's test target inherits the pods completely and never links the SDK itself: the host app provides the symbols. In this mode the sample Podfile adds the two header directories above to the test target, so the tests compile.

Package URLs match the kits' byte for byte (the core has no .git suffix), so SwiftPM treats them as one package identity.

🤖 Generated with Claude Code

@thomson-t
thomson-t added this pull request to stack #427 September 29, 2026 21:25
@thomson-t
thomson-t marked this pull request as ready for review September 30, 2026 13:19
@thomson-t
thomson-t requested a review from a team as a code owner September 30, 2026 13:19
Copilot AI balanced review requested due to automatic review settings September 30, 2026 13:19
@cursor

cursor Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Adds an alternative iOS dependency linking mechanism via Swift Package Manager with build-time project modification, though the feature is opt-in and CocoaPods defaults remain unchanged.

Overview
Adds an opt-in Swift Package Manager (SPM) mode for iOS apps to consume the mParticle Apple SDK and associated kits directly via SwiftPM while continuing to use CocoaPods for React Native.

When enabled via $RNMParticleUseSPM = true in the Podfile, react-native-mparticle.podspec omits its CocoaPods SDK dependencies, builds as a static framework, and uses a build phase script to resolve SPM-generated headers. A new Podfile helper in ios/mparticle_spm.rb links pinned Swift packages into the Xcode project and guards against duplicate SDK installations.

Also updates CI to test both CocoaPods and SPM modes in a matrix build, adds a verification script (check-single-copy.sh) to detect duplicate Mach-O class symbols in release archives, and adds migration documentation.

Reviewed by Cursor Bugbot for commit 777b3d6. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The helper can modify unrelated application targets, and the global platform switch breaks mixed iOS/tvOS Podfiles.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds opt-in Swift Package Manager support for mParticle’s iOS SDKs while preserving default CocoaPods behavior.

Changes:

  • Adds an SPM Podfile helper and conditional podspec configuration.
  • Updates sample tests, documentation, and migration guidance.
  • Extends CI to test both dependency modes and detect duplicate SDK copies.
File Description
react-native-mparticle.podspec Configures SPM header discovery and dependency behavior.
ios/​mparticle_spm.rb Adds and pins Swift packages in app targets.
sample/​ios/​Podfile Enables sample SPM mode via environment flag.
sample/​ios/​MParticleSampleTests/​RNMPRoktPlaceholderTests.m Uses the shared SDK import header.
sample/​ios/​MParticleSampleTests/​RCTConvertCommerceMappingTests.m Uses the shared SDK import header.
scripts/​ios/​check-single-copy.sh Checks archived apps for duplicate SDK classes.
.github/​workflows/​pull-request.yml Tests CocoaPods and SPM configurations.
README.md Documents SPM setup and troubleshooting.
MIGRATING.md Adds SPM migration and rollback instructions.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

# against their headers and never links them, so it is a static framework even under
# `use_frameworks! :linkage => :dynamic`.
s.static_framework = true
s.platforms = { :ios => ios_platform } # the Rokt kit Swift package is iOS-only

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that this needed addressing. $RNMParticleUseSPM applies to the whole Podfile, and the Rokt kit Swift package is iOS-only, so letting tvOS targets share a Podfile with SPM mode would need a separate design. Instead, 777b3d6 corrects the README: SPM mode is iOS-only, and a Podfile with a tvOS target that uses this package must stay on CocoaPods.

Comment thread ios/mparticle_spm.rb
Comment on lines +42 to +48
def self.application_targets(installer)
installer.aggregate_targets.flat_map do |aggregate|
aggregate.user_targets
.select { |t| t.product_type == 'com.apple.product-type.application' }
.map { |t| [aggregate.user_project, t] }
end.uniq { |project, target| [project.path.to_s, target.uuid] }
end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 777b3d6. application_targets now keeps only aggregates whose platform is iOS and whose pod targets include react-native-mparticle. It matches by pod_name, so scoped variants still match. The conflict guard now uses pod_name too. The README now says the packages go to every iOS application target that uses this package.

With `$RNMParticleUseSPM = true` in the Podfile, the pod drops its
mParticle-Apple-SDK-ObjC and RoktContracts dependencies and compiles only
against their headers. A build phase finds the headers from the path Xcode
records for each package, so no DerivedData layout is assumed. The pod is a
static framework in this mode, so every CocoaPods linkage works. Without the
flag, the podspec resolves exactly as before.

The new ios/mparticle_spm.rb helper, called from post_install, adds the
mParticle core and each kit as exactly pinned Swift packages to the app
target. It fails pod install if a pod would add a second copy of the SDK.

The sample switches mode with MP_USE_SPM=1, and CI runs both modes. The
CocoaPods leg keeps the required "iOS Sample App" check name. Each leg
archives a Release build and checks that it holds one copy of each SDK
class. README and MIGRATING document the mode.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants