Skip to content
C.W.K.
Stream
Lesson 04 of 06 · published

project.yml Is the Source; the Xcode Project Is Output

~16 min · packages-projects, xcodegen, xcode-project, ios, targets

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"TEST SUCCEEDED. Executed 2 tests — the two that already existed. The new test file compiled nowhere."

Why iOS Needs an Xcode Project, and Why Nobody Edits It

A SwiftPM package cannot express an iOS app: its asset catalog and app icon, an embedded share extension, an embedded watch app, entitlements, an explicit Info.plist, provisioning and a test host. Those live in an .xcodeproj, whose project.pbxproj is a file no human should hand-merge. XcodeGen resolves the tension: you write a readable project.yml, run xcodegen generate, and the Xcode project is produced from it. Every iOS and watch app in this family works this way. The YAML is the source; the project is output.

What Goes in project.yml

  • Project-wide settings that must never drift: DEVELOPMENT_TEAM, CODE_SIGN_STYLE: Automatic, SWIFT_VERSION, and MARKETING_VERSION plus CURRENT_PROJECT_VERSION at project level, so an app and its extension always carry the same version pair — App Store Connect refuses an extension whose numbers differ from its host.
  • packages: the shared Swift package, by path.
  • targets: the app (type: application), its extensions (app-extension), the watch app, and test bundles — each with its sources, dependencies, info properties, entitlements properties and a scheme.

The Two Clicks That Do Not Survive

Anything you change in Xcode's UI changes the generated project, and the next xcodegen generate replaces it. Picking the team in Signing & Capabilities works exactly until then. That is why the team ID is written in project.yml, and why the family's build-number bump edits project.yml, never the pbxproj.

The File the Suite Never Ran

XcodeGen snapshots the file list when it runs. A new .swift file added afterwards is not in the project at all. For an app source that shows up as "type not found". For a test file it is silent: xcodebuild test reports ** TEST SUCCEEDED ** for the tests the project knew about, and the new ones never ran. The family fixed it twice over: the test script runs xcodegen generate before xcodebuild test, and a check asserts every Swift file under the source and test folders appears in the generated project, so a stale project fails loudly instead of reporting a green it never earned. (Family repositories disagree about whether the generated project is committed at all; either way, regenerate before you trust a result.)

Code

SparkMobile: one universal app, one share extension, one test bundle·yaml
name: SparkMobile
options:
  bundleIdPrefix: com.example
  deploymentTarget:
    iOS: "17.0"
  createIntermediateGroups: true

settings:
  base:
    DEVELOPMENT_TEAM: EXAMPLE_TEAM        # in source, so regeneration can never drop it
    CODE_SIGN_STYLE: Automatic
    SWIFT_VERSION: "6.0"
    MARKETING_VERSION: 1.0.0
    CURRENT_PROJECT_VERSION: 1            # project-wide: app and extension always match

packages:
  SparkKit:
    path: ../SparkKit

targets:
  SparkMobile:
    type: application
    platform: iOS
    sources: [Sources]
    dependencies:
      - package: SparkKit
        product: SparkCore
      - target: SparkShare
    info:
      path: Resources/Info.plist
      properties:
        CFBundleDisplayName: Spark
        ITSAppUsesNonExemptEncryption: false
        UILaunchScreen: {}
    entitlements:
      path: Resources/SparkMobile.entitlements
      properties:
        com.apple.security.application-groups: [group.com.example.spark]
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.example.spark.mobile
        TARGETED_DEVICE_FAMILY: "1,2"
    scheme:
      testTargets: [SparkMobileTests]

  SparkShare:
    type: app-extension
    platform: iOS
    sources: [ShareExtension]
    entitlements:
      path: ShareExtension/SparkShare.entitlements
      properties:
        com.apple.security.application-groups: [group.com.example.spark]
    info:
      path: ShareExtension/Info.plist
      properties:
        CFBundleDisplayName: Spark
        CFBundleShortVersionString: $(MARKETING_VERSION)
        CFBundleVersion: $(CURRENT_PROJECT_VERSION)
        NSExtension:
          NSExtensionPointIdentifier: com.apple.share-services
          NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: com.example.spark.mobile.share

  SparkMobileTests:
    type: bundle.unit-test
    platform: iOS
    sources: [Tests]
    dependencies:
      - target: SparkMobile
Regenerate first, then prove the project knows every file·bash
xcodegen generate

# Every Swift file on disk must appear in the generated project, or the suite lies.
missing=0
for f in $(find Sources Tests ShareExtension -name '*.swift'); do
  grep -q "path = $(basename "$f");" SparkMobile.xcodeproj/project.pbxproj || { echo "not in project: $f"; missing=1; }
done
[ "$missing" -eq 0 ] || { echo 'run xcodegen generate'; exit 1; }

xcodebuild test -scheme SparkMobile -destination "platform=iOS Simulator,id=$SPARK_SIMULATOR_ID"

External links

Exercise

Install XcodeGen (brew install xcodegen), create the SparkMobile folders with one minimal Swift file in each of Sources, ShareExtension and Tests, write the project.yml above with your own team placeholder, and run xcodegen generate. Add a second test file without regenerating and run the file-coverage check — confirm it fails and names the file. Regenerate and confirm it passes. Then change the team in Xcode's UI, regenerate, and see where your change went.
Hint
grep -c NewTests.swift SparkMobile.xcodeproj/project.pbxproj prints 0 before regeneration and a positive count after. The team you pick in the UI survives only until the next generate, which is the whole argument for writing it in the YAML.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.