Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: test

on:
push:
branches: [main]
pull_request: {}
workflow_dispatch: {}

jobs:
selftest:
# Must be macOS — the action calls `xcrun swift package update`.
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- name: Show toolchain
run: |
xcodebuild -version
xcrun swift --version

- name: Run the action against the fixture project (dry-run)
id: action
uses: ./
with:
project: test/fixture/Sample.xcodeproj
dry-run: 'true'

- name: Assert outputs are set
env:
CHANGED: ${{ steps.action.outputs.dependencies-changed }}
COUNT: ${{ steps.action.outputs.changed-count }}
run: |
set -euo pipefail
echo "dependencies-changed='${CHANGED}'"
echo "changed-count='${COUNT}'"
if [ -z "${CHANGED}" ]; then
echo "::error::dependencies-changed output was not set"
exit 1
fi
if [ "${CHANGED}" != "true" ] && [ "${CHANGED}" != "false" ]; then
echo "::error::dependencies-changed must be 'true' or 'false' (got '${CHANGED}')"
exit 1
fi
if ! [[ "${COUNT}" =~ ^[0-9]+$ ]]; then
echo "::error::changed-count must be an integer (got '${COUNT}')"
exit 1
fi
# The fixture has no committed Package.resolved, so the fresh
# resolution should always report at least one new pin.
if [ "${CHANGED}" != "true" ]; then
echo "::error::expected dependencies-changed=true for a fixture with no prior Package.resolved"
exit 1
fi

- name: Ensure dry-run did not write Package.resolved into the fixture
run: |
set -euo pipefail
resolved="test/fixture/Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
if [ -e "$resolved" ]; then
echo "::error::dry-run unexpectedly wrote $resolved"
exit 1
fi
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Changelog

All notable changes to this project will be documented in this file. This
project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.0.0] - 2026-06-05

Initial public release as a Marketplace composite action.

### Added
- `action.yml` composite action wrapping `update_spm.py`.
- Inputs: `project`, `working-directory`, `xcode-version`,
`fail-when-outdated`, `dry-run`.
- Outputs: `dependencies-changed`, `changed-count`.
- `--search-dir` autodetection so `project` can be omitted when there is
exactly one `.xcodeproj` under the working directory.
- `--fail-when-outdated` flag for using the action as a CI guard.
- `$GITHUB_OUTPUT` emission directly from the Python script.
- Self-test workflow at `.github/workflows/test.yml` that runs the action
against a checked-in pbxproj fixture in dry-run mode.
- Example consumer workflow at `examples/update-spm.yml`.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Maksym Bilan

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
121 changes: 120 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,120 @@
# xcode-spm-update
# Update Xcode Swift Packages

A composite GitHub Action that bumps an Xcode project's Swift Package Manager
pins on Xcode 16+, where `xcodebuild -resolvePackageDependencies` stopped
reliably updating versions.

## Why

`xcodebuild -resolvePackageDependencies` is the documented way to refresh SPM
pins from the command line. In Xcode 16+ that path frequently no-ops even when
newer versions are available within the declared constraints — which is why
most "update SPM" CI jobs (and the only off-the-shelf action that wraps
xcodebuild) silently stopped doing their job.

This action routes around the problem without reimplementing version solving:

1. Parses `XCRemoteSwiftPackageReference` entries (repo URL + version
requirement) straight out of `project.pbxproj`.
2. Writes a throwaway `Package.swift` that declares those same dependencies.
3. Runs `xcrun swift package update` on the synthetic manifest — the real
PubGrub solver resolves the full transitive graph.
4. Copies the resulting `Package.resolved` back into the Xcode project's
internal workspace.

The action is pure Python standard library — no `pip install`, no Node
dependencies — and it must be run on a macOS runner because it shells out to
the Swift toolchain.

## Usage

```yaml
jobs:
update:
runs-on: macos-15 # any macos-* image works
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4

- id: update
uses: maximbilan/xcode-spm-update@v1
with:
project: MyApp.xcodeproj # optional; autodetects if omitted
xcode-version: '16.4' # optional; pin to your team's Xcode

- name: Open PR
if: steps.update.outputs.dependencies-changed == 'true'
uses: peter-evans/create-pull-request@v6
with:
branch: deps/spm-update
title: 'Update Swift package dependencies'
commit-message: 'chore: update Swift package dependencies'
body: '${{ steps.update.outputs.changed-count }} package(s) changed.'
```

A complete working example lives at
[`examples/update-spm.yml`](examples/update-spm.yml).

## Inputs

| Name | Required | Default | Description |
|------|----------|---------|-------------|
| `project` | no | `''` | Path to the `.xcodeproj`. When empty, autodetects a single `.xcodeproj` under `working-directory`. |
| `working-directory` | no | `.` | Directory the action runs in (used for autodetection and as the script's cwd). |
| `xcode-version` | no | `''` | Xcode version to select via `xcode-select` before resolving (e.g. `16.4`). Leave empty to use the runner default. |
| `fail-when-outdated` | no | `false` | When `true`, the action exits non-zero if any pin would change. Useful as a CI guard on a PR branch. |
| `dry-run` | no | `false` | When `true`, resolve into a temp directory and report changes without writing `Package.resolved` back. |

## Outputs

| Name | Description |
|------|-------------|
| `dependencies-changed` | `'true'` if at least one pin would change, otherwise `'false'`. |
| `changed-count` | Number of pins that changed, as an integer string. |

## Caveats

- **Project-internal Package.resolved.** This action writes
`Package.resolved` to
`<MyApp.xcodeproj>/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`
— the path Xcode reads when SPM is managed inside the `.xcodeproj` itself.
Projects that drive SPM from a standalone `.xcworkspace` (the resolved file
lives under the workspace, not the project) will need their workflow
adjusted, since this action does not write to that location.
- **Unsupported reference kinds.** Only `XCRemoteSwiftPackageReference` is
parsed. Local packages (`XCLocalSwiftPackageReference`) and registry
dependencies (`.package(id:)`) are skipped — they don't appear in the
remote-reference section. Mirror those dependencies in your team's normal
workflow.
- **Match the Xcode used locally.** Run the action with the same Xcode version
your team uses (the `xcode-version` input). Package.resolved has a format
version and an `originHash` field that changes across Xcode releases; if the
CI-resolved file disagrees with what your local Xcode expects, Xcode will
silently re-resolve on first open and clobber the PR.

## How it works internally

`update_spm.py` is the entire implementation. From the repo root it does:

1. Reads `project.pbxproj` and slices out the
`Begin XCRemoteSwiftPackageReference section` ... `End ...` block.
2. For each reference: pulls `repositoryURL` and the `requirement = { ... }`
dict, then maps the pbxproj `kind` to a Swift `.package(url:...)` argument:
`upToNextMajorVersion` → `from:`, `upToNextMinorVersion` →
`.upToNextMinor(from:)`, `exactVersion` → `exact:`, `versionRange` →
`"a"..<"b"`, `branch:` and `revision:` map literally.
3. Writes the synthetic `Package.swift` to a temp directory and runs
`xcrun swift package update --package-path <tmp>`.
4. Diffs the new `Package.resolved` against the project's current one and
reports which identities changed.
5. Unless `--dry-run` or `--fail-when-outdated`, copies the new file in place.

The script is invoked from the action via `$GITHUB_ACTION_PATH/update_spm.py`,
so the action is fully self-contained — consumers do not need to vendor the
script into their own repo.

## License

[MIT](LICENSE)
71 changes: 71 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: 'Update Xcode Swift Packages'
description: 'Bump an Xcode project''s Swift Package Manager pins on Xcode 16+ by resolving a synthetic Package.swift with swift package update.'
author: 'Maksym Bilan'
branding:
icon: 'refresh-cw'
color: 'orange'

inputs:
project:
description: 'Path to the .xcodeproj. If empty, the action autodetects a single .xcodeproj under working-directory.'
required: false
default: ''
working-directory:
description: 'Directory the action runs in (used for autodetection and as the cwd of the script).'
required: false
default: '.'
xcode-version:
description: 'Xcode version to select via xcode-select before resolving (e.g. "16.4"). Leave empty to use the runner default.'
required: false
default: ''
fail-when-outdated:
description: 'If "true", the action exits non-zero when any dependency would be updated. Useful as a CI guard.'
required: false
default: 'false'
dry-run:
description: 'If "true", resolve into a temp dir and report changes without writing Package.resolved back to the project.'
required: false
default: 'false'

outputs:
dependencies-changed:
description: '"true" if at least one pin would change, otherwise "false".'
value: ${{ steps.run.outputs.dependencies-changed }}
changed-count:
description: 'Number of pins that changed (integer, as a string).'
value: ${{ steps.run.outputs.changed-count }}

runs:
using: 'composite'
steps:
- name: Select Xcode
if: inputs.xcode-version != ''
shell: bash
run: |
set -euo pipefail
sudo xcode-select -s "/Applications/Xcode_${{ inputs.xcode-version }}.app"
xcodebuild -version

- name: Resolve Swift package dependencies
id: run
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
INPUT_PROJECT: ${{ inputs.project }}
INPUT_DRY_RUN: ${{ inputs.dry-run }}
INPUT_FAIL_WHEN_OUTDATED: ${{ inputs.fail-when-outdated }}
run: |
set -euo pipefail
args=()
if [ -n "$INPUT_PROJECT" ]; then
args+=(--project "$INPUT_PROJECT")
else
args+=(--search-dir .)
fi
if [ "$INPUT_DRY_RUN" = "true" ]; then
args+=(--dry-run)
fi
if [ "$INPUT_FAIL_WHEN_OUTDATED" = "true" ]; then
args+=(--fail-when-outdated)
fi
python3 "$GITHUB_ACTION_PATH/update_spm.py" "${args[@]}"
50 changes: 50 additions & 0 deletions examples/update-spm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Update Swift Packages

# Bumps SPM dependencies to the latest versions allowed by the constraints in
# project.pbxproj, then opens a PR with the new Package.resolved.
#
# Why not just `xcodebuild -resolvePackageDependencies`? On Xcode 16+ that path
# stopped reliably bumping versions. The maximbilan/xcode-spm-update action
# routes around it by handing a synthetic manifest to `swift package update`,
# which still resolves correctly.

on:
schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
workflow_dispatch: {} # allow manual runs from the Actions tab

permissions:
contents: write
pull-requests: write

jobs:
update:
# The action shells out to `xcrun swift package update`, so the job must run
# on a macOS runner.
runs-on: macos-15
steps:
- uses: actions/checkout@v4

- name: Update Swift packages
id: update
uses: maximbilan/xcode-spm-update@v1
with:
# Omit `project` to autodetect a single .xcodeproj under the repo root.
project: MySwimPro.xcodeproj
# Pin the Xcode version so Package.resolved format/originHash matches
# what your team's Xcode expects.
xcode-version: '16.4'

- name: Open pull request
if: steps.update.outputs.dependencies-changed == 'true'
uses: peter-evans/create-pull-request@v6
with:
branch: deps/spm-update
delete-branch: true
commit-message: 'chore: update Swift package dependencies'
title: 'Update Swift package dependencies'
body: |
Automated bump of Swift Package Manager pins.

${{ steps.update.outputs.changed-count }} package(s) changed.
labels: dependencies
31 changes: 31 additions & 0 deletions test/fixture/Sample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// !$*UTF8*$!
// Minimal pbxproj fixture used by .github/workflows/test.yml.
// It is NOT a fully-formed Xcode project — it only contains the
// XCRemoteSwiftPackageReference section that update_spm.py parses.
{
archiveVersion = 1;
objectVersion = 56;
rootObject = AAAA000000000000000000AA /* Project object */;
objects = {

/* Begin XCRemoteSwiftPackageReference section */
BBBB000000000000000001BB /* XCRemoteSwiftPackageReference "swift-log" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-log";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.0;
};
};
BBBB000000000000000002BB /* XCRemoteSwiftPackageReference "swift-collections" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-collections";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */

};
}
Loading
Loading