spm_version_updates 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +47 -0
- data/lib/spm_version_updates/allow_host_normalizer.rb +53 -0
- data/lib/spm_version_updates/credential_redactor.rb +16 -0
- data/lib/spm_version_updates/fail_on_threshold.rb +45 -0
- data/lib/spm_version_updates/git_host_normalizer.rb +63 -0
- data/lib/spm_version_updates/git_operations.rb +150 -0
- data/lib/spm_version_updates/manifest_parser.rb +258 -0
- data/lib/spm_version_updates/package_resolved.rb +46 -0
- data/lib/spm_version_updates/repository_link.rb +112 -0
- data/lib/spm_version_updates/repository_update_rules.rb +222 -0
- data/lib/spm_version_updates/semver.rb +55 -0
- data/lib/spm_version_updates/spm_checker.rb +532 -0
- data/lib/spm_version_updates/spm_package_context.rb +49 -0
- data/lib/spm_version_updates/update_severity.rb +67 -0
- data/lib/spm_version_updates/upgrade_suggestion.rb +62 -0
- data/lib/spm_version_updates/version.rb +5 -0
- data/lib/spm_version_updates/version_tag_fetcher.rb +110 -0
- data/lib/spm_version_updates/version_tags_persistent_cache.rb +99 -0
- data/lib/spm_version_updates/xcode_parser.rb +71 -0
- data/lib/spm_version_updates/xcode_project_package_reader.rb +132 -0
- data/lib/spm_version_updates.rb +21 -0
- data/spm_version_updates.gemspec +46 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 922e9383223719cafb033ce65ca8b710013bc1654217973443b1b1b99eead693
|
|
4
|
+
data.tar.gz: b6a5fa439183d0cfcb8192b050565020b8f314e18f573d88b8e4ce40fb619bfb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cc90410091871b58726de87d0f8e992e3abf08c75799dc1901c1afd893ca5736f39c02076b136523a3ba437a5796e51cf196abcd728e513b4e1e27dd9332945f
|
|
7
|
+
data.tar.gz: a7329c84f09b9615cf25872e4e3181694b306e717dc978b36b8edbd8334543339f68d4bd2402ae84c15e425cad2005e6ace00475c41598c36ca81309f1f3f95a
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Copyright (c) 2023-2024 Harold Martin <harold.martin@gmail.com>
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# spm_version_updates
|
|
2
|
+
|
|
3
|
+
Core library for detecting available updates to Swift Package Manager
|
|
4
|
+
dependencies. It powers both the
|
|
5
|
+
[`danger-spm_version_updates`](https://rubygems.org/gems/danger-spm_version_updates)
|
|
6
|
+
Danger plugin and the
|
|
7
|
+
[Swift Package Version Updates GitHub Action](https://github.com/hbmartin/github-action-spm_version_updates),
|
|
8
|
+
and can be used directly from any Ruby program.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
gem install spm_version_updates
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Xcode-project mode additionally requires the `xcodeproj` gem; manifest mode
|
|
17
|
+
has no extra dependencies.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "spm_version_updates"
|
|
23
|
+
|
|
24
|
+
checker = SpmChecker.new
|
|
25
|
+
|
|
26
|
+
# Manifest mode: check one or more Package.swift files (a Package.resolved
|
|
27
|
+
# next to each manifest is used automatically when present).
|
|
28
|
+
warnings = checker.check_manifests(["path/to/Package.swift"])
|
|
29
|
+
|
|
30
|
+
# Xcode mode: check the packages referenced by an Xcode project.
|
|
31
|
+
# warnings = checker.check_for_updates("path/to/App.xcodeproj")
|
|
32
|
+
|
|
33
|
+
warnings.each { |warning| puts warning }
|
|
34
|
+
|
|
35
|
+
# Structured details (repository URL, current/available version, severity,
|
|
36
|
+
# suggested update command, ...) for each warning:
|
|
37
|
+
checker.warning_details.each { |detail| p detail }
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Behavior is configurable through accessors on `SpmChecker` — for example
|
|
41
|
+
`check_when_exact`, `check_branches`, `report_above_maximum`,
|
|
42
|
+
`report_pre_releases`, `ignore_repos`, and allow-host restrictions. See the
|
|
43
|
+
class documentation for the full list.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "git_host_normalizer"
|
|
4
|
+
require_relative "git_operations"
|
|
5
|
+
|
|
6
|
+
# Normalizes user-provided allow-host entries into hostnames.
|
|
7
|
+
class AllowHostNormalizer
|
|
8
|
+
MALFORMED_SCHEME_PATTERN = %r{\A[a-z][a-z0-9+\-.]*//}i
|
|
9
|
+
|
|
10
|
+
def self.normalize(entry)
|
|
11
|
+
new(entry).normalize
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.configured_entries(entries)
|
|
15
|
+
Array(entries).filter_map { |entry|
|
|
16
|
+
value = entry.to_s.strip
|
|
17
|
+
value unless value.empty?
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(entry)
|
|
22
|
+
@raw = entry.to_s.strip
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def normalize
|
|
26
|
+
return nil if raw.empty?
|
|
27
|
+
return parsed if parsed && !malformed_scheme?
|
|
28
|
+
return fallback if fallback.match?(GitHostNormalizer::HOST_PATTERN)
|
|
29
|
+
|
|
30
|
+
warn_unparseable
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :raw
|
|
37
|
+
|
|
38
|
+
def parsed
|
|
39
|
+
@parsed ||= GitOperations.host(raw)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def fallback
|
|
43
|
+
@fallback ||= raw.sub(/:\d+\z/, "").downcase
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def malformed_scheme?
|
|
47
|
+
raw.match?(MALFORMED_SCHEME_PATTERN)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def warn_unparseable
|
|
51
|
+
warn("allow-hosts entry #{raw.inspect} could not be parsed as a host and will not match any repository URL")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Redacts credentials embedded in URL userinfo before logging or emitting data.
|
|
4
|
+
module CredentialRedactor
|
|
5
|
+
class << self
|
|
6
|
+
def redact(value)
|
|
7
|
+
value&.to_s&.gsub(%r{([a-z][a-z0-9+\-.]*://)([^/\s@]+)@}i, '\1[REDACTED]@')
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def redact_hash_value(hash, key)
|
|
11
|
+
hash.dup.tap { |copy|
|
|
12
|
+
copy[key] = redact(copy[key]) if copy.key?(key)
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "update_severity"
|
|
4
|
+
|
|
5
|
+
# Parses fail-on inputs and evaluates whether reported updates should fail.
|
|
6
|
+
module FailOnThreshold
|
|
7
|
+
ANY = "any"
|
|
8
|
+
|
|
9
|
+
def self.from_inputs(explicit_fail_on, legacy_fail_on)
|
|
10
|
+
input_name, value = [
|
|
11
|
+
["fail-on", explicit_fail_on],
|
|
12
|
+
["fail-on-updates", legacy_fail_on],
|
|
13
|
+
["fail-on-updates", "false"],
|
|
14
|
+
].find { |_name, candidate| candidate }
|
|
15
|
+
normalize(value, input_name)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.failure_message(threshold, reporter)
|
|
19
|
+
return nil unless threshold
|
|
20
|
+
|
|
21
|
+
count = failure_count(threshold, reporter)
|
|
22
|
+
count.positive? ? build_message(threshold, count) : nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.failure_count(threshold, reporter)
|
|
26
|
+
return reporter.records.size if threshold == ANY
|
|
27
|
+
|
|
28
|
+
UpdateSeverity.count_at_or_above(reporter.severity_counts, threshold)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.normalize(value, input_name)
|
|
32
|
+
normalized = value.downcase
|
|
33
|
+
return nil if ["false", "none"].include?(normalized)
|
|
34
|
+
return ANY if normalized == "true"
|
|
35
|
+
return normalized if UpdateSeverity.threshold?(normalized)
|
|
36
|
+
|
|
37
|
+
raise(ArgumentError, "#{input_name} must be false, true, major, minor, or patch")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.build_message(threshold, count)
|
|
41
|
+
plural = count == 1 ? "" : "s"
|
|
42
|
+
threshold_note = threshold == ANY ? "" : " #{threshold}+"
|
|
43
|
+
"Found #{count}#{threshold_note} SPM dependency update#{plural}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
# Extracts and normalizes hostnames from common git remote URL forms.
|
|
7
|
+
module GitHostNormalizer
|
|
8
|
+
HOST_PATTERN = /\A[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\z/i
|
|
9
|
+
BRACKETED_IPV6_PATTERN = /\A\[(?<address>[^\]]+)\](?::\d+)?\z/
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def host(repo_url)
|
|
13
|
+
url = repo_url.to_s.strip
|
|
14
|
+
return nil if url.empty?
|
|
15
|
+
|
|
16
|
+
parsed_host(url) || scp_like_host(url) || bare_host(url)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parsed_host(url)
|
|
20
|
+
normalize_host(URI.parse(url).host)
|
|
21
|
+
rescue URI::InvalidURIError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def scp_like_host(url)
|
|
26
|
+
match = url.match(%r{\A(?:[^@\s/]+@)?(?<host>[^:\s/]+):(?!/)[^:\s]+\z})
|
|
27
|
+
match && normalize_host(match[:host])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def bare_host(url)
|
|
31
|
+
return nil if url.start_with?("/", "./", "../")
|
|
32
|
+
return nil if url.include?("://")
|
|
33
|
+
|
|
34
|
+
normalize_host(url.split("/", 2).first)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def normalize_host(host)
|
|
38
|
+
normalized = normalized_ipv6_host(host)
|
|
39
|
+
return normalized if normalized
|
|
40
|
+
|
|
41
|
+
normalized = host.to_s.sub(/:\d+\z/, "").downcase
|
|
42
|
+
return normalized if normalized.match?(HOST_PATTERN)
|
|
43
|
+
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalized_ipv6_host(host)
|
|
48
|
+
raw = host.to_s.strip.downcase
|
|
49
|
+
bracketed = raw.match(BRACKETED_IPV6_PATTERN)
|
|
50
|
+
return normalize_ipv6_address(bracketed[:address]) if bracketed
|
|
51
|
+
return normalize_ipv6_address(raw) if raw.count(":") >= 2
|
|
52
|
+
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def normalize_ipv6_address(address)
|
|
57
|
+
parsed = IPAddr.new(address)
|
|
58
|
+
parsed.ipv6? ? parsed.to_s : nil
|
|
59
|
+
rescue IPAddr::Error
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "credential_redactor"
|
|
5
|
+
require_relative "git_host_normalizer"
|
|
6
|
+
require_relative "semver"
|
|
7
|
+
|
|
8
|
+
# Git operations for SPM version checking (migrated from git.rb)
|
|
9
|
+
module GitOperations
|
|
10
|
+
ALLOWED_PROTOCOLS = "https:ssh:git"
|
|
11
|
+
LS_REMOTE_RETRY_DELAYS = [0.25, 0.5].freeze
|
|
12
|
+
NON_INTERACTIVE_ENV = {
|
|
13
|
+
"GIT_ALLOW_PROTOCOL" => ALLOWED_PROTOCOLS,
|
|
14
|
+
"GIT_TERMINAL_PROMPT" => "0"
|
|
15
|
+
}.freeze
|
|
16
|
+
TAG_REF_PATTERNS = ["[0-9]*.[0-9]*", "v[0-9]*.[0-9]*"].freeze
|
|
17
|
+
|
|
18
|
+
# Raised when git cannot complete a remote reference lookup.
|
|
19
|
+
class LsRemoteError < StandardError; end
|
|
20
|
+
|
|
21
|
+
# Removes protocol and trailing .git from a repo URL
|
|
22
|
+
# @param [String] repo_url The URL of the repository
|
|
23
|
+
# @return [String]
|
|
24
|
+
def self.trim_repo_url(repo_url)
|
|
25
|
+
url = repo_url.to_s.strip
|
|
26
|
+
return "" if url.empty?
|
|
27
|
+
|
|
28
|
+
url.split("://").last.gsub(/\.git$/, "")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Extract a readable name for the repo given the url, generally org/repo
|
|
32
|
+
# @return [String]
|
|
33
|
+
def self.repo_name(repo_url)
|
|
34
|
+
match = repo_url.match(%r{([\w-]+/[\w-]+)(.git)?$})
|
|
35
|
+
if match
|
|
36
|
+
match[1] || match[0]
|
|
37
|
+
else
|
|
38
|
+
repo_url
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Extracts the hostname from common git remote URL forms.
|
|
43
|
+
# @return [String, nil]
|
|
44
|
+
def self.host(repo_url)
|
|
45
|
+
GitHostNormalizer.host(repo_url)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Call git to list tags
|
|
49
|
+
# @param [String] repo_url The URL of the dependency's repository
|
|
50
|
+
# @return [Array<SpmVersionUpdates::Semver>]
|
|
51
|
+
def self.version_tags(repo_url)
|
|
52
|
+
output = ls_remote(repo_url, options: ["--tags", "--refs"], patterns: TAG_REF_PATTERNS)
|
|
53
|
+
|
|
54
|
+
versions = output
|
|
55
|
+
.split("\n")
|
|
56
|
+
.filter_map { |line| tag_name(line) }
|
|
57
|
+
.filter_map { |line|
|
|
58
|
+
begin
|
|
59
|
+
SpmVersionUpdates::Semver.new(line)
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
}
|
|
64
|
+
versions.sort!.reverse!
|
|
65
|
+
versions
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Call git to find the last commit on a branch
|
|
69
|
+
# @param [String] repo_url The URL of the dependency's repository
|
|
70
|
+
# @param [String] branch_name The name of the branch on which to find the last commit
|
|
71
|
+
# @return [String, nil]
|
|
72
|
+
def self.branch_last_commit(repo_url, branch_name)
|
|
73
|
+
branch_ref = "refs/heads/#{branch_name}"
|
|
74
|
+
output = ls_remote(repo_url, options: ["--branches"], patterns: [branch_ref])
|
|
75
|
+
|
|
76
|
+
line = output
|
|
77
|
+
.split("\n")
|
|
78
|
+
.find { |remote_ref| remote_ref.split("\t")[1] == branch_ref }
|
|
79
|
+
line&.split("\t")&.first
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Run `git ls-remote` with an argument vector (no shell), so repository URLs
|
|
83
|
+
# are never word-split or interpreted by a shell. Raises after bounded retries
|
|
84
|
+
# instead of masking network/auth failures as "no updates available".
|
|
85
|
+
# @return [String]
|
|
86
|
+
def self.ls_remote(repo_url, options:, patterns: [])
|
|
87
|
+
attempts = 0
|
|
88
|
+
stdout = nil
|
|
89
|
+
stderr = nil
|
|
90
|
+
|
|
91
|
+
loop {
|
|
92
|
+
attempts += 1
|
|
93
|
+
stdout, stderr, status = capture_ls_remote(repo_url, options, patterns)
|
|
94
|
+
return stdout if status.success?
|
|
95
|
+
|
|
96
|
+
break if attempts >= ls_remote_attempts
|
|
97
|
+
|
|
98
|
+
sleep(LS_REMOTE_RETRY_DELAYS.fetch(attempts - 1))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
raise_ls_remote_error(failure_message(repo_url, stderr, attempts))
|
|
102
|
+
rescue Errno::ENOENT
|
|
103
|
+
raise_ls_remote_error("git command not found. Please ensure git is installed and available in your PATH.")
|
|
104
|
+
rescue SystemCallError => error
|
|
105
|
+
raise_ls_remote_error("git ls-remote failed to start: #{error.message}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.ls_remote_attempts
|
|
109
|
+
LS_REMOTE_RETRY_DELAYS.size + 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.capture_ls_remote(repo_url, options, patterns)
|
|
113
|
+
Open3.capture3(
|
|
114
|
+
NON_INTERACTIVE_ENV,
|
|
115
|
+
"git",
|
|
116
|
+
"ls-remote",
|
|
117
|
+
*options,
|
|
118
|
+
"--",
|
|
119
|
+
repo_url,
|
|
120
|
+
*patterns
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.failure_message(repo_url, stderr, attempts)
|
|
125
|
+
details = stderr.to_s.strip
|
|
126
|
+
details = "no stderr" if details.empty?
|
|
127
|
+
"git ls-remote failed for #{redact_credentials(repo_url)} after #{attempts} attempts: #{redact_credentials(details)}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.raise_ls_remote_error(message)
|
|
131
|
+
warn(message)
|
|
132
|
+
raise(LsRemoteError, message)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.tag_name(line)
|
|
136
|
+
line[%r{\A[^\t]+\trefs/tags/(.+)\z}, 1]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.redact_credentials(value)
|
|
140
|
+
CredentialRedactor.redact(value)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_class_method :ls_remote,
|
|
144
|
+
:ls_remote_attempts,
|
|
145
|
+
:capture_ls_remote,
|
|
146
|
+
:failure_message,
|
|
147
|
+
:raise_ls_remote_error,
|
|
148
|
+
:tag_name,
|
|
149
|
+
:redact_credentials
|
|
150
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "git_operations"
|
|
4
|
+
require_relative "package_resolved"
|
|
5
|
+
|
|
6
|
+
# Parses Swift Package Manager manifests (`Package.swift`) and their adjacent
|
|
7
|
+
# `Package.resolved` files.
|
|
8
|
+
#
|
|
9
|
+
# This supports the "SwiftPM-native" repo layout, where dependencies are
|
|
10
|
+
# declared directly in one or more `Package.swift` manifests rather than as
|
|
11
|
+
# `XCRemoteSwiftPackageReference` objects inside an `.xcodeproj`.
|
|
12
|
+
#
|
|
13
|
+
# Manifests are parsed with a lightweight, dependency-free scanner so the action
|
|
14
|
+
# runs on any runner (e.g. `ubuntu-latest`) without requiring Swift or a
|
|
15
|
+
# macOS/Xcode toolchain to be installed.
|
|
16
|
+
#
|
|
17
|
+
# The requirement hashes returned by {get_packages} intentionally mirror the
|
|
18
|
+
# shape produced by `Xcodeproj` for `XCRemoteSwiftPackageReference#requirement`
|
|
19
|
+
# (`"kind"`, `"minimumVersion"`, `"maximumVersion"`, `"version"`, `"branch"`,
|
|
20
|
+
# `"revision"`) so the same comparison logic can be reused for both modes.
|
|
21
|
+
module ManifestParser
|
|
22
|
+
PACKAGE_CALL = ".package("
|
|
23
|
+
|
|
24
|
+
# Find the direct SPM dependencies declared in a `Package.swift` manifest.
|
|
25
|
+
#
|
|
26
|
+
# Local packages (declared with `path:`) and packages without a recognizable
|
|
27
|
+
# version requirement are skipped.
|
|
28
|
+
#
|
|
29
|
+
# Keyed by the normalized repository URL (used to match against
|
|
30
|
+
# `Package.resolved` pins and `ignore-repos`), while the original,
|
|
31
|
+
# scheme-bearing `repository_url` is retained for git operations.
|
|
32
|
+
#
|
|
33
|
+
# @param [String] manifest_path The path to a `Package.swift` file
|
|
34
|
+
# @raise [ManifestPathMustBeSet] if the manifest_path is blank
|
|
35
|
+
# @raise [CouldNotFindManifest] if the file does not exist
|
|
36
|
+
# @return [Hash<String, Hash>] normalized URL => { "repository_url", "requirement" }
|
|
37
|
+
def self.get_packages(manifest_path)
|
|
38
|
+
raise(ManifestPathMustBeSet) if manifest_path.nil? || manifest_path.empty?
|
|
39
|
+
raise(CouldNotFindManifest, manifest_path) unless File.exist?(manifest_path)
|
|
40
|
+
|
|
41
|
+
content = strip_comments(File.read(manifest_path))
|
|
42
|
+
package_calls(content).each_with_object({}) { |call, packages|
|
|
43
|
+
url = call[/\burl\s*:\s*"([^"]+)"/, 1]
|
|
44
|
+
next if url.nil? # local package (path:) or otherwise unrecognized
|
|
45
|
+
|
|
46
|
+
requirement = requirement_for(call)
|
|
47
|
+
next if requirement.nil?
|
|
48
|
+
|
|
49
|
+
packages[GitOperations.trim_repo_url(url)] = { "repository_url" => url, "requirement" => requirement }
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Extract the resolved versions from a `Package.resolved` file.
|
|
54
|
+
#
|
|
55
|
+
# @param [String] resolved_path The path to a `Package.resolved` file
|
|
56
|
+
# @return [Hash<String, String>] normalized repository URL => version or revision
|
|
57
|
+
def self.get_resolved_versions(resolved_path)
|
|
58
|
+
PackageResolved.versions_from(resolved_path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Infer the `Package.resolved` path that sits next to a manifest.
|
|
62
|
+
#
|
|
63
|
+
# @param [String] manifest_path The path to a `Package.swift` file
|
|
64
|
+
# @return [String]
|
|
65
|
+
def self.default_resolved_path(manifest_path)
|
|
66
|
+
File.join(File.dirname(manifest_path), "Package.resolved")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Extract the argument body of each `.package( ... )` call, honoring nested
|
|
70
|
+
# parentheses (e.g. `.upToNextMajor(from: "1.0.0")`) and string literals.
|
|
71
|
+
#
|
|
72
|
+
# @param [String] content The (comment-stripped) manifest source
|
|
73
|
+
# @return [Array<String>]
|
|
74
|
+
def self.package_calls(content)
|
|
75
|
+
calls = []
|
|
76
|
+
search_start = 0
|
|
77
|
+
while (marker_index = content.index(PACKAGE_CALL, search_start))
|
|
78
|
+
open_index = marker_index + PACKAGE_CALL.length - 1
|
|
79
|
+
close_index = matching_paren(content, open_index)
|
|
80
|
+
break if close_index.nil?
|
|
81
|
+
|
|
82
|
+
calls << content[(open_index + 1)...close_index]
|
|
83
|
+
search_start = close_index + 1
|
|
84
|
+
end
|
|
85
|
+
calls
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Map the body of a `.package(...)` call to an Xcodeproj-style requirement.
|
|
89
|
+
#
|
|
90
|
+
# Ordering matters: ranges and the explicit `.upToNextMajor`/`.upToNextMinor`
|
|
91
|
+
# forms are matched before the bare `from:` shorthand because they also
|
|
92
|
+
# contain the substring `from:`.
|
|
93
|
+
#
|
|
94
|
+
# @param [String] call The body of a `.package(...)` call
|
|
95
|
+
# @return [Hash, nil]
|
|
96
|
+
def self.requirement_for(call)
|
|
97
|
+
if (range = call.match(/"([^"]+)"\s*(\.\.[.<])\s*"([^"]+)"/))
|
|
98
|
+
version_range_requirement(range[1], range[2], range[3])
|
|
99
|
+
elsif (version = call[/\.upToNextMinor\s*\(\s*from\s*:\s*"([^"]+)"/, 1])
|
|
100
|
+
{ "kind" => "upToNextMinorVersion", "minimumVersion" => version }
|
|
101
|
+
elsif (version = call[/\.upToNextMajor\s*\(\s*from\s*:\s*"([^"]+)"/, 1] || call[/\bfrom\s*:\s*"([^"]+)"/, 1])
|
|
102
|
+
{ "kind" => "upToNextMajorVersion", "minimumVersion" => version }
|
|
103
|
+
elsif (version = call[/\bexact\s*:\s*"([^"]+)"/, 1] || call[/\.exact\s*\(\s*"([^"]+)"/, 1])
|
|
104
|
+
{ "kind" => "exactVersion", "version" => version }
|
|
105
|
+
elsif (branch = call[/\bbranch\s*:\s*"([^"]+)"/, 1] || call[/\.branch\s*\(\s*"([^"]+)"/, 1])
|
|
106
|
+
{ "kind" => "branch", "branch" => branch }
|
|
107
|
+
elsif (revision = call[/\brevision\s*:\s*"([^"]+)"/, 1] || call[/\.revision\s*\(\s*"([^"]+)"/, 1])
|
|
108
|
+
{ "kind" => "revision", "revision" => revision }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Build a versionRange requirement from a Swift range literal.
|
|
113
|
+
#
|
|
114
|
+
# Xcode's `versionRange` (like Swift's `..<`) uses an exclusive maximum. SwiftPM
|
|
115
|
+
# normalizes a closed range `a...b` to the half-open range `a ..< (b + 1 patch)`,
|
|
116
|
+
# so we do the same here for `...` to keep the inclusive upper bound — otherwise
|
|
117
|
+
# version `b` would be incorrectly excluded from update checks.
|
|
118
|
+
#
|
|
119
|
+
# @return [Hash]
|
|
120
|
+
def self.version_range_requirement(minimum, range_operator, maximum)
|
|
121
|
+
maximum = increment_patch_version(maximum) if range_operator == "..."
|
|
122
|
+
{ "kind" => "versionRange", "minimumVersion" => minimum, "maximumVersion" => maximum }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Increment the patch component of an `x.y.z` version, dropping any
|
|
126
|
+
# pre-release/build suffix (matching how SwiftPM derives the exclusive upper
|
|
127
|
+
# bound of a closed range as `Version(major, minor, patch + 1)`). Returns the
|
|
128
|
+
# input unchanged if it is not a three-part version.
|
|
129
|
+
#
|
|
130
|
+
# @return [String]
|
|
131
|
+
def self.increment_patch_version(version)
|
|
132
|
+
major, minor, patch = version.match(/\A(\d+)\.(\d+)\.(\d+)/)&.captures
|
|
133
|
+
return version if patch.nil?
|
|
134
|
+
|
|
135
|
+
"#{major}.#{minor}.#{patch.to_i + 1}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Find the index of the `)` that closes the `(` at +open_index+, ignoring
|
|
139
|
+
# parentheses and the like that appear inside string literals.
|
|
140
|
+
#
|
|
141
|
+
# @return [Integer, nil]
|
|
142
|
+
def self.matching_paren(content, open_index)
|
|
143
|
+
depth = 0
|
|
144
|
+
index = open_index
|
|
145
|
+
length = content.length
|
|
146
|
+
in_string = false
|
|
147
|
+
while index < length
|
|
148
|
+
char = content[index]
|
|
149
|
+
if in_string
|
|
150
|
+
if char == "\\"
|
|
151
|
+
index += 2
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
in_string = false if char == '"'
|
|
155
|
+
elsif char == '"'
|
|
156
|
+
in_string = true
|
|
157
|
+
elsif char == "("
|
|
158
|
+
depth += 1
|
|
159
|
+
elsif char == ")"
|
|
160
|
+
depth -= 1
|
|
161
|
+
return index if depth.zero?
|
|
162
|
+
end
|
|
163
|
+
index += 1
|
|
164
|
+
end
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Remove `//` line comments and `/* */` block comments while leaving string
|
|
169
|
+
# literals (e.g. URLs containing `//`) untouched.
|
|
170
|
+
#
|
|
171
|
+
# @param [String] content The raw manifest source
|
|
172
|
+
# @return [String]
|
|
173
|
+
def self.strip_comments(content)
|
|
174
|
+
output = +""
|
|
175
|
+
index = 0
|
|
176
|
+
length = content.length
|
|
177
|
+
while index < length
|
|
178
|
+
char = content[index]
|
|
179
|
+
nxt = content[index + 1]
|
|
180
|
+
if char == '"'
|
|
181
|
+
index = copy_string_literal(content, index, output)
|
|
182
|
+
elsif char == "/" && nxt == "/"
|
|
183
|
+
index += 1 while index < length && content[index] != "\n"
|
|
184
|
+
elsif char == "/" && nxt == "*"
|
|
185
|
+
index = skip_block_comment(content, index)
|
|
186
|
+
else
|
|
187
|
+
output << char
|
|
188
|
+
index += 1
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
output
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Copy a double-quoted string literal verbatim into +output+, respecting
|
|
195
|
+
# backslash escapes, and return the index just past the closing quote.
|
|
196
|
+
#
|
|
197
|
+
# @return [Integer]
|
|
198
|
+
def self.copy_string_literal(content, index, output)
|
|
199
|
+
length = content.length
|
|
200
|
+
output << content[index] # opening quote
|
|
201
|
+
index += 1
|
|
202
|
+
while index < length
|
|
203
|
+
char = content[index]
|
|
204
|
+
output << char
|
|
205
|
+
if char == "\\"
|
|
206
|
+
output << content[index + 1] if index + 1 < length
|
|
207
|
+
index += 2
|
|
208
|
+
next
|
|
209
|
+
end
|
|
210
|
+
index += 1
|
|
211
|
+
break if char == '"'
|
|
212
|
+
end
|
|
213
|
+
index
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Return the index just past the closing `*/` of a block comment. Swift block
|
|
217
|
+
# comments nest, so depth is tracked: `/* a /* b */ c */` is a single comment.
|
|
218
|
+
#
|
|
219
|
+
# @return [Integer]
|
|
220
|
+
def self.skip_block_comment(content, index)
|
|
221
|
+
length = content.length
|
|
222
|
+
depth = 1
|
|
223
|
+
index += 2 # skip the opening "/*"
|
|
224
|
+
while index < length && depth.positive?
|
|
225
|
+
if content[index] == "/" && content[index + 1] == "*"
|
|
226
|
+
depth += 1
|
|
227
|
+
index += 2
|
|
228
|
+
elsif content[index] == "*" && content[index + 1] == "/"
|
|
229
|
+
depth -= 1
|
|
230
|
+
index += 2
|
|
231
|
+
else
|
|
232
|
+
index += 1
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
index
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private_class_method :package_calls,
|
|
239
|
+
:requirement_for,
|
|
240
|
+
:version_range_requirement,
|
|
241
|
+
:increment_patch_version,
|
|
242
|
+
:matching_paren,
|
|
243
|
+
:strip_comments,
|
|
244
|
+
:copy_string_literal,
|
|
245
|
+
:skip_block_comment
|
|
246
|
+
|
|
247
|
+
# Raised when manifest mode is invoked without a manifest path.
|
|
248
|
+
class ManifestPathMustBeSet < StandardError
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Raised when a configured Package.swift manifest is missing.
|
|
252
|
+
class CouldNotFindManifest < StandardError
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Raised when manifest mode cannot find an expected Package.resolved file.
|
|
256
|
+
class CouldNotFindResolvedFile < StandardError
|
|
257
|
+
end
|
|
258
|
+
end
|