diff --git a/swift/lib/dependabot/swift/file_fetcher.rb b/swift/lib/dependabot/swift/file_fetcher.rb index 01b48130b52..354f2367dda 100644 --- a/swift/lib/dependabot/swift/file_fetcher.rb +++ b/swift/lib/dependabot/swift/file_fetcher.rb @@ -5,20 +5,24 @@ require "dependabot/experiments" require "dependabot/file_fetchers" require "dependabot/file_fetchers/base" +require "dependabot/swift/xcode_file_helpers" module Dependabot module Swift class FileFetcher < Dependabot::FileFetchers::Base extend T::Sig + XCODEPROJ_SUFFIX = ".xcodeproj" + XCWORKSPACE_SUFFIX = ".xcworkspace" XCODE_SPM_PACKAGE_RESOLVED_PATH = "project.xcworkspace/xcshareddata/swiftpm/Package.resolved" + XCWORKSPACE_PACKAGE_RESOLVED_PATH = "xcshareddata/swiftpm/Package.resolved" sig { override.params(filenames: T::Array[String]).returns(T::Boolean) } def self.required_files_in?(filenames) return true if filenames.include?("Package.swift") if Dependabot::Experiments.enabled?(:enable_swift_xcode_spm) - return filenames.any? { |f| f.end_with?("Package.resolved") } + return filenames.any? { |f| XcodeFileHelpers.xcode_resolved_path?(f) } end false @@ -28,7 +32,7 @@ def self.required_files_in?(filenames) def self.required_files_message if Dependabot::Experiments.enabled?(:enable_swift_xcode_spm) "Repo must contain a Package.swift configuration file or " \ - "an .xcodeproj directory with a Package.resolved file." + "an .xcodeproj/.xcworkspace directory with a Package.resolved file." else "Repo must contain a Package.swift configuration file." end @@ -74,17 +78,62 @@ def fetch_xcode_spm_files(fetched_files) resolved = fetch_file_if_present(File.join(xcodeproj_path, XCODE_SPM_PACKAGE_RESOLVED_PATH)) fetched_files << resolved if resolved end + + xcworkspace_dirs.each do |workspace_path| + workspace_data = fetch_support_file(File.join(workspace_path, "contents.xcworkspacedata")) + fetched_files << workspace_data if workspace_data + + resolved = fetch_file_if_present(File.join(workspace_path, XCWORKSPACE_PACKAGE_RESOLVED_PATH)) + fetched_files << resolved if resolved + end end sig { returns(T::Array[String]) } def xcodeproj_dirs @xcodeproj_dirs ||= T.let( - repo_contents(dir: ".", raise_errors: false) - .select { |entry| entry.type == "dir" && entry.name.end_with?(".xcodeproj") } - .map(&:name), + discover_dirs_with_suffix(XCODEPROJ_SUFFIX), T.nilable(T::Array[String]) ) end + + sig { returns(T::Array[String]) } + def xcworkspace_dirs + @xcworkspace_dirs ||= T.let( + discover_dirs_with_suffix(XCWORKSPACE_SUFFIX) + .reject { |path| path.include?("#{XCODEPROJ_SUFFIX}/") }, + T.nilable(T::Array[String]) + ) + end + + sig { params(suffix: String).returns(T::Array[String]) } + def discover_dirs_with_suffix(suffix) + discovered = T.let([], T::Array[String]) + queue = T.let(["."], T::Array[String]) + visited = T.let({}, T::Hash[String, T::Boolean]) + index = T.let(0, Integer) + + while index < queue.length + dir = T.must(queue[index]) + index += 1 + next if visited[dir] + + visited[dir] = true + + entries = repo_contents(dir: dir, raise_errors: false) + entries.each do |entry| + next unless entry.type == "dir" + + next_dir = dir == "." ? entry.name : File.join(dir, entry.name) + if entry.name.end_with?(suffix) + discovered << next_dir + elsif !entry.name.start_with?(".") + queue << next_dir + end + end + end + + discovered.sort + end end end end diff --git a/swift/lib/dependabot/swift/file_parser.rb b/swift/lib/dependabot/swift/file_parser.rb index cca6c20a34f..3815ba53dff 100644 --- a/swift/lib/dependabot/swift/file_parser.rb +++ b/swift/lib/dependabot/swift/file_parser.rb @@ -10,6 +10,7 @@ require "dependabot/swift/file_parser/xcode_spm_resolver" require "dependabot/swift/package_manager" require "dependabot/swift/language" +require "dependabot/swift/xcode_file_helpers" module Dependabot module Swift @@ -115,13 +116,12 @@ def package_manifest_file @package_manifest_file ||= T.let(get_original_file("Package.swift"), T.nilable(Dependabot::DependencyFile)) end - # All non-support Package.resolved files from Xcode project directories + # All non-support Package.resolved files from Xcode project and workspace directories (.xcodeproj, .xcworkspace) sig { returns(T::Array[Dependabot::DependencyFile]) } def xcode_resolved_files @xcode_resolved_files ||= T.let( dependency_files.select do |f| - f.name.end_with?("Package.resolved") && - f.name.include?(".xcodeproj/") && + XcodeFileHelpers.xcode_resolved_path?(f.name) && !f.support_file? end, T.nilable(T::Array[Dependabot::DependencyFile]) diff --git a/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb b/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb index e753e984c95..aafa68c597d 100644 --- a/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb +++ b/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb @@ -7,14 +7,16 @@ require "dependabot/swift/file_parser" require "dependabot/swift/file_parser/package_resolved_parser" require "dependabot/swift/file_parser/pbxproj_parser" +require "dependabot/swift/xcode_file_helpers" module Dependabot module Swift class FileParser < Dependabot::FileParsers::Base # Orchestrates Xcode-managed SwiftPM dependency parsing. # - # Parses Package.resolved JSON files found inside .xcodeproj directories, - # then enriches each dependency with requirement info extracted from the + # Parses Package.resolved JSON files found inside Xcode project and + # workspace directories (e.g., .xcodeproj and .xcworkspace), then + # enriches each dependency with requirement info extracted from the # corresponding project.pbxproj files. class XcodeSpmResolver extend T::Sig @@ -38,8 +40,10 @@ def parse xcode_resolved_files.each do |resolved_file| resolved_deps = PackageResolvedParser.new(resolved_file).parse - xcodeproj_dir = extract_xcodeproj_dir(resolved_file.name) - pbxproj_requirements = scoped_requirements.fetch(xcodeproj_dir, {}) + pbxproj_requirements = requirements_for_resolved_file( + scoped_requirements: scoped_requirements, + resolved_file_name: resolved_file.name + ) resolved_deps.each do |dep| enriched = enrich_with_pbxproj_requirements(dep, pbxproj_requirements) @@ -59,24 +63,67 @@ def parse attr_reader :pbxproj_files # Collects requirement info from all project.pbxproj support files, - # keyed by xcodeproj directory so each resolved file only sees - # requirements from its own Xcode project. + # keyed by Xcode scope directory so each resolved file can be enriched + # by requirements from its closest matching Xcode scope. sig { returns(T::Hash[T.nilable(String), T::Hash[String, T::Hash[Symbol, T.untyped]]]) } def aggregate_pbxproj_requirements scoped = T.let({}, T::Hash[T.nilable(String), T::Hash[String, T::Hash[Symbol, T.untyped]]]) pbxproj_files.each do |pbxproj_file| - xcodeproj_dir = extract_xcodeproj_dir(pbxproj_file.name) - scoped[xcodeproj_dir] ||= {} + xcode_scope_dir = extract_xcode_scope_dir(pbxproj_file.name) + scoped[xcode_scope_dir] ||= {} PbxprojParser.new(pbxproj_file).parse.each do |name, req_info| - T.must(scoped[xcodeproj_dir])[name] = req_info + T.must(scoped[xcode_scope_dir])[name] = req_info end end scoped end + sig do + params( + scoped_requirements: T::Hash[T.nilable(String), T::Hash[String, T::Hash[Symbol, T.untyped]]] + ).returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) + end + def merge_scopes(scoped_requirements) + scoped_requirements.values.each_with_object({}) do |requirements, merged| + requirements.each { |name, req_info| merged[name] = req_info } + end + end + + sig do + params( + scoped_requirements: T::Hash[T.nilable(String), T::Hash[String, T::Hash[Symbol, T.untyped]]], + resolved_file_name: String + ).returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) + end + def requirements_for_resolved_file(scoped_requirements:, resolved_file_name:) + scope_dir = extract_xcode_scope_dir(resolved_file_name) + return T.must(scoped_requirements[scope_dir]) if scoped_requirements.key?(scope_dir) + + workspace_root = workspace_root_for_scope(scope_dir) + return {} unless workspace_root + + local_scopes = scoped_requirements.select do |candidate_scope, _| + scope_in_workspace_root?(candidate_scope, workspace_root) + end + return {} if local_scopes.empty? + + merge_scopes(local_scopes) + end + + sig { params(candidate_scope: T.nilable(String), workspace_root: String).returns(T::Boolean) } + def scope_in_workspace_root?(candidate_scope, workspace_root) + return false unless candidate_scope + + if workspace_root == "." + !candidate_scope.include?("/") + else + candidate_scope.start_with?("#{workspace_root}/") + end + end + # Enriches a dependency parsed from Package.resolved with requirement # info from the matching project.pbxproj sig do @@ -117,13 +164,18 @@ def enrich_with_pbxproj_requirements(dep, pbxproj_requirements) ) end - # Extracts the .xcodeproj directory name from a file path. - # e.g. "MyApp.xcodeproj/project.xcworkspace/.../Package.resolved" -> "MyApp.xcodeproj" - # e.g. "sub/dir/App.xcodeproj/project.pbxproj" -> "sub/dir/App.xcodeproj" + # Extracts the Xcode scope directory (.xcodeproj or .xcworkspace) + # from a file path. sig { params(path: String).returns(T.nilable(String)) } - def extract_xcodeproj_dir(path) - match = path.match(%r{^(.*?\.xcodeproj)/}) - match&.captures&.first + def extract_xcode_scope_dir(path) + XcodeFileHelpers.extract_xcode_scope_dir(path) + end + + sig { params(scope_dir: T.nilable(String)).returns(T.nilable(String)) } + def workspace_root_for_scope(scope_dir) + return nil unless scope_dir&.end_with?(".xcworkspace") + + File.dirname(scope_dir) end end end diff --git a/swift/lib/dependabot/swift/file_updater.rb b/swift/lib/dependabot/swift/file_updater.rb index 52ca6343414..2c49e05f72f 100644 --- a/swift/lib/dependabot/swift/file_updater.rb +++ b/swift/lib/dependabot/swift/file_updater.rb @@ -7,6 +7,7 @@ require "dependabot/swift/file_updater/lockfile_updater" require "dependabot/swift/file_updater/manifest_updater" require "dependabot/swift/file_updater/xcode_lockfile_updater" +require "dependabot/swift/xcode_file_helpers" module Dependabot module Swift @@ -53,7 +54,8 @@ def updated_xcode_spm_files xcode_resolved_files.each do |resolved_file| updater = XcodeLockfileUpdater.new( resolved_file: resolved_file, - dependencies: dependencies + dependencies: dependencies, + workspace_files: xcode_workspace_files ) next unless updater.lockfile_changed? @@ -100,14 +102,23 @@ def xcode_spm_mode? def xcode_resolved_files @xcode_resolved_files ||= T.let( dependency_files.select do |f| - f.name.end_with?("Package.resolved") && - f.name.include?(".xcodeproj/") && + XcodeFileHelpers.xcode_resolved_path?(f.name) && !f.support_file? end, T.nilable(T::Array[Dependabot::DependencyFile]) ) end + sig { returns(T::Array[Dependabot::DependencyFile]) } + def xcode_workspace_files + @xcode_workspace_files ||= T.let( + dependency_files.select do |f| + f.name.end_with?("contents.xcworkspacedata") && f.support_file? + end, + T.nilable(T::Array[Dependabot::DependencyFile]) + ) + end + sig { returns(String) } def updated_manifest_content ManifestUpdater.new( diff --git a/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb b/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb index ed8b847c2ef..d097c450295 100644 --- a/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb +++ b/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb @@ -7,9 +7,9 @@ require "dependabot/dependency_file" require "dependabot/errors" require "dependabot/shared_helpers" -require "dependabot/file_updaters" -require "dependabot/file_updaters/base" +require "dependabot/swift/file_updater" require "dependabot/swift/url_helpers" +require "dependabot/swift/xcode_file_helpers" module Dependabot module Swift @@ -32,12 +32,14 @@ class XcodeLockfileUpdater sig do params( resolved_file: Dependabot::DependencyFile, - dependencies: T::Array[Dependabot::Dependency] + dependencies: T::Array[Dependabot::Dependency], + workspace_files: T::Array[Dependabot::DependencyFile] ).void end - def initialize(resolved_file:, dependencies:) + def initialize(resolved_file:, dependencies:, workspace_files: []) @resolved_file = resolved_file @dependencies = dependencies + @workspace_files = workspace_files end sig { returns(String) } @@ -82,6 +84,9 @@ def lockfile_changed? sig { returns(T::Array[Dependabot::Dependency]) } attr_reader :dependencies + sig { returns(T::Array[Dependabot::DependencyFile]) } + attr_reader :workspace_files + sig { params(content: String).returns(T::Hash[String, T.untyped]) } def parse_json(content) JSON.parse(content) @@ -222,29 +227,75 @@ def dependencies_for_file @dependencies_for_file ||= T.let( dependencies.select do |dep| dep.requirements.any? do |req| - req_file = req[:file] - if req_file == resolved_file.name - true - elsif req_file&.include?(".xcodeproj/") - # Extract the xcodeproj dir from both files and compare - req_xcodeproj = extract_xcodeproj_dir(req_file) - resolved_xcodeproj = extract_xcodeproj_dir(resolved_file.name) - req_xcodeproj && req_xcodeproj == resolved_xcodeproj - else - false - end + req_file_matches_resolved_scope?(req[:file]) end end, T.nilable(T::Array[Dependabot::Dependency]) ) end - # Extracts the .xcodeproj directory from a file path. - # e.g. "MyApp.xcodeproj/project.xcworkspace/.../Package.resolved" -> "MyApp.xcodeproj" + sig { params(req_file: T.nilable(String)).returns(T::Boolean) } + def req_file_matches_resolved_scope?(req_file) + return false unless req_file + return true if req_file == resolved_file.name + return false unless req_file.include?(".xcodeproj/") || req_file.include?(".xcworkspace/") + + req_scope = extract_xcode_scope_dir(req_file) + resolved_scope = extract_xcode_scope_dir(resolved_file.name) + + return true if req_scope && resolved_scope && req_scope == resolved_scope + + workspace_related_dependency?(req_file) + end + + # Extracts the Xcode scope directory (.xcodeproj or .xcworkspace) + # from a file path. sig { params(path: String).returns(T.nilable(String)) } - def extract_xcodeproj_dir(path) - match = path.match(%r{^(.*?\.xcodeproj)/}) - match&.captures&.first + def extract_xcode_scope_dir(path) + XcodeFileHelpers.extract_xcode_scope_dir(path) + end + + sig { params(req_file: T.nilable(String)).returns(T::Boolean) } + def workspace_related_dependency?(req_file) + return false unless req_file + + workspace_scope = extract_xcode_scope_dir(resolved_file.name) + return false unless workspace_scope&.end_with?(".xcworkspace") + return false unless req_file.include?(".xcodeproj/") + + req_scope = extract_xcode_scope_dir(req_file) + return false unless req_scope + + referenced = referenced_project_scopes_for_workspace(workspace_scope) + return referenced.include?(req_scope) if referenced.any? + + workspace_root = File.dirname(workspace_scope) + + if workspace_root == "." + !req_scope.include?("/") + else + req_scope.start_with?("#{workspace_root}/") + end + end + + sig { params(workspace_scope: String).returns(T::Set[String]) } + def referenced_project_scopes_for_workspace(workspace_scope) + workspace_data_path = "#{workspace_scope}/contents.xcworkspacedata" + file = workspace_files.find { |workspace_file| workspace_file.name == workspace_data_path } + return Set.new unless file&.content + + project_refs = T.must(file.content).scan(/location\s*=\s*"(?:group:)?([^"\n]+\.xcodeproj)"/).flatten + workspace_root = File.dirname(workspace_scope) + + Set.new( + project_refs.map do |project_ref| + if workspace_root == "." + project_ref + else + File.join(workspace_root, project_ref) + end + end + ) end end end diff --git a/swift/lib/dependabot/swift/update_checker.rb b/swift/lib/dependabot/swift/update_checker.rb index 3da88375049..3f3cf666dc9 100644 --- a/swift/lib/dependabot/swift/update_checker.rb +++ b/swift/lib/dependabot/swift/update_checker.rb @@ -9,6 +9,7 @@ require "dependabot/git_commit_checker" require "dependabot/swift/native_requirement" require "dependabot/swift/file_updater/manifest_updater" +require "dependabot/swift/xcode_file_helpers" module Dependabot module Swift @@ -294,8 +295,7 @@ def xcode_version_resolver def xcode_resolved_files @xcode_resolved_files ||= T.let( dependency_files.select do |f| - f.name.end_with?("Package.resolved") && - f.name.include?(".xcodeproj/") && + XcodeFileHelpers.xcode_resolved_path?(f.name) && !f.support_file? end, T.nilable(T::Array[Dependabot::DependencyFile]) diff --git a/swift/lib/dependabot/swift/xcode_file_helpers.rb b/swift/lib/dependabot/swift/xcode_file_helpers.rb new file mode 100644 index 00000000000..19df736102e --- /dev/null +++ b/swift/lib/dependabot/swift/xcode_file_helpers.rb @@ -0,0 +1,47 @@ +# typed: strong +# frozen_string_literal: true + +require "sorbet-runtime" + +module Dependabot + module Swift + module XcodeFileHelpers + extend T::Sig + + XCODEPROJ_SUFFIX = ".xcodeproj/" + XCWORKSPACE_SUFFIX = ".xcworkspace/" + PACKAGE_RESOLVED = "Package.resolved" + + sig { params(path: String).returns(T::Boolean) } + def self.xcode_resolved_path?(path) + return false unless path.end_with?(PACKAGE_RESOLVED) + + path.include?(XCODEPROJ_SUFFIX) || path.include?(XCWORKSPACE_SUFFIX) + end + + sig { params(path: String).returns(T.nilable(String)) } + def self.extract_xcode_scope_dir(path) + # Find the first occurrence of .xcodeproj/ or .xcworkspace/ + xcodeproj_idx = path.index(XCODEPROJ_SUFFIX) + xcworkspace_idx = path.index(XCWORKSPACE_SUFFIX) + + # Determine which match to use (earliest occurrence) + match_idx = T.let(nil, T.nilable(Integer)) + suffix_len = T.let(0, Integer) + + if xcodeproj_idx && (xcworkspace_idx.nil? || xcodeproj_idx < xcworkspace_idx) + match_idx = xcodeproj_idx + suffix_len = XCODEPROJ_SUFFIX.length + elsif xcworkspace_idx + match_idx = xcworkspace_idx + suffix_len = XCWORKSPACE_SUFFIX.length + end + + return nil if match_idx.nil? + + # Return path up to and including the suffix (minus trailing /) + path[0, match_idx + suffix_len - 1] + end + end + end +end diff --git a/swift/spec/dependabot/swift/file_fetcher_spec.rb b/swift/spec/dependabot/swift/file_fetcher_spec.rb index 51aa607167a..5ccc0616fd3 100644 --- a/swift/spec/dependabot/swift/file_fetcher_spec.rb +++ b/swift/spec/dependabot/swift/file_fetcher_spec.rb @@ -97,6 +97,40 @@ end end + context "with a .xcworkspace and sibling .xcodeproj" do + let(:project_name) { "xcode_workspace" } + let(:directory) { "/" } + + it "fetches workspace lockfile/support file and project support files" do + files = file_fetcher_instance.files + names = files.map(&:name) + + expect(names).to include( + "MyApp.xcworkspace/contents.xcworkspacedata", + "MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved", + "AppA.xcodeproj/project.pbxproj", + "AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" + ) + end + end + + context "with nested .xcworkspace under subdirectory" do + let(:project_name) { "xcode_workspace_nested" } + let(:directory) { "/" } + + it "discovers workspace and project files recursively" do + files = file_fetcher_instance.files + names = files.map(&:name) + + expect(names).to include( + "ios/MyApp.xcworkspace/contents.xcworkspacedata", + "ios/MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved", + "ios/AppA.xcodeproj/project.pbxproj", + "ios/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" + ) + end + end + context "with both Package.swift and .xcodeproj present" do let(:project_name) { "xcode_project_with_manifest" } let(:directory) { "/" } diff --git a/swift/spec/dependabot/swift/file_parser/xcode_spm_resolver_spec.rb b/swift/spec/dependabot/swift/file_parser/xcode_spm_resolver_spec.rb index 262e83744f6..0a7e5afbef3 100644 --- a/swift/spec/dependabot/swift/file_parser/xcode_spm_resolver_spec.rb +++ b/swift/spec/dependabot/swift/file_parser/xcode_spm_resolver_spec.rb @@ -137,6 +137,78 @@ end end + context "with workspace-scoped Package.resolved and sibling project" do + let(:project_name) { "xcode_workspace" } + let(:xcode_resolved_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:pbxproj_files) do + [ + Dependabot::DependencyFile.new( + name: "AppA.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "AppA.xcodeproj", "project.pbxproj"), + support_file: true + ) + ] + end + + it "parses and enriches dependencies using available pbxproj requirements" do + dep = resolver.parse.first + + expect(dep.name).to eq("github.com/apple/swift-nio") + expect(dep.requirements.first[:requirement]).to eq(">= 2.54.0, < 3.0.0") + expect(dep.requirements.first[:file]).to eq("AppA.xcodeproj/project.pbxproj") + end + end + + context "with workspace-scoped Package.resolved and unrelated project scope" do + let(:project_name) { "xcode_workspace" } + let(:xcode_resolved_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:pbxproj_files) do + [ + Dependabot::DependencyFile.new( + name: "other/OtherApp.xcodeproj/project.pbxproj", + content: fixture("projects", "xcode_project_multiple", "AppA.xcodeproj", "project.pbxproj"), + support_file: true + ) + ] + end + + it "does not enrich requirements from unrelated scopes" do + dep = resolver.parse.first + req = dep.requirements.first + + expect(req[:requirement]).to eq("= 2.54.0") + expect(req[:file]).to eq("MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved") + end + end + context "with multiple dependencies and requirement types" do let(:project_name) { "xcode_project_multi_req" } let(:xcode_resolved_files) do diff --git a/swift/spec/dependabot/swift/file_updater/xcode_lockfile_updater_spec.rb b/swift/spec/dependabot/swift/file_updater/xcode_lockfile_updater_spec.rb index 32d69153f62..19465699e85 100644 --- a/swift/spec/dependabot/swift/file_updater/xcode_lockfile_updater_spec.rb +++ b/swift/spec/dependabot/swift/file_updater/xcode_lockfile_updater_spec.rb @@ -11,10 +11,13 @@ subject(:updater) do described_class.new( resolved_file: resolved_file, - dependencies: dependencies + dependencies: dependencies, + workspace_files: workspace_files ) end + let(:workspace_files) { [] } + describe "#updated_lockfile_content" do subject(:updated_content) { updater.updated_lockfile_content } @@ -321,5 +324,95 @@ it { is_expected.to be(false) } end + + context "when resolved file is workspace-scoped and dependency comes from sibling project" do + let(:resolved_file) do + Dependabot::DependencyFile.new( + name: "MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + "xcode_workspace", + "MyApp.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + end + + let(:workspace_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcworkspace/contents.xcworkspacedata", + content: fixture("projects", "xcode_workspace", "MyApp.xcworkspace", "contents.xcworkspacedata"), + support_file: true + ) + ] + end + + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "github.com/apple/swift-nio", + version: "2.55.0", + requirements: [{ + requirement: ">= 2.55.0, < 3.0.0", + groups: ["dependencies"], + file: "AppA.xcodeproj/project.pbxproj", + source: { type: "git", url: "https://github.com/apple/swift-nio.git", ref: "2.55.0" } + }], + package_manager: "swift", + metadata: { identity: "swift-nio" } + ) + ] + end + + it { is_expected.to be(true) } + end + + context "when workspace data does not reference the dependency project" do + let(:resolved_file) do + Dependabot::DependencyFile.new( + name: "MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + "xcode_workspace", + "MyApp.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + end + + let(:workspace_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcworkspace/contents.xcworkspacedata", + content: fixture("projects", "xcode_workspace", "MyApp.xcworkspace", "contents.xcworkspacedata"), + support_file: true + ) + ] + end + + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "github.com/apple/swift-nio", + version: "2.55.0", + requirements: [{ + requirement: ">= 2.55.0, < 3.0.0", + groups: ["dependencies"], + file: "OtherApp.xcodeproj/project.pbxproj", + source: { type: "git", url: "https://github.com/apple/swift-nio.git", ref: "2.55.0" } + }], + package_manager: "swift", + metadata: { identity: "swift-nio" } + ) + ] + end + + it { is_expected.to be(false) } + end end end diff --git a/swift/spec/dependabot/swift/file_updater_spec.rb b/swift/spec/dependabot/swift/file_updater_spec.rb index d783b396986..dd7a511946e 100644 --- a/swift/spec/dependabot/swift/file_updater_spec.rb +++ b/swift/spec/dependabot/swift/file_updater_spec.rb @@ -256,6 +256,83 @@ end end + context "with workspace-scoped Package.resolved" do + let(:project_name) { "xcode_workspace" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcworkspace/contents.xcworkspacedata", + content: fixture("projects", project_name, "MyApp.xcworkspace", "contents.xcworkspacedata"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "AppA.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "AppA.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "github.com/apple/swift-nio", + version: "2.55.0", + previous_version: "2.54.0", + requirements: [{ + requirement: ">= 2.55.0, < 3.0.0", + groups: ["dependencies"], + file: "AppA.xcodeproj/project.pbxproj", + source: { + type: "git", + url: "https://github.com/apple/swift-nio.git", + ref: "abc123newrevision", + branch: nil + }, + metadata: { + requirement_string: "from: \"2.55.0\"" + } + }], + previous_requirements: [{ + requirement: ">= 2.54.0, < 3.0.0", + groups: ["dependencies"], + file: "AppA.xcodeproj/project.pbxproj", + source: { + type: "git", + url: "https://github.com/apple/swift-nio.git", + ref: "1234567890abcdef1234567890abcdef12345678", + branch: nil + }, + metadata: { + requirement_string: "from: \"2.54.0\"" + } + }], + package_manager: "swift", + metadata: { identity: "swift-nio" } + ) + ] + end + + it "updates workspace Package.resolved" do + expect(updated_dependency_files.length).to eq(1) + resolved = updated_dependency_files.first + + expect(resolved.name).to eq("MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved") + expect(resolved.content).to include('"version" : "2.55.0"') + end + end + context "with multiple Xcode projects" do let(:project_name) { "xcode_project_multiple" } let(:files) do diff --git a/swift/spec/fixtures/projects/xcode_workspace/AppA.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_workspace/AppA.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..4f1a4b117b8 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_workspace/AppA.xcodeproj/project.pbxproj @@ -0,0 +1,22 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin XCRemoteSwiftPackageReference section */ + A1B2C3D4E5F60001 /* XCRemoteSwiftPackageReference "swift-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.54.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_workspace/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_workspace/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..e5d008a69c2 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_workspace/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "1234567890abcdef1234567890abcdef12345678", + "version" : "2.54.0" + } + } + ], + "version" : 2 +} diff --git a/swift/spec/fixtures/projects/xcode_workspace/MyApp.xcworkspace/contents.xcworkspacedata b/swift/spec/fixtures/projects/xcode_workspace/MyApp.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000000..58abfcf3e93 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_workspace/MyApp.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/swift/spec/fixtures/projects/xcode_workspace/MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_workspace/MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..e5d008a69c2 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_workspace/MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "1234567890abcdef1234567890abcdef12345678", + "version" : "2.54.0" + } + } + ], + "version" : 2 +} diff --git a/swift/spec/fixtures/projects/xcode_workspace_nested/ios/AppA.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_workspace_nested/ios/AppA.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..4f1a4b117b8 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_workspace_nested/ios/AppA.xcodeproj/project.pbxproj @@ -0,0 +1,22 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin XCRemoteSwiftPackageReference section */ + A1B2C3D4E5F60001 /* XCRemoteSwiftPackageReference "swift-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.54.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_workspace_nested/ios/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_workspace_nested/ios/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..e5d008a69c2 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_workspace_nested/ios/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "1234567890abcdef1234567890abcdef12345678", + "version" : "2.54.0" + } + } + ], + "version" : 2 +} diff --git a/swift/spec/fixtures/projects/xcode_workspace_nested/ios/MyApp.xcworkspace/contents.xcworkspacedata b/swift/spec/fixtures/projects/xcode_workspace_nested/ios/MyApp.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000000..58abfcf3e93 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_workspace_nested/ios/MyApp.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/swift/spec/fixtures/projects/xcode_workspace_nested/ios/MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_workspace_nested/ios/MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..e5d008a69c2 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_workspace_nested/ios/MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "1234567890abcdef1234567890abcdef12345678", + "version" : "2.54.0" + } + } + ], + "version" : 2 +}