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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "git_operations"
|
|
5
|
+
|
|
6
|
+
# Parsing for `Package.resolved` files.
|
|
7
|
+
#
|
|
8
|
+
# Handles both the v1 format (pins nested under `"object"`) and the v2+ format
|
|
9
|
+
# (pins at the top level). This is shared between the Xcode-project source mode
|
|
10
|
+
# and the Swift package manifest source mode.
|
|
11
|
+
module PackageResolved
|
|
12
|
+
# Raised when a `Package.resolved` file exists but is not valid JSON.
|
|
13
|
+
class MalformedFileError < StandardError
|
|
14
|
+
attr_reader :path
|
|
15
|
+
|
|
16
|
+
def initialize(path, parse_message)
|
|
17
|
+
@path = path
|
|
18
|
+
super("Malformed Package.resolved at #{path}: #{parse_message}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Extract the resolved version (or revision, when no version is pinned) for
|
|
23
|
+
# every pin in a `Package.resolved` file.
|
|
24
|
+
#
|
|
25
|
+
# @param [String] path The path to a `Package.resolved` file
|
|
26
|
+
# @raise [MalformedFileError] if the file is not valid JSON
|
|
27
|
+
# @return [Hash<String, String>] normalized repository URL => version or revision
|
|
28
|
+
def self.versions_from(path)
|
|
29
|
+
contents = load_contents(path)
|
|
30
|
+
pins = contents["pins"] || contents.dig("object", "pins") || []
|
|
31
|
+
pins.to_h { |pin|
|
|
32
|
+
[
|
|
33
|
+
GitOperations.trim_repo_url(pin["location"] || pin["repositoryURL"]),
|
|
34
|
+
pin.dig("state", "version") || pin.dig("state", "revision"),
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.load_contents(path)
|
|
40
|
+
JSON.load_file!(path)
|
|
41
|
+
rescue JSON::ParserError => error
|
|
42
|
+
raise(MalformedFileError.new(path, error.message))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private_class_method :load_contents
|
|
46
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
# Parses supported git remote URLs and renders host-specific links.
|
|
6
|
+
class RepositoryLink
|
|
7
|
+
HOSTS = {
|
|
8
|
+
"github.com" => {
|
|
9
|
+
path_normalizer: ->(segments) { segments.first(2).join("/") if segments.size >= 2 },
|
|
10
|
+
compare: ->(current, available) { "/compare/#{current}...#{available}" },
|
|
11
|
+
release: ["Releases", "/releases"]
|
|
12
|
+
},
|
|
13
|
+
"gitlab.com" => {
|
|
14
|
+
path_normalizer: ->(segments) { segments.join("/").sub(%r{/-/.*\z}, "").then { |path| path if path.count("/") >= 1 } },
|
|
15
|
+
compare: ->(current, available) { "/-/compare/#{current}...#{available}" },
|
|
16
|
+
release: ["Releases", "/-/releases"]
|
|
17
|
+
},
|
|
18
|
+
"bitbucket.org" => {
|
|
19
|
+
path_normalizer: ->(segments) { segments.first(2).join("/") if segments.size >= 2 },
|
|
20
|
+
compare: ->(current, available) { "/branches/compare/#{available}..#{current}" },
|
|
21
|
+
release: ["Tags", "/downloads/?tab=tags"]
|
|
22
|
+
}
|
|
23
|
+
}.freeze
|
|
24
|
+
SUPPORTED_HOSTS_PATTERN = Regexp.union(HOSTS.keys).source
|
|
25
|
+
REMOTE_PATTERNS = [
|
|
26
|
+
%r{\A(?:https?|git|ssh)://(?:[^@/\s]+@)?(?<host>#{SUPPORTED_HOSTS_PATTERN})(?::\d+)?/(?<path>.+)\z}i,
|
|
27
|
+
%r{\A(?:[^@/\s]+@)?(?<host>#{SUPPORTED_HOSTS_PATTERN})[:/](?<path>.+)\z}i,
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
private_constant :HOSTS,
|
|
31
|
+
:SUPPORTED_HOSTS_PATTERN,
|
|
32
|
+
:REMOTE_PATTERNS
|
|
33
|
+
|
|
34
|
+
def self.from(repository_url)
|
|
35
|
+
link = new(repository_url)
|
|
36
|
+
link if link.valid?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(repository_url)
|
|
40
|
+
@value = repository_url.to_s.strip
|
|
41
|
+
@host = nil
|
|
42
|
+
@raw_path = nil
|
|
43
|
+
@path = nil
|
|
44
|
+
configure_remote(remote_match)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def valid?
|
|
48
|
+
@host && @path
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def compare_url(current, available)
|
|
52
|
+
current_ref = URI.encode_www_form_component(current.to_s)
|
|
53
|
+
available_ref = URI.encode_www_form_component(available.to_s)
|
|
54
|
+
"#{base_url}#{link_builder.fetch(:compare).call(current_ref, available_ref)}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def release_link
|
|
58
|
+
label, path = link_builder.fetch(:release)
|
|
59
|
+
"[#{label}](#{base_url}#{path})"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def markdown_links(updates, separator: "<br>")
|
|
63
|
+
compare_links = updates.map.with_index(1) { |update, index|
|
|
64
|
+
label = updates.size == 1 ? "Compare" : "Compare #{index}"
|
|
65
|
+
"[#{label}](#{compare_url(update[:current], update[:available])})"
|
|
66
|
+
}
|
|
67
|
+
(compare_links + [release_link]).join(separator)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def remote_match
|
|
73
|
+
REMOTE_PATTERNS.each { |pattern|
|
|
74
|
+
match = @value.match(pattern)
|
|
75
|
+
return match if match
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def configure_remote(match)
|
|
82
|
+
return unless match
|
|
83
|
+
|
|
84
|
+
@host = match[:host].downcase
|
|
85
|
+
@raw_path = match[:path]
|
|
86
|
+
@path = normalized_path
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalized_path
|
|
90
|
+
link_builder.fetch(:path_normalizer).call(path_segments)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def path_segments
|
|
94
|
+
@raw_path.to_s
|
|
95
|
+
.split(/[?#]/, 2)
|
|
96
|
+
.first
|
|
97
|
+
.to_s
|
|
98
|
+
.sub(%r{\A/+}, "")
|
|
99
|
+
.sub(%r{/+\z}, "")
|
|
100
|
+
.sub(/\.git\z/i, "")
|
|
101
|
+
.split("/")
|
|
102
|
+
.reject(&:empty?)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def base_url
|
|
106
|
+
"https://#{@host}/#{@path}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def link_builder
|
|
110
|
+
HOSTS.fetch(@host)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "git_operations"
|
|
5
|
+
require_relative "semver"
|
|
6
|
+
require_relative "update_severity"
|
|
7
|
+
|
|
8
|
+
# Loads and evaluates per-repository semantic update suppression rules.
|
|
9
|
+
class RepositoryUpdateRules
|
|
10
|
+
SEMANTIC_TYPES = ["version", "above_maximum"].freeze
|
|
11
|
+
SEVERITY_RANK = {
|
|
12
|
+
"patch" => 0,
|
|
13
|
+
"minor" => 1,
|
|
14
|
+
"major" => 2
|
|
15
|
+
}.freeze
|
|
16
|
+
YAML_KEYS = {
|
|
17
|
+
repositories: "repositories",
|
|
18
|
+
url: "url",
|
|
19
|
+
ignore_until: "ignore-until",
|
|
20
|
+
allowed_updates: "allowed-updates"
|
|
21
|
+
}.freeze
|
|
22
|
+
VALID_YAML_KEYS = {
|
|
23
|
+
root: [YAML_KEYS.fetch(:repositories)],
|
|
24
|
+
entry: YAML_KEYS.values_at(:url, :ignore_until, :allowed_updates)
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# One normalized repository rule from repo-rules YAML.
|
|
28
|
+
Rule = Struct.new(:normalized_url, :ignore_until, :allowed_updates, keyword_init: true) {
|
|
29
|
+
def suppressed?(record)
|
|
30
|
+
return true if suppress_until_version?(record)
|
|
31
|
+
return true if suppress_disallowed_severity?(record)
|
|
32
|
+
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def suppress_until_version?(record)
|
|
39
|
+
return false unless ignore_until
|
|
40
|
+
|
|
41
|
+
available = RepositoryUpdateRules.semver(RepositoryUpdateRules.record_value(record, "available_version"))
|
|
42
|
+
available && available < ignore_until
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def suppress_disallowed_severity?(record)
|
|
46
|
+
return false unless allowed_updates
|
|
47
|
+
|
|
48
|
+
severity = UpdateSeverity.for_versions(
|
|
49
|
+
RepositoryUpdateRules.record_value(record, "current_version"),
|
|
50
|
+
RepositoryUpdateRules.record_value(record, "available_version")
|
|
51
|
+
)
|
|
52
|
+
severity && SEVERITY_RANK.fetch(severity) > SEVERITY_RANK.fetch(allowed_updates)
|
|
53
|
+
end
|
|
54
|
+
}
|
|
55
|
+
private_constant :Rule
|
|
56
|
+
|
|
57
|
+
def self.empty
|
|
58
|
+
new({})
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.load_file(path)
|
|
62
|
+
path = validated_file_path(path)
|
|
63
|
+
yaml_config = YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
|
|
64
|
+
from_hash(yaml_config, source: path)
|
|
65
|
+
rescue Psych::Exception => error
|
|
66
|
+
raise(ArgumentError, "repo-rules YAML is invalid in #{path}: #{error.message}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.from_hash(config = {}, source: "repo rules", **keyword_config)
|
|
70
|
+
effective_config = keyword_config.empty? ? config : keyword_config
|
|
71
|
+
effective_config ||= {}
|
|
72
|
+
raise(ArgumentError, "#{source} must contain a YAML mapping") unless effective_config.kind_of?(Hash)
|
|
73
|
+
|
|
74
|
+
new(parse_repositories(repositories_from(effective_config, source), source))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.semver(value)
|
|
78
|
+
SpmVersionUpdates::Semver.new(value.to_s)
|
|
79
|
+
rescue ArgumentError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.parse_repositories(repositories, source)
|
|
84
|
+
repositories.each_with_object({}).with_index(1) { |(entry, rules), index|
|
|
85
|
+
rule = parse_entry(entry, "#{source} repositories[#{index}]")
|
|
86
|
+
normalized_url = rule.normalized_url
|
|
87
|
+
raise(ArgumentError, "duplicate repo-rules entry for #{normalized_url}") if rules.key?(normalized_url)
|
|
88
|
+
|
|
89
|
+
rules[normalized_url] = rule
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.parse_entry(entry, source)
|
|
94
|
+
string_keys = rule_entry_from(entry, source)
|
|
95
|
+
rule = rule_attributes(string_keys, source)
|
|
96
|
+
|
|
97
|
+
Rule.new(**rule)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.validated_file_path(path)
|
|
101
|
+
path = path.to_s.strip
|
|
102
|
+
raise(ArgumentError, "repo-rules-path was set but no file path was provided") if path.empty?
|
|
103
|
+
raise(ArgumentError, "repo-rules-path file does not exist: #{path}") unless File.file?(path)
|
|
104
|
+
|
|
105
|
+
path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.repositories_from(config, source)
|
|
109
|
+
string_keys = config.transform_keys(&:to_s)
|
|
110
|
+
validate_keys!(string_keys, VALID_YAML_KEYS.fetch(:root), "#{source} root")
|
|
111
|
+
repositories = string_keys.compact.fetch(yaml_key(:repositories), [])
|
|
112
|
+
raise(ArgumentError, "#{source} repositories must be a list") unless repositories.kind_of?(Array)
|
|
113
|
+
|
|
114
|
+
repositories
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.rule_entry_from(entry, source)
|
|
118
|
+
raise(ArgumentError, "#{source} must be a mapping") unless entry.kind_of?(Hash)
|
|
119
|
+
|
|
120
|
+
entry.transform_keys(&:to_s).tap { |string_keys| validate_keys!(string_keys, VALID_YAML_KEYS.fetch(:entry), source) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.rule_attributes(string_keys, source)
|
|
124
|
+
normalized_url = normalized_url_for(required_value(string_keys, yaml_key(:url), source))
|
|
125
|
+
ignore_until = parse_ignore_until(string_keys, source)
|
|
126
|
+
allowed_updates = parse_allowed_updates(string_keys, source)
|
|
127
|
+
raise(ArgumentError, "#{source} must set #{yaml_key(:ignore_until)} or #{yaml_key(:allowed_updates)}") unless ignore_until || allowed_updates
|
|
128
|
+
|
|
129
|
+
{ normalized_url:, ignore_until:, allowed_updates: }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.validate_keys!(values, allowed, source)
|
|
133
|
+
unknown = values.keys - allowed
|
|
134
|
+
return if unknown.empty?
|
|
135
|
+
|
|
136
|
+
raise(ArgumentError, "#{source} contains unknown key(s): #{unknown.join(', ')}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.required_value(values, key, source)
|
|
140
|
+
value = values[key].to_s.strip
|
|
141
|
+
raise(ArgumentError, "#{source} #{key} must be set") if value.empty?
|
|
142
|
+
|
|
143
|
+
value
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.normalized_url_for(value)
|
|
147
|
+
normalized = GitOperations.trim_repo_url(value)
|
|
148
|
+
raise(ArgumentError, "repo-rules url must normalize to a repository URL") if normalized.empty?
|
|
149
|
+
|
|
150
|
+
normalized
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.parse_ignore_until(values, source)
|
|
154
|
+
key = yaml_key(:ignore_until)
|
|
155
|
+
return unless values.key?(key)
|
|
156
|
+
|
|
157
|
+
semver(values[key].to_s.strip).tap { |version|
|
|
158
|
+
raise(ArgumentError, "#{source} #{key} must be a semantic version") unless version
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.parse_allowed_updates(values, source)
|
|
163
|
+
key = yaml_key(:allowed_updates)
|
|
164
|
+
return unless values.key?(key)
|
|
165
|
+
|
|
166
|
+
value = values[key].to_s.strip.downcase
|
|
167
|
+
return value if SEVERITY_RANK.key?(value)
|
|
168
|
+
|
|
169
|
+
raise(ArgumentError, "#{source} #{key} must be patch, minor, or major")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.record_value(record, key)
|
|
173
|
+
record[key] || record[key.to_sym]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.yaml_key(name)
|
|
177
|
+
YAML_KEYS.fetch(name)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private_class_method(
|
|
181
|
+
:parse_repositories,
|
|
182
|
+
:parse_entry,
|
|
183
|
+
:validated_file_path,
|
|
184
|
+
:repositories_from,
|
|
185
|
+
:rule_entry_from,
|
|
186
|
+
:rule_attributes,
|
|
187
|
+
:validate_keys!,
|
|
188
|
+
:required_value,
|
|
189
|
+
:normalized_url_for,
|
|
190
|
+
:parse_ignore_until,
|
|
191
|
+
:parse_allowed_updates,
|
|
192
|
+
:yaml_key
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def initialize(rules_by_repo)
|
|
196
|
+
@rules_by_repo = rules_by_repo
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def empty?
|
|
200
|
+
@rules_by_repo.empty?
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def suppressed?(record)
|
|
204
|
+
return false unless semantic_record?(record)
|
|
205
|
+
|
|
206
|
+
rule = @rules_by_repo[normalized_record_url(record)]
|
|
207
|
+
rule ? rule.suppressed?(record) : false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def semantic_record?(record)
|
|
213
|
+
SEMANTIC_TYPES.include?(self.class.record_value(record, "type").to_s)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def normalized_record_url(record)
|
|
217
|
+
rules = self.class
|
|
218
|
+
GitOperations.trim_repo_url(
|
|
219
|
+
rules.record_value(record, "normalized_url") || rules.record_value(record, "repository_url")
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "semverify"
|
|
4
|
+
|
|
5
|
+
module SpmVersionUpdates
|
|
6
|
+
# SemVer value object used by both the GitHub Action and legacy Danger plugin.
|
|
7
|
+
class Semver
|
|
8
|
+
include Comparable
|
|
9
|
+
|
|
10
|
+
def self.normalize(value)
|
|
11
|
+
value.to_s
|
|
12
|
+
.sub(/\Av(?=\d)/, "")
|
|
13
|
+
.sub(/\A(\d+)\.(\d+)(?=\z|[-+])/, '\1.\2.0')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(value)
|
|
17
|
+
@version = Semverify::Semver.new(self.class.normalize(value))
|
|
18
|
+
rescue Semverify::Error => error
|
|
19
|
+
raise(ArgumentError, error.message)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def <=>(other)
|
|
23
|
+
semver_class = self.class
|
|
24
|
+
other_version = other.kind_of?(semver_class) ? other.version : semver_class.new(other).version
|
|
25
|
+
version <=> other_version
|
|
26
|
+
rescue ArgumentError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def major
|
|
31
|
+
version.major.to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def minor
|
|
35
|
+
version.minor.to_i
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def patch
|
|
39
|
+
version.patch.to_i
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def pre
|
|
43
|
+
version.pre_release.to_s.then { |value| value.empty? ? nil : value }
|
|
44
|
+
end
|
|
45
|
+
alias pre_release pre
|
|
46
|
+
|
|
47
|
+
def to_s
|
|
48
|
+
version.to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
protected
|
|
52
|
+
|
|
53
|
+
attr_reader :version
|
|
54
|
+
end
|
|
55
|
+
end
|