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
15 changes: 15 additions & 0 deletions .github/workflows/build-sample-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,21 @@ jobs:
fi
working-directory: example

- name: Audit CocoaPods deployment targets
if: ${{ inputs.platform == 'ios' }}
working-directory: example
run: |
node ../scripts/test_react_native_ios_autolinking.js
bundle exec ruby ../scripts/test_cocoapods_deployment_target.rb
minimum_ios_version=$(bundle exec ruby -e \
"require './node_modules/react-native/scripts/react_native_pods'; \
require '../ios/cocoapods_deployment_target'; \
print CustomerIO::CocoaPodsDeploymentTarget.maximum(min_ios_version_supported, '15.0')")
bundle exec ruby ../scripts/audit_cocoapods_deployment_targets.rb \
--minimum "$minimum_ios_version" \
ios/Pods \
ios/SampleApp.xcodeproj

# Build the app and upload to Firebase App Distribution

- name: Setup Android environment for Android sample app
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ The official Customer.io SDK for React Native enables you to integrate mobile me
npm install customerio-reactnative
```

If a CocoaPods build reports that a generated dependency target is below the deployment range
supported by Xcode, follow the [deployment-target normalization guide](docs/cocoapods-deployment-target-normalization.md).

---

## SDK Initialization
Expand Down
139 changes: 139 additions & 0 deletions docs/cocoapods-deployment-target-normalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# CocoaPods deployment-target normalization

Xcode validates the deployment target of every generated CocoaPods target, including dependency,
aggregate, privacy-manifest, and resource-bundle targets. Xcode 27 rejects targets below the build
range supported by its iOS SDK even when the dependency itself is source-compatible with your app.

The `customerio-reactnative` package includes an opt-in Podfile helper. It raises low or missing
generated settings to the greater of React Native's current minimum and iOS 15.0. For React Native
0.83 that value is iOS 15.1. It also covers integrated application, Notification Service
Extension, and widget targets, while preserving higher numeric deployment targets. When a target
setting is absent, the helper resolves its target xcconfig and then the same-named project build
configuration, preserving a higher inherited app or extension floor. The helper changes local build
settings in the generated Pods project and CocoaPods-integrated app or extension projects. It does
not rewrite a podspec or change runtime API availability.

The package owns and tests this copy of the helper because customer Podfiles must be able to load it
from the installed npm package. When its behavior changes, update the helper, tests, and guidance
together. It does not depend on a cross-repository source lock or require wrapper copies to remain
byte-identical.

Customer.io deliberately continues to publish native SDKs that support iOS versions below 15. A
podspec can therefore correctly declare that lower library minimum even when React Native or the
application consuming it requires a newer iOS version. CocoaPods carries deployment metadata from
Customer.io and third-party podspecs into generated build targets, but Xcode 27 no longer accepts
targets below the iOS SDK's supported build range. Raising every published podspec to iOS 15 would
unnecessarily drop older applications and would not control metadata from transitive
dependencies. The helper instead aligns the generated targets with the host application's chosen
minimum while leaving the packages' published runtime compatibility unchanged.
This is the supported integration policy for a React Native application that has moved its own
minimum to the required iOS floor.

> [!WARNING]
> This is an opt-in build migration. Integrated app and extension targets below the computed floor
> are raised to that floor. With React Native 0.83, shipping the resulting iOS 15.1 application
> means users on iOS 15.0 and earlier cannot install subsequent app updates. Adopt the helper only
> when your product has intentionally moved its application and extension deployment targets.

Resolve the helper through Node so hoisted and monorepo installations work, then call it after
`react_native_post_install` in the one existing `post_install` block:

```ruby
def node_resolve(script)
Pod::Executable.execute_command('node', ['-p',
"require.resolve('#{script}', {paths: [process.argv[1]]})",
__dir__
]).strip
end

customer_io_package_root = File.dirname(node_resolve('customerio-reactnative/package.json'))
require File.join(customer_io_package_root, 'ios', 'cocoapods_deployment_target')

customer_io_minimum_ios_version = CustomerIO::CocoaPodsDeploymentTarget.maximum(
min_ios_version_supported,
'15.0'
)

post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false
)

CustomerIO::CocoaPodsDeploymentTarget.normalize!(
installer,
minimum_ios_version: customer_io_minimum_ios_version
)
end
```

Remove the generated `ios/Pods` directory and run `pod install` again. When a target build-setting
key is absent, the helper resolves the target xcconfig and matching project configuration before
adding an override. A present target key is authoritative, so lower-precedence xcconfig and project
values are not inspected. This matches Xcode's treatment of an explicitly empty target value: it
is missing and must be normalized, rather than inherited from a lower-precedence value. For each
change, the helper prints a stable project, target, and configuration line with the original
effective value and final value. It fails the install if the selected effective value is
non-numeric, such as `$(CUSTOM_IOS_FLOOR)`, because the generated-project audit cannot prove its
resolved value. A non-numeric value at a lower precedence does not fail when an explicit target
setting already determines the effective value.
SDK-, architecture-, or configuration-qualified keys such as
`IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]` also fail before mutation at the selected precedence.
Xcode can choose a qualified key over an unconditional value, so adding a numeric override would
not prove the effective floor and could lower a higher conditional project setting. Replace the
selected conditional matrix with one numeric, unconditional deployment target before rerunning the
helper.

If the helper reports a non-numeric selected value, use the project, target, and configuration in
the error to locate the authoritative `IPHONEOS_DEPLOYMENT_TARGET`. Replace the macro or inherited
expression at that precedence with its intended numeric version, then run `pod install` again. For
a generated Pods target, make that override durably in the Podfile's `post_install` hook before
calling `CustomerIO::CocoaPodsDeploymentTarget.normalize!`, or correct the podspec that supplies the
expression; do not edit the generated project because the next install replaces it. Do not skip
that target: continuing with an unresolved expression would leave the generated project outside
the deterministic audit. The helper validates every selected value before changing any project,
so this failure does not leave a partially normalized installation.

If an error says a selected xcconfig cannot be read or parsed, repair or remove the reported base
configuration file reference for the reported project, target, and configuration. Lower-precedence
xcconfigs are not parsed when the target build-setting key is present. Run the helper in the
CocoaPods Ruby environment so the public `Xcodeproj::Config` parser is available.
Synchronized-group xcconfig references are not resolved by that public parser; if the helper
reports one, replace it with a standard xcconfig file reference, then run `pod install` again.
These cases fail before any project mutation.

## When the helper is no longer needed

Keep the helper while a supported dependency graph can validly include deployment metadata below
the host application's minimum. This is expected while Customer.io supports older iOS versions or
supported third-party pods continue to declare lower minimums. A `platform` declaration in the
application's Podfile alone does not guarantee that every generated target uses the same value.

The helper becomes unnecessary only when a clean install without it proves that every
target/configuration in every supported React Native and push-provider graph declares an effective
numeric deployment target at or above the host application's minimum. That state would normally
follow an intentional platform-support change across the SDK, wrappers, and relevant dependencies;
it is not a prerequisite for adopting Xcode 27. Keep the audit in CI after removing the helper so
a later dependency update cannot silently reintroduce a lower target.

Repository contributors also have a deterministic CI audit under `scripts/` that prints the target,
matching project, and effective value for every generated target/configuration pair in stable
order. That audit script is not part of the published npm package. It examines every target in each
passed project, including non-integrated targets that the normalizer intentionally does not change;
set those targets to the host minimum explicitly. Pass the `Pods` directory so the audit discovers
every `.xcodeproj` directly under it, including CocoaPods multi-project output, while ignoring
unrelated example projects vendored inside downloaded pod sources. It fails if a supplied path is
missing or contains no projects.

```sh
cd example
bundle exec ruby ../scripts/audit_cocoapods_deployment_targets.rb \
--minimum 15.1 \
ios/Pods \
ios/SampleApp.xcodeproj
```

Keep this audit next to the React Native simulator build and unsigned generic-device archive.
Passing those checks does not prove real-device push delivery, signed archive export, or App Store
submission.
1 change: 1 addition & 0 deletions example/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ gem "logger"
gem "benchmark"
gem "mutex_m"
gem 'fastlane'
gem 'minitest', '~> 5.25'

plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
1 change: 1 addition & 0 deletions example/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ DEPENDENCIES
fastlane-plugin-versioning_android
fastlane-plugin-versioning_ios
logger
minitest (~> 5.25)
mutex_m
xcodeproj (< 1.26.0)

Expand Down
19 changes: 17 additions & 2 deletions example/ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,23 @@ load "/tmp/override_cio_sdk.rb"
# -------------

# Resolve scripts with node to allow for hoisting
def node_require(script)
require Pod::Executable.execute_command('node', ['-p',
def node_resolve(script)
Pod::Executable.execute_command('node', ['-p',
"require.resolve(
'#{script}',
{paths: [process.argv[1]]},
)", __dir__]).strip
end

def node_require(script)
require node_resolve(script)
end

node_require('react-native/scripts/react_native_pods.rb')
node_require('react-native-permissions/scripts/setup.rb')

require_relative '../../ios/cocoapods_deployment_target'

require_relative "../scripts/ios_project_setup_utils.rb"

platform :ios, min_ios_version_supported
Expand All @@ -29,6 +35,10 @@ setup_permissions([
])

push_provider = (ENV["PUSH_PROVIDER"] || "apn").downcase
customer_io_minimum_ios_version = CustomerIO::CocoaPodsDeploymentTarget.maximum(
min_ios_version_supported,
'15.0'
)

app_target_name = "SampleApp"
nse_target_name = "NotificationServiceExtension"
Expand Down Expand Up @@ -74,6 +84,11 @@ target app_target_name do
:mac_catalyst_enabled => false
)

CustomerIO::CocoaPodsDeploymentTarget.normalize!(
installer,
minimum_ios_version: customer_io_minimum_ios_version
)

end

end
Expand Down
13 changes: 10 additions & 3 deletions example/react-native.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@ module.exports = {
root: path.join(__dirname, '..'),

platforms: {
// Codegen script incorrectly fails without this
// So we explicitly specify the platforms with empty object
ios: {},
// Codegen fails without explicitly configured platforms. The package
// also publishes a rich-push podspec, so pin the primary podspec to
// keep React Native autolinking deterministic.
ios: {
podspecPath: path.join(
__dirname,
'..',
'customerio-reactnative.podspec'
),
},
android: {},
},
},
Expand Down
Loading
Loading