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
9 changes: 9 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ on:
workflow_dispatch: {}

jobs:
unit:
# Pure-Python logic tests (lockfile discovery + in-place merge). No Swift
# toolchain needed, so run them fast on Linux.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run merge unit tests
run: python3 test/test_merge.py

selftest:
# Must be macOS — the action calls `xcrun swift package update`.
runs-on: macos-latest
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.DS_Store
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- Update **every** `Package.resolved` Xcode reads for the project, not just the
one embedded in the `.xcodeproj`. Previously, projects built through a
`.xcworkspace` (via `xcodebuild -workspace`) resolved against the workspace's
own lockfile, which the Action never touched — so dependency bumps landed in
the project lockfile but never reached real builds, and the two files drifted.
The Action now discovers the project lockfile and each sibling
`.xcworkspace/xcshareddata/swiftpm/Package.resolved`.

### Changed
- Lockfiles are now patched **in place** (values only) instead of being
overwritten wholesale: each file keeps its own schema version (`v2`/`v3`),
`originHash`, formatting, and any pins that belong only to that container
(e.g. dependencies of a workspace's local packages). This yields minimal,
values-only diffs with no formatting churn.
- `changed-count` now reports the number of distinct pins changed across all
lockfiles; `dependencies-changed` is `true` if any lockfile would change.

## [1.0.0] - 2026-06-05

Initial public release as a Marketplace composite action.
Expand Down
37 changes: 23 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,16 @@ Rather than fight `xcodebuild`, the Action sidesteps it:
synthetic Package.swift ──► xcrun swift package update ──► fresh Package.resolved
│ copy back into
│ merge resolved pins into
│ every lockfile Xcode reads
.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
.xcodeproj/project.xcworkspace/…/Package.resolved (the project)
<MyApp>.xcworkspace/…/Package.resolved (each workspace)
```

In other words: read the package URLs and version requirements straight out of `project.pbxproj`, hand them to the real Swift Package Manager solver (which still works fine), and drop the resulting lockfile back into the place Xcode reads it from.
In other words: read the package URLs and version requirements straight out of `project.pbxproj`, hand them to the real Swift Package Manager solver (which still works fine), and merge the resulting pins back into the place(s) Xcode reads them from.

Xcode keeps a **separate** `Package.resolved` per container — one inside the `.xcodeproj`, and one inside each `.xcworkspace`. `xcodebuild -workspace` (what most CI/release builds run) reads the **workspace** copy, so updating only the project copy silently misses real builds. The Action therefore updates every lockfile it finds, patching each **in place** (values only) so it keeps its own schema version and formatting, and never touches pins that belong only to a workspace's local packages.

Implementation is a single pure-Python script using only the standard library — no `pip install`, no Node dependencies — and it must run on a macOS runner because it shells out to the Swift toolchain.

Expand Down Expand Up @@ -93,17 +97,18 @@ A complete working example lives at [`examples/update-spm.yml`](examples/update-

| 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. |
| `dependencies-changed` | `'true'` if at least one pin would change in any lockfile, otherwise `'false'`. |
| `changed-count` | Number of distinct package pins that changed across all lockfiles, as an integer string. |

## Caveats

- **Project-internal `Package.resolved` only.** This Action writes 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` (where the resolved
file lives under the workspace, not the project) will need their workflow
adjusted, since this Action does not write to that location.
- **Existing workspace lockfiles only.** The Action updates the project's own
lockfile and every sibling `<MyApp>.xcworkspace/…/Package.resolved` that
already exists. It will **not** create a workspace lockfile from scratch: a
workspace's resolved graph includes dependencies pulled in by its local
packages, which the synthetic manifest never sees, so a freshly-authored file
would be incomplete. Open the workspace in Xcode once to generate the initial
lockfile, then the Action keeps it current.
- **Unsupported reference kinds.** Only `XCRemoteSwiftPackageReference` is
parsed. Local packages (`XCLocalSwiftPackageReference`) and registry
dependencies (`.package(id:)`) are skipped — they don't appear in the
Expand All @@ -129,9 +134,13 @@ A complete working example lives at [`examples/update-spm.yml`](examples/update-
`"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.
4. Finds every `Package.resolved` Xcode reads for the project (the one embedded
in the `.xcodeproj` plus each sibling `.xcworkspace`'s copy) and computes, per
file, which pins would change.
5. Unless `--dry-run` or `--fail-when-outdated`, merges the new pins into each
lockfile in place — updating only the pins present in the fresh resolution,
preserving every file's schema version, `originHash`, and formatting, and
leaving workspace-only pins untouched.

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
Expand Down
2 changes: 1 addition & 1 deletion examples/update-spm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
uses: maximbilan/xcode-spm-update@v1
with:
# Omit `project` to autodetect a single .xcodeproj under the repo root.
project: MySwimPro.xcodeproj
project: MyApp.xcodeproj
# Pin the Xcode version so Package.resolved format/originHash matches
# what your team's Xcode expects.
xcode-version: '16.4'
Expand Down
135 changes: 135 additions & 0 deletions test/test_merge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Unit tests for the lockfile-discovery and in-place merge logic in update_spm.py.

Pure standard library, no Swift toolchain or network needed — these cover the
part of the Action that broke real builds: writing to *every* Package.resolved
Xcode reads (project + each workspace) while preserving each file's format and
never clobbering pins that belong only to a workspace's local packages.

Run: python3 test/test_merge.py
"""

import json
import os
import sys
import tempfile

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import update_spm as u # noqa: E402


def _pin(identity, version, location=None):
return {
"identity": identity,
"kind": "remoteSourceControl",
"location": location or f"https://github.com/example/{identity}.git",
"state": {"revision": "0" * 40, "version": version},
}


def _write(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(u._dump_resolved(data))


def test_dump_matches_swiftpm_format():
# Space before the colon, 2-space indent, sorted keys, trailing newline.
out = u._dump_resolved({"version": 2, "pins": []})
assert out == '{\n "pins" : [],\n "version" : 2\n}\n', repr(out)


def test_find_targets_discovers_project_and_workspace_not_local_pkg():
with tempfile.TemporaryDirectory() as d:
proj = os.path.join(d, "MyApp.xcodeproj")
proj_lock = os.path.join(proj, "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved")
ws_lock = os.path.join(d, "MyApp.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved")
local_lock = os.path.join(d, "LocalPkg", "Package.resolved")
for p in (proj_lock, ws_lock, local_lock):
_write(p, {"version": 2, "pins": []})

targets = u.find_resolved_targets(proj, d)
reals = {os.path.realpath(t["path"]) for t in targets}

assert targets[0]["kind"] == "project"
assert os.path.realpath(proj_lock) in reals
assert os.path.realpath(ws_lock) in reals, "sibling .xcworkspace lockfile must be a target"
assert os.path.realpath(local_lock) not in reals, "a local package lockfile must NOT be a target"
# The .xcodeproj's *internal* project.xcworkspace must be counted once, as 'project'.
assert [t["kind"] for t in targets].count("project") == 1
assert len(targets) == len(reals), "targets must be de-duplicated"


def test_merge_noop_is_byte_identical():
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "Package.resolved")
_write(path, {"version": 3, "originHash": "keepme",
"pins": [_pin("alpha", "1.0.0"), _pin("beta", "2.0.0")]})
original = open(path, encoding="utf-8").read()
by_ident = {p["identity"]: p for p in json.loads(original)["pins"]}

data, changes = u.plan_merge(path, by_ident)
assert changes == []
assert u._dump_resolved(data) == original, "no-op merge must not alter a single byte"


def test_merge_updates_values_preserves_schema_and_foreign_pins():
with tempfile.TemporaryDirectory() as d:
# v2 workspace file: has a pin ('local-only-dep') the resolution never sees.
path = os.path.join(d, "Package.resolved")
_write(path, {"version": 2, "pins": [
_pin("remote-dep", "1.0.0"),
_pin("local-only-dep", "3.2.1"), # local-package dep, not in synthetic manifest
]})
foreign_before = [p for p in json.loads(open(path).read())["pins"]
if p["identity"] == "local-only-dep"][0]

resolution = {"remote-dep": _pin("remote-dep", "9.9.9")} # note: no 'local-only-dep'
data, changes = u.plan_merge(path, resolution)

assert data["version"] == 2, "v2 must stay v2"
assert "originHash" not in data, "must not inject originHash into a v2 file"
assert [c[0] for c in changes] == ["remote-dep"]
pins = {p["identity"]: p for p in data["pins"]}
assert pins["remote-dep"]["state"]["version"] == "9.9.9"
assert pins["local-only-dep"] == foreign_before, "workspace-only pin must be untouched"
assert len(data["pins"]) == 2, "no pin added or removed"


def test_merge_preserves_v3_origin_hash():
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "Package.resolved")
_write(path, {"version": 3, "originHash": "abc123", "pins": [_pin("alpha", "1.0.0")]})
data, changes = u.plan_merge(path, {"alpha": _pin("alpha", "1.1.0")})
assert data["version"] == 3
assert data["originHash"] == "abc123", "originHash must be preserved (requirements unchanged)"
assert changes == [("alpha", "1.0.0", "1.1.0")]


def test_merge_adds_new_pin_sorted():
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "Package.resolved")
_write(path, {"version": 2, "pins": [_pin("mmm", "1.0.0")]})
resolution = {"mmm": _pin("mmm", "1.0.0"), "aaa": _pin("aaa", "5.0.0")}
data, changes = u.plan_merge(path, resolution)
idents = [p["identity"] for p in data["pins"]]
assert idents == sorted(idents) == ["aaa", "mmm"], "pins must stay identity-sorted"
assert ("aaa", None, "5.0.0") in changes


def main():
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
failures = 0
for t in tests:
try:
t()
print(f"ok {t.__name__}")
except AssertionError as e:
failures += 1
print(f"FAIL {t.__name__}: {e}")
print(f"\n{len(tests) - failures}/{len(tests)} passed")
return 1 if failures else 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading