Shipping an iOS build headlessly over SSH
I wanted to build and install my iOS app from a remote agent session —
no Xcode window open, nobody at the Mac. xcodebuild over SSH refused
to codesign. Here's the workaround that ended up clean enough to use
in production.
The symptom
error: errSecInternalComponent
Command CodeSign failed with a nonzero exit code
The login keychain was unlocked. The signing identity was installed. Manual builds from a Terminal window worked fine. But the same build launched over SSH died at the codesign step.
The root cause
An SSH session's security context can't reach the GUI login keychain
even when it's unlocked, because the SecurityAgent that fronts the
keychain is a per-session daemon tied to the GUI login. SSH lands in
a different audit session. launchctl asuser would let me push a
job into the GUI session, but it requires root and I didn't have
passwordless sudo set up.
The workaround
Drop a one-shot LaunchAgent into the GUI session of UID 501 (the console login), let it run the build inside that session where the keychain is properly available, then tear it down.
ssh user@mac 'cat > /tmp/build.sh << "EOF"
#!/bin/bash
set -x
exec > /tmp/build.log 2>&1
cd "$HOME/code/myapp"
/usr/bin/xcodebuild \
-project MyApp.xcodeproj \
-scheme MyApp \
-configuration Release \
-destination "generic/platform=iOS" \
-derivedDataPath /tmp/myapp-build \
-allowProvisioningUpdates clean build
EOF
chmod +x /tmp/build.sh
cat > ~/Library/LaunchAgents/com.example.build.plist << "EOF"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.example.build</string>
<key>ProgramArguments</key><array>
<string>/bin/bash</string><string>/tmp/build.sh</string>
</array>
<key>RunAtLoad</key><true/>
</dict></plist>
EOF
launchctl bootout gui/501/com.example.build 2>/dev/null
launchctl bootstrap gui/501 ~/Library/LaunchAgents/com.example.build.plist'
Poll /tmp/build.log for "BUILD SUCCEEDED" or whatever finish-marker
your build produces. Then install over SSH (this part is happy in any
session because devicectl doesn't need keychain access):
ssh user@mac 'xcrun devicectl device install app \
--device <device-uuid> \
/tmp/myapp-build/Build/Products/Release-iphoneos/MyApp.app'
Clean up:
ssh user@mac '
launchctl bootout gui/501/com.example.build 2>/dev/null
rm -f ~/Library/LaunchAgents/com.example.build.plist /tmp/build.*'
Why this works
launchctl bootstrap gui/501 <plist> schedules the job in the GUI
session of user 501 — the same session your console login is in. The
build runs as that user, in that audit session, with access to the
unlocked keychain. RunAtLoad: true makes it kick off immediately.
The job runs to completion and exits.
Installing the app via devicectl doesn't need the keychain, so the
ordinary SSH session does that part fine.
What I'd do differently
The polling for "build complete" is the ugliest part. Instead of
tail-watching the log, I'd give the build script a final line that
writes a sentinel file (touch /tmp/build.done) and poll for that.
Or use KeepAlive: false plus launchctl print gui/501/<label> to
detect job exit. Either is cleaner than parsing log output.
The same pattern works for any iOS project on the Mac that hits codesign issues over SSH. If you have multiple iOS apps to build remotely, generalize the script — pass the project path and scheme as arguments — and you have a portable "remote iOS build" recipe.