From 244a1e8bd6f441b44a8393bbafdc257d63767e92 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Mon, 9 Mar 2026 01:30:16 -0500 Subject: [PATCH 01/15] extend file updater to support xcode swiftpm dependency update --- swift/lib/dependabot/swift/file_updater.rb | 68 +++- .../file_updater/xcode_lockfile_updater.rb | 264 ++++++++++++++ .../xcode_lockfile_updater_spec.rb | 325 ++++++++++++++++++ .../dependabot/swift/file_updater_spec.rb | 241 +++++++++++++ 4 files changed, 896 insertions(+), 2 deletions(-) create mode 100644 swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb create mode 100644 swift/spec/dependabot/swift/file_updater/xcode_lockfile_updater_spec.rb diff --git a/swift/lib/dependabot/swift/file_updater.rb b/swift/lib/dependabot/swift/file_updater.rb index 1a5e1ec1b62..144328c5dfa 100644 --- a/swift/lib/dependabot/swift/file_updater.rb +++ b/swift/lib/dependabot/swift/file_updater.rb @@ -1,10 +1,12 @@ # typed: strong # frozen_string_literal: true +require "dependabot/experiments" require "dependabot/file_updaters" require "dependabot/file_updaters/base" require "dependabot/swift/file_updater/lockfile_updater" require "dependabot/swift/file_updater/manifest_updater" +require "dependabot/swift/file_updater/xcode_lockfile_updater" module Dependabot module Swift @@ -13,6 +15,18 @@ class FileUpdater < Dependabot::FileUpdaters::Base sig { override.returns(T::Array[Dependabot::DependencyFile]) } def updated_dependency_files + if xcode_spm_mode? + updated_xcode_spm_files + else + updated_classic_spm_files + end + end + + private + + # Classic SPM update: uses swift CLI to resolve and update + sig { returns(T::Array[Dependabot::DependencyFile]) } + def updated_classic_spm_files updated_files = T.let([], T::Array[Dependabot::DependencyFile]) SharedHelpers.in_a_temporary_repo_directory(T.must(manifest).directory, repo_contents_path) do @@ -31,7 +45,34 @@ def updated_dependency_files updated_files end - private + # Xcode SPM update: updates Package.resolved files in-place without CLI + sig { returns(T::Array[Dependabot::DependencyFile]) } + def updated_xcode_spm_files + updated_files = T.let([], T::Array[Dependabot::DependencyFile]) + + xcode_resolved_files.each do |resolved_file| + updater = XcodeLockfileUpdater.new( + resolved_file: resolved_file, + dependencies: dependencies + ) + + next unless updater.lockfile_changed? + + updated_content = updater.updated_lockfile_content + next if updated_content == resolved_file.content + + updated_files << updated_file(file: resolved_file, content: updated_content) + end + + if updated_files.empty? + raise Dependabot::DependencyFileNotFound.new( + nil, + "No Package.resolved files needed updating for the specified dependencies" + ) + end + + updated_files + end sig { returns(Dependabot::Dependency) } def dependency @@ -42,7 +83,30 @@ def dependency sig { override.void } def check_required_files - raise "A Package.swift file must be provided!" unless manifest + return if manifest + return if xcode_spm_mode? && xcode_resolved_files.any? + + raise "A Package.swift file or Xcode Package.resolved must be provided!" + end + + sig { returns(T::Boolean) } + def xcode_spm_mode? + return false unless Dependabot::Experiments.enabled?(:enable_swift_xcode_spm) + + manifest.nil? && xcode_resolved_files.any? + end + + # All Package.resolved files under .xcodeproj directories + 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/") && + !f.support_file? + end, + T.nilable(T::Array[Dependabot::DependencyFile]) + ) end sig { returns(String) } diff --git a/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb b/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb new file mode 100644 index 00000000000..9f5c9726bf9 --- /dev/null +++ b/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb @@ -0,0 +1,264 @@ +# typed: strict +# frozen_string_literal: true + +require "json" +require "sorbet-runtime" +require "dependabot/dependency" +require "dependabot/dependency_file" +require "dependabot/errors" +require "dependabot/shared_helpers" +require "dependabot/swift/file_updater" +require "dependabot/swift/url_helpers" + +module Dependabot + module Swift + class FileUpdater < Dependabot::FileUpdaters::Base + # Updates Xcode-managed Package.resolved files in-place without running + # the Swift CLI. This is used for Xcode SPM projects that don't have a + # Package.swift manifest file. + # + # Preserves the original schema version (v1/v2/v3) and minimizes changes + # to the file structure to produce clean diffs. + class XcodeLockfileUpdater + extend T::Sig + + SUPPORTED_VERSIONS = T.let([1, 2, 3].freeze, T::Array[Integer]) + + # Maps schema version to the JSON keys used for each pin field + PIN_KEYS = T.let( + { + 1 => { url: "repositoryURL", identity: "package", pins_path: %w(object pins) }, + 2 => { url: "location", identity: "identity", pins_path: ["pins"] }, + 3 => { url: "location", identity: "identity", pins_path: ["pins"] } + }.freeze, + T::Hash[Integer, T::Hash[Symbol, T.untyped]] + ) + + sig do + params( + resolved_file: Dependabot::DependencyFile, + dependencies: T::Array[Dependabot::Dependency] + ).void + end + def initialize(resolved_file:, dependencies:) + @resolved_file = resolved_file + @dependencies = dependencies + end + + sig { returns(String) } + def updated_lockfile_content + content = resolved_file.content + unless content + raise Dependabot::DependencyFileNotParseable.new( + resolved_file.name, + "#{resolved_file.name} has no content" + ) + end + + parsed = parse_json(content) + schema_version = detect_schema_version(parsed) + keys = T.must(PIN_KEYS[schema_version]) + + update_pins(parsed, schema_version, keys) + + # Use JSON.pretty_generate to match Xcode's output format: + # - 2-space indentation + # - space before colon (e.g., "key" : "value") + JSON.pretty_generate( + parsed, + indent: " ", + space: " ", + space_before: " ", + object_nl: "\n", + array_nl: "\n" + ) + "\n" + end + + # Returns true if any dependency in the given file needs updating + sig { returns(T::Boolean) } + def lockfile_changed? + dependencies_for_file.any? + end + + private + + sig { returns(Dependabot::DependencyFile) } + attr_reader :resolved_file + + sig { returns(T::Array[Dependabot::Dependency]) } + attr_reader :dependencies + + sig { params(content: String).returns(T::Hash[String, T.untyped]) } + def parse_json(content) + JSON.parse(content) + rescue JSON::ParserError => e + raise Dependabot::DependencyFileNotParseable.new( + resolved_file.name, + "#{resolved_file.name} is not valid JSON: #{e.message}" + ) + end + + sig { params(parsed: T::Hash[String, T.untyped]).returns(Integer) } + def detect_schema_version(parsed) + version = parsed["version"] + + unless version.is_a?(Integer) && SUPPORTED_VERSIONS.include?(version) + raise Dependabot::DependencyFileNotParseable.new( + resolved_file.name, + "#{resolved_file.name} has unsupported schema version: #{version.inspect}. " \ + "Supported versions: #{SUPPORTED_VERSIONS.join(', ')}" + ) + end + + version + end + + sig do + params( + parsed: T::Hash[String, T.untyped], + schema_version: Integer, + keys: T::Hash[Symbol, T.untyped] + ).void + end + def update_pins(parsed, schema_version, keys) + pins_path = T.cast(keys[:pins_path], T::Array[String]) + pins = dig_pins(parsed, pins_path) + + unless pins.is_a?(Array) + raise Dependabot::DependencyFileNotParseable.new( + resolved_file.name, + "#{resolved_file.name} is missing the expected 'pins' array " \ + "(schema version #{schema_version})" + ) + end + + dependencies_for_file.each do |dep| + update_pin_for_dependency(pins, dep, keys, schema_version) + end + end + + sig do + params( + parsed: T::Hash[String, T.untyped], + path: T::Array[String] + ).returns(T.untyped) + end + def dig_pins(parsed, path) + # Navigate nested hash using path keys + # Path is either ["object", "pins"] for v1 or ["pins"] for v2/v3 + current = T.let(parsed, T.untyped) + path.each do |key| + break unless current.is_a?(Hash) + + current = current[key] + end + current + end + + sig do + params( + pins: T::Array[T::Hash[String, T.untyped]], + dependency: Dependabot::Dependency, + keys: T::Hash[Symbol, T.untyped], + schema_version: Integer + ).void + end + def update_pin_for_dependency(pins, dependency, keys, schema_version) + pin = find_pin_for_dependency(pins, dependency, keys, schema_version) + return unless pin + + state = pin["state"] + return unless state.is_a?(Hash) + + source = dependency.requirements.first&.dig(:source) + new_version = dependency.version + new_ref = source&.dig(:ref) + + # Update version if we have a new one + if new_version + state["version"] = new_version + # When updating to a new version, update revision if provided in source + # The ref from source is typically the git SHA corresponding to the version tag + state["revision"] = new_ref if new_ref && looks_like_sha?(new_ref) + elsif new_ref + # Revision-only update (no version, just SHA) + state["revision"] = new_ref + state.delete("version") + end + end + + # Checks if a string looks like a git SHA (40 hex characters) + sig { params(str: String).returns(T::Boolean) } + def looks_like_sha?(str) + str.match?(/\A[0-9a-f]{40}\z/i) + end + + sig do + params( + pins: T::Array[T::Hash[String, T.untyped]], + dependency: Dependabot::Dependency, + keys: T::Hash[Symbol, T.untyped], + schema_version: Integer + ).returns(T.nilable(T::Hash[String, T.untyped])) + end + def find_pin_for_dependency(pins, dependency, keys, schema_version) + identity_key = T.cast(keys[:identity], String) + url_key = T.cast(keys[:url], String) + identity = dependency.metadata[:identity] + + pins.find do |pin| + pin_identity = pin[identity_key] + # v1 uses display name which may be mixed case + pin_identity = pin_identity&.downcase if schema_version == 1 + + if identity && pin_identity == identity + true + else + # Fall back to URL matching + pin_url = pin[url_key] + next false unless pin_url.is_a?(String) + + normalized_pin_url = SharedHelpers.scp_to_standard(pin_url) + pin_name = UrlHelpers.normalize_name(normalized_pin_url) + + pin_name == dependency.name + end + end + end + + # Returns only the dependencies that are relevant to this resolved file + sig { returns(T::Array[Dependabot::Dependency]) } + def dependencies_for_file + @dependencies_for_file ||= T.let( + dependencies.select do |dep| + dep.requirements.any? do |req| + # Match if the requirement file is the resolved file itself + # or if the requirement file is a pbxproj in the same xcodeproj + 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 + 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(path: String).returns(T.nilable(String)) } + def extract_xcodeproj_dir(path) + match = path.match(%r{^(.*?\.xcodeproj)/}) + match&.captures&.first + end + end + end + end +end 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 new file mode 100644 index 00000000000..32d69153f62 --- /dev/null +++ b/swift/spec/dependabot/swift/file_updater/xcode_lockfile_updater_spec.rb @@ -0,0 +1,325 @@ +# typed: false +# frozen_string_literal: true + +require "json" +require "spec_helper" +require "dependabot/dependency" +require "dependabot/dependency_file" +require "dependabot/swift/file_updater/xcode_lockfile_updater" + +RSpec.describe Dependabot::Swift::FileUpdater::XcodeLockfileUpdater do + subject(:updater) do + described_class.new( + resolved_file: resolved_file, + dependencies: dependencies + ) + end + + describe "#updated_lockfile_content" do + subject(:updated_content) { updater.updated_lockfile_content } + + context "with v2 Package.resolved" do + let(:resolved_file) do + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + "xcode_project", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + end + + context "when updating version" do + 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: "MyApp.xcodeproj/project.pbxproj", + source: { + type: "git", + url: "https://github.com/apple/swift-nio.git", + ref: "abc123def456", + branch: nil + } + }], + previous_requirements: [{ + requirement: ">= 2.54.0, < 3.0.0", + groups: ["dependencies"], + file: "MyApp.xcodeproj/project.pbxproj", + source: { + type: "git", + url: "https://github.com/apple/swift-nio.git", + ref: "6213ba7a06febe8fef60563a4a7d26a4085783cf", + branch: nil + } + }], + package_manager: "swift", + metadata: { identity: "swift-nio" } + ) + ] + end + + it "updates the version in Package.resolved" do + expect(updated_content).to include('"version" : "2.55.0"') + expect(updated_content).not_to include('"version" : "2.54.0"') + end + + it "preserves the schema version" do + parsed = JSON.parse(updated_content) + expect(parsed["version"]).to eq(2) + end + + it "preserves the identity and location" do + parsed = JSON.parse(updated_content) + pin = parsed["pins"].first + expect(pin["identity"]).to eq("swift-nio") + expect(pin["location"]).to eq("https://github.com/apple/swift-nio.git") + end + end + + context "when dependency is not in resolved file" do + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "github.com/vapor/vapor", + version: "4.0.0", + requirements: [{ + requirement: ">= 4.0.0, < 5.0.0", + groups: ["dependencies"], + file: "MyApp.xcodeproj/project.pbxproj", + source: { + type: "git", + url: "https://github.com/vapor/vapor.git", + ref: "4.0.0", + branch: nil + } + }], + package_manager: "swift", + metadata: { identity: "vapor" } + ) + ] + end + + it "leaves the file unchanged" do + parsed = JSON.parse(updated_content) + expect(parsed["pins"].length).to eq(1) + expect(parsed["pins"].first["identity"]).to eq("swift-nio") + expect(parsed["pins"].first["state"]["version"]).to eq("2.54.0") + end + end + end + + context "with v1 Package.resolved" do + let(:resolved_file) do + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + "xcode_project_v1_resolved", + "MyApp.xcodeproj", + "project.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: "MyApp.xcodeproj/project.pbxproj", + source: { + type: "git", + url: "https://github.com/apple/swift-nio.git", + ref: "newrevision123", + branch: nil + } + }], + previous_requirements: [{ + requirement: ">= 2.54.0, < 3.0.0", + groups: ["dependencies"], + file: "MyApp.xcodeproj/project.pbxproj", + source: { + type: "git", + url: "https://github.com/apple/swift-nio.git", + ref: "6213ba7a06febe8fef60563a4a7d26a4085783cf", + branch: nil + } + }], + package_manager: "swift", + metadata: { identity: "swift-nio" } + ) + ] + end + + it "updates the version while preserving v1 format" do + parsed = JSON.parse(updated_content) + expect(parsed["version"]).to eq(1) + expect(parsed["object"]["pins"].first["state"]["version"]).to eq("2.55.0") + end + + it "preserves v1-specific keys" do + parsed = JSON.parse(updated_content) + pin = parsed["object"]["pins"].first + expect(pin).to have_key("package") + expect(pin).to have_key("repositoryURL") + end + end + + context "with v3 Package.resolved" do + let(:resolved_file) do + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + "xcode_project_v3_resolved", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + end + + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "github.com/apple/swift-nio", + version: "2.60.0", + previous_version: "2.54.0", + requirements: [{ + requirement: ">= 2.60.0, < 3.0.0", + groups: ["dependencies"], + file: "MyApp.xcodeproj/project.pbxproj", + source: { + type: "git", + url: "https://github.com/apple/swift-nio.git", + ref: "newrev456", + branch: nil + } + }], + package_manager: "swift", + metadata: { identity: "swift-nio" } + ) + ] + end + + it "updates the version while preserving v3 format" do + parsed = JSON.parse(updated_content) + expect(parsed["version"]).to eq(3) + expect(parsed["pins"].first["state"]["version"]).to eq("2.60.0") + end + end + + context "with invalid JSON" do + let(:resolved_file) do + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: "{ invalid json" + ) + end + + let(:dependencies) { [] } + + it "raises DependencyFileNotParseable" do + expect { updated_content } + .to raise_error(Dependabot::DependencyFileNotParseable, /not valid JSON/) + end + end + + context "with unsupported schema version" do + let(:resolved_file) do + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: '{ "version": 99, "pins": [] }' + ) + end + + let(:dependencies) { [] } + + it "raises DependencyFileNotParseable" do + expect { updated_content } + .to raise_error(Dependabot::DependencyFileNotParseable, /unsupported schema version/) + end + end + end + + describe "#lockfile_changed?" do + subject { updater.lockfile_changed? } + + let(:resolved_file) do + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + "xcode_project", + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + end + + context "when dependency matches the resolved file" do + 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: "MyApp.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 dependency does not match the resolved file" do + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: "github.com/apple/swift-collections", + version: "1.0.5", + requirements: [{ + requirement: ">= 1.0.5, < 2.0.0", + groups: ["dependencies"], + file: "OtherApp.xcodeproj/project.pbxproj", + source: { type: "git", url: "https://github.com/apple/swift-collections.git", ref: "1.0.5" } + }], + package_manager: "swift", + metadata: { identity: "swift-collections" } + ) + ] + 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 0e26f8dd300..d783b396986 100644 --- a/swift/spec/dependabot/swift/file_updater_spec.rb +++ b/swift/spec/dependabot/swift/file_updater_spec.rb @@ -1,9 +1,11 @@ # typed: false # frozen_string_literal: true +require "json" require "spec_helper" require "dependabot/dependency" require "dependabot/dependency_file" +require "dependabot/experiments" require "dependabot/swift/file_updater" require_common_spec "file_updaters/shared_examples_for_file_updaters" @@ -160,4 +162,243 @@ end end end + + context "when enable_swift_xcode_spm experiment is enabled" do + before { Dependabot::Experiments.register(:enable_swift_xcode_spm, true) } + after { Dependabot::Experiments.register(:enable_swift_xcode_spm, false) } + + describe "#updated_dependency_files" do + subject(:updated_dependency_files) { updater.updated_dependency_files } + + let(:project_name) { "xcode_project" } + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + + context "when updating a dependency version" do + 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: "MyApp.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: "MyApp.xcodeproj/project.pbxproj", + source: { + type: "git", + url: "https://github.com/apple/swift-nio.git", + ref: "6213ba7a06febe8fef60563a4a7d26a4085783cf", + branch: nil + }, + metadata: { + requirement_string: "from: \"2.54.0\"" + } + }], + package_manager: "swift", + metadata: { identity: "swift-nio" } + ) + ] + end + + it "returns the updated Package.resolved file" do + expect(updated_dependency_files.length).to eq(1) + resolved = updated_dependency_files.first + expect(resolved.name).to eq( + "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" + ) + end + + it "updates the version in the resolved file" do + resolved = updated_dependency_files.first + expect(resolved.content).to include('"version" : "2.55.0"') + expect(resolved.content).not_to include('"version" : "2.54.0"') + end + + it "preserves the schema version" do + resolved = updated_dependency_files.first + parsed = JSON.parse(resolved.content) + expect(parsed["version"]).to eq(2) + end + end + + context "with multiple Xcode projects" do + let(:project_name) { "xcode_project_multiple" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "AppA.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "AppA.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "AppA.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ), + Dependabot::DependencyFile.new( + name: "AppB.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "AppB.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "AppB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "AppB.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + + context "when updating dependency in AppA only" do + 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: "newrevision", + branch: nil + } + }], + 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: "oldrevision", + branch: nil + } + }], + package_manager: "swift", + metadata: { identity: "swift-nio" } + ) + ] + end + + it "only updates AppA's Package.resolved" do + expect(updated_dependency_files.length).to eq(1) + expect(updated_dependency_files.first.name).to include("AppA.xcodeproj") + end + + it "does not modify AppB's Package.resolved" do + expect(updated_dependency_files.map(&:name)).not_to include( + a_string_matching(/AppB\.xcodeproj/) + ) + end + end + end + + context "with v1 Package.resolved format" do + let(:project_name) { "xcode_project_v1_resolved" } + let(:files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.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: "MyApp.xcodeproj/project.pbxproj", + source: { + type: "git", + url: "https://github.com/apple/swift-nio.git", + ref: "newrevision", + branch: nil + } + }], + package_manager: "swift", + metadata: { identity: "swift-nio" } + ) + ] + end + + it "preserves v1 format while updating" do + resolved = updated_dependency_files.first + parsed = JSON.parse(resolved.content) + expect(parsed["version"]).to eq(1) + expect(parsed["object"]["pins"].first["state"]["version"]).to eq("2.55.0") + end + end + end + end end From 936ff2745fd0353725799a06a30101fd1bf8fd48 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Wed, 11 Mar 2026 01:14:34 -0500 Subject: [PATCH 02/15] extend swift update checker to support xcode swift pm --- .../swift/file_parser/xcode_spm_resolver.rb | 4 +- swift/lib/dependabot/swift/update_checker.rb | 76 +++++ .../update_checker/requirements_updater.rb | 107 ++++++- .../update_checker/xcode_version_resolver.rb | 214 +++++++++++++ .../spec/dependabot/swift/file_parser_spec.rb | 10 +- .../dependabot/swift/update_checker_spec.rb | 280 ++++++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 2 +- .../MyApp.xcodeproj/project.pbxproj | 22 ++ .../xcshareddata/swiftpm/Package.resolved | 14 + .../xcshareddata/swiftpm/Package.resolved | 2 +- .../AppB.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../MyApp.xcodeproj/project.pbxproj | 22 ++ .../xcshareddata/swiftpm/Package.resolved | 14 + .../xcshareddata/swiftpm/Package.resolved | 4 +- .../MyApp.xcodeproj/project.pbxproj | 22 ++ .../xcshareddata/swiftpm/Package.resolved | 14 + .../MyApp.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- 20 files changed, 800 insertions(+), 19 deletions(-) create mode 100644 swift/lib/dependabot/swift/update_checker/xcode_version_resolver.rb create mode 100644 swift/spec/fixtures/projects/xcode_project_branch_pin/MyApp.xcodeproj/project.pbxproj create mode 100644 swift/spec/fixtures/projects/xcode_project_branch_pin/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 swift/spec/fixtures/projects/xcode_project_needs_update/MyApp.xcodeproj/project.pbxproj create mode 100644 swift/spec/fixtures/projects/xcode_project_needs_update/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 swift/spec/fixtures/projects/xcode_project_up_to_date/MyApp.xcodeproj/project.pbxproj create mode 100644 swift/spec/fixtures/projects/xcode_project_up_to_date/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved 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 61c84574158..e753e984c95 100644 --- a/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb +++ b/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb @@ -92,6 +92,7 @@ def enrich_with_pbxproj_requirements(dep, pbxproj_requirements) pbxproj_file = req_info[:file] requirement_str = req_info[:requirement] requirement_string = req_info[:requirement_string] + kind = req_info[:kind] new_requirements = dep.requirements.map do |req| req.merge( @@ -101,7 +102,8 @@ def enrich_with_pbxproj_requirements(dep, pbxproj_requirements) # declaration_string is not applicable for Xcode-managed SPM # (no Package.swift manifest to extract it from) declaration_string: nil, - requirement_string: requirement_string + requirement_string: requirement_string, + kind: kind }.compact ) end diff --git a/swift/lib/dependabot/swift/update_checker.rb b/swift/lib/dependabot/swift/update_checker.rb index de4eeee3e5e..a9c6bfb58f7 100644 --- a/swift/lib/dependabot/swift/update_checker.rb +++ b/swift/lib/dependabot/swift/update_checker.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "sorbet-runtime" +require "dependabot/experiments" require "dependabot/update_checkers" require "dependabot/update_checkers/base" require "dependabot/update_checkers/version_filters" @@ -17,6 +18,7 @@ class UpdateChecker < Dependabot::UpdateCheckers::Base require_relative "update_checker/requirements_updater" require_relative "update_checker/version_resolver" require_relative "update_checker/latest_version_resolver" + require_relative "update_checker/xcode_version_resolver" sig { override.returns(T.nilable(Dependabot::Version)) } def latest_version @@ -50,12 +52,23 @@ def lowest_resolvable_security_fix_version sig { override.returns(T::Array[T::Hash[Symbol, T.untyped]]) } def updated_requirements + return updated_xcode_requirements if xcode_spm_mode? + RequirementsUpdater.new( requirements: old_requirements, target_version: T.must(preferred_resolvable_version) ).updated_requirements end + sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def updated_xcode_requirements + RequirementsUpdater.new( + requirements: old_requirements, + target_version: T.must(preferred_resolvable_version), + xcode_mode: true + ).updated_requirements + end + private sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } @@ -63,8 +76,17 @@ def old_requirements dependency.requirements end + sig { returns(T::Boolean) } + def xcode_spm_mode? + return false unless Dependabot::Experiments.enabled?(:enable_swift_xcode_spm) + + manifest.nil? && xcode_resolved_files.any? + end + sig { returns(T.nilable(Dependabot::Version)) } def fetch_latest_version + return fetch_xcode_latest_version if xcode_spm_mode? + return unless git_commit_checker.pinned_ref_looks_like_version? && latest_version_tag tag = latest_version_tag @@ -73,8 +95,22 @@ def fetch_latest_version tag.fetch(:version) end + sig { returns(T.nilable(Dependabot::Version)) } + def fetch_xcode_latest_version + # For branch-pinned or revision-only dependencies, don't report a latest version + # since they can't be meaningfully updated to version-based pins + return nil unless xcode_version_resolver.version_pinned? + + tag = latest_version_tag + return unless tag + + tag.fetch(:version) + end + sig { returns(T.nilable(Dependabot::Version)) } def fetch_lowest_security_fix_version + return fetch_xcode_lowest_security_fix_version if xcode_spm_mode? + return unless git_commit_checker.pinned_ref_looks_like_version? && latest_version_tag tag = lowest_security_fix_version_tag @@ -83,16 +119,30 @@ def fetch_lowest_security_fix_version tag.fetch(:version) end + sig { returns(T.nilable(Dependabot::Version)) } + def fetch_xcode_lowest_security_fix_version + xcode_version_resolver.lowest_security_fix_version + end + sig { returns(T.nilable(Dependabot::Version)) } def fetch_latest_resolvable_version + return fetch_xcode_latest_resolvable_version if xcode_spm_mode? + latest_resolvable_version = version_resolver_for(unlocked_requirements).latest_resolvable_version return current_version unless latest_resolvable_version Version.new(latest_resolvable_version) end + sig { returns(T.nilable(Dependabot::Version)) } + def fetch_xcode_latest_resolvable_version + xcode_version_resolver.latest_resolvable_version + end + sig { returns(T.nilable(Dependabot::Version)) } def fetch_lowest_resolvable_security_fix_version + return fetch_xcode_lowest_security_fix_version if xcode_spm_mode? + lowest_resolvable_security_fix_version = version_resolver_for( force_lowest_security_fix_requirements ).latest_resolvable_version @@ -218,6 +268,32 @@ def filter_lower_tags(tags_array) tags_array .select { |tag| tag.fetch(:version) > current_version } end + + sig { returns(XcodeVersionResolver) } + def xcode_version_resolver + @xcode_version_resolver ||= T.let( + XcodeVersionResolver.new( + dependency: dependency, + credentials: credentials, + ignored_versions: ignored_versions, + raise_on_ignored: raise_on_ignored, + security_advisories: security_advisories + ), + T.nilable(XcodeVersionResolver) + ) + end + + 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/") && + !f.support_file? + end, + T.nilable(T::Array[Dependabot::DependencyFile]) + ) + end end end end diff --git a/swift/lib/dependabot/swift/update_checker/requirements_updater.rb b/swift/lib/dependabot/swift/update_checker/requirements_updater.rb index 6dbccc01761..27cd21a881a 100644 --- a/swift/lib/dependabot/swift/update_checker/requirements_updater.rb +++ b/swift/lib/dependabot/swift/update_checker/requirements_updater.rb @@ -1,4 +1,4 @@ -# typed: strong +# typed: strict # frozen_string_literal: true require "dependabot/update_checkers/base" @@ -14,11 +14,13 @@ class RequirementsUpdater sig do params( requirements: T::Array[T::Hash[Symbol, T.untyped]], - target_version: T.nilable(T.any(String, Gem::Version)) + target_version: T.nilable(T.any(String, Gem::Version)), + xcode_mode: T::Boolean ).void end - def initialize(requirements:, target_version:) + def initialize(requirements:, target_version:, xcode_mode: false) @requirements = requirements + @xcode_mode = xcode_mode return unless target_version && Version.correct?(target_version) @@ -27,6 +29,8 @@ def initialize(requirements:, target_version:) sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } def updated_requirements + return updated_xcode_requirements if xcode_mode + NativeRequirement.map_requirements(requirements) do |requirement| T.must(requirement.update_if_needed(T.must(target_version))) end @@ -39,6 +43,103 @@ def updated_requirements sig { returns(T.nilable(Gem::Version)) } attr_reader :target_version + + sig { returns(T::Boolean) } + attr_reader :xcode_mode + + # For Xcode projects, we update the version in the requirement while preserving the kind. + sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def updated_xcode_requirements + requirements.map do |req| + next req unless target_version + + updated_req = update_xcode_requirement(req) + updated_req + end + end + + sig { params(requirement: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) } + def update_xcode_requirement(requirement) + metadata = requirement[:metadata] || {} + requirement_string = metadata[:requirement_string] + kind = metadata[:kind] + + new_requirement_string = build_xcode_requirement_string(requirement_string, kind) + new_requirement = build_xcode_requirement(kind) + + requirement.merge( + requirement: new_requirement, + metadata: metadata.merge( + requirement_string: new_requirement_string + ).compact + ) + end + + sig do + params( + requirement_string: T.nilable(String), + kind: T.nilable(String) + ).returns(T.nilable(String)) + end + def build_xcode_requirement_string(requirement_string, kind) + return requirement_string unless target_version + + case kind + when "upToNextMajorVersion" + "from: \"#{target_version}\"" + when "upToNextMinorVersion" + ".upToNextMinor(from: \"#{target_version}\")" + when "exactVersion" + "exact: \"#{target_version}\"" + when "versionRange" + min = target_version.to_s + max = bump_version(min, :major) + "\"#{min}\"..<\"#{max}\"" + else + # Default: update to exact version for unknown kinds + "exact: \"#{target_version}\"" + end + end + + sig do + params( + kind: T.nilable(String) + ).returns(T.nilable(String)) + end + def build_xcode_requirement(kind) + return nil unless target_version + + case kind + when "upToNextMajorVersion" + max = bump_version(target_version.to_s, :major) + ">= #{target_version}, < #{max}" + when "upToNextMinorVersion" + max = bump_version(target_version.to_s, :minor) + ">= #{target_version}, < #{max}" + when "exactVersion" + "= #{target_version}" + when "versionRange" + max = bump_version(target_version.to_s, :major) + ">= #{target_version}, < #{max}" + else + # Default: exact version + "= #{target_version}" + end + end + + sig { params(version_str: String, bump_type: Symbol).returns(String) } + def bump_version(version_str, bump_type) + parts = version_str.split(".").map(&:to_i) + + case bump_type + when :major + [(parts[0] || 0) + 1, 0, 0] + when :minor + [parts[0] || 0, (parts[1] || 0) + 1, 0] + else + parts + end.join(".") + end end end end diff --git a/swift/lib/dependabot/swift/update_checker/xcode_version_resolver.rb b/swift/lib/dependabot/swift/update_checker/xcode_version_resolver.rb new file mode 100644 index 00000000000..299933dbffd --- /dev/null +++ b/swift/lib/dependabot/swift/update_checker/xcode_version_resolver.rb @@ -0,0 +1,214 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" +require "dependabot/git_commit_checker" +require "dependabot/swift/update_checker" +require "dependabot/swift/version" + +module Dependabot + module Swift + class UpdateChecker < Dependabot::UpdateCheckers::Base + # Resolves versions for Xcode-only SwiftPM projects (no Package.swift). + # + # Unlike the classic VersionResolver which relies on `swift package update`, + # this resolver uses GitCommitChecker to find the latest available version + # from git tags, since we cannot run the Swift CLI without a manifest. + class XcodeVersionResolver + extend T::Sig + + sig do + params( + dependency: Dependabot::Dependency, + credentials: T::Array[Dependabot::Credential], + ignored_versions: T::Array[String], + raise_on_ignored: T::Boolean, + security_advisories: T::Array[Dependabot::SecurityAdvisory] + ).void + end + def initialize(dependency:, credentials:, ignored_versions:, raise_on_ignored:, security_advisories:) + @dependency = dependency + @credentials = credentials + @ignored_versions = ignored_versions + @raise_on_ignored = raise_on_ignored + @security_advisories = security_advisories + end + + sig { returns(T.nilable(Dependabot::Version)) } + def latest_resolvable_version + return nil unless version_pinned? + + tag = git_commit_checker.local_tag_for_latest_version + return nil unless tag + + version = tag.fetch(:version) + return nil unless version_meets_requirements?(version) + + Version.new(version) + end + + sig { returns(T.nilable(Dependabot::Version)) } + def latest_version_within_requirements + return nil unless version_pinned? + + requirement = dependency_requirement + return nil unless requirement + + tags = git_commit_checker.local_tags_for_allowed_versions + matching_tags = tags.select do |tag| + version = tag.fetch(:version) + requirement.satisfied_by?(version) + end + + latest_tag = matching_tags.max_by { |tag| tag.fetch(:version) } + return nil unless latest_tag + + Version.new(latest_tag.fetch(:version)) + end + + sig { returns(T.nilable(Dependabot::Version)) } + def lowest_security_fix_version + return nil unless version_pinned? + + tags = git_commit_checker.local_tags_for_allowed_versions + relevant_tags = filter_vulnerable_versions(tags) + relevant_tags = filter_lower_tags(relevant_tags) + + lowest_tag = relevant_tags.min_by { |tag| tag.fetch(:version) } + return nil unless lowest_tag + + Version.new(lowest_tag.fetch(:version)) + end + + sig { returns(T::Boolean) } + def branch_has_updates? + return false unless branch_pinned? + + branch = dependency_branch + return false unless branch + + begin + git_commit_checker.branch_or_ref_in_release?(branch) + rescue StandardError + false + end + end + + sig { returns(T::Boolean) } + def version_pinned? + return false unless dependency.version + + Version.correct?(dependency.version) + end + + sig { returns(T::Boolean) } + def branch_pinned? + source = dependency.requirements.first&.dig(:source) + return false unless source + + source[:branch].is_a?(String) && !source[:branch].empty? + end + + sig { returns(T::Boolean) } + def revision_pinned? + return true unless dependency.version + return false if version_pinned? + + # Has a revision but no version + source = dependency.requirements.first&.dig(:source) + source&.dig(:ref).is_a?(String) + end + + sig { returns(T.nilable(String)) } + def dependency_branch + source = dependency.requirements.first&.dig(:source) + source&.dig(:branch) + end + + private + + sig { returns(Dependabot::Dependency) } + attr_reader :dependency + + sig { returns(T::Array[Dependabot::Credential]) } + attr_reader :credentials + + sig { returns(T::Array[String]) } + attr_reader :ignored_versions + + sig { returns(T::Boolean) } + attr_reader :raise_on_ignored + + sig { returns(T::Array[Dependabot::SecurityAdvisory]) } + attr_reader :security_advisories + + sig { returns(Dependabot::GitCommitChecker) } + def git_commit_checker + @git_commit_checker ||= T.let( + Dependabot::GitCommitChecker.new( + dependency: dependency, + credentials: credentials, + ignored_versions: ignored_versions, + raise_on_ignored: raise_on_ignored, + consider_version_branches_pinned: true + ), + T.nilable(Dependabot::GitCommitChecker) + ) + end + + sig { returns(T.nilable(Dependabot::Requirement)) } + def dependency_requirement + req_string = dependency.requirements.first&.dig(:requirement) + return nil unless req_string + + Requirement.new(req_string) + rescue Gem::Requirement::BadRequirementError + nil + end + + sig { params(version: T.untyped).returns(T::Boolean) } + def version_meets_requirements?(version) + requirement = dependency_requirement + return true unless requirement + + requirement.satisfied_by?(version) + end + + sig do + params( + tags: T::Array[T::Hash[Symbol, T.untyped]] + ).returns(T::Array[T::Hash[Symbol, T.untyped]]) + end + def filter_vulnerable_versions(tags) + return tags if security_advisories.empty? + + tags.reject do |tag| + version = tag.fetch(:version) + security_advisories.any? { |advisory| advisory.vulnerable?(version) } + end + end + + sig do + params( + tags: T::Array[T::Hash[Symbol, T.untyped]] + ).returns(T::Array[T::Hash[Symbol, T.untyped]]) + end + def filter_lower_tags(tags) + current = current_version + return tags unless current + + tags.select { |tag| tag.fetch(:version) > current } + end + + sig { returns(T.nilable(Dependabot::Version)) } + def current_version + return nil unless dependency.version + + Version.new(dependency.version) + rescue ArgumentError + nil + end + end + end + end +end diff --git a/swift/spec/dependabot/swift/file_parser_spec.rb b/swift/spec/dependabot/swift/file_parser_spec.rb index c8332a54126..c1135aa4feb 100644 --- a/swift/spec/dependabot/swift/file_parser_spec.rb +++ b/swift/spec/dependabot/swift/file_parser_spec.rb @@ -572,12 +572,12 @@ it "associates requirements with correct pbxproj files" do deps = parser.parse - nio = deps.find { |d| d.name == "github.com/apple/swift-nio" } - collections = deps.find { |d| d.name == "github.com/apple/swift-collections" } + nio_dep = deps.find { |d| d.name == "github.com/apple/swift-nio" } + collections_dep = deps.find { |d| d.name == "github.com/apple/swift-collections" } - # With scoped requirements, nio comes from AppA and collections from AppB - expect(nio.requirements.first[:file]).to eq("AppA.xcodeproj/project.pbxproj") - expect(collections.requirements.first[:file]).to eq("AppB.xcodeproj/project.pbxproj") + # With scoped requirements, swift-nio comes from AppA and swift-collections from AppB + expect(nio_dep.requirements.first[:file]).to eq("AppA.xcodeproj/project.pbxproj") + expect(collections_dep.requirements.first[:file]).to eq("AppB.xcodeproj/project.pbxproj") end end diff --git a/swift/spec/dependabot/swift/update_checker_spec.rb b/swift/spec/dependabot/swift/update_checker_spec.rb index 0f9d99a01b5..067b38c7345 100644 --- a/swift/spec/dependabot/swift/update_checker_spec.rb +++ b/swift/spec/dependabot/swift/update_checker_spec.rb @@ -269,4 +269,284 @@ it { is_expected.to be_nil } end end + + context "when enable_swift_xcode_spm experiment is enabled" do + before { Dependabot::Experiments.register(:enable_swift_xcode_spm, true) } + after { Dependabot::Experiments.register(:enable_swift_xcode_spm, false) } + + let(:dependency_files) do + [ + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + let(:repo_contents_path) { build_tmp_repo(project_name, path: "projects") } + let(:file_parser) do + Dependabot::Swift::FileParser.new( + dependency_files: dependency_files, + repo_contents_path: repo_contents_path, + source: nil + ) + end + let(:dependencies) { file_parser.parse } + let(:dependency) { dependencies.find { |dep| dep.name == name } } + + let(:stub_xcode_upload_pack) do + stub_request(:get, "#{url}.git/info/refs?service=git-upload-pack") + .to_return( + status: 200, + body: fixture("git", "upload_packs", upload_pack_fixture), + headers: { + "content-type" => "application/x-git-upload-pack-advertisement" + } + ) + end + + context "with Xcode project that needs update (v2 resolved)" do + let(:project_name) { "xcode_project_needs_update" } + let(:name) { "github.com/quick/quick" } + let(:url) { "https://github.com/Quick/Quick" } + let(:upload_pack_fixture) { "quick" } + + before { stub_xcode_upload_pack } + + describe "#xcode_spm_mode?" do + subject { checker.send(:xcode_spm_mode?) } + + it { is_expected.to be true } + end + + describe "#can_update?" do + subject { checker.can_update?(requirements_to_unlock: :own) } + + it { is_expected.to be_truthy } + end + + describe "#latest_version" do + subject { checker.latest_version } + + it "returns latest version from git tags" do + expect(subject).to be_a(Dependabot::Swift::Version) + expect(subject.to_s).to eq("7.0.2") + end + end + + describe "#latest_resolvable_version" do + subject { checker.latest_resolvable_version } + + it "returns the latest version that satisfies requirements" do + expect(subject).to be_a(Dependabot::Swift::Version) + expect(subject.to_s).to eq("7.0.2") + end + end + + describe "#updated_requirements" do + subject(:updated_requirements) { checker.updated_requirements } + + it "returns updated requirements with new version" do + expect(updated_requirements.first[:requirement]).to eq(">= 7.0.2, < 8.0.0") + expect(updated_requirements.first[:file]).to eq("MyApp.xcodeproj/project.pbxproj") + end + end + end + + context "with Xcode project that is up to date" do + let(:project_name) { "xcode_project_up_to_date" } + let(:name) { "github.com/quick/quick" } + let(:url) { "https://github.com/Quick/Quick" } + let(:upload_pack_fixture) { "quick" } + + before { stub_xcode_upload_pack } + + describe "#can_update?" do + subject { checker.can_update?(requirements_to_unlock: :own) } + + it { is_expected.to be_falsey } + end + + describe "#latest_version" do + subject { checker.latest_version } + + it { is_expected.to eq(dependency.version) } + end + + describe "#latest_resolvable_version" do + subject { checker.latest_resolvable_version } + + it { is_expected.to eq(dependency.version) } + end + end + + context "with Xcode project pinned to branch" do + let(:project_name) { "xcode_project_branch_pin" } + let(:name) { "github.com/quick/quick" } + let(:url) { "https://github.com/Quick/Quick" } + let(:upload_pack_fixture) { "quick" } + + before { stub_xcode_upload_pack } + + describe "#latest_version" do + subject { checker.latest_version } + + it "returns nil for branch-pinned dependencies" do + # Branch-pinned dependencies don't have a semver version + expect(subject).to be_nil + end + end + end + + context "with multiple Xcode projects" do + let(:project_name) { "xcode_project_needs_update" } + # Use the same fixture format as single xcode project but for multi-project test + let(:dependency_files) do + [ + Dependabot::DependencyFile.new( + name: "AppA.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + + let(:name) { "github.com/quick/quick" } + let(:url) { "https://github.com/Quick/Quick" } + let(:upload_pack_fixture) { "quick" } + + # Directly construct multiple dependencies to test multi-project behavior + let(:dependencies) do + [ + Dependabot::Dependency.new( + name: name, + version: "7.0.0", + requirements: [ + { + file: "AppA.xcodeproj/project.pbxproj", + requirement: ">= 7.0.0, < 8.0.0", + groups: [], + source: { type: "git", url: "https://github.com/Quick/Quick.git", ref: "7.0.0", branch: nil }, + metadata: { kind: "upToNextMajorVersion", requirement_string: "from: \"7.0.0\"" } + } + ], + package_manager: "swift" + ) + ] + end + let(:dependency) { dependencies.first } + + before { stub_xcode_upload_pack } + + describe "#xcode_spm_mode?" do + subject { checker.send(:xcode_spm_mode?) } + + it { is_expected.to be true } + end + + describe "#can_update?" do + subject { checker.can_update?(requirements_to_unlock: :own) } + + it "returns true when dependency has updates available" do + expect(subject).to be_truthy + end + end + end + + context "with revision-only pinned dependency" do + let(:project_name) { "xcode_project_revision_only" } + let(:name) { "github.com/quick/quick" } + let(:url) { "https://github.com/Quick/Quick" } + let(:upload_pack_fixture) { "quick" } + + before { stub_xcode_upload_pack } + + describe "#latest_version" do + subject { checker.latest_version } + + it "returns nil for revision-pinned dependencies" do + # Revision-pinned dependencies don't have a semver version + expect(subject).to be_nil + end + end + end + + context "with classic SPM (Package.swift present)" do + # Test that xcode_spm_mode? returns false when Package.swift is present + # We mock the dependencies directly since parsing Package.swift requires Swift toolchain + let(:project_name) { "xcode_project_needs_update" } + let(:name) { "github.com/quick/quick" } + let(:url) { "https://github.com/Quick/Quick" } + let(:upload_pack_fixture) { "quick" } + + # Override dependency_files to include a Package.swift + let(:dependency_files) do + [ + Dependabot::DependencyFile.new( + name: "Package.swift", + content: "// swift-tools-version:5.9\nimport PackageDescription\nlet package = Package(name: \"App\")" + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.pbxproj", + content: fixture("projects", project_name, "MyApp.xcodeproj", "project.pbxproj"), + support_file: true + ), + Dependabot::DependencyFile.new( + name: "MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", + content: fixture( + "projects", + project_name, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved" + ) + ) + ] + end + + let(:dependency) do + Dependabot::Dependency.new( + name: name, + version: "7.0.0", + requirements: [{ file: "Package.swift", requirement: ">= 7.0.0, < 8.0.0", groups: [], + source: { type: "git", url: url } }], + package_manager: "swift" + ) + end + + before { stub_xcode_upload_pack } + + describe "#xcode_spm_mode?" do + subject { checker.send(:xcode_spm_mode?) } + + it { is_expected.to be false } + end + end + end end diff --git a/swift/spec/fixtures/projects/xcode_project/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d5bb07372d6..e5d008a69c2 100644 --- a/swift/spec/fixtures/projects/xcode_project/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/swift/spec/fixtures/projects/xcode_project/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,7 +5,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf", + "revision" : "1234567890abcdef1234567890abcdef12345678", "version" : "2.54.0" } } diff --git a/swift/spec/fixtures/projects/xcode_project_branch_pin/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_branch_pin/MyApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..a9c1e2f3893 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_branch_pin/MyApp.xcodeproj/project.pbxproj @@ -0,0 +1,22 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin XCRemoteSwiftPackageReference section */ + A1B2C3D4E5F60001 /* XCRemoteSwiftPackageReference "Quick" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Quick/Quick.git"; + requirement = { + kind = branch; + branch = "main"; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_project_branch_pin/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_branch_pin/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..7d3eaaa9e1c --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_branch_pin/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "quick", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Quick/Quick.git", + "state" : { + "branch" : "main", + "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf" + } + } + ], + "version" : 2 +} diff --git a/swift/spec/fixtures/projects/xcode_project_multiple/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_multiple/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d5bb07372d6..e5d008a69c2 100644 --- a/swift/spec/fixtures/projects/xcode_project_multiple/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/swift/spec/fixtures/projects/xcode_project_multiple/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,7 +5,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf", + "revision" : "1234567890abcdef1234567890abcdef12345678", "version" : "2.54.0" } } diff --git a/swift/spec/fixtures/projects/xcode_project_multiple/AppB.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_multiple/AppB.xcodeproj/project.pbxproj index a3b87e9ddd1..dbc12754f83 100644 --- a/swift/spec/fixtures/projects/xcode_project_multiple/AppB.xcodeproj/project.pbxproj +++ b/swift/spec/fixtures/projects/xcode_project_multiple/AppB.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ repositoryURL = "https://github.com/apple/swift-collections.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.0.0; + minimumVersion = 1.0.5; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/swift/spec/fixtures/projects/xcode_project_multiple/AppB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_multiple/AppB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b7a76a334d..2c841fa822d 100644 --- a/swift/spec/fixtures/projects/xcode_project_multiple/AppB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/swift/spec/fixtures/projects/xcode_project_multiple/AppB.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", - "version" : "1.0.4" + "revision" : "0987654321fedcba0987654321fedcba09876543", + "version" : "1.0.5" } } ], diff --git a/swift/spec/fixtures/projects/xcode_project_needs_update/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_needs_update/MyApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..aa8b33ed7c9 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_needs_update/MyApp.xcodeproj/project.pbxproj @@ -0,0 +1,22 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin XCRemoteSwiftPackageReference section */ + A1B2C3D4E5F60001 /* XCRemoteSwiftPackageReference "Quick" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Quick/Quick.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 7.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_project_needs_update/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_needs_update/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..084c2e59340 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_needs_update/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "quick", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Quick/Quick.git", + "state" : { + "revision" : "1f5d542036f34e22cd0f6c0a8f94fb432d318adb", + "version" : "7.0.0" + } + } + ], + "version" : 2 +} diff --git a/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5f9a09f6916..cb5a7c3589c 100644 --- a/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,9 +1,9 @@ { "pins" : [ { - "identity" : "swift-nio", + "identity" : "quick", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", + "location" : "https://github.com/Quick/Quick.git", "state" : { "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf" } diff --git a/swift/spec/fixtures/projects/xcode_project_up_to_date/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_up_to_date/MyApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..aa8b33ed7c9 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_up_to_date/MyApp.xcodeproj/project.pbxproj @@ -0,0 +1,22 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin XCRemoteSwiftPackageReference section */ + A1B2C3D4E5F60001 /* XCRemoteSwiftPackageReference "Quick" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Quick/Quick.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 7.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + + }; + rootObject = A1B2C3D4E5F60000 /* Project object */; +} diff --git a/swift/spec/fixtures/projects/xcode_project_up_to_date/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_up_to_date/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..b23fc0fc5a3 --- /dev/null +++ b/swift/spec/fixtures/projects/xcode_project_up_to_date/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "quick", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Quick/Quick.git", + "state" : { + "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf", + "version" : "7.0.2" + } + } + ], + "version" : 2 +} diff --git a/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.pbxproj b/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.pbxproj index 3d547e1ee6d..4f1a4b117b8 100644 --- a/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.pbxproj +++ b/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.pbxproj @@ -12,7 +12,7 @@ repositoryURL = "https://github.com/apple/swift-nio.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.40.0; + minimumVersion = 2.54.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 81794724110..7f2e13eb9b0 100644 --- a/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/swift/spec/fixtures/projects/xcode_project_v1_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "repositoryURL": "https://github.com/apple/swift-nio.git", "state": { "branch": null, - "revision": "6213ba7a06febe8fef60563a4a7d26a4085783cf", + "revision": "1234567890abcdef1234567890abcdef12345678", "version": "2.54.0" } } diff --git a/swift/spec/fixtures/projects/xcode_project_v3_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_v3_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9e38d7cf143..1d9ab321b22 100644 --- a/swift/spec/fixtures/projects/xcode_project_v3_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/swift/spec/fixtures/projects/xcode_project_v3_resolved/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf", + "revision" : "1234567890abcdef1234567890abcdef12345678", "version" : "2.54.0" } } From a34086b27f142cb35fc4d2a321d9707e3e603083 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Wed, 11 Mar 2026 15:49:11 -0500 Subject: [PATCH 03/15] refactor according to pr comments --- swift/lib/dependabot/swift/update_checker.rb | 8 +- .../update_checker/requirements_updater.rb | 25 +++- .../update_checker/xcode_version_resolver.rb | 116 ++---------------- .../dependabot/swift/update_checker_spec.rb | 66 ---------- 4 files changed, 35 insertions(+), 180 deletions(-) diff --git a/swift/lib/dependabot/swift/update_checker.rb b/swift/lib/dependabot/swift/update_checker.rb index a9c6bfb58f7..ed5d27780b0 100644 --- a/swift/lib/dependabot/swift/update_checker.rb +++ b/swift/lib/dependabot/swift/update_checker.rb @@ -60,6 +60,8 @@ def updated_requirements ).updated_requirements end + private + sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } def updated_xcode_requirements RequirementsUpdater.new( @@ -69,8 +71,6 @@ def updated_xcode_requirements ).updated_requirements end - private - sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } def old_requirements dependency.requirements @@ -274,9 +274,7 @@ def xcode_version_resolver @xcode_version_resolver ||= T.let( XcodeVersionResolver.new( dependency: dependency, - credentials: credentials, - ignored_versions: ignored_versions, - raise_on_ignored: raise_on_ignored, + git_commit_checker: git_commit_checker, security_advisories: security_advisories ), T.nilable(XcodeVersionResolver) diff --git a/swift/lib/dependabot/swift/update_checker/requirements_updater.rb b/swift/lib/dependabot/swift/update_checker/requirements_updater.rb index 27cd21a881a..f993e6a3615 100644 --- a/swift/lib/dependabot/swift/update_checker/requirements_updater.rb +++ b/swift/lib/dependabot/swift/update_checker/requirements_updater.rb @@ -65,7 +65,7 @@ def update_xcode_requirement(requirement) kind = metadata[:kind] new_requirement_string = build_xcode_requirement_string(requirement_string, kind) - new_requirement = build_xcode_requirement(kind) + new_requirement = build_xcode_requirement(requirement_string, kind) requirement.merge( requirement: new_requirement, @@ -92,9 +92,8 @@ def build_xcode_requirement_string(requirement_string, kind) when "exactVersion" "exact: \"#{target_version}\"" when "versionRange" - min = target_version.to_s - max = bump_version(min, :major) - "\"#{min}\"..<\"#{max}\"" + max = extract_version_range_max(requirement_string) + "\"#{target_version}\"..<\"#{max}\"" else # Default: update to exact version for unknown kinds "exact: \"#{target_version}\"" @@ -103,10 +102,11 @@ def build_xcode_requirement_string(requirement_string, kind) sig do params( + requirement_string: T.nilable(String), kind: T.nilable(String) ).returns(T.nilable(String)) end - def build_xcode_requirement(kind) + def build_xcode_requirement(requirement_string, kind) return nil unless target_version case kind @@ -119,7 +119,7 @@ def build_xcode_requirement(kind) when "exactVersion" "= #{target_version}" when "versionRange" - max = bump_version(target_version.to_s, :major) + max = extract_version_range_max(requirement_string) ">= #{target_version}, < #{max}" else # Default: exact version @@ -127,6 +127,19 @@ def build_xcode_requirement(kind) end end + # Extracts the upper bound from a versionRange requirement string. + # Format: "min"..<"max" or "min"..."max" + sig { params(requirement_string: T.nilable(String)).returns(String) } + def extract_version_range_max(requirement_string) + return bump_version(target_version.to_s, :major) unless requirement_string + + # Match patterns like "1.0.0"..<"2.0.0" or "1.0.0"..."2.0.0" + match = requirement_string.match(/\.{2,3}= 7.0.0, < 8.0.0", groups: [], - source: { type: "git", url: url } }], - package_manager: "swift" - ) - end - - before { stub_xcode_upload_pack } - - describe "#xcode_spm_mode?" do - subject { checker.send(:xcode_spm_mode?) } - - it { is_expected.to be false } - end - end end end From b33385ec47db4bafeac26a2778171caf6aaaba09 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Wed, 11 Mar 2026 19:49:41 -0500 Subject: [PATCH 04/15] fix failing lint and spec errors --- .../dependabot/swift/update_checker_spec.rb | 36 ++++++++----------- .../xcshareddata/swiftpm/Package.resolved | 4 +-- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/swift/spec/dependabot/swift/update_checker_spec.rb b/swift/spec/dependabot/swift/update_checker_spec.rb index 7b0c1755caa..f5e430f31a6 100644 --- a/swift/spec/dependabot/swift/update_checker_spec.rb +++ b/swift/spec/dependabot/swift/update_checker_spec.rb @@ -332,20 +332,20 @@ end describe "#latest_version" do - subject { checker.latest_version } + subject(:latest_version) { checker.latest_version } it "returns latest version from git tags" do - expect(subject).to be_a(Dependabot::Swift::Version) - expect(subject.to_s).to eq("7.0.2") + expect(latest_version).to be_a(Dependabot::Swift::Version) + expect(latest_version.to_s).to eq("7.0.2") end end describe "#latest_resolvable_version" do - subject { checker.latest_resolvable_version } + subject(:latest_resolvable_version) { checker.latest_resolvable_version } it "returns the latest version that satisfies requirements" do - expect(subject).to be_a(Dependabot::Swift::Version) - expect(subject.to_s).to eq("7.0.2") + expect(latest_resolvable_version).to be_a(Dependabot::Swift::Version) + expect(latest_resolvable_version.to_s).to eq("7.0.2") end end @@ -397,10 +397,8 @@ describe "#latest_version" do subject { checker.latest_version } - it "returns nil for branch-pinned dependencies" do - # Branch-pinned dependencies don't have a semver version - expect(subject).to be_nil - end + # Branch-pinned dependencies don't have a semver version + it { is_expected.to be_nil } end end @@ -459,27 +457,23 @@ describe "#can_update?" do subject { checker.can_update?(requirements_to_unlock: :own) } - it "returns true when dependency has updates available" do - expect(subject).to be_truthy - end + it { is_expected.to be_truthy } end end context "with revision-only pinned dependency" do let(:project_name) { "xcode_project_revision_only" } - let(:name) { "github.com/quick/quick" } - let(:url) { "https://github.com/Quick/Quick" } - let(:upload_pack_fixture) { "quick" } + let(:name) { "github.com/apple/swift-nio" } + let(:url) { "https://github.com/apple/swift-nio" } - before { stub_xcode_upload_pack } + # No upload pack stub needed - revision-only dependencies return nil immediately + # without making git requests describe "#latest_version" do subject { checker.latest_version } - it "returns nil for revision-pinned dependencies" do - # Revision-pinned dependencies don't have a semver version - expect(subject).to be_nil - end + # Revision-pinned dependencies don't have a semver version + it { is_expected.to be_nil } end end end diff --git a/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cb5a7c3589c..5f9a09f6916 100644 --- a/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/swift/spec/fixtures/projects/xcode_project_revision_only/MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,9 +1,9 @@ { "pins" : [ { - "identity" : "quick", + "identity" : "swift-nio", "kind" : "remoteSourceControl", - "location" : "https://github.com/Quick/Quick.git", + "location" : "https://github.com/apple/swift-nio.git", "state" : { "revision" : "6213ba7a06febe8fef60563a4a7d26a4085783cf" } From 94ac90b7355d21b49d4bdda6a91aaebc12454293 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Thu, 12 Mar 2026 01:23:48 -0500 Subject: [PATCH 05/15] merge update checker changes --- swift/lib/dependabot/swift/file_updater.rb | 1 - .../swift/file_updater/xcode_lockfile_updater.rb | 13 ------------- swift/lib/dependabot/swift/metadata_finder.rb | 14 ++++++++++++++ swift/lib/dependabot/swift/update_checker.rb | 13 +++++++++++-- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/swift/lib/dependabot/swift/file_updater.rb b/swift/lib/dependabot/swift/file_updater.rb index 144328c5dfa..52ca6343414 100644 --- a/swift/lib/dependabot/swift/file_updater.rb +++ b/swift/lib/dependabot/swift/file_updater.rb @@ -96,7 +96,6 @@ def xcode_spm_mode? manifest.nil? && xcode_resolved_files.any? end - # All Package.resolved files under .xcodeproj directories sig { returns(T::Array[Dependabot::DependencyFile]) } def xcode_resolved_files @xcode_resolved_files ||= T.let( 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 9f5c9726bf9..ea2543f281f 100644 --- a/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb +++ b/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb @@ -13,12 +13,6 @@ module Dependabot module Swift class FileUpdater < Dependabot::FileUpdaters::Base - # Updates Xcode-managed Package.resolved files in-place without running - # the Swift CLI. This is used for Xcode SPM projects that don't have a - # Package.swift manifest file. - # - # Preserves the original schema version (v1/v2/v3) and minimizes changes - # to the file structure to produce clean diffs. class XcodeLockfileUpdater extend T::Sig @@ -74,7 +68,6 @@ def updated_lockfile_content ) + "\n" end - # Returns true if any dependency in the given file needs updating sig { returns(T::Boolean) } def lockfile_changed? dependencies_for_file.any? @@ -174,7 +167,6 @@ def update_pin_for_dependency(pins, dependency, keys, schema_version) new_version = dependency.version new_ref = source&.dig(:ref) - # Update version if we have a new one if new_version state["version"] = new_version # When updating to a new version, update revision if provided in source @@ -187,7 +179,6 @@ def update_pin_for_dependency(pins, dependency, keys, schema_version) end end - # Checks if a string looks like a git SHA (40 hex characters) sig { params(str: String).returns(T::Boolean) } def looks_like_sha?(str) str.match?(/\A[0-9a-f]{40}\z/i) @@ -208,7 +199,6 @@ def find_pin_for_dependency(pins, dependency, keys, schema_version) pins.find do |pin| pin_identity = pin[identity_key] - # v1 uses display name which may be mixed case pin_identity = pin_identity&.downcase if schema_version == 1 if identity && pin_identity == identity @@ -226,14 +216,11 @@ def find_pin_for_dependency(pins, dependency, keys, schema_version) end end - # Returns only the dependencies that are relevant to this resolved file sig { returns(T::Array[Dependabot::Dependency]) } def dependencies_for_file @dependencies_for_file ||= T.let( dependencies.select do |dep| dep.requirements.any? do |req| - # Match if the requirement file is the resolved file itself - # or if the requirement file is a pbxproj in the same xcodeproj req_file = req[:file] if req_file == resolved_file.name true diff --git a/swift/lib/dependabot/swift/metadata_finder.rb b/swift/lib/dependabot/swift/metadata_finder.rb index 9ff53789dc5..18141cc6172 100644 --- a/swift/lib/dependabot/swift/metadata_finder.rb +++ b/swift/lib/dependabot/swift/metadata_finder.rb @@ -17,6 +17,11 @@ def look_up_source case new_source_type when "git" then find_source_from_git_url when "registry" then find_source_from_registry + when "default", nil + # For dependencies without explicit source info (e.g., Xcode-managed + # SPM dependencies parsed from Package.resolved), attempt to infer + # source from the dependency name which is typically a normalized URL + find_source_from_dependency_name else raise "Unexpected source type: #{new_source_type}" end end @@ -34,6 +39,15 @@ def find_source_from_git_url Source.from_url(url) end + sig { returns(T.nilable(Dependabot::Source)) } + def find_source_from_dependency_name + name = dependency.name + return nil unless name.include?("/") + + url = "https://#{name}" + Source.from_url(url) + end + sig { returns(T.noreturn) } def find_source_from_registry raise NotImplementedError diff --git a/swift/lib/dependabot/swift/update_checker.rb b/swift/lib/dependabot/swift/update_checker.rb index ed5d27780b0..3da88375049 100644 --- a/swift/lib/dependabot/swift/update_checker.rb +++ b/swift/lib/dependabot/swift/update_checker.rb @@ -54,9 +54,13 @@ def lowest_resolvable_security_fix_version def updated_requirements return updated_xcode_requirements if xcode_spm_mode? + # If no target version is available, return old requirements unchanged + target = preferred_resolvable_version + return old_requirements unless target + RequirementsUpdater.new( requirements: old_requirements, - target_version: T.must(preferred_resolvable_version) + target_version: target ).updated_requirements end @@ -64,9 +68,14 @@ def updated_requirements sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } def updated_xcode_requirements + # If no target version is available (e.g., revision-only or branch-pinned + # dependency), return old requirements unchanged + target = preferred_resolvable_version + return old_requirements unless target + RequirementsUpdater.new( requirements: old_requirements, - target_version: T.must(preferred_resolvable_version), + target_version: target, xcode_mode: true ).updated_requirements end From 97d8502556c595d25c6a1c781c022b6e5641bd46 Mon Sep 17 00:00:00 2001 From: Mark Allen Date: Mon, 16 Mar 2026 12:38:42 +0000 Subject: [PATCH 06/15] feat: add xcworkspace support for xcode swiftpm --- swift/lib/dependabot/swift/file_fetcher.rb | 55 ++++++++++++-- swift/lib/dependabot/swift/file_parser.rb | 2 +- .../swift/file_parser/xcode_spm_resolver.rb | 35 +++++---- swift/lib/dependabot/swift/file_updater.rb | 2 +- .../file_updater/xcode_lockfile_updater.rb | 53 ++++++++++---- swift/lib/dependabot/swift/update_checker.rb | 2 +- .../dependabot/swift/file_fetcher_spec.rb | 34 +++++++++ .../file_parser/xcode_spm_resolver_spec.rb | 36 ++++++++++ .../xcode_lockfile_updater_spec.rb | 35 +++++++++ .../dependabot/swift/file_updater_spec.rb | 72 +++++++++++++++++++ .../AppA.xcodeproj/project.pbxproj | 22 ++++++ .../xcshareddata/swiftpm/Package.resolved | 14 ++++ .../contents.xcworkspacedata | 7 ++ .../xcshareddata/swiftpm/Package.resolved | 14 ++++ .../ios/AppA.xcodeproj/project.pbxproj | 22 ++++++ .../xcshareddata/swiftpm/Package.resolved | 14 ++++ .../contents.xcworkspacedata | 7 ++ .../xcshareddata/swiftpm/Package.resolved | 14 ++++ 18 files changed, 406 insertions(+), 34 deletions(-) create mode 100644 swift/spec/fixtures/projects/xcode_workspace/AppA.xcodeproj/project.pbxproj create mode 100644 swift/spec/fixtures/projects/xcode_workspace/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 swift/spec/fixtures/projects/xcode_workspace/MyApp.xcworkspace/contents.xcworkspacedata create mode 100644 swift/spec/fixtures/projects/xcode_workspace/MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 swift/spec/fixtures/projects/xcode_workspace_nested/ios/AppA.xcodeproj/project.pbxproj create mode 100644 swift/spec/fixtures/projects/xcode_workspace_nested/ios/AppA.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 swift/spec/fixtures/projects/xcode_workspace_nested/ios/MyApp.xcworkspace/contents.xcworkspacedata create mode 100644 swift/spec/fixtures/projects/xcode_workspace_nested/ios/MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/swift/lib/dependabot/swift/file_fetcher.rb b/swift/lib/dependabot/swift/file_fetcher.rb index 01b48130b52..94b3da03cd8 100644 --- a/swift/lib/dependabot/swift/file_fetcher.rb +++ b/swift/lib/dependabot/swift/file_fetcher.rb @@ -11,7 +11,10 @@ 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) @@ -28,7 +31,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 +77,61 @@ 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]) + + until queue.empty? + dir = queue.shift + next unless dir + 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..cdb58f308fb 100644 --- a/swift/lib/dependabot/swift/file_parser.rb +++ b/swift/lib/dependabot/swift/file_parser.rb @@ -121,7 +121,7 @@ def xcode_resolved_files @xcode_resolved_files ||= T.let( dependency_files.select do |f| f.name.end_with?("Package.resolved") && - f.name.include?(".xcodeproj/") && + (f.name.include?(".xcodeproj/") || f.name.include?(".xcworkspace/")) && !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..3ded0efdb6c 100644 --- a/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb +++ b/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb @@ -35,11 +35,12 @@ def parse dependency_set = Dependabot::FileParsers::Base::DependencySet.new scoped_requirements = aggregate_pbxproj_requirements + all_requirements = merge_all_requirements(scoped_requirements) 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, {}) + xcode_scope_dir = extract_xcode_scope_dir(resolved_file.name) + pbxproj_requirements = scoped_requirements.fetch(xcode_scope_dir, all_requirements) resolved_deps.each do |dep| enriched = enrich_with_pbxproj_requirements(dep, pbxproj_requirements) @@ -59,24 +60,35 @@ 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_all_requirements(scoped_requirements) + scoped_requirements.values.each_with_object({}) do |requirements, merged| + requirements.each { |name, req_info| merged[name] = req_info } + end + end + # Enriches a dependency parsed from Package.resolved with requirement # info from the matching project.pbxproj sig do @@ -117,12 +129,11 @@ 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)/}) + def extract_xcode_scope_dir(path) + match = path.match(%r{^(.*?\.(?:xcodeproj|xcworkspace))/}) match&.captures&.first end end diff --git a/swift/lib/dependabot/swift/file_updater.rb b/swift/lib/dependabot/swift/file_updater.rb index 52ca6343414..9edc4bad5bb 100644 --- a/swift/lib/dependabot/swift/file_updater.rb +++ b/swift/lib/dependabot/swift/file_updater.rb @@ -101,7 +101,7 @@ def xcode_resolved_files @xcode_resolved_files ||= T.let( dependency_files.select do |f| f.name.end_with?("Package.resolved") && - f.name.include?(".xcodeproj/") && + (f.name.include?(".xcodeproj/") || f.name.include?(".xcworkspace/")) && !f.support_file? end, T.nilable(T::Array[Dependabot::DependencyFile]) 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 ea2543f281f..abdbb299fe5 100644 --- a/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb +++ b/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb @@ -221,30 +221,53 @@ 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.untyped).returns(T::Boolean) } + def req_file_matches_resolved_scope?(req_file) + return false unless req_file.is_a?(String) + 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)/}) + def extract_xcode_scope_dir(path) + match = path.match(%r{^(.*?\.(?:xcodeproj|xcworkspace))/}) match&.captures&.first end + + sig { params(req_file: T.untyped).returns(T::Boolean) } + def workspace_related_dependency?(req_file) + return false unless req_file.is_a?(String) + + workspace_scope = extract_xcode_scope_dir(resolved_file.name) + return false unless workspace_scope&.end_with?(".xcworkspace") + return false unless req_file.include?(".xcodeproj/") + + workspace_root = File.dirname(workspace_scope) + req_scope = extract_xcode_scope_dir(req_file) + return false unless req_scope + + if workspace_root == "." + !req_scope.include?("/") + else + req_scope.start_with?("#{workspace_root}/") + 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..a61714b0b10 100644 --- a/swift/lib/dependabot/swift/update_checker.rb +++ b/swift/lib/dependabot/swift/update_checker.rb @@ -295,7 +295,7 @@ def xcode_resolved_files @xcode_resolved_files ||= T.let( dependency_files.select do |f| f.name.end_with?("Package.resolved") && - f.name.include?(".xcodeproj/") && + (f.name.include?(".xcodeproj/") || f.name.include?(".xcworkspace/")) && !f.support_file? end, T.nilable(T::Array[Dependabot::DependencyFile]) 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..09d56d9b80d 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,42 @@ 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 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..b19cd0c1471 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 @@ -321,5 +321,40 @@ 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(: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 end end diff --git a/swift/spec/dependabot/swift/file_updater_spec.rb b/swift/spec/dependabot/swift/file_updater_spec.rb index d783b396986..0cac7d9e0b4 100644 --- a/swift/spec/dependabot/swift/file_updater_spec.rb +++ b/swift/spec/dependabot/swift/file_updater_spec.rb @@ -256,6 +256,78 @@ end end + context "with workspace-scoped Package.resolved" do + let(:project_name) { "xcode_workspace" } + let(:files) do + [ + 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 +} From c87df05057c3ddb65dd8c678625242fb75f55c60 Mon Sep 17 00:00:00 2001 From: Mark Allen Date: Mon, 16 Mar 2026 12:46:44 +0000 Subject: [PATCH 07/15] refactor: simplify nilable req_file guards --- .../swift/file_updater/xcode_lockfile_updater.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 abdbb299fe5..c61a1d3efdd 100644 --- a/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb +++ b/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb @@ -228,9 +228,9 @@ def dependencies_for_file ) end - sig { params(req_file: T.untyped).returns(T::Boolean) } + sig { params(req_file: T.nilable(String)).returns(T::Boolean) } def req_file_matches_resolved_scope?(req_file) - return false unless req_file.is_a?(String) + return false unless req_file return true if req_file == resolved_file.name return false unless req_file.include?(".xcodeproj/") || req_file.include?(".xcworkspace/") @@ -250,9 +250,9 @@ def extract_xcode_scope_dir(path) match&.captures&.first end - sig { params(req_file: T.untyped).returns(T::Boolean) } + sig { params(req_file: T.nilable(String)).returns(T::Boolean) } def workspace_related_dependency?(req_file) - return false unless req_file.is_a?(String) + return false unless req_file workspace_scope = extract_xcode_scope_dir(resolved_file.name) return false unless workspace_scope&.end_with?(".xcworkspace") From 0a40a45c927e3bfc6f512d0da95ec8107ecb03ee Mon Sep 17 00:00:00 2001 From: Mark Allen Date: Mon, 16 Mar 2026 14:01:48 +0000 Subject: [PATCH 08/15] fix: tighten xcode workspace scope matching --- swift/lib/dependabot/swift/file_fetcher.rb | 3 +- swift/lib/dependabot/swift/file_parser.rb | 4 +- .../swift/file_parser/xcode_spm_resolver.rb | 52 ++++++++++++++-- swift/lib/dependabot/swift/file_updater.rb | 17 +++++- .../file_updater/xcode_lockfile_updater.rb | 39 ++++++++++-- swift/lib/dependabot/swift/update_checker.rb | 4 +- .../dependabot/swift/xcode_file_helpers.rb | 25 ++++++++ .../file_parser/xcode_spm_resolver_spec.rb | 36 +++++++++++ .../xcode_lockfile_updater_spec.rb | 60 ++++++++++++++++++- .../dependabot/swift/file_updater_spec.rb | 5 ++ 10 files changed, 225 insertions(+), 20 deletions(-) create mode 100644 swift/lib/dependabot/swift/xcode_file_helpers.rb diff --git a/swift/lib/dependabot/swift/file_fetcher.rb b/swift/lib/dependabot/swift/file_fetcher.rb index 94b3da03cd8..7644e64c574 100644 --- a/swift/lib/dependabot/swift/file_fetcher.rb +++ b/swift/lib/dependabot/swift/file_fetcher.rb @@ -5,6 +5,7 @@ require "dependabot/experiments" require "dependabot/file_fetchers" require "dependabot/file_fetchers/base" +require "dependabot/swift/xcode_file_helpers" module Dependabot module Swift @@ -21,7 +22,7 @@ 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 diff --git a/swift/lib/dependabot/swift/file_parser.rb b/swift/lib/dependabot/swift/file_parser.rb index cdb58f308fb..9e48c49a4ac 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 @@ -120,8 +121,7 @@ def package_manifest_file def xcode_resolved_files @xcode_resolved_files ||= T.let( dependency_files.select do |f| - f.name.end_with?("Package.resolved") && - (f.name.include?(".xcodeproj/") || f.name.include?(".xcworkspace/")) && + 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 3ded0efdb6c..08718d4f3e2 100644 --- a/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb +++ b/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb @@ -7,6 +7,7 @@ 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 @@ -35,12 +36,13 @@ def parse dependency_set = Dependabot::FileParsers::Base::DependencySet.new scoped_requirements = aggregate_pbxproj_requirements - all_requirements = merge_all_requirements(scoped_requirements) xcode_resolved_files.each do |resolved_file| resolved_deps = PackageResolvedParser.new(resolved_file).parse - xcode_scope_dir = extract_xcode_scope_dir(resolved_file.name) - pbxproj_requirements = scoped_requirements.fetch(xcode_scope_dir, all_requirements) + 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) @@ -83,12 +85,44 @@ def aggregate_pbxproj_requirements 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_all_requirements(scoped_requirements) + 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 @@ -133,8 +167,14 @@ def enrich_with_pbxproj_requirements(dep, pbxproj_requirements) # from a file path. sig { params(path: String).returns(T.nilable(String)) } def extract_xcode_scope_dir(path) - match = path.match(%r{^(.*?\.(?:xcodeproj|xcworkspace))/}) - match&.captures&.first + 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 9edc4bad5bb..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/") || f.name.include?(".xcworkspace/")) && + 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 c61a1d3efdd..1867abf6777 100644 --- a/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb +++ b/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb @@ -9,6 +9,7 @@ require "dependabot/shared_helpers" require "dependabot/swift/file_updater" require "dependabot/swift/url_helpers" +require "dependabot/swift/xcode_file_helpers" module Dependabot module Swift @@ -31,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) } @@ -81,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) @@ -246,8 +252,7 @@ def req_file_matches_resolved_scope?(req_file) # from a file path. sig { params(path: String).returns(T.nilable(String)) } def extract_xcode_scope_dir(path) - match = path.match(%r{^(.*?\.(?:xcodeproj|xcworkspace))/}) - match&.captures&.first + XcodeFileHelpers.extract_xcode_scope_dir(path) end sig { params(req_file: T.nilable(String)).returns(T::Boolean) } @@ -258,16 +263,40 @@ def workspace_related_dependency?(req_file) return false unless workspace_scope&.end_with?(".xcworkspace") return false unless req_file.include?(".xcodeproj/") - workspace_root = File.dirname(workspace_scope) 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 = 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 end diff --git a/swift/lib/dependabot/swift/update_checker.rb b/swift/lib/dependabot/swift/update_checker.rb index a61714b0b10..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/") || f.name.include?(".xcworkspace/")) && + 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..ac12945bf1a --- /dev/null +++ b/swift/lib/dependabot/swift/xcode_file_helpers.rb @@ -0,0 +1,25 @@ +# typed: strict +# frozen_string_literal: true + +require "sorbet-runtime" + +module Dependabot + module Swift + module XcodeFileHelpers + extend T::Sig + + XCODE_RESOLVED_FILE_REGEX = %r{(?:\.xcodeproj|\.xcworkspace)/.*Package\.resolved\z} + XCODE_SCOPE_REGEX = %r{^(.*?\.(?:xcodeproj|xcworkspace))/} + + sig { params(path: String).returns(T::Boolean) } + def self.xcode_resolved_path?(path) + XCODE_RESOLVED_FILE_REGEX.match?(path) + end + + sig { params(path: String).returns(T.nilable(String)) } + def self.extract_xcode_scope_dir(path) + path.match(XCODE_SCOPE_REGEX)&.captures&.first + end + end + end +end 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 09d56d9b80d..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 @@ -173,6 +173,42 @@ 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 b19cd0c1471..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 } @@ -337,6 +340,16 @@ ) 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( @@ -356,5 +369,50 @@ 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 0cac7d9e0b4..dd7a511946e 100644 --- a/swift/spec/dependabot/swift/file_updater_spec.rb +++ b/swift/spec/dependabot/swift/file_updater_spec.rb @@ -260,6 +260,11 @@ 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"), From 8c14206142a632b51b9da86443aa7fece902ce16 Mon Sep 17 00:00:00 2001 From: Mark Allen Date: Mon, 16 Mar 2026 14:11:23 +0000 Subject: [PATCH 09/15] fix: ensure file content is not nil before scanning for project references --- .../lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1867abf6777..d097c450295 100644 --- a/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb +++ b/swift/lib/dependabot/swift/file_updater/xcode_lockfile_updater.rb @@ -284,7 +284,7 @@ def referenced_project_scopes_for_workspace(workspace_scope) file = workspace_files.find { |workspace_file| workspace_file.name == workspace_data_path } return Set.new unless file&.content - project_refs = file.content.scan(/location\s*=\s*"(?:group:)?([^"\n]+\.xcodeproj)"/).flatten + project_refs = T.must(file.content).scan(/location\s*=\s*"(?:group:)?([^"\n]+\.xcodeproj)"/).flatten workspace_root = File.dirname(workspace_scope) Set.new( From aa5c3f585977406d9178f448369525c346699976 Mon Sep 17 00:00:00 2001 From: Mark Allen Date: Mon, 16 Mar 2026 14:15:48 +0000 Subject: [PATCH 10/15] fix: change Sorbet type from strict to strong in xcode_file_helpers --- swift/lib/dependabot/swift/xcode_file_helpers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swift/lib/dependabot/swift/xcode_file_helpers.rb b/swift/lib/dependabot/swift/xcode_file_helpers.rb index ac12945bf1a..9cd93ef1777 100644 --- a/swift/lib/dependabot/swift/xcode_file_helpers.rb +++ b/swift/lib/dependabot/swift/xcode_file_helpers.rb @@ -1,4 +1,4 @@ -# typed: strict +# typed: strong # frozen_string_literal: true require "sorbet-runtime" From 34ac3083bb0008d8aeb2d2bd9556847d438b3a66 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Tue, 17 Mar 2026 12:57:40 -0500 Subject: [PATCH 11/15] Update comment for support files Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- swift/lib/dependabot/swift/file_parser.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swift/lib/dependabot/swift/file_parser.rb b/swift/lib/dependabot/swift/file_parser.rb index 9e48c49a4ac..3815ba53dff 100644 --- a/swift/lib/dependabot/swift/file_parser.rb +++ b/swift/lib/dependabot/swift/file_parser.rb @@ -116,7 +116,7 @@ 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( From c3e0ef52487f81d24c2fc1029113ea772592f6cf Mon Sep 17 00:00:00 2001 From: Abhishek Date: Tue, 17 Mar 2026 12:58:58 -0500 Subject: [PATCH 12/15] Update comment to match functionality Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 08718d4f3e2..aafa68c597d 100644 --- a/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb +++ b/swift/lib/dependabot/swift/file_parser/xcode_spm_resolver.rb @@ -14,8 +14,9 @@ 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 From 2063c7a45014927df19fe22d70941456b795c4b6 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Tue, 17 Mar 2026 13:05:51 -0500 Subject: [PATCH 13/15] Update loop structure for queue Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- swift/lib/dependabot/swift/file_fetcher.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/swift/lib/dependabot/swift/file_fetcher.rb b/swift/lib/dependabot/swift/file_fetcher.rb index 7644e64c574..1ce50772802 100644 --- a/swift/lib/dependabot/swift/file_fetcher.rb +++ b/swift/lib/dependabot/swift/file_fetcher.rb @@ -110,10 +110,11 @@ 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) - until queue.empty? - dir = queue.shift - next unless dir + while index < queue.length + dir = queue[index] + index += 1 next if visited[dir] visited[dir] = true @@ -129,7 +130,6 @@ def discover_dirs_with_suffix(suffix) queue << next_dir end end - end discovered.sort end From a1c6da6908f63f9d91e4697296585d87ff899db9 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Tue, 17 Mar 2026 13:14:42 -0500 Subject: [PATCH 14/15] fix sorbet errors --- swift/lib/dependabot/swift/file_fetcher.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/swift/lib/dependabot/swift/file_fetcher.rb b/swift/lib/dependabot/swift/file_fetcher.rb index 1ce50772802..354f2367dda 100644 --- a/swift/lib/dependabot/swift/file_fetcher.rb +++ b/swift/lib/dependabot/swift/file_fetcher.rb @@ -113,7 +113,7 @@ def discover_dirs_with_suffix(suffix) index = T.let(0, Integer) while index < queue.length - dir = queue[index] + dir = T.must(queue[index]) index += 1 next if visited[dir] @@ -130,6 +130,7 @@ def discover_dirs_with_suffix(suffix) queue << next_dir end end + end discovered.sort end From 42292146cd04687bfd8ea86a03d23283eb4f4d88 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Tue, 17 Mar 2026 16:09:26 -0500 Subject: [PATCH 15/15] fix regex performance issue --- .../dependabot/swift/xcode_file_helpers.rb | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/swift/lib/dependabot/swift/xcode_file_helpers.rb b/swift/lib/dependabot/swift/xcode_file_helpers.rb index 9cd93ef1777..19df736102e 100644 --- a/swift/lib/dependabot/swift/xcode_file_helpers.rb +++ b/swift/lib/dependabot/swift/xcode_file_helpers.rb @@ -8,17 +8,39 @@ module Swift module XcodeFileHelpers extend T::Sig - XCODE_RESOLVED_FILE_REGEX = %r{(?:\.xcodeproj|\.xcworkspace)/.*Package\.resolved\z} - XCODE_SCOPE_REGEX = %r{^(.*?\.(?:xcodeproj|xcworkspace))/} + XCODEPROJ_SUFFIX = ".xcodeproj/" + XCWORKSPACE_SUFFIX = ".xcworkspace/" + PACKAGE_RESOLVED = "Package.resolved" sig { params(path: String).returns(T::Boolean) } def self.xcode_resolved_path?(path) - XCODE_RESOLVED_FILE_REGEX.match?(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) - path.match(XCODE_SCOPE_REGEX)&.captures&.first + # 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