SwiftPM binary-target wrapper promoted to dynamic .framework via productTypes ships an empty dylib (static archive dead-stripped at wrapper link)
Summary
When PackageSettings.productTypes promotes a SwiftPM target that wraps a .binaryTarget (static xcframework) from its default static product to .framework (dynamic), the generated wrapper dylib is built from the wrapper’s own sources only and exports zero symbols from the underlying static xcframework. Downstream consumer targets then fail to link with Undefined symbols: _OBJC_CLASS_$_<class>, …, even though the wrapper resolves cleanly at the SwiftPM graph level and the xcframework is correctly attached to the wrapper’s Link Binary with Libraries build phase.
The cause is ld performing its standard dead-strip pass on the static archive’s members because the wrapper’s own object file holds no references into it. Tuist generates the wrapper’s link line without -ObjC, -all_load, or -force_load, so members are dropped at the wrapper’s link step.
This is mechanically distinct from the issues addressed by #6520 / #10704 / #10089 (which cover cached static-xcframework relinking) and from the symptom phrasing in #8056 (which describes missing cross-package transitive deps). The wrapper-link dead-strip case is, to my knowledge, not currently tracked.
Minimal reproduction
Full repro available at https://github.com/shgew/tuist-spm-wrapper-deadstrip — clone, then ./verify.sh runs the full scenario matrix.
Files inlined below for review. Five files, ~80 lines total. Uses Google’s public swift-package-manager-google-mobile-ads package as the SwiftPM binary-wrapper. Any other SwiftPM package that ships a static xcframework wrapped by an empty sibling target reproduces the same way (Singular, Vungle, AppLovin, Firebase static targets, …).
Tuist.swift
import ProjectDescription
let tuist = Tuist(
project: .tuist(
generationOptions: .options(defaultConfiguration: "Debug")
)
)
Tuist/Package.swift
// swift-tools-version: 6.0
import PackageDescription
#if TUIST
import Foundation
import ProjectDescription
// Set TUIST_REPRO_LINKING=dynamic to promote the SwiftPM product to a dynamic
// framework (the case that reproduces the bug). Without it, products stay at
// their default product type and no problem occurs.
private let dynamic = ProcessInfo.processInfo.environment["TUIST_REPRO_LINKING"] == "dynamic"
let packageSettings = PackageSettings(
productTypes: dynamic ? ["GoogleMobileAds": .framework] : [:]
)
#endif
let package = Package(
name: "TuistEmptyWrapperRepro",
dependencies: [
.package(
url: "https://github.com/googleads/swift-package-manager-google-mobile-ads.git",
exact: "12.14.0"
)
]
)
Project.swift
import Foundation
import ProjectDescription
// Two consumer dynamic-framework targets, both linking the GoogleMobileAds
// product. Sufficient to surface both the link-failure mode (no workaround)
// and the duplicate-class mode (per-consumer workaround).
let workaround: SettingsDictionary =
ProcessInfo.processInfo.environment["TUIST_REPRO_WORKAROUND"] == "1"
? ["OTHER_LDFLAGS": "$(inherited) -framework GoogleMobileAds"]
: [:]
let project = Project(
name: "TuistEmptyWrapperRepro",
targets: [
.target(
name: "Consumer",
destinations: .iOS,
product: .framework,
bundleId: "io.repro.consumer",
deploymentTargets: .iOS("15.0"),
sources: ["Consumer/**"],
dependencies: [.external(name: "GoogleMobileAds")],
settings: .settings(base: workaround)
),
.target(
name: "Consumer2",
destinations: .iOS,
product: .framework,
bundleId: "io.repro.consumer2",
deploymentTargets: .iOS("15.0"),
sources: ["Consumer2/**"],
dependencies: [.external(name: "GoogleMobileAds")],
settings: .settings(base: workaround)
)
]
)
Consumer/Consumer.swift
import GoogleMobileAds
import UIKit
public enum Consumer {
public static func startSDK() {
MobileAds.shared.start(completionHandler: nil)
}
public static func makeBanner() -> UIView {
BannerView(adSize: AdSizeBanner)
}
}
Consumer2/Consumer2.swift
import GoogleMobileAds
import UIKit
public enum Consumer2 {
public static func loadInterstitial(rootViewController: UIViewController) {
InterstitialAd.load(
with: "<your-ad-unit-id>",
request: Request()
) { ad, _ in
ad?.present(from: rootViewController)
}
}
}
Steps
# Scenario A — default product types, wrapper stays static. Baseline; no bug.
TUIST_REPRO_LINKING=static tuist install
TUIST_REPRO_LINKING=static tuist generate --no-open --cache-profile none
xcodebuild -workspace TuistEmptyWrapperRepro.xcworkspace -scheme Consumer \
-destination 'generic/platform=iOS Simulator' -configuration Debug \
-derivedDataPath DerivedData build
# Scenario B — productTypes promotes wrapper to dynamic. Reproduces.
TUIST_REPRO_LINKING=dynamic tuist install
TUIST_REPRO_LINKING=dynamic tuist generate --no-open --cache-profile none
xcodebuild -workspace TuistEmptyWrapperRepro.xcworkspace -scheme Consumer \
-destination 'generic/platform=iOS Simulator' -configuration Debug \
-derivedDataPath DerivedData build
# Scenario C — productTypes promotion + per-consumer workaround.
# Link succeeds but produces duplicate ObjC class warnings at runtime.
TUIST_REPRO_LINKING=dynamic TUIST_REPRO_WORKAROUND=1 \
tuist generate --no-open --cache-profile none
xcodebuild ... build # both schemes
Expected behavior
In scenario B, Consumer.framework should link successfully against GoogleMobileAdsTarget.framework. The wrapper dylib should re-export the static xcframework’s symbols, since the wrapper’s Link Binary with Libraries phase contains GoogleMobileAds.xcframework and the wrapper’s packaging is a dynamic .framework.
Actual behavior
Scenario B
Consumer link fails:
Undefined symbols for architecture arm64:
"_GADAdSizeBanner", referenced from:
static Consumer.Consumer.makeBanner() -> __C.UIView in Consumer.o
"_OBJC_CLASS_$_GADBannerView", referenced from:
in Consumer.o
ld: symbol(s) not found for architecture arm64
Inspection of the wrapper dylib confirms it exports zero _OBJC_CLASS_$_GAD* symbols:
$ nm -a DerivedData/Build/Products/Debug-iphonesimulator/\
GoogleMobileAdsTarget.framework/GoogleMobileAdsTarget \
| grep -c '_OBJC_CLASS_\$_GAD'
0
$ stat -f %z .../GoogleMobileAdsTarget.framework/GoogleMobileAdsTarget
157568
Scenario C (per-consumer -framework GoogleMobileAds workaround)
Link succeeds. Each consumer’s dylib now contains a full copy of the static archive:
GoogleMobileAdsTarget.framework size=157568 _OBJC_CLASS_$_GAD count=0
Consumer.framework size=6849616 _OBJC_CLASS_$_GAD count=372
Consumer2.framework size=6850576 _OBJC_CLASS_$_GAD count=372
At app load, the ObjC runtime emits one objc[…]: Class GAD<X> is implemented in both Consumer.framework/Consumer and Consumer2.framework/Consumer2. One of the two will be used. Which one is undefined. warning per duplicated class — 372 warnings for this minimal example.
Scenario A (no promotion, baseline)
MACH_O_TYPE = staticlib on the wrapper. The wrapper’s static archive flows into each dynamic consumer’s link transitively. Both consumers then carry the symbols. No link error; same duplicate-class problem at runtime if multiple consumers exist. (The static path’s runtime behavior is well-known and out of scope for this report; the report’s focus is the link failure in scenario B, which appears with one consumer.)
Root cause analysis
The package layout that triggers the issue is the canonical SwiftPM “binary-target wrapped by a sibling target” idiom. From swift-package-manager-google-mobile-ads/Package.swift:
.target(
name: "GoogleMobileAdsTarget",
dependencies: [
.target(name: "GoogleMobileAds"),
.product(name: "GoogleUserMessagingPlatform", package: "GoogleUserMessagingPlatform"),
],
path: "GoogleMobileAdsTarget" // sole content: an empty placeholder.swift
),
.binaryTarget(
name: "GoogleMobileAds",
url: "…/googlemobileadsios-spm-12.14.0.zip",
checksum: "…"
)
The wrapper exists to declare the binary and pull in transitive SwiftPM deps so SPM’s standard target-dep machinery links them alongside the xcframework. Many vendors use this layout (Singular, Vungle, AppLovin, Firebase static targets, …).
Tuist’s PackageInfoMapper.map(target:) (cli/Sources/TuistLoader/SwiftPackageManager/PackageInfoMapper.swift lines 543-803) handles this graph as follows:
Product.from(name:type:products:productTypes:baseProductType:) (lines 997-1019) consults productTypes against both the target name and the product name. With productTypes["GoogleMobileAds"] = .framework, the wrapper’s product is resolved as .framework.
mapDependency(name:packageInfo:packageType:…) (lines 842-852) resolves the wrapper’s .target("GoogleMobileAds") dep to .xcframework(path: artifactPath, …).
- The wrapper is emitted as
PBXNativeTarget GoogleMobileAdsTarget with productType = "com.apple.product-type.framework", default MACH_O_TYPE (dynamic), and GoogleMobileAds.xcframework in PBXFrameworksBuildPhase.
Settings.from(target:productName:packageFolder:settings:…) (lines 1314+) emits OTHER_LDFLAGS = ("$(inherited)", "-L$(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)") and nothing related to force-loading.
At wrapper link time, the inputs are:
placeholder.o (compiled from placeholder.swift, empty body)
- the static archive at
GoogleMobileAds.xcframework/<slice>/GoogleMobileAds.framework/GoogleMobileAds (an ar archive)
- transitive frameworks (UMP, WebKit)
ld’s standard static-archive-loading rule: a member is pulled only when it satisfies a currently-undefined external symbol. placeholder.o has no external symbol references. The OTHER_LDFLAGS does not contain -ObjC, -all_load, or -Wl,-force_load,<binary path>. All archive members are dropped.
The wrapper’s dylib emerges as a 157 KB shell with Swift-runtime stubs only, exporting zero of the ~400 ObjC classes that live in the static archive. Downstream consumers see only the empty wrapper on the link line and fail to resolve their references.
Relationship to existing tracked issues and merged fixes
A search of the repository for related work surfaces:
#8056 (open): “Transitive dependencies of binary targets are not resolved when using local SPM package”. The phrasing centers on cross-package transitive deps. The dosu auto-summary recommends “explicitly declare all transitive deps in your Tuist target’s dependencies”. The case in this report is mechanically different — the binary’s link-binary-with-libraries wiring is correct; the issue is dead-strip at wrapper link, not missing wiring.
#6520 (merged 2024-07-16): adds static xcframeworks to consumer link phases when the static xcframework reaches the consumer through a dynamic-xcframework edge. Scoped to the cached-binaries pathway (App → DynamicXCFramework → StaticXCFramework).
#10704 (merged May 2026): partially reverts #6520‘s relinking, routing those same statics through FRAMEWORK_SEARCH_PATHS instead because relinking at the consumer level produced duplicate-symbol bugs in libraries with global state (FirebaseApp singleton registry). Confirms the team’s model: “the dynamic xcframework already absorbed their symbols during its build; relinking here causes duplicate symbols”. Same scope: cached binaries only.
#6757, #6767, #6768, #9203, #9419, #9602, #10089: all in the same family — static-xcframework-through-dynamic-boundary — and all scoped to the cached binaries pathway.
#10704‘s model is exactly the right one for the case in this report: the dynamic wrapper should absorb the underlying static archive’s symbols at its own link step. The gap is that the cached pathway does this (cached artifacts are pre-built with the archive members flattened in), while the source-build pathway (i.e. running tuist generate without tuist cache) emits a wrapper link line that doesn’t force-load anything, so dead-strip wins.
Possible directions for a fix
I’m not proposing a particular implementation; the choice involves trade-offs Tuist maintainers are best placed to make. Three plausible directions:
- Auto-inject
-ObjC on wrappers that depend on a .binary target and are promoted to a dynamic product. Surgical at PackageInfoMapper.map(target:): the function already has product, target.dependencies, and packageInfo.targets in scope. ObjC class implementations get force-loaded; the wrapper dylib carries the symbols its packaging implies. Does not help binary archives that contain non-ObjC symbols Swift cross-module specialization needs (rare in practice for the vendors using this layout).
- Auto-inject
-Wl,-force_load,<path-to-xcframework-binary> for each .binary dep of a promoted wrapper. More surgical (only the binary in question is force-loaded, not every static archive on the link line). Requires picking the right $(BUILT_PRODUCTS_DIR)/<framework>.framework/<framework> path expression — Xcode’s xcframework selector resolves the slice at build time, not at project generation time.
- Lint and refuse the promotion. Emit a clear diagnostic at generate time describing the dead-strip risk and pointing the user at
PackageSettings.targetSettings[wrapperName] for an explicit OTHER_LDFLAGS declaration.
(2) is the closest analogue of what #6520/#10704 do at the cache layer, applied at the source-build layer.
Environment
- Tuist 4.195.6 (also reproduced on 4.174.2, 4.192.3)
- Xcode 26.5.0 RC (also reproduced on 16.x with the GoogleMobileAds 11.x SDK)
- macOS 15.x arm64
- Swift tools version 6.0
The issue does not depend on tuist cache (reproduces with and without; cache pathway happens to mask it because cached artifacts are pre-flattened).