|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require 'digest' |
| 4 | +require 'json' |
| 5 | +require 'octokit' |
| 6 | +require 'open-uri' |
| 7 | +require 'rake' |
| 8 | +require 'rubygems/version' |
| 9 | +require 'uri' |
| 10 | + |
| 11 | +COMPONENTS_JSON_GLOB = File.join(File.expand_path('..', __dir__), 'configs', 'components', '*.json') |
| 12 | + |
| 13 | +def github_client |
| 14 | + @github_client ||= Octokit::Client.new(access_token: ENV['GITHUB_TOKEN']).tap do |client| |
| 15 | + client.auto_paginate = true |
| 16 | + end |
| 17 | +end |
| 18 | + |
| 19 | +# Extract GitHub owner and repo from a URL string. |
| 20 | +# Returns [owner, repo] or nil if not a GitHub URL. |
| 21 | +def github_owner_repo(url) |
| 22 | + return nil unless url.to_s =~ %r{github\.com/([^/]+)/([^/\s?#]+)} |
| 23 | + |
| 24 | + [Regexp.last_match(1), Regexp.last_match(2).sub(/\.git$/, '')] |
| 25 | +end |
| 26 | + |
| 27 | +# Normalize a version string by stripping common tag prefixes. |
| 28 | +def normalize_version(tag) |
| 29 | + tag.sub(/\Av(?=\d)/, '') |
| 30 | + .sub(/\Arefs\/tags\/v?/, '') |
| 31 | + .sub(/\Arelease-/, '') |
| 32 | + .sub(/\Aopenssl-/, '') |
| 33 | +end |
| 34 | + |
| 35 | +# Try to parse a version from a normalized string, returning nil if unparseable. |
| 36 | +def try_version(str) |
| 37 | + Gem::Version.new(str) |
| 38 | +rescue ArgumentError |
| 39 | + nil |
| 40 | +end |
| 41 | + |
| 42 | +def latest_github_release(owner, repo) |
| 43 | + github_client.latest_release("#{owner}/#{repo}") |
| 44 | +rescue StandardError => e |
| 45 | + warn " Warning: could not fetch latest release for #{owner}/#{repo}: #{e}" |
| 46 | + nil |
| 47 | +end |
| 48 | + |
| 49 | +# *sighs* this looks a bit complicated. some repos have weird tags like release-$ver or $major_$minor_$patch |
| 50 | +# and somme people, like openssl, maintain multiple streams. So the latest tag might not be the highest version |
| 51 | +# We do some version comparison to find the actual highest version |
| 52 | +def latest_github_tag(owner, repo) |
| 53 | + github_client.tags("#{owner}/#{repo}", per_page: 100) |
| 54 | + .map { |tag| [tag.name, try_version(normalize_version(tag.name))] } |
| 55 | + .reject { |_, version| version.nil? || version.prerelease? } |
| 56 | + .max_by { |_, version| version } |
| 57 | + .first |
| 58 | +end |
| 59 | + |
| 60 | +def current_version(data) |
| 61 | + if data['ref'].to_s =~ %r{refs/tags/(.*)} |
| 62 | + normalize_version(Regexp.last_match(1)) |
| 63 | + else |
| 64 | + normalize_version(data['version']) |
| 65 | + end |
| 66 | +end |
| 67 | + |
| 68 | +def latest_upstream_tag(owner, repo, ref) |
| 69 | + if ref.nil? |
| 70 | + latest_github_release(owner, repo)&.tag_name || latest_github_tag(owner, repo) |
| 71 | + else |
| 72 | + latest_github_tag(owner, repo) |
| 73 | + end |
| 74 | +end |
| 75 | + |
| 76 | +def download_digest(url) |
| 77 | + digest = Digest::SHA256.new |
| 78 | + |
| 79 | + OpenURI.open_uri(url, 'rb') do |io| |
| 80 | + digest << io.read(1024 * 16) until io.eof? |
| 81 | + end |
| 82 | + |
| 83 | + digest.hexdigest |
| 84 | +end |
| 85 | + |
| 86 | +def updated_release_url(current_url:, current_version:, latest_version:, latest_tag:, release:) |
| 87 | + uri = URI(current_url) |
| 88 | + updated_path = uri.path.sub(%r{(/releases/download/)[^/]+(/)}, "\\1#{latest_tag}\\2") |
| 89 | + updated_path = updated_path.gsub(current_version, latest_version) unless current_version.empty? |
| 90 | + asset_name = File.basename(updated_path) |
| 91 | + release_asset = release&.assets&.find { |asset| asset.name == asset_name } |
| 92 | + |
| 93 | + return release_asset.browser_download_url if release_asset |
| 94 | + |
| 95 | + uri.path = updated_path |
| 96 | + uri.to_s |
| 97 | +end |
| 98 | + |
| 99 | +def update_component_file(path, latest_tag:, latest_version:) |
| 100 | + data = JSON.parse(File.read(path)) |
| 101 | + owner, repo = github_owner_repo(data['url']) |
| 102 | + release = latest_github_release(owner, repo) |
| 103 | + current_ver = current_version(data) |
| 104 | + updated = false |
| 105 | + |
| 106 | + if data.key?('version') && data['version'] != latest_version |
| 107 | + data['version'] = latest_version |
| 108 | + updated = true |
| 109 | + end |
| 110 | + |
| 111 | + desired_ref = "refs/tags/#{latest_tag}" |
| 112 | + if data.key?('ref') && data['ref'] != desired_ref |
| 113 | + data['ref'] = desired_ref |
| 114 | + updated = true |
| 115 | + end |
| 116 | + |
| 117 | + if data['url'].include?('github.com') && data['url'].include?('/releases/download/') |
| 118 | + new_url = updated_release_url( |
| 119 | + current_url: data['url'], |
| 120 | + current_version: current_ver, |
| 121 | + latest_version: latest_version, |
| 122 | + latest_tag: latest_tag, |
| 123 | + release: release |
| 124 | + ) |
| 125 | + |
| 126 | + if new_url != data['url'] |
| 127 | + data['url'] = new_url |
| 128 | + updated = true |
| 129 | + end |
| 130 | + end |
| 131 | + |
| 132 | + digest = download_digest(data['url']) |
| 133 | + data['sha256sum'] = digest |
| 134 | + |
| 135 | + File.write(path, "#{JSON.pretty_generate(data)}\n") if updated |
| 136 | + |
| 137 | + { updated: updated, url: data['url'] } |
| 138 | +end |
| 139 | + |
| 140 | +def check_component(path) |
| 141 | + name = File.basename(path, '.json') |
| 142 | + data = JSON.parse(File.read(path)) |
| 143 | + |
| 144 | + owner, repo = github_owner_repo(data['url']) |
| 145 | + |
| 146 | + return { name: name, status: :skip, reason: 'No GitHub URL detected' } unless owner |
| 147 | + |
| 148 | + current_ver_str = current_version(data) |
| 149 | + current_ver = try_version(current_ver_str) |
| 150 | + latest_tag = latest_upstream_tag(owner, repo, data['ref']) |
| 151 | + |
| 152 | + return { name: name, status: :error, reason: 'Could not determine latest upstream version' } if latest_tag.nil? |
| 153 | + |
| 154 | + latest_ver_str = normalize_version(latest_tag) |
| 155 | + latest_ver = try_version(latest_ver_str) |
| 156 | + |
| 157 | + return { name: name, status: :error, reason: "Could not parse upstream version '#{latest_ver_str}'" } if latest_ver.nil? |
| 158 | + return { name: name, status: :error, reason: "Could not parse current version '#{current_ver_str}'" } if current_ver.nil? |
| 159 | + |
| 160 | + if latest_ver > current_ver |
| 161 | + { name: name, path: path, status: :outdated, current: current_ver_str, latest: latest_ver_str, tag: latest_tag } |
| 162 | + else |
| 163 | + { name: name, path: path, status: :up_to_date, current: current_ver_str } |
| 164 | + end |
| 165 | +end |
| 166 | + |
| 167 | +def component_results |
| 168 | + Dir[COMPONENTS_JSON_GLOB].map { |path| check_component(path) } |
| 169 | +end |
| 170 | + |
| 171 | +def print_component_results(results) |
| 172 | + puts "Checking #{results.length} component(s) for updates...\n\n" |
| 173 | + |
| 174 | + outdated = [] |
| 175 | + errors = [] |
| 176 | + skipped = [] |
| 177 | + |
| 178 | + results.each do |result| |
| 179 | + print " #{result[:name]}... " |
| 180 | + $stdout.flush |
| 181 | + |
| 182 | + case result[:status] |
| 183 | + when :up_to_date |
| 184 | + puts "up to date (#{result[:current]})" |
| 185 | + when :outdated |
| 186 | + puts "OUTDATED: #{result[:current]} -> #{result[:latest]}" |
| 187 | + outdated << result |
| 188 | + when :skip |
| 189 | + puts "skipped (#{result[:reason]})" |
| 190 | + skipped << result |
| 191 | + when :error |
| 192 | + puts "error (#{result[:reason]})" |
| 193 | + errors << result |
| 194 | + end |
| 195 | + end |
| 196 | + |
| 197 | + puts "\n" |
| 198 | + |
| 199 | + unless outdated.empty? |
| 200 | + puts '=== Components with available updates ===' |
| 201 | + outdated.each do |result| |
| 202 | + puts " #{result[:name]}: #{result[:current]} -> #{result[:latest]} (upstream tag: #{result[:tag]})" |
| 203 | + end |
| 204 | + puts '' |
| 205 | + end |
| 206 | + |
| 207 | + unless errors.empty? |
| 208 | + puts '=== Errors encountered ===' |
| 209 | + errors.each { |result| puts " #{result[:name]}: #{result[:reason]}" } |
| 210 | + puts '' |
| 211 | + end |
| 212 | + |
| 213 | + unless skipped.empty? |
| 214 | + puts '=== Skipped (no checkable upstream) ===' |
| 215 | + skipped.each { |result| puts " #{result[:name]}: #{result[:reason]}" } |
| 216 | + puts '' |
| 217 | + end |
| 218 | + |
| 219 | + puts 'All components are up to date.' if outdated.empty? && errors.empty? |
| 220 | + |
| 221 | + { outdated: outdated, errors: errors, skipped: skipped } |
| 222 | +end |
| 223 | + |
| 224 | +namespace :vox do |
| 225 | + desc 'Print non-rubygem components with upstream GitHub updates' |
| 226 | + task :print_outdated_components do |
| 227 | + print_component_results(component_results) |
| 228 | + end |
| 229 | + |
| 230 | + desc 'Update outdated non-rubygem components backed by GitHub releases or tags' |
| 231 | + task :update_outdated_components do |
| 232 | + summary = print_component_results(component_results) |
| 233 | + |
| 234 | + summary[:outdated].each do |result| |
| 235 | + update_component_file(result[:path], latest_tag: result[:tag], latest_version: result[:latest]) |
| 236 | + puts "Updated #{result[:name]} to #{result[:latest]}" |
| 237 | + end |
| 238 | + |
| 239 | + abort 'One or more components could not be checked.' unless summary[:errors].empty? |
| 240 | + puts 'No component files needed changes.' if summary[:outdated].empty? |
| 241 | + end |
| 242 | +end |
0 commit comments