Hello, Xcode: The Toolchain Shock
I expected a compiler. I found a religion.
The first time you open Xcode, it downloads things. Not a quick download. A large, considered download that takes long enough that you start questioning your choices. Then it opens, and you are presented with an interface that contains more panels than you expected, each with its own vocabulary, each assuming you already know what the others do.
I looked at it for about thirty seconds, understood none of it, and closed it.
The Mac platform itself was not completely foreign. I had written Objective-C and C++ professionally. Objective-C never felt as alien as it probably should have: I had spent years writing Smalltalk, and [object message] is just Smalltalk with square brackets and a C compiler underneath. But knowing the platform and knowing how to ship an application on it are different things entirely.
I wrote Renala in VS Code. I built it with xcodebuild on the command line. I opened Xcode proper exactly twice during the entire project: once to create the signing certificate, and once to manage the provisioning profile before the first App Store submission. Both times I closed it as quickly as possible.
Xcode the IDE and Xcode the toolchain are separable things. The toolchain: xcodebuild, xcrun, the command-line tools: is what actually compiles your code, signs your binary, and produces a distributable app. The IDE is one interface to that toolchain. It is not the only one.
The .xcodeproj problem
The file that represents the Xcode project is Renala.xcodeproj. It is actually a directory, not a file. Inside it lives project.pbxproj: hundreds or thousands of lines of a custom property list format that encodes every file reference, every build phase, every configuration setting.
Here is a representative excerpt:
/* Begin PBXBuildFile section */
2B3F1A4C2B9E3A0100C4E8D1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3F1A4B2B9E3A0100C4E8D1 /* ContentView.swift */; };
2B3F1A502B9E3A0100C4E8D1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2B3F1A4F2B9E3A0100C4E8D1 /* Assets.xcassets */; };
/* End PBXBuildFile section */
Every file in the project has two entries: one declaring its existence, one declaring it participates in a build phase (compile, link, copy resources). Add a Swift file without updating the project, and Xcode does not compile it. Delete a file without updating the project, and Xcode reports a missing reference. The format was designed to be written by Xcode, not by humans. It merges in Git the way a car merges with a tree. Two people adding a file at the same time produce a conflict that no diff tool handles gracefully.
The obvious solution is not to edit it by hand. Let something generate it.
XcodeGen
XcodeGen is a tool that reads a project.yml file and generates the .xcodeproj. The YAML is human-readable, version-control-friendly, and compact. Here is what a target definition looks like:
targets:
Renala:
type: application
platform: macOS
sources:
- Sources/Renala
settings:
PRODUCT_BUNDLE_IDENTIFIER: com.renala.app
MACOSX_DEPLOYMENT_TARGET: "14.0"
SWIFT_VERSION: "6.0"
That target definition replaces the equivalent pbxproj footprint: file references, build phases, configuration blocks. A build setting is a key-value pair. A capability is a declaration, not a sequence of checkboxes you clicked in an IDE panel.
The workflow: edit project.yml, run make generate, commit the YAML. The .xcodeproj is regenerated on demand and treated as a build artifact, not a source file. If a merge conflict appears in the .xcodeproj, the answer is not to resolve it. The answer is to resolve the project.yml conflict and regenerate.
The cost: one more dependency to maintain, and XcodeGen must keep pace with Xcode’s configuration changes across major versions. So far, it has.
The dual build system
There are two build systems. They solve different problems.
Swift Package Manager (Package.swift) handles the test suite. swift test discovers and runs tests without involving Xcode at all. SPM produces executables and test runners. It is fast, scriptable, and CI-friendly. The Swift Package Manager documentation is a reasonable starting point if this is new.
Xcode (Renala.xcodeproj, driven by xcodebuild) produces the .app bundle: signed, sandboxed, with the correct entitlements, ready for distribution. This is what the App Store requires. SPM has no built-in way to produce a signed, sandboxed .app bundle ready for the App Store.1
Both systems share the same source files under Sources/Renala/. Here is the catch: a file added to Sources/ is discovered by SPM automatically, but it needs an entry in project.yml before the Xcode project knows it exists. Forget this, and you get a build that passes swift test but fails xcodebuild. Confusing the first time it happens.
The diagram below shows how the pieces relate:
graph LR
accTitle: Build system workflow
accDescr: project.yml feeds xcodegen to generate the Xcode project. Package.swift runs tests via swift test. A Makefile unifies all commands: make generate, make build, make test, make run.
PY[project.yml] -->|xcodegen generate| XP[Renala.xcodeproj]
PS[Package.swift] -->|swift test| T[Tests]
XP -->|xcodebuild| APP[Renala.app]
MF[Makefile] -->|make generate| PY
MF -->|make build| XP
MF -->|make test| PS
MF -->|make run| APP
The Makefile hides the underlying commands behind a single vocabulary. make build calls xcodebuild with the right flags. make test calls swift test. make run kills any existing instance, builds, and launches. You do not need to remember the xcodebuild invocation.
The concepts that do not translate
Deploy a web server and it runs. Deploy a macOS app and the OS asks: who signed this, what is it allowed to do, and can I trust it? Three concepts that have no backend equivalent.
Bundle identifier. A reverse-DNS string that uniquely identifies your application (think Android’s applicationId or an npm package scope): com.renala.app. It connects your signing certificate to your app, your App Store listing to your binary, your iCloud container to your data. You pick it once and do not change it. Apple’s systems use it everywhere.
Entitlements. Your app’s permission manifest. The App Sandbox starts by denying everything: no file access, no network, no camera. Each capability you need is explicitly declared in a .entitlements file. Here is a minimal subset for a disk analyzer:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ...>
<plist version="1.0">
<dict>
<!-- Required for App Store distribution -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Read-write (not read-only): needed for Move to Trash -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
If the entitlement is missing, the operation fails at runtime. No warning at the app level. No fallback. The system logs a sandbox denial in Console.app, but the app itself sees nothing.2 Just silence and a button that does nothing. A disk analyzer needs to read files, obviously. But moving files to Trash requires read-write, not read-only. I had read-only for the first two weeks. The Trash button did nothing. Silently. The Entitlements reference on Apple Developer lists every available key.
Code signing. Apple’s chain of trust, and the thing that makes macOS apps tamper-evident. Your app is signed with a certificate issued by Apple to you as a developer. The signature seals the entire bundle: binary, resources, and embedded entitlements (which are baked into the code signature itself, not a separate file that gets “covered”).3
Modify the signed binary and the signature becomes invalid. For apps downloaded from the internet or the App Store, macOS Gatekeeper (the security subsystem that checks app signatures at launch) refuses to run it.4 When you submit to the App Store, Apple re-signs the app with their own certificate.
graph TD
accTitle: Code signing chain of trust
accDescr: Apple Developer Account issues a Developer Certificate, which signs Renala.app. Entitlements are embedded in the app binary. macOS Gatekeeper verifies the signature at launch. The App Store re-signs the app during processing.
DEV[Apple Developer Account] -->|issues| CERT[Developer Certificate]
CERT -->|signs| APP[Renala.app binary]
ENT[Renala.entitlements] -->|embedded in| APP
APP -->|verified at launch| MACOS[macOS Gatekeeper]
APP -->|re-signed during processing| APPSTORE[App Store]
If the signature does not match the entitlements, or if the binary has been modified after signing, the system refuses to run it. Apple’s Code Signing documentation covers the mechanics. The WWDC 2021 session Distribute apps in Xcode with cloud signing walks through the distribution chain if this is new territory.
The error messages when something goes wrong are often unhelpful. OSStatus error -67030 is not documentation. It is a puzzle from a civilization that does not want to be understood. The OSStatus lookup tool by Seth Willits is a useful reference for decoding these.
The lesson
The toolchain is macOS development. Fighting it because it does not resemble other toolchains wastes time. Learning its vocabulary, understanding why things are the way they are, makes the rest easier.
XcodeGen and the Makefile give it a saner interface, one that fits how a developer from elsewhere thinks. The .xcodeproj still exists. The signing still happens. The entitlements are still real. The Makefile just means you do not have to remember the twenty-flag xcodebuild invocation to build a release archive.
By the time I reached the first App Store submission, the toolchain had stopped being a source of friction. The Makefile handled the commands, XcodeGen handled the project file, and I stayed in VS Code for everything else. I opened Xcode when I had to. The rest of the time it sat in the Applications folder, doing nothing.
That is where you want it to be.
Footnotes
-
Community tools like
swift-bundlercan produce.appbundles on top of SPM for direct distribution, but no Apple-supported path exists within SPM itself for the full App Store pipeline (signing, sandboxing, entitlements, provisioning). ↩ -
The system does log sandbox denials. In Console.app, filter by
subsystem == "com.apple.sandbox"and you will seeDENYentries with the denied operation and path. The problem is that the app itself receives no error, no exception, and no indication that a sandbox denial occurred. You have to know to look in Console. ↩ -
Entitlements are not a separate file that the signature “covers.” During code signing, the entitlements from your
.entitlementsfile are embedded into the CMS signature blob attached to the binary. At runtime, macOS reads entitlements from the signature, not from a file on disk. ↩ -
Gatekeeper’s enforcement is context-dependent. It performs full signature verification on quarantined apps (those downloaded from the internet). For apps built locally or previously approved, routine launches may not re-verify the full cryptographic signature. The strictest enforcement applies to distributed apps, which is the scenario described here. ↩
