From 2346ab68b36dc152c8b48a4abe56fb6ae36d688b Mon Sep 17 00:00:00 2001 From: Maksym Bilan Date: Fri, 5 Jun 2026 16:30:18 +0200 Subject: [PATCH] Publish as composite Marketplace action - action.yml: composite action with project/working-directory/xcode-version/ fail-when-outdated/dry-run inputs and dependencies-changed/changed-count outputs. - update_spm.py: add --search-dir autodetect, --fail-when-outdated, and $GITHUB_OUTPUT emission. Synthetic-manifest logic unchanged. - examples/update-spm.yml: consumer workflow that calls the action and opens a PR via peter-evans/create-pull-request. - .github/workflows/test.yml: macOS self-test that runs the action against a committed pbxproj fixture in dry-run and asserts the outputs. - README, LICENSE (MIT), CHANGELOG for v1.0.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 62 +++++ CHANGELOG.md | 23 ++ LICENSE | 21 ++ README.md | 121 ++++++++- action.yml | 71 ++++++ examples/update-spm.yml | 50 ++++ test/fixture/Sample.xcodeproj/project.pbxproj | 31 +++ update_spm.py | 241 ++++++++++++++++++ 8 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 action.yml create mode 100644 examples/update-spm.yml create mode 100644 test/fixture/Sample.xcodeproj/project.pbxproj create mode 100644 update_spm.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..09f0bae --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..42866bb --- /dev/null +++ b/CHANGELOG.md @@ -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`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e67f23b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 244fded..c62c724 100644 --- a/README.md +++ b/README.md @@ -1 +1,120 @@ -# xcode-spm-update \ No newline at end of file +# 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 + `/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 `. +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) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..c6a0050 --- /dev/null +++ b/action.yml @@ -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[@]}" diff --git a/examples/update-spm.yml b/examples/update-spm.yml new file mode 100644 index 0000000..9f6bb11 --- /dev/null +++ b/examples/update-spm.yml @@ -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 diff --git a/test/fixture/Sample.xcodeproj/project.pbxproj b/test/fixture/Sample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..cdd38ff --- /dev/null +++ b/test/fixture/Sample.xcodeproj/project.pbxproj @@ -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 */ + + }; +} diff --git a/update_spm.py b/update_spm.py new file mode 100644 index 0000000..4908f99 --- /dev/null +++ b/update_spm.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +update_spm.py — Update an Xcode project's Swift Package dependencies from the CLI. + +Why this exists: `xcodebuild -resolvePackageDependencies` (the usual CLI path, and +what the only off-the-shelf Action wraps) stopped reliably bumping versions in +Xcode 16+. This routes around it: it reads the dependency *requirements* straight +out of project.pbxproj, writes a throwaway Package.swift, lets the real `swift` +toolchain do the resolving (PubGrub solver, transitive graph, the works), then +copies the freshly resolved Package.resolved back into the Xcode project. + +It does NOT reimplement version solving. SPM does that. We only do plumbing. + +Usage: + python3 update_spm.py [--project path/to/MyApp.xcodeproj] [--search-dir .] + [--dry-run] [--fail-when-outdated] + +Exit codes: + 0 success (Package.resolved updated, or already up to date) + 1 usage / parse error + 2 `swift package update` failed + 3 --fail-when-outdated and dependencies were out of date +""" + +import argparse +import glob +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile + +# Each remote package reference: grab its name, repositoryURL, and the *inner* +# `requirement = { ... }` block (parsing that block on its own avoids the nested +# braces tripping up the key/value scan). +REMOTE_RE = re.compile( + r'XCRemoteSwiftPackageReference "(?P[^"]+)" \*/ = \{' + r'.*?repositoryURL\s*=\s*"(?P[^"]+)";' + r'.*?requirement\s*=\s*\{(?P.*?)\};', + re.S, +) +KV_RE = re.compile(r'(\w+)\s*=\s*"?([^";{}]+?)"?\s*;') + + +def parse_dependencies(pbxproj_path): + """Return a list of dicts: {name, url, requirement_swift} parsed from pbxproj.""" + text = open(pbxproj_path, encoding="utf-8").read() + try: + section = text.split("Begin XCRemoteSwiftPackageReference section")[1] + section = section.split("End XCRemoteSwiftPackageReference section")[0] + except IndexError: + return [] # no remote packages + + deps = [] + for m in REMOTE_RE.finditer(section): + req = dict(KV_RE.findall(m.group("req"))) + deps.append({ + "name": m.group("name"), + "url": m.group("url"), + "requirement_swift": _requirement_to_swift(req, m.group("name")), + }) + deps.sort(key=lambda d: d["name"].lower()) + return deps + + +def _requirement_to_swift(req, name): + """Translate a pbxproj requirement dict into a Package.swift argument string.""" + kind = req.get("kind", "") + if kind == "upToNextMajorVersion": + return f'from: "{req["minimumVersion"]}"' + if kind == "upToNextMinorVersion": + return f'.upToNextMinor(from: "{req["minimumVersion"]}")' + if kind == "exactVersion": + return f'exact: "{req.get("version", req.get("minimumVersion"))}"' + if kind == "versionRange": + return f'"{req["minimumVersion"]}"..<"{req["maximumVersion"]}"' + if kind == "branch": + return f'branch: "{req["branch"]}"' + if kind == "revision": + return f'revision: "{req["revision"]}"' + raise ValueError(f"Unsupported requirement kind '{kind}' for dependency '{name}'") + + +def render_manifest(deps, tools_version="5.9"): + lines = [ + f"// swift-tools-version:{tools_version}", + "// AUTO-GENERATED throwaway manifest used only to resolve dependency versions.", + "import PackageDescription", + "", + "let package = Package(", + ' name: "SPMResolver",', + " dependencies: [", + ] + for d in deps: + lines.append(f' .package(url: "{d["url"]}", {d["requirement_swift"]}),') + lines += [ + " ],", + " targets: []", + ")", + "", + ] + return "\n".join(lines) + + +def find_resolved_path(project_path): + """Locate the Package.resolved Xcode reads for this .xcodeproj.""" + candidate = os.path.join( + project_path, "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved" + ) + return candidate + + +def summarize_diff(old_path, new_path): + def load(p): + if not os.path.exists(p): + return {} + data = json.load(open(p)) + return {pin["identity"]: pin["state"].get("version") or pin["state"].get("revision", "")[:7] + for pin in data.get("pins", [])} + old, new = load(old_path), load(new_path) + changed = [] + for ident in sorted(set(old) | set(new)): + a, b = old.get(ident), new.get(ident) + if a != b: + changed.append((ident, a, b)) + return changed + + +def autodetect_project(search_dir): + """Find a single .xcodeproj under search_dir. Returns the path or None.""" + matches = sorted(glob.glob(os.path.join(search_dir, "*.xcodeproj"))) + if not matches: + matches = sorted(glob.glob(os.path.join(search_dir, "**", "*.xcodeproj"), recursive=True)) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + print(f"error: multiple .xcodeproj found under {search_dir}; pass --project to disambiguate:", + file=sys.stderr) + for m in matches: + print(f" {m}", file=sys.stderr) + return None + + +def emit_outputs(changed_count): + """Write GitHub Actions outputs if $GITHUB_OUTPUT is set.""" + path = os.environ.get("GITHUB_OUTPUT") + if not path: + return + with open(path, "a", encoding="utf-8") as fh: + fh.write(f"dependencies-changed={'true' if changed_count > 0 else 'false'}\n") + fh.write(f"changed-count={changed_count}\n") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--project", default="", + help="Path to the .xcodeproj (autodetected from --search-dir if omitted)") + ap.add_argument("--search-dir", default=".", + help="Directory to search for a .xcodeproj when --project is not given (default: .)") + ap.add_argument("--tools-version", default="5.9") + ap.add_argument("--dry-run", action="store_true", + help="Generate manifest and resolve into a temp dir, but don't overwrite the project's Package.resolved") + ap.add_argument("--print-manifest", action="store_true", help="Print the synthetic Package.swift and exit") + ap.add_argument("--fail-when-outdated", action="store_true", + help="Exit with code 3 if any dependency would be updated (useful for CI guards)") + args = ap.parse_args() + + project = args.project or autodetect_project(args.search_dir) + if not project: + if not args.project: + print(f"error: no .xcodeproj found under {args.search_dir}; pass --project", + file=sys.stderr) + return 1 + + pbxproj = os.path.join(project, "project.pbxproj") + if not os.path.exists(pbxproj): + print(f"error: {pbxproj} not found", file=sys.stderr) + return 1 + + print(f"Project: {project}") + deps = parse_dependencies(pbxproj) + if not deps: + print("No remote Swift Package dependencies found.") + emit_outputs(0) + return 0 + print(f"Found {len(deps)} direct dependencies:") + for d in deps: + print(f" - {d['name']:<28} {d['requirement_swift']}") + + manifest = render_manifest(deps, args.tools_version) + if args.print_manifest: + print("\n--- synthetic Package.swift ---\n" + manifest) + return 0 + + tmp = tempfile.mkdtemp(prefix="spmresolve_") + open(os.path.join(tmp, "Package.swift"), "w").write(manifest) + + print("\nResolving with `swift package update` ...") + proc = subprocess.run( + ["xcrun", "swift", "package", "update", "--package-path", tmp], + capture_output=True, text=True, + ) + sys.stdout.write(proc.stdout) + sys.stderr.write(proc.stderr) + if proc.returncode != 0: + print("error: swift package update failed", file=sys.stderr) + return 2 + + new_resolved = os.path.join(tmp, "Package.resolved") + target = find_resolved_path(project) + changed = summarize_diff(target, new_resolved) + changed_count = len(changed) + emit_outputs(changed_count) + + if not changed: + print("\nAll dependencies already up to date. No changes.") + return 0 + + print(f"\n{changed_count} package(s) changed:") + for ident, old, new in changed: + print(f" {ident:<28} {old or '(new)'} -> {new}") + + if args.fail_when_outdated: + print("\n--fail-when-outdated set: exiting with code 3 because pins are stale.", + file=sys.stderr) + return 3 + + if args.dry_run: + print(f"\n[dry-run] Updated Package.resolved left at: {new_resolved}") + return 0 + + os.makedirs(os.path.dirname(target), exist_ok=True) + shutil.copyfile(new_resolved, target) + print(f"\nWrote updated pins to {target}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())