Module: Kettle::Gha::Pins::VersionRubric

Defined in:
lib/kettle/gha/pins/version_rubric.rb

Overview

Shared version ordering and upgrade selection rules for GitHub Action pins.

Constant Summary collapse

VALID_UPGRADE_LEVELS =
%w[major minor patch].freeze
DEFAULT_UPGRADE_LEVEL =
"major"
RELEASE_TAG_RE =
/\A(?:\d+|\d+\.\d+(?:\.\d+)?(?:[-.]?[0-9A-Za-z.-]+)?)\z/

Class Method Summary collapse

Class Method Details

.allowed_by_level?(current_version, current, entry, level) ⇒ Boolean

Returns:

  • (Boolean)


181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/kettle/gha/pins/version_rubric.rb', line 181

def allowed_by_level?(current_version, current, entry, level)
  return true if level == "major"
  return false if major_line?(entry.fetch(:version))
  return false if major_line?(current_version)

  case level
  when "patch"
    entry.fetch(:version_obj).segments[0, 2] == current.segments[0, 2]
  when "minor"
    entry.fetch(:version_obj).segments[0] == current.segments[0]
  else
    true
  end
end

.build_release_versions(release_tags:, tag_shas:, release_dates: {}) ⇒ Object



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/kettle/gha/pins/version_rubric.rb', line 40

def build_release_versions(release_tags:, tag_shas:, release_dates: {})
  released_tags = release_tags.each_with_object({}) { |tag, memo| memo[tag.to_s] = true }
  releases = release_tags.each_with_object([]) do |tag, memo|
    release_entry = entry(tag: tag, sha: tag_shas[tag.to_s], released_at: release_dates[tag.to_s])
    memo << release_entry if release_entry
  end
  tag_versions = tag_shas.each_with_object([]) do |(tag, sha), memo|
    next if released_tags[tag.to_s]

    tag_entry = entry(tag: tag, sha: sha)
    memo << tag_entry if tag_entry
  end

  sort_versions(canonicalize_equivalent_release_versions(releases + tag_versions)).reverse
end

.canonical_version_for(resolved_version, known_versions, resolved_sha: nil, comment_version: nil) ⇒ Object

Canonical spellings are version-specific and SHA-aware.

  • For SHA-tracked tags, prefer the most explicit spelling that resolves to the same SHA as the currently resolved target.
  • If the resolved SHA is known but the currently-commented spellings are tied to a different SHA, keep the in-file spelling when the semantic version is unchanged, so we don't rewrite between aliases that no longer represent the same object.
  • Otherwise fall back to the resolved version form.


144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/kettle/gha/pins/version_rubric.rb', line 144

def canonical_version_for(resolved_version, known_versions, resolved_sha: nil, comment_version: nil)
  resolved = parse(resolved_version)
  return resolved_version.to_s unless resolved

  equivalents = entries_for_semantic_version(known_versions, resolved)
  if !resolved_sha.to_s.empty?
    sha_matched_equivalents = equivalents.select do |entry|
      entry.fetch(:sha).to_s == resolved_sha.to_s
    end
    equivalents = sha_matched_equivalents unless sha_matched_equivalents.empty?

    if sha_matched_equivalents.empty? &&
        comment_version &&
        parse(comment_version) == resolved
      return strip_leading_v(comment_version)
    end
  end
  preferred = equivalents.max_by { |entry| sort_key(entry) }
  preferred ? preferred[:version] : resolved.to_s
end

.canonicalize_equivalent_release_versions(releases) ⇒ Object



56
57
58
59
60
61
# File 'lib/kettle/gha/pins/version_rubric.rb', line 56

def canonicalize_equivalent_release_versions(releases)
  releases.each_with_object([]) do |release, groups|
    group = groups.find { |entries| equivalent_release_tag?(entries.first, release) }
    group ? group << release : groups << [release]
  end.map { |entries| entries.max_by { |entry| sort_key(entry) } }
end

.choose_upgrade_target(current_version, versions, level) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/kettle/gha/pins/version_rubric.rb', line 84

def choose_upgrade_target(current_version, versions, level)
  normalized_level = normalize_upgrade_level(level)
  current = parse(current_version)
  return nil if current.nil?
  return nil if normalized_level != "major" && major_line?(current_version)

  versions.select do |entry|
    entry.fetch(:version_obj, nil).is_a?(Gem::Version) &&
      entry.fetch(:version_obj) > current &&
      (!entry.fetch(:version_obj).prerelease? || current.prerelease?) &&
      allowed_by_level?(current_version, current, entry, normalized_level)
  end.max_by { |entry| sort_key(entry) }
end

.comment_update_version(comment_version, resolved_version, resolved_sha: nil, known_versions: []) ⇒ Object

The comment on uses: is a cached record that needs to do two things:

  1. Track the currently resolved target (if the target changes, update the comment even when both values parse to version objects).
  2. Normalize equivalent spellings (v2, v2.0, v2.0.0...) to the most specific canonical form available for that SHA in known_versions.


115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/kettle/gha/pins/version_rubric.rb', line 115

def comment_update_version(comment_version, resolved_version, resolved_sha: nil, known_versions: [])
  comment = parse(comment_version)
  resolved = parse(resolved_version)
  return nil unless comment && resolved

  canonical = canonical_version_for(
    resolved_version,
    known_versions,
    resolved_sha: resolved_sha,
    comment_version: comment_version
  )
  return nil if comment_version.to_s == canonical

  canonical
end

.entries_for_semantic_version(versions, version_obj) ⇒ Object



131
132
133
# File 'lib/kettle/gha/pins/version_rubric.rb', line 131

def entries_for_semantic_version(versions, version_obj)
  versions.select { |entry| entry.fetch(:version_obj, nil) == version_obj }
end

.entry(tag:, sha: nil, released_at: nil) ⇒ Object



27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/kettle/gha/pins/version_rubric.rb', line 27

def entry(tag:, sha: nil, released_at: nil)
  version_obj = parse(tag)
  return nil unless version_obj

  {
    tag: tag.to_s,
    version_obj: version_obj,
    version: version_obj.to_s,
    sha: sha,
    released_at: released_at.to_s
  }
end

.equivalent_release_tag?(left, right) ⇒ Boolean

Returns:

  • (Boolean)


174
175
176
177
178
179
# File 'lib/kettle/gha/pins/version_rubric.rb', line 174

def equivalent_release_tag?(left, right)
  left[:version_obj] == right[:version_obj] &&
    left[:sha] &&
    right[:sha] &&
    left[:sha] == right[:sha]
end

.latest_outdated_target(current_version, versions) ⇒ Object



98
99
100
101
102
103
104
105
106
107
# File 'lib/kettle/gha/pins/version_rubric.rb', line 98

def latest_outdated_target(current_version, versions)
  current = parse(current_version)
  return nil if current.nil?

  versions.select do |entry|
    entry.fetch(:version_obj, nil).is_a?(Gem::Version) &&
      entry.fetch(:version_obj) > current &&
      (!entry.fetch(:version_obj).prerelease? || current.prerelease?)
  end.max_by { |entry| sort_key(entry) }
end

.major_line?(value) ⇒ Boolean

Returns:

  • (Boolean)


23
24
25
# File 'lib/kettle/gha/pins/version_rubric.rb', line 23

def major_line?(value)
  value.to_s.match?(/\A\d+\z/)
end

.normalize_upgrade_level(level) ⇒ Object



169
170
171
172
# File 'lib/kettle/gha/pins/version_rubric.rb', line 169

def normalize_upgrade_level(level)
  normalized = level.to_s.downcase
  VALID_UPGRADE_LEVELS.include?(normalized) ? normalized : DEFAULT_UPGRADE_LEVEL
end

.parse(value) ⇒ Object



14
15
16
17
18
19
20
21
# File 'lib/kettle/gha/pins/version_rubric.rb', line 14

def parse(value)
  normalized = value.to_s.sub(/\A[vV]/, "")
  return nil unless normalized.match?(RELEASE_TAG_RE)

  Gem::Version.new(normalized)
rescue ArgumentError
  nil
end

.sort_key(entry) ⇒ Object



67
68
69
# File 'lib/kettle/gha/pins/version_rubric.rb', line 67

def sort_key(entry)
  [entry.fetch(:version_obj), *specificity(entry)]
end

.sort_versions(versions) ⇒ Object



63
64
65
# File 'lib/kettle/gha/pins/version_rubric.rb', line 63

def sort_versions(versions)
  versions.sort_by { |entry| sort_key(entry) }
end

.specificity(entry) ⇒ Object



71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/kettle/gha/pins/version_rubric.rb', line 71

def specificity(entry)
  text = entry.fetch(:tag).to_s.sub(/\A[vV]/, "")
  release_text, suffix = text.split(/[-.](?=[A-Za-z])/, 2)
  numeric_segments = release_text.to_s.split(".").take_while { |part| part.match?(/\A\d+\z/) }

  [
    numeric_segments.length,
    suffix ? 0 : 1,
    text.length,
    entry.fetch(:tag).to_s
  ]
end

.strip_leading_v(text) ⇒ Object



165
166
167
# File 'lib/kettle/gha/pins/version_rubric.rb', line 165

def strip_leading_v(text)
  text.to_s.sub(/\A[vV]/, "")
end