How do a Swift package’s platforms declaration and its dependants interact, and how do you build the same package for every destination?

20.9.4 · L3 · Interview

Predict first

What do the macOS-13 dependant and the tvOS, watchOS and visionOS builds of the core report?

// === Core/Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "Core", platforms: [.iOS(.v17), .macOS(.v14), .tvOS(.v17), .watchOS(.v10), .visionOS(.v1)],
  products: [.library(name: "Core", targets: ["Core"])], targets: [.target(name: "Core")])
// === Core/Sources/Core/Core.swift ===
public func hi() -> String { "hi" }
// === App/Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "App", platforms: [.macOS(.v13)],
  dependencies: [.package(path: "../Core")], targets: [.target(name: "App", dependencies: [.product(name: "Core", package: "Core")])])
// === App/Sources/App/App.swift ===
import Core
public func go() -> String { hi() }
  • requires minimum platform version 14.0 … supports 13.0; then BUILD SUCCEEDED for all three
  • all succeed
  • only the macOS build fails to find Core

Mental model. The package’s platforms line is a minimum-height sign on a ride: the thing riding it, and everything stacked above, must meet the highest sign.

A package lists the minimum OS per platform it supports: platforms: [.iOS(.v17), .macOS(.v14), .tvOS(.v17), .watchOS(.v10), .visionOS(.v1)]. A package that does not list a platform is built for it with the toolchain’s default minimum (and any API newer than that is an error), which is why shared cores should list every platform they target. The dependency rule is strict: a package’s product requires at least the minimum platform version its package declares. The check puts a macOS 13 package on top of a core that requires macOS 14 and the build fails with “requires minimum platform version 14.0 for the macOS platform, but this target supports 13.0”. The fix is to raise the dependant or lower the core (with @available in the core for the newer API).

To check every destination from one command line, xcodebuild -scheme Core -destination 'generic/platform=tvOS Simulator' build (and watchOS, visionOS and iOS) builds the package for that SDK; all three reported BUILD SUCCEEDED here. Use the generic destination so no simulator needs to be booted. Put the matrix in CI, run the unit tests of the core on one destination (the host Mac with swift test is fastest), and gate platform-specific files with #if os() or canImport (Q837) so one target can compile everywhere. For an app, the equivalent is separate targets or one multiplatform target with SUPPORTED_PLATFORMS; that Xcode-project side was not run here, only the package side.

The dependency raises the floor; the generic-destination builds succeed.

Common traps

  • Omitting a platform from platforms and finding out in a downstream build. List every platform you build for.
  • Raising a core’s minimum OS casually. Every dependant must raise theirs, or the build fails like this one.

They might follow up with

  • How do I test the core quickly? swift test on the Mac host.
  • Why use the generic destination? It needs no booted device.

Related questions

  • 20.1.1 How do you structure one codebase for several Apple platforms: shared cores, UI leaves, #if os, canImport and…
  • 20.1.3 How do you run one shared core with several UI leaves on a single release train?
  • 20.7.1 What are os(), canImport() and targetEnvironment() actually true for on each Apple platform, and where does…

← How do windows, volumes and immersive spaces fit together in SwiftUI…