So splitting targets into different projects is a way to go, but do not make cross-project references and add target dependency. The idea is to build the subproject fully separately.
Claude helped with implementation, so I asked it to write the rest of the post.
The Solution
Two scripts, triggered at different build stages:
1. Scheme Pre-Action — BuildPkgTestCMD.sh
Builds the CLI project via a separate xcodebuild invocation before the main build starts.
2. Run Script Build Phase — CopyPkgTestCMD.sh
Copies the built binary into the app bundle after the Resources phase.
Both scripts check EFFECTIVE_PLATFORM_NAME and skip on non-Catalyst builds (e.g. iOS).
Gotchas We Hit
Build settings leaking into the nested xcodebuild call.
Scheme pre-actions inherit all build settings from the parent target as environment variables. This means the nested xcodebuild silently picks up Catalyst platform, signing, and arch settings. Fix: wrap the call with env -i, passing through only selected variables:
env -i \
PATH="$PATH" \
HOME="$HOME" \
TMPDIR="${TMPDIR:-/tmp}" \
xcodebuild \
-project "$PKGTESTCMD_PROJECT" \
-target PkgTestCMD \
-configuration "${CONFIGURATION}" \
SYMROOT="${BUILD_DIR}/PkgTestCMD" \
-quiet
Script sandboxing blocks reading the script file itself.
With ENABLE_USER_SCRIPT_SANDBOXING = YES (the default in recent Xcode), the Run Script phase can’t read its own .sh file unless it’s declared in inputPaths. Add the script path as the first input:
inputPaths:
$(SRCROOT)/PkgTest/CopyPkgTestCMD.sh
$(BUILD_DIR)/PkgTestCMD/$(CONFIGURATION)/PkgTestCMD
Archive fails with sandbox file-write-create deny.
During archive, the .app inside BUILT_PRODUCTS_DIR is actually a symlink to INSTALL_DIR/Applications/... The sandbox builds its write-allow list from declared outputPaths but doesn’t resolve symlinks — so the write to the real path gets denied. Fix: declare both paths in outputPaths:
outputPaths:
$(BUILT_PRODUCTS_DIR)/$(EXECUTABLE_FOLDER_PATH)/PkgTestCMD
$(DSTROOT)$(INSTALL_PATH)/$(EXECUTABLE_FOLDER_PATH)/PkgTestCMD
The first covers debug/run builds, the second covers the resolved archive path.
Result
Debug builds, iOS builds, and archives all work correctly. The CLI binary ends up at PkgTest.app/Contents/MacOS/PkgTestCMD alongside the main executable.