On this page 10 sections
- When a package is worth extracting
- The shape of a good Package.swift
- Public API, access control and what you cannot take back
- Semantic versioning and Git tags
- Products, platforms and resources
- Documentation, samples and a private package registry
- Privacy manifests and required reason APIs in a package
- Breaking changes without surprising people
- Key takeaways
- Frequently asked questions
This is a guide to being that author. It is not a tutorial on how to consume packages in Xcode. The same after-launch discipline I describe for maintaining an iOS app applies here, except your “users” are other developers, and a sloppy major version is how you lose them.
When a package is worth extracting
Extract a package when the code has a boundary and a second consumer, or when you honestly need one to share work between an app, a widget, an App Clip and a test target. Do not extract a package because a blog post said “modularise everything”, or because a folder feels untidy.
Good candidates:
- A well-defined library (parsing, networking helpers, a design system of genuinely reusable views) with its own tests.
- Code shared between two products you ship, or between an app and a tool.
- A piece you are prepared to version and document, including when it changes.
Poor candidates:
- A dumping ground named
SharedorUtilitiesthat imports everything. - Types that are secretly coupled to one app’s database or storyboard.
- A wrapper that only exists to hide a messy API you have not finished.
If the first consumer is still the same app, a local package in the same repository is a good start. You get a real Package.swift and a public API without the ceremony of a separate release. Promote it to its own repository when a second repository needs it, or when a tag would help you reason about upgrades.
The shape of a good Package.swift
Package.swift is the contract. Keep it small and explicit.
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "HarbourFormatters",
platforms: [
.iOS(.v17),
.macOS(.v14)
],
products: [
.library(name: "HarbourFormatters", targets: ["HarbourFormatters"])
],
dependencies: [],
targets: [
.target(
name: "HarbourFormatters",
resources: [.process("Resources")],
swiftSettings: [
.enableUpcomingFeature("ExistentialAny")
]
),
.testTarget(
name: "HarbourFormattersTests",
dependencies: ["HarbourFormatters"]
)
]
)A few choices in that file are deliberate:
- The tools version is the Swift tools version, not “whatever Xcode I had open”. Bumping it is a statement about who can consume the package. Do not bump it casually in a patch release.
- Platforms are the minimum you actually test. An
.iOS(.v13)you have never compiled is a bug report waiting to happen. - One product per library unless you really have two products. A
Fooand aFooInternalproduct is how consumers import the wrong thing. - Dependencies are a cost you pass on. Every package you add becomes someone else’s build, their privacy audit, and their licence review. Prefer the standard library. If you must depend on something, pin a lower bound you mean, and check the licence with the same care you would for an app — the licence compatibility checker is a starting point, not legal advice.
Put Package.swift at the root, include a LICENSE and a README.md, and keep sources under Sources/TargetName. That layout is what tools and humans expect.
Public API, access control and what you cannot take back
Everything you mark public or open is a promise. internal is the default and should stay the default. Prefer public structs and functions over open classes; open is a subclassing contract, and it is much harder to walk back than a function you can overload with a new parameter in a major version.
Design the API as if a stranger will call it:
- Use types to make illegal states hard to build, rather than a pile of optional parameters whose combinations you do not test.
- Name methods after the work they do, not after your current module layout.
- Avoid leaking implementation types. If you return a type from a dependency, that dependency is now part of your public contract.
- Do not make a type
publicso a test can see it. Tests belong in a test target in the same package, or you can use@testable importforinternalsymbols.@testableis not a reason to ship a wider API.
Once a symbol is public in a minor or patch release, removing it, renaming it, or changing its signature is a breaking change. Adding a new optional method, or a new case to a frozen public enum, needs care; adding a case to a public enum the consumer switches on exhaustively is a break. Swift’s library evolution features (@frozen, @inlinable, and so on) matter more if you ship a binary, but the same mindset helps a source package: be conservative about what you show.
Semantic versioning and Git tags
Swift Package Manager resolves versions from Git tags that follow semantic versioning: 1.2.3, optionally with a v prefix you must use consistently. The tag must point at the commit you mean to ship, and it must be on the repository consumers will clone.
The contract, briefly:
- MAJOR for incompatible API changes.
- MINOR for backwards-compatible additions, such as a new public function.
- PATCH for backwards-compatible fixes.
Pre-1.0 versions (0.x) are allowed to be looser, but your consumers may not read that disclaimer. If anyone is already depending on you, be as careful in 0.9 as you would be in 1.2.
Consumers write ranges such as from: "1.2.0" (meaning >= 1.2.0 < 2.0.0 in the usual SPM interpretation). Before you tag a release, look at the range you expect people to use; the semver range checker is a useful way to see whether 1.4.0 will be pulled in by from: "1.2.0" (it will) and whether 2.0.0 will (it will not). That is the whole point of a major version.
A release process that has worked for me:
- Update the changelog (the changelog generator can draft from conventional commits; you still edit the result).
- Run tests on the platforms you claim.
- Tag the commit, push the tag, and check that a fresh consumer can resolve it.
- Do not move or delete a tag that someone might already have resolved. If the tag is wrong, ship a new patch.
Do not rely on a branch name as a version. Branches move. Tags, once published, should not.
Products, platforms and resources
Declare only the platforms you test, and add a resource rule when the target contains assets, localisation catalogs or privacy manifests. .process is the usual choice for a privacy manifest or a small bundle of strings; .copy keeps a directory layout you must not flatten.
If the package needs a setting — a compiler flag, an upcoming language feature — put it on the target, and document it. A feature flag that changes the public API between toolchains is a trap; prefer API that compiles everywhere you claim to support.
Binary targets and XCFrameworks are a different shipping problem (size, notarisation, debug symbols, a checksum in Package.swift). A small open-source library should usually be source. If you do ship a binary, pin the checksum, and have a source-available or inspectable story you can stand behind.
Avoid a product that only exists to re-export five other packages. You are not saving your consumers work; you are hiding their dependency graph.
Documentation, samples and a private package registry
The README should say what the package is, what it is not, the platforms and Swift versions you support, a five-line usage example, and how to depend on a version. DocC is the next step, not a substitute for that page.
DocC can build from doc comments and a documentation catalog. Document the public types, especially anything subtle (thread safety, what an empty input does, whether a method is main-actor isolated). A sample target or a short example in the README is worth more than a generated list of symbols with no prose.
If the package is private, you can still use SPM. Options include a private Git host, and package collections or a registry if your organisation has one. The important part is still the tag and the access control on the repository, not a different versioning scheme. SSH or HTTPS credentials are the consumer’s problem; do not embed tokens in Package.swift.
Do not publish a package without a licence file. Consumers and some corporate scanners will reject it. Pick a licence you mean, and check that your dependencies are compatible, again with the usual caveat that a tool is not a lawyer.
Privacy manifests and required reason APIs in a package
Apps on the App Store must include privacy manifests, and third-party SDKs are expected to ship their own. If your package uses a required-reason API, or collects data, it should contain a PrivacyInfo.xcprivacy resource, with reasons that are true for the package, not copied from an app you happen to also maintain.
This is the same subject as privacy manifests and App Store review, from the other side of the relationship. Your consumers will fail review if your package files a fingerprinting API without a reason, or if your manifest says you collect nothing and you clearly do.
Put the manifest in the target’s resources so it is merged into the app. Keep it in version control and treat a change to it as a release-worthy change. If the package has no such APIs and collects nothing, a minimal, honest manifest is still a kindness to consumers, and an empty lie is worse than no file.
Do not declare tracking you do not do “just in case”. Do not hide required-reason calls behind #if so the manifest and the binary disagree.
Breaking changes without surprising people
You will need a major version eventually. The job is to make it boring.
- Announce it, in the changelog and the README, with a migration paragraph that names the old symbol and the new one.
- Ship deprecations first when you can: mark the old API
deprecatedin a 1.x minor, then remove it in 2.0. Give people a release they can adopt without a fork. - Do not bundle unrelated breaks in the same major unless you have to. A major version is already expensive for consumers.
- Keep a 1.x patch train for a while if people are stuck, especially for security or privacy-manifest fixes.
- Test the upgrade. From a sample app pinned to the last 1.x, move to 2.0 and walk the migration yourself.
A changed default, a tighter OS requirement, a tools-version bump, and a new default isolation can all be breaks even if the lines of Swift look similar. If a consumer has to change their code or abandon a platform you previously supported, bump the major (or at least be honest in the notes if you are still in 0.x).
Key takeaways
- Extract a package when there is a real boundary and a consumer, not as a folder-tidying exercise.
- Keep
Package.swiftexplicit: tools version, platforms you test, a small product list, and few dependencies. - Public API is a promise. Prefer a narrow surface, and treat signature changes as major versions.
- Tag immutable semantic versions, write a changelog, and remember how
from:ranges resolve. - Ship a README, DocC or at least doc comments, a licence, tests, and a privacy manifest that matches the code.
- Deprecate in a minor, remove in a major, and keep a patch train when you can.
If you are extracting a library and want a second pair of eyes on the API or the first tag, tell me about it.
Frequently asked questions
How do you create a Swift package in Xcode?
File > New > Package, or swift package init in a folder, then fill in Package.swift, the source target and a test target. For an existing app, you can add a local package in the same workspace and move code across once the public types are clear. The package is ready to share when it has a licence, a README, tests, and a Git tag.
How does semantic versioning work for Swift packages?
Swift Package Manager reads Git tags such as 1.2.3. A major bump is for incompatible API changes, a minor for compatible additions, and a patch for compatible fixes. A consumer who writes from: "1.2.0" will accept 1.4.0 and will not accept 2.0.0. Do not move a tag after it has been published.
Should a Swift package include a privacy manifest?
Yes if it uses required-reason APIs or collects data, and it is a good idea in any case if the package is going into App Store apps. The manifest must match the code. Consumers inherit your declarations, and a mismatch is their review problem as well as yours.
How do you publish a Swift package without CocoaPods?
Host the repository, tag a semantic version, and give consumers the URL. Swift Package Manager clones the tag. A package registry or a private Git host works the same way for private code. CocoaPods is optional and not required for a modern Swift package.
How do you break a Swift package API safely?
Mark the old API as deprecated in a minor release, document the replacement, then remove it in a new major version. Spell out the migration in the changelog, keep patching the previous major for a while if you can, and try the upgrade from a sample project before you announce it.
Written by Lee Johnson. Published 10 November 2026.