SwiftPM-first projects: Swift package manifest mode
A usage and migration guide for repositories whose Swift Package Manager
dependencies live in Package.swift manifests rather than as
XCRemoteSwiftPackageReference objects inside an .xcodeproj.
If your app still builds through an Xcode project but the source of truth for
dependencies is one or more Package.swift files, this guide is for you.
The scenario
A typical modular iOS repo looks like this:
podcasts.xcodeproj/
Modules/Package.swift
Modules/Package.resolved
BuildTools/Package.swift
BuildTools/Package.resolved
The main podcasts.xcodeproj has an empty packageReferences section — the real
dependencies are declared in Modules/Package.swift and BuildTools/Package.swift,
and the resolved pins live in the Package.resolved file next to each manifest.
Pointing the action at podcasts.xcodeproj in this layout doesn't work well: the
.xcodeproj owns no remote package references, so the action finds nothing to
check. If an Xcode-adjacent Package.resolved exists, the action logs a warning
that the project may need manifest mode; otherwise Package.resolved is not in
any Xcode-adjacent workspace location.
TL;DR — the workflow you want
name: Swift Package Version Updates
on:
pull_request:
paths:
- "Modules/Package.swift"
- "Modules/Package.resolved"
- "BuildTools/Package.swift"
- "BuildTools/Package.resolved"
- ".github/workflows/spm-version-updates.yml"
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
spm-version-updates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hbmartin/github-action-spm_version_updates@v1
with:
package-manifest-paths: |
Modules/Package.swift
BuildTools/Package.swift
report-above-maximum: true
No temporary Xcode project. No synthetic Package.resolved. No repo-specific
parser. No macOS runner — manifest parsing is pure Ruby and runs on
ubuntu-latest without a Swift or Xcode toolchain. Manifest mode also skips the
xcodeproj runtime dependency during action setup.
Fork pull requests
Fork PRs need extra care if you switch the trigger to pull_request_target so
the action can write a summary comment. With that trigger, checking out the PR
head makes Package.swift untrusted input: a malicious fork can change package
URLs, and this action will ask git ls-remote to contact those remotes over
Git's https, ssh, or git transports. file, ext, and remote-helper
transports are blocked. Keep checkout credentials off, avoid extra secrets or
SSH keys, and set allow-hosts to the git hosts your manifests are expected to
use:
on:
pull_request_target:
permissions:
contents: read
pull-requests: write
jobs:
spm-version-updates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- uses: hbmartin/github-action-spm_version_updates@v1
with:
package-manifest-paths: Modules/Package.swift
allow-hosts: github.com
Migrating from the synthetic-.xcodeproj workaround
Before manifest mode existed, repos in this layout had to bridge the gap with a CI helper that would:
- read
Modules/Package.swiftandBuildTools/Package.swift, - extract the
.package(...)declarations, - generate a temporary
.xcodeproj, - inject synthetic
XCRemoteSwiftPackageReferenceobjects, - merge both
Package.resolvedfiles, and - write the merged file to a path the action understood, e.g.
.spm-version-updates/PackageChecks.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.
The action now does all of this natively, so that adapter can be deleted.
Migration steps:
-
Switch the action inputs. Replace
xcode-project-path:(pointed at the generated project) withpackage-manifest-paths:listing your real manifests:- uses: hbmartin/github-action-spm_version_updates@v1 with: - xcode-project-path: .spm-version-updates/PackageChecks.xcodeproj + package-manifest-paths: | + Modules/Package.swift + BuildTools/Package.swift -
Delete the generator step from your workflow (the step that built the synthetic project before invoking the action).
-
Delete the helper script and its output from the repo, e.g. the
.spm-version-updates/directory and any committedPackageChecks.xcodeproj. Add.spm-version-updates/to.gitignoreif it was ever generated locally. -
Update the
on.pull_request.pathsfilter to watch the manifests and resolved files directly (see the workflow above) instead of the generated project.
That's the whole migration — everything else (constraint handling, PR comment formatting) behaves the same.
How Package.resolved is located
By default, the action reads the Package.resolved that sits next to each
manifest:
| Manifest | Inferred resolved file |
|---|---|
Modules/Package.swift |
Modules/Package.resolved |
BuildTools/Package.swift |
BuildTools/Package.resolved |
Every expected resolved file must exist — if one is missing the action fails and names the missing file, rather than silently producing incomplete results.
If your resolved files live elsewhere, list them explicitly with
package-resolved-paths:
with:
package-manifest-paths: |
Modules/Package.swift
BuildTools/Package.swift
package-resolved-paths: |
Modules/Package.resolved
BuildTools/Package.resolved
Pins from every resolved file are merged into a single lookup keyed by normalized repository URL, so a dependency declared in one manifest will match its pin wherever it is recorded.
Supported .package(...) declaration forms
Manifests are parsed with a lightweight scanner that understands the common SwiftPM forms:
.package(url: "https://github.com/foo/bar", from: "1.2.3")
.package(url: "https://github.com/foo/bar", exact: "1.2.3")
.package(url: "https://github.com/foo/bar", branch: "main")
.package(url: "https://github.com/foo/bar", revision: "abcdef")
.package(url: "https://github.com/foo/bar", "1.0.0"..<"2.0.0")
.package(url: "https://github.com/foo/bar", "1.0.0"..."2.0.0")
.package(url: "https://github.com/foo/bar", .upToNextMajor(from: "1.2.3"))
.package(url: "https://github.com/foo/bar", .upToNextMinor(from: "1.2.3"))
.package(url: "https://github.com/foo/bar", .exact("1.2.3"))
Notes:
- Local packages (
.package(path: ...)) and any declarations inside//or/* */comments (including nested block comments) are ignored. - A closed range
"1.0.0"..."2.0.0"is normalized the same way SwiftPM does — to the half-open range"1.0.0"..<"2.0.1"— so the inclusive upper bound is preserved.
How each constraint is handled
| Constraint | Manifest form | Behavior |
|---|---|---|
| Up to next major | from: / .upToNextMajor(from:) |
Reports newer versions within the same major version. |
| Up to next minor | .upToNextMinor(from:) |
Reports newer versions within the same major and minor version. |
| Version range | "1.0.0"..<"2.0.0" |
Reports newer versions below the maximum. |
| Exact | exact: / .exact(...) |
Skipped unless check-when-exact: true. |
| Branch | branch: / .branch(...) |
Reports newer commits on the branch unless check-branches: false. |
| Revision | revision: / .revision(...) |
Skipped unless check-revisions: true. A pinned commit has no general "newer" version, so when enabled the action only reports the latest tagged release for reference. |
A mix of from, branch, revision, and exact constraints in the same repo is
fully supported. Toggle the relevant behaviors explicitly:
with:
package-manifest-paths: |
Modules/Package.swift
BuildTools/Package.swift
check-when-exact: false
check-branches: true
check-revisions: false
When report-above-maximum: true, the action additionally surfaces the newest
version that exists above your constraint (for example a new major release your
from:/range would not pick up). Pre-release versions are excluded from reports
unless you set report-pre-releases: true.
Multiple manifests and source attribution
When you pass more than one manifest, every warning is annotated with the manifest it came from, so you know exactly where to make the change:
⚠️ Found 2 potential dependency updates:
1. Newer version of onevcat/Kingfisher: 8.0.0
Source: Modules/Package.swift
2. Newer version of SwiftGen/SwiftGenPlugin: 6.7.0
Source: BuildTools/Package.swift
(The Source: line appears only in manifest mode.)
Configuration reference
| Input | Description | Default |
|---|---|---|
package-manifest-paths |
Newline-separated list of Package.swift paths. Provide this or xcode-project-path. |
|
package-resolved-paths |
Optional newline-separated list of Package.resolved paths. Defaults to a Package.resolved next to each manifest. |
inferred |
xcode-project-path |
Path to an .xcodeproj (classic mode). Provide this or package-manifest-paths. |
|
check-when-exact |
Check for updates even when using exact constraints. |
false |
check-branches |
Check for newer commits on branch-pinned dependencies. | true |
check-revisions |
Report the latest tagged version for revision-pinned dependencies. | false |
report-above-maximum |
Report versions above the maximum constraint range. | false |
report-pre-releases |
Include pre-release versions in update reports. | false |
ignore-repos |
Comma-separated list of repository URLs to ignore. | '' |
repo-rules-path |
Path to a YAML file with per-repository semantic update suppression rules. | '' |
allow-hosts |
Comma-separated list of git remote hostnames allowed for enabled version lookups. Empty allows any host for the allowed git protocols. A blocked lookup fails the action and writes blocked=true plus error-message. |
'' |
comment |
Post or update the pull request comment. Set false to disable all PR commenting while keeping outputs, the step summary, and annotations. |
true |
comment-on-success |
Post an up-to-date pull request comment on clean runs. By default, clean runs delete the prior generated comment instead. | false |
open-tracking-issue |
On runs without a pull request context, open or update one tracking issue with the update report, and close it once dependencies are up to date. Requires issues: write. |
false |
cache-version-tags |
Persist successful git tag lookups between runs with actions/cache. |
true |
version-tags-cache-ttl |
Freshness window, in seconds, for persisted git tag lookups. Set 0 to disable persistent cache reads and writes. |
21600 |
setup-ruby |
Set up Ruby and install this action's bundle. Set to false only for later invocations in the same job after an earlier invocation has already run setup. |
true |
github-token |
GitHub token for posting the PR comment. | ${{ github.token }} |
Provide exactly one of package-manifest-paths or xcode-project-path.
Supplying both (or neither) fails with a clear error.
When invoking the action more than once in a job, keep setup-ruby enabled on
the first invocation and use setup-ruby: false on later manifest-mode
invocations to avoid repeating Ruby setup and Bundler cache work.
Per-repository rules
ignore-repos still skips a dependency entirely before lookup. For dependencies
that should still be checked but should not report selected semantic updates, set
repo-rules-path to a YAML file:
repositories:
- url: "https://github.com/example/noise"
ignore-until: "2.0.0"
- url: "https://github.com/example/no-major"
allowed-updates: "minor"
ignore-until reports version X and newer, while suppressing lower available
versions. allowed-updates: minor allows patch and minor reports but suppresses
major reports. Rules apply only to semantic version and above_maximum
reports; branch and revision reports use their existing controls.
Using the Danger plugin
Manifest mode is also available in the danger-spm_version_updates
plugin for repos that already run Danger:
spm_version_updates.check_manifests(["Modules/Package.swift", "BuildTools/Package.swift"])
check_manifests accepts a single path or a list, plus an optional second
argument with explicit Package.resolved paths (by default a Package.resolved
next to each manifest is used). Warnings include Compare/Releases links, the
originating manifest, and a ready-to-run swift package update command. The
plugin accessors mirror the action inputs of the same name; see the
README.
A note on versioning
Examples above pin to @v1. Pinning to the major tag means you automatically
receive backward-compatible updates. You can also pin to an exact release (e.g.
@v1.0.0) or to a commit SHA for maximum reproducibility.
Still using a classic Xcode project?
Manifest mode is purely additive. If your .xcodeproj directly owns its remote
package references, keep using xcode-project-path — that mode is unchanged. See
the main README for details on both modes.