From 777b3d6fda47f1533c8bd3f58f182cf3d2efb6e6 Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Mon, 28 Sep 2026 21:46:48 -0400 Subject: [PATCH] feat(ios): add opt-in Swift Package Manager mode for the mParticle SDKs 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) --- .github/workflows/pull-request.yml | 29 +++++- MIGRATING.md | 16 ++++ README.md | 44 ++++++++- ios/mparticle_spm.rb | 93 +++++++++++++++++++ react-native-mparticle.podspec | 44 ++++++++- .../RCTConvertCommerceMappingTests.m | 10 +- .../RNMPRoktPlaceholderTests.m | 2 +- sample/ios/Podfile | 34 +++++-- scripts/ios/check-single-copy.sh | 29 ++++++ 9 files changed, 280 insertions(+), 21 deletions(-) create mode 100644 ios/mparticle_spm.rb create mode 100755 scripts/ios/check-single-copy.sh diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 31cdf43..2597c96 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -114,9 +114,20 @@ jobs: run: ./gradlew assembleDebug ios-sample-app: - # Keep this display name in sync with branch protection required checks ("iOS Sample App"). - name: iOS Sample App + # The CocoaPods leg keeps the display name the "main" ruleset requires ("iOS Sample App"). + name: ${{ matrix.check_name }} runs-on: macos-15 + strategy: + fail-fast: false + matrix: + include: + - ios_deps: cocoapods + check_name: iOS Sample App + - ios_deps: spm + check_name: iOS Sample App (SPM) + env: + # sample/ios/Podfile takes the mParticle SDKs from Swift Package Manager when this is 1. + MP_USE_SPM: ${{ matrix.ios_deps == 'spm' && '1' || '0' }} steps: - name: Checkout uses: actions/checkout@v7 @@ -178,6 +189,20 @@ jobs: -only-testing:MParticleSampleTests/RNMParticleDuplicateSDKTests \ | bundle exec xcpretty -k + - name: Archive iOS sample app and check for a single copy of each SDK + working-directory: sample/ios + run: | + set -o pipefail && xcodebuild -workspace MParticleSample.xcworkspace \ + -configuration Release \ + -scheme MParticleSample \ + -destination 'generic/platform=iOS' \ + -derivedDataPath ios/build \ + -archivePath ios/build/MParticleSample.xcarchive \ + CODE_SIGNING_ALLOWED=NO \ + archive \ + | bundle exec xcpretty -k + ../../scripts/ios/check-single-copy.sh ios/build/MParticleSample.xcarchive/Products/Applications/MParticleSample.app + pr-notify: if: > github.event_name == 'pull_request' && diff --git a/MIGRATING.md b/MIGRATING.md index 6b31444..bc3ab56 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -2,6 +2,22 @@ This document provides migration guidance for changes in `react-native-mparticle`. +## Moving an iOS app to Swift Package Manager mode + +Optional, iOS only. The default CocoaPods setup keeps working unchanged. See [README › Swift Package Manager (opt-in)](./README.md#swift-package-manager-opt-in) for what the mode does. + +1. In `ios/Podfile`, add `$RNMParticleUseSPM = true` and `require_relative '../node_modules/react-native-mparticle/ios/mparticle_spm'` above the first `target` block. +2. Remove every mParticle and Rokt pod you declared, such as `pod 'mParticle-Rokt'`, `pod 'mParticle-Apple-SDK'` or `pod 'Rokt-Widget'`. +3. Remove the mParticle, Rokt, `RoktContracts`, `RoktUXHelper` and `DcuiSchema` names from any `pre_install` hook that makes them dynamic frameworks. If the hook lists nothing else, delete it. +4. If you added mParticle or Rokt Swift packages to the app target by hand, remove them in Xcode (target › General › Frameworks, Libraries, and Embedded Content, and the project's Package Dependencies). The helper adds them back, pinned. +5. In `post_install`, after `react_native_post_install(...)`, call `mparticle_spm_post_install(installer, kits: [...])` with one entry per kit. +6. Run `pod install`. It prints each package it adds, and fails with a list of pods to remove if any would add a second copy of the SDK. +7. Build, run a Debug build once to confirm no red box, and commit the `.xcodeproj` change and `Package.resolved`. + +A Swift `AppDelegate` needs no change: `import mParticle_Apple_SDK` resolves from the Swift package. + +To go back, undo these steps: remove `$RNMParticleUseSPM` and the helper call, restore your kit pods and the `pre_install` hook, remove the packages from the app target, and run `pod install`. + ## Migrating embedded placements to placeholder names `MParticle.Rokt.selectPlacements` can find each embedded `RoktLayoutView` by its diff --git a/README.md b/README.md index 9a07862..3917fda 100644 --- a/README.md +++ b/README.md @@ -356,9 +356,49 @@ npm start and build your workspace from xCode. -### Swift Package Manager +### Swift Package Manager (opt-in) -Take the mParticle SDK and its kits from one dependency manager only. If the core comes from CocoaPods (through this package) and a kit such as `mParticle-Rokt` is added to the app with Swift Package Manager, the app contains two copies of the SDK. It builds and archives without warnings, then crashes at runtime. Debug builds detect this and show a red box: `[mParticle] The mParticle SDK is loaded more than once`. To fix it, remove the mParticle and Rokt Swift packages from the app and add the kits with CocoaPods, as shown above. +By default this package takes the mParticle SDKs from CocoaPods. In Swift Package Manager mode, React Native and this package still install with CocoaPods, but the mParticle core SDK and its kits come from Swift Package Manager and are linked into your app target. Use it when you add kits with Swift Package Manager, or when you need mParticle SDK releases that are not published to CocoaPods. iOS only: `$RNMParticleUseSPM` applies to the whole Podfile, so a Podfile with a tvOS target that uses this package must stay on CocoaPods. + +Every mParticle and Rokt SDK must then come from Swift Package Manager, and none from CocoaPods. If the core comes from one and a kit from the other, the app contains two copies of the SDK: it builds and archives without warnings, then crashes at runtime. + +1. At the top of `ios/Podfile`, before any `target` block: + + ```ruby + $RNMParticleUseSPM = true + require_relative '../node_modules/react-native-mparticle/ios/mparticle_spm' + ``` + +2. Remove the mParticle kit pods (for example `pod 'mParticle-Rokt'`) and the mParticle entries of the `pre_install` dynamic-framework hook shown above. + +3. At the end of `post_install`, after `react_native_post_install(...)`, list your kits: + + ```ruby + mparticle_spm_post_install(installer, kits: [ + { url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt', version: '9.6.1' }, + ]) + ``` + + `core_version:` pins the core SDK; it defaults to the version this release was tested with. + +4. Run `pod install`, then build as usual. + +**What `mparticle_spm_post_install` changes.** On each `pod install` it edits your app's `.xcodeproj`: it adds a Swift package reference for the mParticle 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. + +**Linkage.** This package's pod is always built as a static framework in this mode, so it works with each CocoaPods linkage: + +| Podfile linkage | Supported | +| --------------------------------------- | --------- | +| Static libraries (React Native default) | ✓ | +| `use_frameworks! :linkage => :static` | ✓ | +| `use_frameworks! :linkage => :dynamic` | ✓ | + +**Troubleshooting.** + +- `pod install` fails with `[mParticle] $RNMParticleUseSPM is set, but these pods would add a second copy of the mParticle/Rokt SDKs`: remove the pods it lists, including entries in a `pre_install` hook, and add those kits through `mparticle_spm_post_install` instead. +- `pod install` fails with `The Swift pod mParticle-Apple-SDK depends upon mParticle-Apple-SDK-ObjC, which does not define modules`: an mParticle kit pod such as `mParticle-Rokt` is still declared. CocoaPods reports this before the check above can run. Remove the kit pod and add the kit through `mparticle_spm_post_install`. +- The build fails with `[mParticle] $RNMParticleUseSPM is set but the mParticle-Apple-SDK Swift package is not linked into the app target`: `mparticle_spm_post_install` did not run, or the package was removed from the app target afterwards. Call it in `post_install` and run `pod install` again. +- A Debug build shows the red box `[mParticle] The mParticle SDK is loaded more than once`: the SDK comes from both CocoaPods and Swift Package Manager. Remove the mParticle and Rokt pods and Swift packages you added by hand, then set up one mode as described above. ## Android (Manual Setup) diff --git a/ios/mparticle_spm.rb b/ios/mparticle_spm.rb new file mode 100644 index 0000000..6523936 --- /dev/null +++ b/ios/mparticle_spm.rb @@ -0,0 +1,93 @@ +# Opt-in Swift Package Manager mode for the mParticle SDKs (see README › Swift Package Manager). +# +# Podfile usage (bare React Native): +# +# $RNMParticleUseSPM = true # must be set before `target` / use_native_modules! +# require_relative '../node_modules/react-native-mparticle/ios/mparticle_spm' +# ... +# post_install do |installer| +# react_native_post_install(installer, ...) +# mparticle_spm_post_install(installer, kits: [ +# { url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt', version: '9.6.1' }, +# ]) +# end +# +# What it does: +# 1. Refuses to install when a pod would add a second copy of the mParticle / Rokt SDKs (mixing pods +# and Swift packages builds and archives silently, then crashes at runtime). +# 2. Adds the mParticle core (and any kits) as Swift packages to every iOS application target that +# uses this pod, idempotently, pinned to an exact version. The app target owns the only copy of each +# SDK. +require 'xcodeproj' + +module MParticleSPM + # Keep these URLs byte-identical to the ones the mParticle kits use, so SwiftPM unifies them into one + # package identity (mparticle-apple-sdk). No `.git` suffix for the core. + CORE_URL = 'https://github.com/mParticle/mparticle-apple-sdk'.freeze + CORE_PRODUCT = 'mParticle-Apple-SDK'.freeze + # Default core version when the Podfile does not pass one. Bump with each tested native release. + DEFAULT_CORE_VERSION = '9.6.1'.freeze + CONFLICTING_PODS = /\A(mParticle-.*|Rokt-Widget|RoktContracts|RoktUXHelper|DcuiSchema)\z/.freeze + + def self.guard!(installer) + conflicts = installer.pod_targets.map(&:pod_name).uniq.grep(CONFLICTING_PODS) + return if conflicts.empty? + + raise Pod::Informative, + "[mParticle] $RNMParticleUseSPM is set, but these pods would add a second copy of the " \ + "mParticle/Rokt SDKs next to the Swift packages: #{conflicts.sort.join(', ')}. Remove them " \ + "(including any mParticle pre_install dynamic-framework hook entries) and add kits via " \ + "mparticle_spm_post_install(installer, kits: [...]) instead." + end + + # Only iOS apps that use this pod: other apps in the same Podfile must not get the SDKs. + def self.application_targets(installer) + aggregates = installer.aggregate_targets.select do |aggregate| + aggregate.platform.name == :ios && aggregate.pod_targets.any? { |pod| pod.pod_name == 'react-native-mparticle' } + end + aggregates.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 + + def self.add_package(project, target, url:, product:, version:) + ref = project.root_object.package_references.find do |r| + r.is_a?(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference) && r.repositoryURL == url + end + unless ref + ref = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference) + ref.repositoryURL = url + project.root_object.package_references << ref + end + requirement = { 'kind' => 'exactVersion', 'version' => version } + changed = ref.requirement != requirement + ref.requirement = requirement + + return changed if target.package_product_dependencies.any? { |d| d.product_name == product } + + dep = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) + dep.package = ref + dep.product_name = product + target.package_product_dependencies << dep + build_file = project.new(Xcodeproj::Project::Object::PBXBuildFile) + build_file.product_ref = dep + target.frameworks_build_phase.files << build_file + true + end +end + +def mparticle_spm_post_install(installer, core_version: MParticleSPM::DEFAULT_CORE_VERSION, kits: []) + MParticleSPM.guard!(installer) + packages = [{ url: MParticleSPM::CORE_URL, product: MParticleSPM::CORE_PRODUCT, version: core_version }] + kits + touched = {} + MParticleSPM.application_targets(installer).each do |project, target| + packages.each do |pkg| + added = MParticleSPM.add_package(project, target, **pkg.slice(:url, :product, :version)) + Pod::UI.puts "[mParticle] #{target.name} <- #{pkg[:product]} #{pkg[:version]}".green if added + end + touched[project.path.to_s] = project + end + touched.each_value(&:save) +end diff --git a/react-native-mparticle.podspec b/react-native-mparticle.podspec index 8e6d6c4..86ff8ba 100644 --- a/react-native-mparticle.podspec +++ b/react-native-mparticle.podspec @@ -18,12 +18,52 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/mParticle/react-native-mparticle.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" + # Opt-in Swift Package Manager mode: set `$RNMParticleUseSPM = true` at the top of the Podfile and + # call `mparticle_spm_post_install` (ios/mparticle_spm.rb) in post_install. When it is unset, this + # podspec resolves exactly as before. + use_spm = defined?($RNMParticleUseSPM) && $RNMParticleUseSPM + + if use_spm + # The app target links mParticle and RoktContracts as Swift packages. This pod only compiles + # 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 + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => '"$(DERIVED_FILE_DIR)/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"' + } + # Xcode writes a module map holding the absolute checkout path of each package, for build and + # archive alike, under OBJROOT. Linking that directory into this target's derived sources means no + # DerivedData or -clonedSourcePackagesDirPath layout is assumed. Declaring RoktContracts-Swift.h + # as an input orders this phase, and so this target's compile, after the RoktContracts package. + maps = '${OBJROOT}/GeneratedModuleMaps-${PLATFORM_NAME}' + s.script_phase = { + :name => '[mParticle] Locate Swift Package headers', + :execution_position => :before_compile, + :input_files => ["#{maps}/mParticle_Apple_SDK_ObjC.modulemap", "#{maps}/RoktContracts-Swift.h"], + :output_files => ['${DERIVED_FILE_DIR}/mParticleSPMInclude'], + :script => <<~'SH' + set -eu + MAP="${OBJROOT}/GeneratedModuleMaps-${PLATFORM_NAME}/mParticle_Apple_SDK_ObjC.modulemap" + INC=$(sed -n 's/^umbrella "\(.*\)"$/\1/p' "$MAP" 2>/dev/null || true) + if [ -z "$INC" ] || [ ! -d "$INC" ]; then + echo "error: [mParticle] \$RNMParticleUseSPM is set but the mParticle-Apple-SDK Swift package is not linked into the app target. Call mparticle_spm_post_install in your Podfile post_install." + exit 1 + fi + mkdir -p "${DERIVED_FILE_DIR}" + ln -sfn "$INC" "${DERIVED_FILE_DIR}/mParticleSPMInclude" + SH + } + end + if respond_to?(:install_modules_dependencies, true) install_modules_dependencies(s) else s.dependency "React-Core" end - s.dependency 'mParticle-Apple-SDK-ObjC', '>= 9.2.2', '< 10.0' - s.dependency 'RoktContracts', '~> 2.0' + unless use_spm + s.dependency 'mParticle-Apple-SDK-ObjC', '>= 9.2.2', '< 10.0' + s.dependency 'RoktContracts', '~> 2.0' + end end diff --git a/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m b/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m index accab77..a2be6b2 100644 --- a/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m +++ b/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m @@ -2,14 +2,8 @@ #import #import "../../../ios/RNMParticle/RNMParticle.h" -// Match RNMParticle.mm / pod umbrella so tests compile against the same SDK the library uses. -#if defined(__has_include) && __has_include() -#import -#elif defined(__has_include) && __has_include() -#import -#else -#import -#endif +// The same SDK headers the library compiles against, in CocoaPods and Swift Package Manager mode. +#import "../../../ios/RNMParticle/RNMPSDKImports.h" // Implemented on `RCTConvert` in `RNMParticle.mm` (react-native-mparticle pod). // Parameter types mirror the implementations there. diff --git a/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m b/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m index fce176d..008dfda 100644 --- a/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m +++ b/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m @@ -2,7 +2,7 @@ #import #import #import -@import RoktContracts; +#import "../../../ios/RNMParticle/RNMPSDKImports.h" #import "../../../ios/RNMParticle/RNMPRokt.h" #import "../../../ios/RNMParticle/RoktPlaceholderRegistry.h" diff --git a/sample/ios/Podfile b/sample/ios/Podfile index 809630e..23804f8 100644 --- a/sample/ios/Podfile +++ b/sample/ios/Podfile @@ -9,11 +9,21 @@ require Pod::Executable.execute_command('node', ['-p', platform :ios, '15.6' prepare_react_native_project! -pre_install do |installer| - installer.pod_targets.each do |pod| - if pod.name == 'mParticle-Apple-SDK' || pod.name == 'mParticle-Apple-SDK-ObjC' || pod.name == 'mParticle-Apple-SDK-Swift' || pod.name == 'mParticle-Rokt' || pod.name == 'Rokt-Widget' || pod.name == 'RoktContracts' || pod.name == 'RoktUXHelper' || pod.name == 'DcuiSchema' - def pod.build_type; - Pod::BuildType.new(:linkage => :dynamic, :packaging => :framework) +# MP_USE_SPM=1 takes the mParticle SDKs from Swift Package Manager instead of CocoaPods +# (README › Swift Package Manager). Must be set before `target`, which evaluates the podspec. +# It adds the Swift packages to MParticleSample.xcodeproj: discard that change before switching back +# to CocoaPods, or the app links both copies (Debug builds then show a red box). +$RNMParticleUseSPM = ENV['MP_USE_SPM'] == '1' +require_relative '../node_modules/react-native-mparticle/ios/mparticle_spm' if $RNMParticleUseSPM +ROKT_KIT = { url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt', version: '9.6.1' }.freeze + +unless $RNMParticleUseSPM + pre_install do |installer| + installer.pod_targets.each do |pod| + if pod.name == 'mParticle-Apple-SDK' || pod.name == 'mParticle-Apple-SDK-ObjC' || pod.name == 'mParticle-Apple-SDK-Swift' || pod.name == 'mParticle-Rokt' || pod.name == 'Rokt-Widget' || pod.name == 'RoktContracts' || pod.name == 'RoktUXHelper' || pod.name == 'DcuiSchema' + def pod.build_type; + Pod::BuildType.new(:linkage => :dynamic, :packaging => :framework) + end end end end @@ -38,7 +48,7 @@ target 'MParticleSample' do # 5.3 resolves RoktUXHelper 1.0.0, which requires an exact `DcuiSchema` version, so the # schema can no longer float out of sync with the RoktUXHelper sources (the desync that # the previous 2.7.0 pin guarded against). - pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0' + pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0' unless $RNMParticleUseSPM target 'MParticleSampleTests' do inherit! :complete @@ -53,5 +63,17 @@ target 'MParticleSample' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) + if $RNMParticleUseSPM + mparticle_spm_post_install(installer, kits: [ROKT_KIT]) + # The unit tests compile against the SDK headers but never link the SDK: the host app does. + # Point them at the headers the react-native-mparticle pod's script phase located. + spm_headers = '"$(OBJROOT)/Pods.build/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/react-native-mparticle.build/DerivedSources/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"' + installer.aggregate_targets.select { |t| t.name.end_with?('-MParticleSampleTests') }.each do |t| + t.xcconfigs.each do |build_config, xcconfig| + xcconfig.attributes['HEADER_SEARCH_PATHS'] = "#{xcconfig.attributes['HEADER_SEARCH_PATHS']} #{spm_headers}" + xcconfig.save_as(t.xcconfig_path(build_config)) + end + end + end end end diff --git a/scripts/ios/check-single-copy.sh b/scripts/ios/check-single-copy.sh new file mode 100755 index 0000000..ec2c0ee --- /dev/null +++ b/scripts/ios/check-single-copy.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Counts the Mach-O images in an .app that define each class. Expects exactly 1 each, so exits non-zero +# when an SDK is linked twice (for example from both CocoaPods and Swift Package Manager). +# Usage: scripts/ios/check-single-copy.sh path/to/App.app (use an archived or Release .app; Debug builds keep app code in App.debug.dylib, which is not scanned) +set -u +APP="${1}" +bins=("${APP}/$(basename "${APP}" .app)") +while IFS= read -r fw; do + bins+=("${fw}/$(basename "${fw}" .framework)") +done < <(find "${APP}/Frameworks" -maxdepth 1 -name '*.framework' 2>/dev/null || true) + +defines_class() { + local binary="${1}" cls="${2}" symbols objc + symbols="$(nm -U "${binary}" 2>/dev/null || true)" + objc="$(otool -oV "${binary}" 2>/dev/null || true)" + grep -qE "_OBJC_CLASS_\\\$_${cls}\$" <<<"${symbols}" || grep -qE "^ +name +0x[0-9a-f]+ ${cls}\$" <<<"${objc}" +} + +status=0 +for cls in MParticle RoktEmbeddedView MPKitRokt RNMPRokt RoktPlaceholderRegistry RoktNativeLayoutComponentView; do + hits=() + for b in "${bins[@]}"; do + [[ -f ${b} ]] || continue + if defines_class "${b}" "${cls}"; then hits+=("$(basename "${b}")"); fi + done + echo "${cls}: ${#hits[@]} [${hits[*]-}]" + [[ ${#hits[@]} -eq 1 ]] || status=1 +done +exit "${status}"