Skip to content

Commit 50ff170

Browse files
Extend Swift file updater to support xcode swiftpm dependency update (#14394)
* extend file updater to support xcode swiftpm dependency update * extend swift update checker to support xcode swift pm * refactor according to pr comments * fix failing lint and spec errors * merge update checker changes * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 74ce553 commit 50ff170

6 files changed

Lines changed: 908 additions & 4 deletions

File tree

swift/lib/dependabot/swift/file_updater.rb

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# typed: strong
22
# frozen_string_literal: true
33

4+
require "dependabot/experiments"
45
require "dependabot/file_updaters"
56
require "dependabot/file_updaters/base"
67
require "dependabot/swift/file_updater/lockfile_updater"
78
require "dependabot/swift/file_updater/manifest_updater"
9+
require "dependabot/swift/file_updater/xcode_lockfile_updater"
810

911
module Dependabot
1012
module Swift
@@ -13,6 +15,18 @@ class FileUpdater < Dependabot::FileUpdaters::Base
1315

1416
sig { override.returns(T::Array[Dependabot::DependencyFile]) }
1517
def updated_dependency_files
18+
if xcode_spm_mode?
19+
updated_xcode_spm_files
20+
else
21+
updated_classic_spm_files
22+
end
23+
end
24+
25+
private
26+
27+
# Classic SPM update: uses swift CLI to resolve and update
28+
sig { returns(T::Array[Dependabot::DependencyFile]) }
29+
def updated_classic_spm_files
1630
updated_files = T.let([], T::Array[Dependabot::DependencyFile])
1731

1832
SharedHelpers.in_a_temporary_repo_directory(T.must(manifest).directory, repo_contents_path) do
@@ -31,7 +45,34 @@ def updated_dependency_files
3145
updated_files
3246
end
3347

34-
private
48+
# Xcode SPM update: updates Package.resolved files in-place without CLI
49+
sig { returns(T::Array[Dependabot::DependencyFile]) }
50+
def updated_xcode_spm_files
51+
updated_files = T.let([], T::Array[Dependabot::DependencyFile])
52+
53+
xcode_resolved_files.each do |resolved_file|
54+
updater = XcodeLockfileUpdater.new(
55+
resolved_file: resolved_file,
56+
dependencies: dependencies
57+
)
58+
59+
next unless updater.lockfile_changed?
60+
61+
updated_content = updater.updated_lockfile_content
62+
next if updated_content == resolved_file.content
63+
64+
updated_files << updated_file(file: resolved_file, content: updated_content)
65+
end
66+
67+
if updated_files.empty?
68+
raise Dependabot::DependencyFileNotFound.new(
69+
nil,
70+
"No Package.resolved files needed updating for the specified dependencies"
71+
)
72+
end
73+
74+
updated_files
75+
end
3576

3677
sig { returns(Dependabot::Dependency) }
3778
def dependency
@@ -42,7 +83,29 @@ def dependency
4283

4384
sig { override.void }
4485
def check_required_files
45-
raise "A Package.swift file must be provided!" unless manifest
86+
return if manifest
87+
return if xcode_spm_mode? && xcode_resolved_files.any?
88+
89+
raise "A Package.swift file or Xcode Package.resolved must be provided!"
90+
end
91+
92+
sig { returns(T::Boolean) }
93+
def xcode_spm_mode?
94+
return false unless Dependabot::Experiments.enabled?(:enable_swift_xcode_spm)
95+
96+
manifest.nil? && xcode_resolved_files.any?
97+
end
98+
99+
sig { returns(T::Array[Dependabot::DependencyFile]) }
100+
def xcode_resolved_files
101+
@xcode_resolved_files ||= T.let(
102+
dependency_files.select do |f|
103+
f.name.end_with?("Package.resolved") &&
104+
f.name.include?(".xcodeproj/") &&
105+
!f.support_file?
106+
end,
107+
T.nilable(T::Array[Dependabot::DependencyFile])
108+
)
46109
end
47110

48111
sig { returns(String) }
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "json"
5+
require "sorbet-runtime"
6+
require "dependabot/dependency"
7+
require "dependabot/dependency_file"
8+
require "dependabot/errors"
9+
require "dependabot/shared_helpers"
10+
require "dependabot/file_updaters"
11+
require "dependabot/file_updaters/base"
12+
require "dependabot/swift/url_helpers"
13+
14+
module Dependabot
15+
module Swift
16+
class FileUpdater < Dependabot::FileUpdaters::Base
17+
class XcodeLockfileUpdater
18+
extend T::Sig
19+
20+
SUPPORTED_VERSIONS = T.let([1, 2, 3].freeze, T::Array[Integer])
21+
22+
# Maps schema version to the JSON keys used for each pin field
23+
PIN_KEYS = T.let(
24+
{
25+
1 => { url: "repositoryURL", identity: "package", pins_path: %w(object pins) },
26+
2 => { url: "location", identity: "identity", pins_path: ["pins"] },
27+
3 => { url: "location", identity: "identity", pins_path: ["pins"] }
28+
}.freeze,
29+
T::Hash[Integer, T::Hash[Symbol, T.untyped]]
30+
)
31+
32+
sig do
33+
params(
34+
resolved_file: Dependabot::DependencyFile,
35+
dependencies: T::Array[Dependabot::Dependency]
36+
).void
37+
end
38+
def initialize(resolved_file:, dependencies:)
39+
@resolved_file = resolved_file
40+
@dependencies = dependencies
41+
end
42+
43+
sig { returns(String) }
44+
def updated_lockfile_content
45+
content = resolved_file.content
46+
unless content
47+
raise Dependabot::DependencyFileNotParseable.new(
48+
resolved_file.name,
49+
"#{resolved_file.name} has no content"
50+
)
51+
end
52+
53+
parsed = parse_json(content)
54+
schema_version = detect_schema_version(parsed)
55+
keys = T.must(PIN_KEYS[schema_version])
56+
57+
update_pins(parsed, schema_version, keys)
58+
59+
# Use JSON.pretty_generate to match Xcode's output format:
60+
# - 2-space indentation
61+
# - space before colon (e.g., "key" : "value")
62+
JSON.pretty_generate(
63+
parsed,
64+
indent: " ",
65+
space: " ",
66+
space_before: " ",
67+
object_nl: "\n",
68+
array_nl: "\n"
69+
) + "\n"
70+
end
71+
72+
sig { returns(T::Boolean) }
73+
def lockfile_changed?
74+
dependencies_for_file.any?
75+
end
76+
77+
private
78+
79+
sig { returns(Dependabot::DependencyFile) }
80+
attr_reader :resolved_file
81+
82+
sig { returns(T::Array[Dependabot::Dependency]) }
83+
attr_reader :dependencies
84+
85+
sig { params(content: String).returns(T::Hash[String, T.untyped]) }
86+
def parse_json(content)
87+
JSON.parse(content)
88+
rescue JSON::ParserError => e
89+
raise Dependabot::DependencyFileNotParseable.new(
90+
resolved_file.name,
91+
"#{resolved_file.name} is not valid JSON: #{e.message}"
92+
)
93+
end
94+
95+
sig { params(parsed: T::Hash[String, T.untyped]).returns(Integer) }
96+
def detect_schema_version(parsed)
97+
version = parsed["version"]
98+
99+
unless version.is_a?(Integer) && SUPPORTED_VERSIONS.include?(version)
100+
raise Dependabot::DependencyFileNotParseable.new(
101+
resolved_file.name,
102+
"#{resolved_file.name} has unsupported schema version: #{version.inspect}. " \
103+
"Supported versions: #{SUPPORTED_VERSIONS.join(', ')}"
104+
)
105+
end
106+
107+
version
108+
end
109+
110+
sig do
111+
params(
112+
parsed: T::Hash[String, T.untyped],
113+
schema_version: Integer,
114+
keys: T::Hash[Symbol, T.untyped]
115+
).void
116+
end
117+
def update_pins(parsed, schema_version, keys)
118+
pins_path = T.cast(keys[:pins_path], T::Array[String])
119+
pins = dig_pins(parsed, pins_path)
120+
121+
unless pins.is_a?(Array)
122+
raise Dependabot::DependencyFileNotParseable.new(
123+
resolved_file.name,
124+
"#{resolved_file.name} is missing the expected 'pins' array " \
125+
"(schema version #{schema_version})"
126+
)
127+
end
128+
129+
dependencies_for_file.each do |dep|
130+
update_pin_for_dependency(pins, dep, keys, schema_version)
131+
end
132+
end
133+
134+
sig do
135+
params(
136+
parsed: T::Hash[String, T.untyped],
137+
path: T::Array[String]
138+
).returns(T.untyped)
139+
end
140+
def dig_pins(parsed, path)
141+
# Navigate nested hash using path keys
142+
# Path is either ["object", "pins"] for v1 or ["pins"] for v2/v3
143+
current = T.let(parsed, T.untyped)
144+
path.each do |key|
145+
break unless current.is_a?(Hash)
146+
147+
current = current[key]
148+
end
149+
current
150+
end
151+
152+
sig do
153+
params(
154+
pins: T::Array[T::Hash[String, T.untyped]],
155+
dependency: Dependabot::Dependency,
156+
keys: T::Hash[Symbol, T.untyped],
157+
schema_version: Integer
158+
).void
159+
end
160+
def update_pin_for_dependency(pins, dependency, keys, schema_version)
161+
pin = find_pin_for_dependency(pins, dependency, keys, schema_version)
162+
return unless pin
163+
164+
state = pin["state"]
165+
return unless state.is_a?(Hash)
166+
167+
source = dependency.requirements.first&.dig(:source)
168+
new_version = dependency.version
169+
new_ref = source&.dig(:ref)
170+
171+
if new_version
172+
state["version"] = new_version
173+
# When updating to a new version, update revision if provided in source
174+
# The ref from source is typically the git SHA corresponding to the version tag
175+
state["revision"] = new_ref if new_ref && looks_like_sha?(new_ref)
176+
elsif new_ref
177+
# Revision-only update (no version, just SHA)
178+
state["revision"] = new_ref
179+
state.delete("version")
180+
end
181+
end
182+
183+
sig { params(str: String).returns(T::Boolean) }
184+
def looks_like_sha?(str)
185+
str.match?(/\A[0-9a-f]{40}\z/i)
186+
end
187+
188+
sig do
189+
params(
190+
pins: T::Array[T::Hash[String, T.untyped]],
191+
dependency: Dependabot::Dependency,
192+
keys: T::Hash[Symbol, T.untyped],
193+
schema_version: Integer
194+
).returns(T.nilable(T::Hash[String, T.untyped]))
195+
end
196+
def find_pin_for_dependency(pins, dependency, keys, schema_version)
197+
identity_key = T.cast(keys[:identity], String)
198+
url_key = T.cast(keys[:url], String)
199+
identity = dependency.metadata[:identity]
200+
201+
pins.find do |pin|
202+
pin_identity = pin[identity_key]
203+
pin_identity = pin_identity&.downcase if schema_version == 1
204+
205+
if identity && pin_identity == identity
206+
true
207+
else
208+
# Fall back to URL matching
209+
pin_url = pin[url_key]
210+
next false unless pin_url.is_a?(String)
211+
212+
normalized_pin_url = SharedHelpers.scp_to_standard(pin_url)
213+
pin_name = UrlHelpers.normalize_name(normalized_pin_url)
214+
215+
pin_name == dependency.name
216+
end
217+
end
218+
end
219+
220+
sig { returns(T::Array[Dependabot::Dependency]) }
221+
def dependencies_for_file
222+
@dependencies_for_file ||= T.let(
223+
dependencies.select do |dep|
224+
dep.requirements.any? do |req|
225+
req_file = req[:file]
226+
if req_file == resolved_file.name
227+
true
228+
elsif req_file&.include?(".xcodeproj/")
229+
# Extract the xcodeproj dir from both files and compare
230+
req_xcodeproj = extract_xcodeproj_dir(req_file)
231+
resolved_xcodeproj = extract_xcodeproj_dir(resolved_file.name)
232+
req_xcodeproj && req_xcodeproj == resolved_xcodeproj
233+
else
234+
false
235+
end
236+
end
237+
end,
238+
T.nilable(T::Array[Dependabot::Dependency])
239+
)
240+
end
241+
242+
# Extracts the .xcodeproj directory from a file path.
243+
# e.g. "MyApp.xcodeproj/project.xcworkspace/.../Package.resolved" -> "MyApp.xcodeproj"
244+
sig { params(path: String).returns(T.nilable(String)) }
245+
def extract_xcodeproj_dir(path)
246+
match = path.match(%r{^(.*?\.xcodeproj)/})
247+
match&.captures&.first
248+
end
249+
end
250+
end
251+
end
252+
end

swift/lib/dependabot/swift/metadata_finder.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ def look_up_source
1717
case new_source_type
1818
when "git" then find_source_from_git_url
1919
when "registry" then find_source_from_registry
20+
when "default", nil
21+
# For dependencies without explicit source info (e.g., Xcode-managed
22+
# SPM dependencies parsed from Package.resolved), attempt to infer
23+
# source from the dependency name which is typically a normalized URL
24+
find_source_from_dependency_name
2025
else raise "Unexpected source type: #{new_source_type}"
2126
end
2227
end
@@ -34,6 +39,15 @@ def find_source_from_git_url
3439
Source.from_url(url)
3540
end
3641

42+
sig { returns(T.nilable(Dependabot::Source)) }
43+
def find_source_from_dependency_name
44+
name = dependency.name
45+
return nil unless name.include?("/")
46+
47+
url = "https://#{name}"
48+
Source.from_url(url)
49+
end
50+
3751
sig { returns(T.noreturn) }
3852
def find_source_from_registry
3953
raise NotImplementedError

0 commit comments

Comments
 (0)