Build Apple Clients via Remote Mac Builder

Guide for building/testing iOS, tvOS, and macOS apps from a Linux workstation using a remote Mac over SSH and XcodeBuildMCP. Covers syncing, code signing, and log capture.

Sby Skills Guide Bot
DevelopmentAdvanced
208/6/2026
Claude CodeCursorWindsurf
#xcodebuild#ios#code-signing#remote-build#ssh

Recommended for


name: mac-builder description: Use when building, testing, running, or deploying the Silo Apple clients (iOS, tvOS, macOS) from the Linux workstation, which has no Xcode — all Apple toolchain work runs on the remote mac-builder Mac over SSH and XcodeBuildMCP. Covers syncing the working tree to the Mac, XcodeGen, simulator and device builds, code signing and the keychain trap, reading real build errors, and on-device log capture. Not for editing Swift source (do that locally) and not for server or Android work.

Building the Apple clients on mac-builder

This repo is developed on a Linux host with no Xcode. Every xcodebuild, simctl, devicectl, and signing operation runs on a remote Mac reached through the SSH alias mac-builder.

Read this before the first build in a session. Full background, one-time setup, and the rationale behind each rule: docs/mac-builder.md.

Two channels, and why the difference matters

  1. XcodeBuildMCP (mcp__xcodebuildmcp__*) — a long-lived stdio MCP server running on the Mac over SSH. Structured build/test/device/simulator/UI-automation/LLDB tools. Prefer these.
  2. Ad-hoc ssh mac-builder '<command>' — for what MCP doesn't cover: xcodegen, keychain unlocking, reading raw build logs, devicectl --console.

These are separate SSH sessions. State set in one does not apply to the other. That single fact causes the worst failure in this workflow — see Signing.

If MCP tools are missing entirely, the server isn't connected; fall back to ad-hoc SSH and tell the user. If only some tools exist (simulator but no device/macOS/debug), the wrapper's XCODEBUILDMCP_ENABLED_WORKFLOWS is wrong — see docs/mac-builder.md.

Step 1 — Sync the working tree, and verify it

The Mac has its own checkout at ~/silo-apple-deploy. It is build scratch. Never edit source there, never commit from it. Local Linux checkout is the source of truth.

A build result from a stale tree is worse than no build. Always sync, always verify.

Full sync — first sync of a session, after a branch switch, or after large changes:

rsync -az --delete --exclude 'DerivedData' --exclude 'build' \
  /srv/dev/github/SiloServer/silo-apple/ mac-builder:~/silo-apple-deploy/

Incremental sync — the fast path while iterating (seconds, not tens of seconds). Diff against a fixed base commit and reset first, so each push is the full cumulative delta:

git diff <base-commit> > /tmp/silo.patch
ssh mac-builder 'cd ~/silo-apple-deploy && git checkout -- .'
ssh mac-builder 'cd ~/silo-apple-deploy && git apply - && git status --short' < /tmp/silo.patch

Then verify — do not skip this:

ssh mac-builder 'cd ~/silo-apple-deploy && git log --oneline -1 && git status --short | wc -l'

The commit must match local HEAD and the count must match local git status --short | wc -l. If either differs, fix the sync before building. Do not report a build result from an unverified tree.

Step 2 — Regenerate the project

Silo.xcodeproj is generated by XcodeGen and is not committed. A stale one silently omits new files, producing confusing "cannot find type" errors.

ssh mac-builder 'cd ~/silo-apple-deploy/iosApp && /opt/homebrew/bin/xcodegen generate'

Required after any sync touching project.yml, adding/removing files, or changing signing xcconfigs. Cheap — just run it after every sync. Use the absolute path: a non-interactive SSH session has no Homebrew on PATH.

Step 3 — Session defaults

session_show_defaults          # always call before the first build in a session

If unset, set them once so later calls take no arguments:

// session_set_defaults
{
  "projectPath": "/Users/<mac-user>/silo-apple-deploy/iosApp/Silo.xcodeproj",
  "scheme": "Silo",
  "simulatorId": "<uuid from list_sims>",
  "configuration": "Debug"
}

Get <mac-user> from ssh mac-builder pwd. Discover IDs with list_sims / list_devicesnever hardcode simulator or device UUIDs; they change on runtime reinstall and re-pairing, and device IDs are personal hardware identifiers that must not be committed to this public repo.

Schemes: Silo (iOS), SiloTV (tvOS), SiloMac (macOS).

Building and testing

Simulator — the default

Needs no developer signing identity, so it never hits the login-keychain trap. Normal simulator build/run tools still apply Xcode's local ad-hoc signature and simulated entitlements. Keep that signature for any flow that signs in: Silo stores its session in the Keychain, and an installed CODE_SIGNING_ALLOWED=NO product has no Keychain entitlement, so authentication exists only in memory and disappears on the next launch.

| Intent | Tool | |---|---| | Build | build_sim | | Build and run in one call | build_run_sim (prefer over build + launch — fewer SSH round trips) | | Unit tests | test_sim | | tvOS / macOS | same tools with {"scheme": "SiloTV"} / build_macos, test_macos | | Screenshot, UI tree | screenshot, snapshot_ui | | Interact | tap, type_text, swipe, button | | Debug | debug_attach_sim, debug_breakpoint_add, debug_stack, debug_variables |

Fast compile check, no signing

Quickest confirmation that a platform still builds:

ssh mac-builder 'cd ~/silo-apple-deploy/iosApp && \
  xcodebuild build -project Silo.xcodeproj -scheme SiloTV \
  -destination "generic/platform=tvOS" CODE_SIGNING_ALLOWED=NO 2>&1 \
  | grep -E "error:|BUILD (SUCCEEDED|FAILED)"'

-scheme SiloMac -destination "platform=macOS" for the Mac app.

This is compile-only. Never install or launch this unsigned product for authenticated UI validation. Use build_run_sim or a normal simulator xcodebuild without CODE_SIGNING_ALLOWED=NO, reuse the same simulator, and verify the built app contains simulated application-identifier / keychain-access-groups entitlements before asking the user to sign in. Once authenticated, prefer stop/launch over reinstalling when the binary has not changed.

Physical device

Requires real signing. Discover the device first:

ssh mac-builder 'xcrun devicectl list devices'

An Apple TV reads unavailable while asleep — ask the user to wake it rather than retrying.

Build with the keychain unlock in the same SSH command (see below), then:

ssh mac-builder 'xcrun devicectl device install app --device <DEVICE_ID> \
  ~/silo-build-ios/Build/Products/Debug-iphoneos/Silo.app'
ssh mac-builder 'xcrun devicectl device process launch --device <DEVICE_ID> org.siloserver.silo'

Use the stable derived-data paths ~/silo-build-ios / ~/silo-build-tvos so incremental builds stay warm and product paths are predictable. tvOS products land in Debug-appletvos — installing a Debug-appletvsimulator bundle fails confusingly, so never mix destinations.

Signing and the keychain trap

security unlock-keychain does not persist across SSH sessions. Each connection is its own security session. Unlocking in one ssh call has no effect on the next one, and none on the already-running MCP server — a different, long-lived session.

Symptom: the build dies in a CodeSign step, often on an embedded extension rather than the app. MCP output shows only "Command CodeSign failed"; the real cause (User interaction is not allowed) is in the raw log.

Rules:

  • Normally signed simulator builds are unaffected. Xcode uses "Sign to Run Locally" and does not need the developer login keychain.
  • CODE_SIGNING_ALLOWED=NO is compile-only. It is safe for build checks, but an installed Silo product cannot persist its Keychain-backed login across relaunches.
  • Device builds: unlock inside the same ssh command as xcodebuild:
ssh mac-builder 'security unlock-keychain -p "$KEYCHAIN_PASSWORD" ~/Library/Keychains/login.keychain-db && \
  cd ~/silo-apple-deploy/iosApp && \
  xcodebuild build -project Silo.xcodeproj -scheme Silo \
    -destination "id=<DEVICE_ID>" -derivedDataPath ~/silo-build-ios \
    DEVELOPMENT_TEAM=<TEAM_ID> -allowProvisioningUpdates 2>&1 | tail -30'
  • If codesign still fails right after a successful unlock, the partition list is blocking non-interactive access:
ssh mac-builder 'security unlock-keychain -p "$KEYCHAIN_PASSWORD" ~/Library/Keychains/login.keychain-db && \
  security set-key-partition-list -S apple-tool:,apple:,codesign: -s \
    -k "$KEYCHAIN_PASSWORD" ~/Library/Keychains/login.keychain-db'
  • Cheap isolation test instead of a five-minute build:
ssh mac-builder 'security find-identity -v -p codesigning'
ssh mac-builder 'cp /bin/echo /tmp/cs-probe; codesign --force --sign <IDENTITY_HASH> /tmp/cs-probe; rm -f /tmp/cs-probe'

Watch for CSSMERR_TP_CERT_REVOKED — revoked certs linger in the keychain and get picked ahead of the good one.

Secret handling. The keychain password and team ID are not stored on the Linux host and must not be written into this repo. Ask the user, or have them unlock the keychain out-of-band. Never inline a password in a command — agent sessions write full transcripts to disk. If a device build isn't essential to the task, use a simulator or unsigned build and say so.

Signing configuration is in-repo: iosApp/Signing/Local.xcconfig.sampleLocal.xcconfig (gitignored) overrides bundle IDs, entitlements, and DEVELOPMENT_TEAM for Personal Teams. Run xcodegen generate after editing it.

Reading the real build errors

MCP output is summarized and truncated. Full logs are on the Mac; the tool result gives the exact path under ~/Library/Developer/XcodeBuildMCP/workspaces/<workspace>-<hash>/logs/.

ssh mac-builder 'grep -E "error:|CSSMERR|Command CodeSign" "<log-path>" | head -20'
ssh mac-builder 'grep -B5 -A25 "CodeSign /Users.*<Target>" "<log-path>"'   # signing failures

For raw xcodebuild output, always filter with grep -E "error:" — real errors hide under thousands of lines of toolchain noise. Do not diagnose from the tail alone.

Capturing device logs

OSLog does not reach devicectl --console; print() to stdout does.

ssh mac-builder 'nohup xcrun devicectl device process launch \
  --device <DEVICE_ID> --console org.siloserver.silo \
  >> /tmp/silo-run.log 2>&1 </dev/null & disown'

All three of nohup, </dev/null, and disown are required, or a SIGHUP / SIGTTIN stop / shell exit kills the stream.

Tail with tail -n 0 -F (capital -F survives truncation) piped through grep --line-buffered — without line buffering, events arrive in minute-long bursts.

Closing the --console stream terminates the app on the device. Never pkill that process mid-test; if you do, you caused the App terminated due to signal 15 and must relaunch. --console is launch-time only — you cannot attach to an app the user is already using.

Checklist before reporting a result

  • [ ] Synced, and commit + dirty-file count verified against local
  • [ ] xcodegen generate run after the sync
  • [ ] Errors read from the full log, not the truncated tool output
  • [ ] Destination matches the claim (simulator build ≠ device verification)
  • [ ] Authenticated simulator runs use a normally signed product, not CODE_SIGNING_ALLOWED=NO
  • [ ] Nothing committed or edited on the Mac

When the simulator misbehaves, check the disk first

A near-full boot volume on the Mac does not announce itself. It presents as CoreSimulator corruption, and every symptom points somewhere else:

  • xcrun simctl install hangs forever on a ~100 MB app.
  • A booted device shuts itself down between two commands.
  • simctl boot reports "delete the device properly or erase contents and settings".
  • ps / pgrep block, because simulator processes are wedged in kernel wait.
  • A stray simctl diagnose sits holding a device lock — a downstream symptom, not the cause.
ssh mac-builder 'df -h /System/Volumes/Data'

Below ~15 GiB free, expect trouble. ~/Library/Developer/CoreSimulator/Devices runs to tens of GB on its own. Reclaim with xcrun simctl erase <udid> for devices you aren't using (this wipes their app data and any signed-in session) and xcrun simctl delete unavailable.

~/silo-build-ios and ~/silo-build-tvos are symlinks to /Volumes/NVMe/..., which has far more room than the boot volume. The documented paths above still work unchanged — don't convert them back into real directories on /.

Gotchas

  • SourceKit "No such module" errors are IDE index artifacts. If xcodebuild says BUILD SUCCEEDED, the code is fine.
  • mac-builder's Tailscale direct path sometimes drops and falls back to a lossy DERP relay. SSH then times out while tailscale ping mac-builder still answers (via DERP, ~300ms). The machine is not down; it recovers on its own. Connecting by raw Tailscale IP may work while the hostname does not.
  • Device install can fail on a Developer Disk Image mismatch when the device OS is newer than the Mac's Xcode. Updating Xcode is the fix; the build isn't wrong.
  • Homebrew is not on the non-interactive SSH PATH. Use absolute paths: /opt/homebrew/bin/xcodegen, /opt/homebrew/bin/xcodebuildmcp.
  • Commits, pushes, and PRs happen on Linux against the real working tree — never from ~/silo-apple-deploy.
  • Codex memory may reference a helper at /home/dev/.local/bin/silo-apple-deploy. That script no longer exists. Use the sync steps above.

Not for this skill

  • Editing Swift source — do that in the local checkout, then sync.
  • Silo server or Android work.
  • Client bugs reproducible without a build.
Related skills