Lab 2.1 — Multi-target project setup

Duration: ~90 minutes Difficulty: Intermediate Prereqs: Phase 1 complete; Xcode 16+, Apple Developer account (free tier OK)

Goal

Build a real iOS app with multiple targets (main app + widget extension + macOS Catalyst), three build configurations (Debug / Release-Staging / Release) backed by xcconfig files, and three schemes that select the right configuration. By the end, you’ll have a project where adding a fourth environment is a 10-minute task and switching between Dev / Staging / Prod backends is a scheme picker click away.

What you’ll build

App name: LabTwoOne — a tiny note-taking app

  • iOS app (the main target)
  • iOS Widget Extension (shows latest note on Home Screen)
  • macOS Catalyst variant
  • Unit test target

Three build configurations (Debug, Release-Staging, Release), each pointing at a different “backend URL” (we’ll just print it — no real backend). Three schemes wire each configuration into a runnable build.

Steps

Step 1 — Create the base project (5 min)

  1. Xcode → File → New → Project → iOS → App
  2. Product Name: LabTwoOne
  3. Interface: SwiftUI, Language: Swift, Storage: None
  4. Save to a folder of your choice
  5. Run ⌘R; confirm the default app launches

Step 2 — Create the xcconfig files (15 min)

  1. In the project navigator, right-click the project → New Group → name it Config
  2. Right-click Config → New File → iOS → Other → Configuration Settings File
  3. Create three files (each via the same dialog):
    • Shared.xcconfig
    • Debug.xcconfig
    • ReleaseStaging.xcconfig
    • Release.xcconfig

Contents:

Shared.xcconfig:

SWIFT_VERSION                = 6.0
IPHONEOS_DEPLOYMENT_TARGET   = 17.0
MARKETING_VERSION            = 1.0.0
CURRENT_PROJECT_VERSION      = 1
PRODUCT_BUNDLE_IDENTIFIER    = com.yourname.LabTwoOne$(BUNDLE_ID_SUFFIX)

Debug.xcconfig:

#include "Shared.xcconfig"
BUNDLE_ID_SUFFIX             = .dev
API_BASE_URL                 = https:/$()/api.dev.example.com
SWIFT_OPTIMIZATION_LEVEL     = -Onone

ReleaseStaging.xcconfig:

#include "Shared.xcconfig"
BUNDLE_ID_SUFFIX             = .staging
API_BASE_URL                 = https:/$()/api.staging.example.com
SWIFT_OPTIMIZATION_LEVEL     = -O
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) STAGING

Release.xcconfig:

#include "Shared.xcconfig"
BUNDLE_ID_SUFFIX             =
API_BASE_URL                 = https:/$()/api.example.com
SWIFT_OPTIMIZATION_LEVEL     = -O

Step 3 — Add the Release-Staging configuration (5 min)

  1. Click the project icon at the top of the project navigator
  2. Select the project (not the target) in the panel
  3. Info tab → Configurations section
  4. Click +Duplicate “Release” Configuration → name it Release-Staging

You should now see three configurations: Debug, Release, Release-Staging.

Step 4 — Wire the xcconfig files to configurations (5 min)

Still in Info → Configurations, for each configuration row, expand it and set:

ConfigurationBased on Configuration File (Project level)
DebugDebug.xcconfig
Release-StagingReleaseStaging.xcconfig
ReleaseRelease.xcconfig

Build (⌘B). If you see “build setting BUNDLE_ID_SUFFIX is undefined” warnings, you mistyped a key. Fix and rebuild.

Step 5 — Plumb API_BASE_URL into Info.plist (10 min)

  1. Open the auto-generated Info settings (target → Info tab — Xcode 13+ stores these in target settings, not a separate Info.plist file)
  2. Add a custom key:
    • Key: APIBaseURL
    • Type: String
    • Value: $(API_BASE_URL)
  3. Create LabTwoOne/AppEnvironment.swift:
import Foundation

enum AppEnvironment {
    static let apiBaseURL: URL = {
        guard let raw = Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String,
              let url = URL(string: raw) else {
            fatalError("APIBaseURL missing or invalid in Info.plist")
        }
        return url
    }()

    static var isStaging: Bool {
        #if STAGING
        return true
        #else
        return false
        #endif
    }
}
  1. Edit ContentView.swift to display the values:
struct ContentView: View {
    var body: some View {
        VStack(spacing: 12) {
            Text("LabTwoOne")
                .font(.title)
            Text("API: \(AppEnvironment.apiBaseURL.absoluteString)")
                .font(.caption)
            if AppEnvironment.isStaging {
                Text("⚠️ STAGING")
                    .font(.caption.bold())
                    .foregroundStyle(.orange)
            }
        }
        .padding()
    }
}
  1. Build (⌘B); run (⌘R). You should see the Debug API URL.

Step 6 — Create the Staging scheme (10 min)

  1. Product → Scheme → Manage Schemes
  2. Select the existing LabTwoOne scheme → click the duplicate icon (or right-click → Duplicate)
  3. Name the new one LabTwoOne (Staging)
  4. Tick “Shared” for both schemes
  5. With the new scheme selected, click “Edit…”
  6. For each of Run, Test, Profile, Analyze, Archive:
    • Set “Build Configuration” → Release-Staging
  7. Close the editor

Switch the scheme picker (top-left of Xcode toolbar) to LabTwoOne (Staging) and run. You should see the staging URL and the orange ⚠️ STAGING label.

Switch back to LabTwoOne and run — back to the dev URL, no label.

✅ Checkpoint: scheme-driven environment switching is working.

Step 7 — Add a Widget Extension target (15 min)

  1. File → New → Target → iOS → Widget Extension
  2. Product Name: LabTwoOneWidget
  3. Include Configuration Intent: NO (keep it simple)
  4. Embed in Application: LabTwoOne
  5. Activate the new scheme when prompted

Xcode generates a starter widget. Run the LabTwoOneWidget scheme; choose a Simulator → after build, the Home Screen appears with the widget gallery available.

Now wire the widget to the same AppEnvironment:

  1. Click AppEnvironment.swift in the project navigator
  2. File Inspector (⌥⌘1) → Target Membership → tick LabTwoOneWidget
  3. The widget target now has the same xcconfig-driven environment access

Add to the widget’s LabTwoOneWidgetEntryView:

Text("API: \(AppEnvironment.apiBaseURL.host() ?? "?")")
    .font(.caption2)

Run the widget scheme; confirm the host appears.

Step 8 — Add a macOS Catalyst target (10 min)

  1. Click the project icon → select the LabTwoOne target
  2. General tab → Supported Destinations section → click + → choose Mac (Designed for iPad) or Mac Catalyst (choose Mac Catalyst for a deeper Mac feel)
  3. Confirm the prompt
  4. The scheme’s destination picker now shows “My Mac (Mac Catalyst)”
  5. Run on Mac Catalyst → confirm the app launches as a native Mac window

Step 9 — Add the .xcode-version file + Xcode pin guard (10 min)

  1. Open Terminal in the project root
  2. echo "16.2" > .xcode-version (use your actual Xcode version: xcodebuild -version | head -1 | awk '{print $2}')
  3. Create scripts/check-xcode-version.sh:
#!/usr/bin/env bash
set -e

EXPECTED=$(cat .xcode-version)
ACTUAL=$(xcodebuild -version | head -1 | awk '{print $2}')

if [[ ! "$ACTUAL" == "$EXPECTED"* ]]; then
    echo "❌ Expected Xcode $EXPECTED, found $ACTUAL"
    echo "   Switch with: sudo xcode-select -s /Applications/Xcode-$EXPECTED.app"
    exit 1
fi

echo "✅ Xcode $ACTUAL matches expected $EXPECTED"
  1. chmod +x scripts/check-xcode-version.sh
  2. Run it: ./scripts/check-xcode-version.sh → should print ✅

Add the script as a Run Script Build Phase on the main target:

  1. Target → Build Phases → + → New Run Script Phase
  2. Drag the new phase to the top (above Compile Sources)
  3. Script body: "${SRCROOT}/scripts/check-xcode-version.sh"
  4. Add ${SRCROOT}/.xcode-version to Input Files so Xcode caches the result

Now every build verifies the Xcode version.

Step 10 — Verify everything (10 min)

  1. Clean build folder (⌘⇧K)
  2. Run LabTwoOne (Debug) → confirm dev URL + no staging label
  3. Run LabTwoOne (Staging) → confirm staging URL + orange label
  4. Run LabTwoOneWidget → confirm widget shows host
  5. Switch destination to “My Mac (Mac Catalyst)” → run → confirm Mac launch
  6. Run the unit tests (⌘U) for both LabTwoOne schemes → all should pass

Commit everything to git:

git init
git add .
git commit -m "Lab 2.1 — multi-target project with xcconfig-driven environments"

Validation checklist

  • Three build configurations exist: Debug, Release-Staging, Release
  • Four xcconfig files exist and are wired at the project level
  • APIBaseURL in Info.plist resolves to a different URL per configuration
  • Two shared schemes: LabTwoOne (Debug) and LabTwoOne (Staging) (Release-Staging)
  • Widget extension builds and shows environment data
  • Mac Catalyst destination builds and runs
  • .xcode-version file exists; build phase script runs on each build
  • All targets pass unit tests

Stretch goals

  1. Add an iOS Notification Service Extension target (just create it, don’t implement) — wire it to AppEnvironment so all four targets share environment.
  2. Add a LabTwoOneKit Swift package (local SPM) — move AppEnvironment and add a Note model into it. All targets import the package instead of duplicating files via target membership.
  3. Add a CI workflow (.github/workflows/ci.yml) that runs ./scripts/check-xcode-version.sh and then xcodebuild test on both schemes.

What you’ve internalized

  • The mental model of project → target → scheme → configuration
  • How xcconfig files externalize build settings and survive merge conflicts
  • The Info.plist bridge from build settings to runtime Swift code
  • Multi-target target membership for sharing source files
  • The Xcode version pin pattern that scales to a real team

Next: Lab 2.2 — Debug a buggy app