Swift Package Versioning
Introduction
When building iOS, macOS, or any Swift-based applications, managing dependencies efficiently becomes crucial as your project grows. Swift Package Manager (SPM) provides a robust system for integrating external code libraries, but understanding how to properly version these dependencies is vital to maintaining a stable and reliable application.
In this guide, you'll learn how Swift Package Manager handles versioning, how to specify version requirements for dependencies, and best practices for maintaining your own Swift packages with proper version control.
Understanding Semantic Versioning
Swift Package Manager follows a convention called Semantic Versioning (or SemVer), which is a versioning scheme that uses a three-part number: MAJOR.MINOR.PATCH
.
- MAJOR: Incremented when you make incompatible API changes
- MINOR: Incremented when you add functionality in a backward-compatible manner
- PATCH: Incremented when you make backward-compatible bug fixes
For example, in version 2.6.1
:
2
is the major version6
is the minor version1
is the patch version
Pre-release and Build Metadata
SemVer also supports:
- Pre-release versions:
1.0.0-alpha
,1.0.0-beta.2
- Build metadata:
1.0.0+20130313144700
Version Requirements in Swift Packages
When adding dependencies to your Swift package or app project, you need to specify version requirements. Swift Package Manager supports several ways to define version constraints:
1. Exact Version
Use this when you need a specific version of a package:
.package(url: "https://github.com/apple/swift-log.git", exact: "1.4.0")
2. Version Range
Specify a range of acceptable versions:
.package(url: "https://github.com/apple/swift-log.git", "1.0.0"..<"2.0.0")
This means "any version from 1.0.0 up to, but not including, 2.0.0".
3. Up to Next Major
Most commonly used approach, specifying a minimum version and accepting any version up to the next major release:
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0")
This is equivalent to "1.0.0"..<"2.0.0"
.
4. Up to Next Minor
Accept all patch updates but not minor or major:
.package(url: "https://github.com/apple/swift-log.git", .upToNextMinor(from: "1.2.0"))
This is equivalent to "1.2.0"..<"1.3.0"
.
5. Branch, Revision, or Local
For development purposes, you can also specify:
// Using a specific branch
.package(url: "https://github.com/apple/swift-log.git", branch: "development")
// Using a specific commit
.package(url: "https://github.com/apple/swift-log.git", revision: "a13491010b8441a46f8c15ca7925f83e52dbb8e0")
// Using a local path (for local development)
.package(path: "../MyLocalPackage")
Practical Example: Creating a Project with Dependencies
Let's create a simple project that demonstrates how to specify and manage package versions:
1. Creating a Swift Package with Dependencies
// Package.swift
import PackageDescription
let package = Package(
name: "MyWeatherApp",
platforms: [
.iOS(.v14),
.macOS(.v11)
],
products: [
.library(
name: "MyWeatherApp",
targets: ["MyWeatherApp"]),
],
dependencies: [
// Network requests library with exact version
.package(url: "https://github.com/Alamofire/Alamofire.git", exact: "5.6.1"),
// JSON parsing library with version range
.package(url: "https://github.com/SwiftyJSON/SwiftyJSON.git", from: "5.0.0"),
// Logging library allowing minor updates
.package(url: "https://github.com/apple/swift-log.git", .upToNextMinor(from: "1.4.0")),
],
targets: [
.target(
name: "MyWeatherApp",
dependencies: [
"Alamofire",
"SwiftyJSON",
.product(name: "Logging", package: "swift-log")
]),
.testTarget(
name: "MyWeatherAppTests",
dependencies: ["MyWeatherApp"]),
]
)
2. Using the Dependencies in Your Code
// Sources/MyWeatherApp/WeatherService.swift
import Foundation
import Alamofire
import SwiftyJSON
import Logging
public class WeatherService {
private let logger = Logger(label: "com.myapp.weatherservice")
public init() {}
public func fetchWeather(for city: String, completion: @escaping (Result<WeatherInfo, Error>) -> Void) {
let url = "https://api.weatherapi.com/v1/current.json"
let parameters: [String: String] = [
"key": "YOUR_API_KEY",
"q": city
]
logger.info("Fetching weather for \(city)")
AF.request(url, parameters: parameters)
.validate()
.responseData { response in
switch response.result {
case .success(let data):
do {
let json = try JSON(data: data)
let temperature = json["current"]["temp_c"].doubleValue
let condition = json["current"]["condition"]["text"].stringValue
let weather = WeatherInfo(
city: city,
temperature: temperature,
condition: condition
)
self.logger.info("Successfully fetched weather: \(temperature)°C, \(condition)")
completion(.success(weather))
} catch {
self.logger.error("JSON parsing error: \(error.localizedDescription)")
completion(.failure(error))
}
case .failure(let error):
self.logger.error("Network request failed: \(error.localizedDescription)")
completion(.failure(error))
}
}
}
}
public struct WeatherInfo {
public let city: String
public let temperature: Double
public let condition: String
}
Package Resolution Process
When Swift Package Manager resolves your dependencies:
- It reads the
Package.swift
file to determine dependencies - For each dependency, it calculates the set of versions that satisfy your requirements
- It tries to find a combination of package versions that satisfy all constraints
- The resolved versions are stored in a
Package.resolved
file
Package.resolved File
The Package.resolved
file tracks the exact versions of dependencies that were resolved. Here's an example:
{
"object": {
"pins": [
{
"package": "Alamofire",
"repositoryURL": "https://github.com/Alamofire/Alamofire.git",
"state": {
"branch": null,
"revision": "78424be314842833c04bc3bef5b72e85fff99204",
"version": "5.6.1"
}
},
{
"package": "swift-log",
"repositoryURL": "https://github.com/apple/swift-log.git",
"state": {
"branch": null,
"revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
"version": "1.4.2"
}
},
{
"package": "SwiftyJSON",
"repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git",
"state": {
"branch": null,
"revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
"version": "5.0.1"
}
}
]
},
"version": 1
}
Updating Dependencies
To update your package dependencies to the latest versions according to your specified constraints:
swift package update
To update a specific dependency:
swift package update Alamofire
Versioning Your Own Packages
If you're creating a Swift package that others might use, follow these best practices:
- Use Git tags for versioning: Create a tag for each release, following semantic versioning:
git tag 1.0.0
git push --tags
-
Document breaking changes: When releasing a new major version, provide migration guides.
-
Keep a changelog: Document changes between versions in a CHANGELOG.md file.
Example Package Versioning Strategy
Here's an example versioning workflow for your own package:
- Start with version
0.1.0
during initial development - Release
1.0.0
when the API is stable - For bug fixes, bump patch:
1.0.1
,1.0.2
, etc. - For new features, bump minor:
1.1.0
,1.2.0
, etc. - For breaking changes, bump major:
2.0.0
,3.0.0
, etc.
Common Versioning Issues and Solutions
Version Conflicts
When two packages depend on different versions of the same package:
PackageA → PackageC (1.0.0 ... 2.0.0)
PackageB → PackageC (1.5.0 ... 3.0.0)
The resolver will choose version 1.5.0 through 2.0.0 of PackageC, which satisfies both constraints.
If the constraints can't be satisfied, you'll get an error:
error: the package dependency graph contains cycles
Solution: Update one of the packages to accept a wider range of versions, or contact the package maintainers.
Using Older Swift Package Manager Features
If you need to support older Swift tools versions, specify it in your Package.swift
:
// swift-tools-version:5.3
import PackageDescription
let package = Package(
// Package definition here
)
Summary
Swift Package versioning is a crucial aspect of dependency management that helps maintain project stability. By understanding semantic versioning and how to specify version requirements, you can:
- Ensure your app builds consistently across different environments
- Benefit from bug fixes and new features in dependencies without breaking changes
- Avoid dependency conflicts and versioning issues
- Properly version your own packages for others to use
Remember these key points:
- Use semantic versioning (MAJOR.MINOR.PATCH)
- Choose the right version requirements for your dependencies
- Keep the
Package.resolved
file under version control - Use proper versioning when releasing your own packages
Additional Resources
- Swift Package Manager Documentation
- Semantic Versioning Specification
- Swift Evolution Proposal for Package Manager
Exercises
- Create a simple Swift package with at least two dependencies using different versioning constraints.
- Update one of your dependencies and examine the changes in the
Package.resolved
file. - Create your own Swift package and practice releasing different versions using Git tags.
- Experiment with resolving a version conflict between two dependencies that require different versions of a third package.
- Add a pre-release dependency to a package and observe how the Swift Package Manager handles it.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)