rake-gem-maintenance 0.1.7 → 0.2.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.
@@ -1,49 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rake
4
- module GemMaintenance
5
- # Creates a new scoped API key on rubygems.org via the v2 API.
6
- # Handles OTP header injection and maps HTTP error codes to actionable messages.
7
- class RubyGemsApiKeyCreator
8
- def initialize(host: "https://rubygems.org")
9
- @host = host
10
- end
4
+ module Gem
5
+ module Maintenance
6
+ # Creates a new scoped API key on rubygems.org via the v2 API.
7
+ # Handles OTP header injection and maps HTTP error codes to actionable messages.
8
+ class RubyGemsApiKeyCreator
9
+ def initialize(host: "https://rubygems.org")
10
+ @host = host
11
+ end
11
12
 
12
- def create(username, password, otp: nil)
13
- require "net/http"
13
+ def create(username, password, otp: nil)
14
+ require "net/http"
14
15
 
15
- request = build_request(username, password, otp)
16
- response = http_client.request(request)
17
- parse_response(response)
18
- end
16
+ request = build_request(username, password, otp)
17
+ response = http_client.request(request)
18
+ parse_response(response)
19
+ end
19
20
 
20
- private
21
+ private
21
22
 
22
- def build_request(username, password, otp)
23
- uri = URI("#{@host}/api/v1/api_key")
24
- key_name = "rake-gem-maintenance-ci-#{Time.now.strftime('%Y%m%d')}"
25
- req = Net::HTTP::Post.new(uri)
26
- req.basic_auth(username, password)
27
- req["OTP"] = otp if otp
28
- req["Content-Type"] = "application/x-www-form-urlencoded"
29
- req.body = "name=#{key_name}&push_rubygem=true"
30
- req
31
- end
23
+ def build_request(username, password, otp)
24
+ uri = URI("#{@host}/api/v1/api_key")
25
+ key_name = "rake-gem-maintenance-ci-#{Time.now.strftime('%Y%m%d')}"
26
+ req = Net::HTTP::Post.new(uri)
27
+ req.basic_auth(username, password)
28
+ req["OTP"] = otp if otp
29
+ req["Content-Type"] = "application/x-www-form-urlencoded"
30
+ req.body = "name=#{key_name}&push_rubygem=true"
31
+ req
32
+ end
32
33
 
33
- def http_client
34
- uri = URI(@host)
35
- http = Net::HTTP.new(uri.hostname, uri.port)
36
- http.use_ssl = true
37
- http
38
- end
34
+ def http_client
35
+ uri = URI(@host)
36
+ http = Net::HTTP.new(uri.hostname, uri.port)
37
+ http.use_ssl = true
38
+ http
39
+ end
39
40
 
40
- def parse_response(response)
41
- case response.code.to_i
42
- when 200, 201 then response.body.strip
43
- when 401 then abort "[ERROR] Invalid credentials for #{@host}."
44
- when 403 then abort "[ERROR] Forbidden. Check your MFA settings on #{@host}."
45
- when 449 then abort "[ERROR] OTP required by #{@host}. Set RUBYGEMS_OTP_SEED and retry."
46
- else abort "[ERROR] #{@host} returned #{response.code}: #{response.body.strip}"
41
+ def parse_response(response)
42
+ case response.code.to_i
43
+ when 200, 201 then response.body.strip
44
+ when 401 then abort "[ERROR] Invalid credentials for #{@host}."
45
+ when 403 then abort "[ERROR] Forbidden. Check your MFA settings on #{@host}."
46
+ when 449 then abort "[ERROR] OTP required by #{@host}. Set RUBYGEMS_OTP_SEED and retry."
47
+ else abort "[ERROR] #{@host} returned #{response.code}: #{response.body.strip}"
48
+ end
47
49
  end
48
50
  end
49
51
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module Rake
7
+ module Gem
8
+ module Maintenance
9
+ # Fetches the latest stable Ruby version and maintained minors from endoflife.date.
10
+ class RubyVersionChecker
11
+ API_URL = "https://endoflife.date/api/ruby.json"
12
+
13
+ def latest_stable
14
+ cycles.max_by { |c| ::Gem::Version.new(c[:latest]) }&.fetch(:latest)
15
+ end
16
+
17
+ def maintained_minors
18
+ cycles.map { |c| c[:cycle] }.sort_by { |v| ::Gem::Version.new(v) }
19
+ end
20
+
21
+ private
22
+
23
+ def cycles
24
+ @cycles ||= fetch_cycles
25
+ end
26
+
27
+ def fetch_cycles
28
+ response = Net::HTTP.get(URI(API_URL))
29
+ JSON.parse(response, symbolize_names: true).reject { |c| c[:eol] }
30
+ rescue StandardError
31
+ []
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rake
4
+ module Gem
5
+ module Maintenance
6
+ # Updates Ruby version references in .ruby-version, gemspec, and CI config files.
7
+ class RubyVersionUpdater
8
+ def initialize(
9
+ ruby_version_file: ".ruby-version",
10
+ gemspec_files: Dir.glob("*.gemspec"),
11
+ github_workflow_files: Dir.glob(".github/workflows/*.yml"),
12
+ woodpecker_config_files: Dir.glob(".woodpecker/*.yml")
13
+ )
14
+ @ruby_version_file = ruby_version_file
15
+ @gemspec_files = Array(gemspec_files)
16
+ @github_workflow_files = Array(github_workflow_files)
17
+ @woodpecker_config_files = Array(woodpecker_config_files)
18
+ end
19
+
20
+ def update(checker:)
21
+ latest = checker.latest_stable
22
+ minors = checker.maintained_minors
23
+ return [] if latest.nil? || minors.empty?
24
+
25
+ [
26
+ write_ruby_version_file(latest),
27
+ *@gemspec_files.map { |f| patch_gemspec(f, latest) },
28
+ *@github_workflow_files.map { |f| patch_github_workflow(f, minors) },
29
+ *@woodpecker_config_files.map { |f| patch_woodpecker_config(f, latest) }
30
+ ].compact
31
+ end
32
+
33
+ private
34
+
35
+ def write_ruby_version_file(latest)
36
+ current = File.exist?(@ruby_version_file) ? File.read(@ruby_version_file).strip : nil
37
+ return if current == latest
38
+
39
+ File.write(@ruby_version_file, "#{latest}\n")
40
+ @ruby_version_file
41
+ end
42
+
43
+ def patch_gemspec(path, latest)
44
+ return unless File.exist?(path)
45
+
46
+ content = File.read(path)
47
+ updated = rewrite_required_ruby_version(content, latest)
48
+ return if updated == content
49
+
50
+ File.write(path, updated)
51
+ path
52
+ end
53
+
54
+ def patch_github_workflow(path, minors)
55
+ return unless File.exist?(path)
56
+
57
+ matrix_value = (minors + ["truffleruby"]).map { |v| "'#{v}'" }.join(", ")
58
+ content = File.read(path)
59
+ updated = content.gsub(/ruby-version:\s*\[.*?\]/, "ruby-version: [ #{matrix_value} ]")
60
+ return if updated == content
61
+
62
+ File.write(path, updated)
63
+ path
64
+ end
65
+
66
+ def patch_woodpecker_config(path, latest)
67
+ return unless File.exist?(path)
68
+
69
+ content = File.read(path)
70
+ updated = content.gsub(/\bruby:(\d+\.\d+\.\d+)([-\w]*)/, "ruby:#{latest}\\2")
71
+ return if updated == content
72
+
73
+ File.write(path, updated)
74
+ path
75
+ end
76
+
77
+ def rewrite_required_ruby_version(content, latest)
78
+ latest_parts = latest.split(".").map(&:to_i)
79
+ content.gsub(/required_ruby_version\s*=\s*["'](>=\s*)(\d+)\.(\d+)\.\d+["']/) do
80
+ prefix = ::Regexp.last_match(1)
81
+ req_major = ::Regexp.last_match(2).to_i
82
+ req_minor = ::Regexp.last_match(3).to_i
83
+ next ::Regexp.last_match(0) unless same_minor?(req_major, req_minor, latest_parts)
84
+
85
+ "required_ruby_version = \"#{prefix}#{latest}\""
86
+ end
87
+ end
88
+
89
+ def same_minor?(major, minor, latest_parts)
90
+ latest_parts[0] == major && latest_parts[1] == minor
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require "rake/tasklib"
5
+ require_relative "gem_publisher"
6
+ require_relative "repos"
7
+
8
+ module Rake
9
+ module Gem
10
+ module Maintenance
11
+ # Defines the publish:rubygems Rake task for pushing a built .gem to rubygems.org.
12
+ #
13
+ # Expects a .gem file to already exist in the working directory (run gem build first).
14
+ # Uses the GEM_HOST_API_KEY and RUBYGEMS_OTP_SEED env vars configured via Repos.
15
+ class RubygemsPublishTask < ::Rake::TaskLib
16
+ GEM_FILE_GLOB = "*.gem"
17
+
18
+ attr_accessor :gem_file_glob, :gem_publisher_class
19
+
20
+ def initialize
21
+ super
22
+ @gem_file_glob = GEM_FILE_GLOB
23
+ @gem_publisher_class = GemPublisher
24
+
25
+ yield self if block_given?
26
+ define_tasks
27
+ end
28
+
29
+ private
30
+
31
+ def define_tasks
32
+ task_instance = self
33
+ namespace :publish do
34
+ desc "Push the built .gem file to rubygems.org"
35
+ task :rubygems do
36
+ task_instance.send(:publish_to_rubygems)
37
+ end
38
+ end
39
+ end
40
+
41
+ def publish_to_rubygems
42
+ publisher = gem_publisher_class.new(Repos.rubygems)
43
+ publisher.publish(gem_file)
44
+ return if publisher.successful_repos.include?("rubygems")
45
+
46
+ raise "Publish to rubygems.org failed — check output above"
47
+ end
48
+
49
+ def gem_file
50
+ file = Dir.glob(gem_file_glob).first
51
+ raise "No .gem file found — run gem build first" unless file
52
+
53
+ file
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end