Our portfolio has 27 apps. None of them have a committed .xcodeproj file. Every project is generated from a single project.yml by XcodeGen, and the Xcode project file sits in .gitignore. This is not a philosophical choice — it is the only way I have found to maintain this many apps without losing my mind to merge conflicts, stale build settings, and invisible configuration drift.
This post covers how we structure project.yml for multi-platform targets, how compile flags create paid app variants from the same codebase, and the specific XcodeGen behaviors that cost us days before we understood them.
Why XcodeGen?
An Xcode project file (.xcodeproj/project.pbxproj) is a 2,000+ line property list that Xcode rewrites on nearly every interaction. Add a file, reorder a group, change a build setting — the diff is enormous and meaningless. For a solo developer this is tolerable. For 27 projects where automation needs to read and modify build configuration programmatically, it is a dead end.
XcodeGen replaces that with a human-readable YAML file. A typical project.yml for one of our apps is around 80 lines. It declares targets, dependencies, build settings, and entitlements. Run xcodegen generate, and a fresh .xcodeproj appears. The YAML is the source of truth; the project file is a build artifact.
The Basic Structure
Here is a simplified project.yml for one of our quiz apps:
name: CSCSQuiz
options:
bundleIdPrefix: com.techconcepts
deploymentTarget:
iOS: "16.0"
macOS: "13.0"
packages:
AppFoundation:
path: ../AppFoundation
targets:
CSCSQuiz:
type: application
supportedDestinations: [iOS, macOS]
sources: [Sources]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.techconcepts.cscs-quiz
MARKETING_VERSION: "1.1.0"
CURRENT_PROJECT_VERSION: 5
GENERATE_INFOPLIST_FILE: true
DEVELOPMENT_TEAM: 29B9U6XT2Q
dependencies:
- package: AppFoundation
entitlements:
path: Sources/CSCSQuiz.entitlements
This generates a project that builds for both iOS and macOS from the same source directory. No separate targets, no Catalyst bridging. The supportedDestinations key is the modern XcodeGen way to declare multi-platform support — it replaces the older platform: key and maps directly to Xcode's native multi-platform target feature.
Multi-Platform: supportedDestinations vs platform
This distinction matters and the documentation is sparse. The older approach uses separate targets:
# Old approach: two targets, same code
targets:
MyApp-iOS:
type: application
platform: iOS
sources: [Sources]
MyApp-macOS:
type: application
platform: macOS
sources: [Sources]
The modern approach uses one target with multiple destinations:
# Modern approach: one target, multiple platforms
targets:
MyApp:
type: application
supportedDestinations: [iOS, macOS, visionOS]
sources: [Sources]
The second approach produces a single Xcode target that compiles for all declared platforms. This is what Apple now recommends for most apps, and it is what our portfolio uses. One target means one set of build settings, one scheme, and one place to configure signing.
The catch: any tooling that detects platform by looking for the platform: key will miss supportedDestinations targets entirely. We learned this when our screenshot automation script skipped every multi-platform app because it was grepping for platform: iOS and finding nothing.
Paid Analogues: Compile Flags in project.yml
Several of our free apps have paid one-time-purchase counterparts. eXpense (free with subscription) has a paid variant. Email Converter (free with in-app purchase) has one too. Same codebase, different product — the paid version removes all monetization UI and ships as a standalone purchase on the App Store.
The mechanism is a Swift active compilation condition set in project.yml:
targets:
eXpensePaid:
type: application
supportedDestinations: [iOS, macOS]
sources: [Sources]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.techconcepts.ledgr-finance
SWIFT_ACTIVE_COMPILATION_CONDITIONS: PAID_VERSION
MARKETING_VERSION: "1.0.0"
dependencies:
- package: AppFoundation
In Swift, this flag controls what gets compiled:
#if !PAID_VERSION
import RevenueCat
import RevenueCatUI
// Subscription paywall, trial prompts, restore button
#else
// None of this code exists in the binary
struct AppPaywallView: View {
var body: some View { EmptyView() }
}
#endif
A critical detail: SWIFT_ACTIVE_COMPILATION_CONDITIONS set at the project level (under settings.base at root) applies to all targets, including test targets. If your tests verify that the paywall appears, the PAID_VERSION flag will suppress it in tests too. Always set this flag under the specific target's settings.base, not at project level.
Scheme Management
XcodeGen auto-generates schemes for every target. For most apps, this is fine. For apps with multiple related targets (main app + widget extension + paid variant), the scheme list gets noisy. We use explicit scheme declarations to control what appears:
schemes:
CSCSQuiz:
build:
targets:
CSCSQuiz: all
run:
config: Debug
archive:
config: Release
The schemes: block in project.yml lists all schemes regardless of platform. When running xcodebuild -list, every scheme appears even if it only makes sense for one platform. Before feeding a scheme name to xcodebuild -destination 'platform=iOS Simulator', we cross-reference it against targets that actually support iOS. Passing a macOS-only scheme to an iOS simulator destination produces a cryptic rc=70 error about being unable to find a destination.
The Stats
Entitlements: XcodeGen Regenerates Them
This one cost us an App Store rejection. XcodeGen regenerates .entitlements files from the entitlements: block in project.yml on every xcodegen generate. If you edit the .entitlements file directly — say, removing an unused entitlement — the next xcodegen generate overwrites your change silently.
The fix is obvious once you know it: all entitlement changes must happen in project.yml, not in the .entitlements file. We had an app rejected under Guideline 2.1(b) because it declared the autofill-credential-provider entitlement but did not implement AutoFill. The entitlement had been removed from the .entitlements file directly, then reappeared on the next project generation.
Widget Extensions and Info.plist
Widget extensions are a special case. XcodeGen 2.45+ requires a path: key when using both info: and properties: in a target definition. Without it, the generated project misses the Info.plist entirely, and the widget fails to load at runtime with no compile-time error.
targets:
WattoraWidget:
type: appExtension
platform: iOS
sources: [Widget]
info:
path: Widget/WattoraWidget-Info.plist
properties:
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
Note that widget extensions do not support supportedDestinations: [iOS, visionOS]. WidgetKit is iOS and macOS only — adding visionOS to a widget target produces build errors that are easy to misread as a WidgetKit configuration issue rather than a platform support issue.
Build Settings That Matter
Three project.yml settings that we set on every app and would break things if omitted:
- GENERATE_INFOPLIST_FILE: true — Without this, apps that do not have a manual
Info.plistwill archive successfully but failaltool --upload-appwith "Archive Missing Bundle Identifier." The error appears only at upload time, not during build. - CURRENT_PROJECT_VERSION — Must be incremented before every App Store upload. If a previous build with the same number has been uploaded (even if it was never released), the upload fails with "build already exists." We set this in
project.ymlrather than as a build-phase script so it is visible and diffable. - DEVELOPMENT_TEAM — Set at the target level, not project level. This ensures automatic signing resolves correctly for both iOS and macOS platforms. Setting it at project level sometimes causes code signing conflicts when a target has platform-specific provisioning profiles.
The Generation Workflow
Every build starts with xcodegen generate. Our build scripts never assume the .xcodeproj exists. The workflow is:
# 1. Generate the project
xcodegen generate
# 2. Resolve Swift Package dependencies
xcodebuild -resolvePackageDependencies
# 3. Build or archive
xcodebuild archive \
-scheme "CSCSQuiz" \
-archivePath build/CSCSQuiz.xcarchive \
-destination "generic/platform=iOS"
One consequence of this approach: SourceKit (the engine behind Xcode's syntax highlighting and autocomplete) sometimes reports false errors immediately after generation. "Cannot find type" and "No such module" warnings appear in the editor but vanish on a real build. We have learned to ignore SourceKit diagnostics until after a successful xcodebuild — they are not authoritative for generated projects.
Automation at Scale
The real payoff of XcodeGen is automation. Because project.yml is plain YAML, scripts can read and modify it trivially. Our screenshot automation pipeline parses each app's project.yml to discover the scheme name, the supported platforms, and the bundle identifier — then launches the correct simulator, builds for the right destination, and captures screenshots without any hardcoded configuration.
When we create paid analogues, a script copies the base project.yml, modifies the bundle identifier, adds the PAID_VERSION flag, and generates a complete project. When we update AppFoundation and need to rebuild all 27 apps, a loop over directories with xcodegen generate && xcodebuild confirms nothing broke.
This is what matters about XcodeGen: it turns project configuration from a GUI state into a diffable, scriptable text file. For one app, the benefit is marginal. For 27, it is the difference between a maintainable portfolio and configuration chaos.
Key Takeaways
- Use
supportedDestinationsover separate per-platform targets. One target, multiple platforms, less configuration. - Never edit
.entitlementsdirectly. All entitlement changes go inproject.yml. XcodeGen overwrites the file on every generation. - Set
SWIFT_ACTIVE_COMPILATION_CONDITIONSper target, not at project level. Project-level flags leak into test targets. - Always increment
CURRENT_PROJECT_VERSIONbefore uploading. The error only appears at upload time, never during build. - SourceKit errors after
xcodegen generateare false positives. Runxcodebuildto confirm real issues. - Widget extensions need an explicit
info: path:in XcodeGen 2.45+. Omitting it causes runtime failures with no compile-time warning.