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.
- checksums.yaml +4 -4
- data/.woodpecker/publish.yml +1 -1
- data/.woodpecker/verify.yml +1 -1
- data/CLAUDE.md +7 -7
- data/Gemfile.lock +2 -2
- data/README.md +9 -9
- data/lib/rake/gem/maintenance/api_key_renewer.rb +37 -35
- data/lib/rake/gem/maintenance/ci_environment.rb +7 -5
- data/lib/rake/gem/maintenance/credential_store.rb +46 -44
- data/lib/rake/gem/maintenance/gem_publisher.rb +86 -84
- data/lib/rake/gem/maintenance/gem_push.rb +44 -42
- data/lib/rake/gem/maintenance/install_tasks.rb +6 -5
- data/lib/rake/gem/maintenance/otp_provider.rb +33 -31
- data/lib/rake/gem/maintenance/renew_api_key_task.rb +111 -109
- data/lib/rake/gem/maintenance/repos.rb +81 -79
- data/lib/rake/gem/maintenance/ruby_gems_api_key_creator.rb +39 -37
- data/lib/rake/gem/maintenance/ruby_version_checker.rb +36 -0
- data/lib/rake/gem/maintenance/ruby_version_updater.rb +95 -0
- data/lib/rake/gem/maintenance/rubygems_publish_task.rb +58 -0
- data/lib/rake/gem/maintenance/upgrade_task.rb +269 -241
- data/lib/rake/gem/maintenance/version.rb +4 -2
- data/lib/rake/gem/maintenance/version_bump_task.rb +83 -81
- data/lib/rake/gem/maintenance/woodpecker_secret_store.rb +69 -67
- data/lib/rake/gem/maintenance.rb +3 -0
- data/scripts/ci_publish_rubygems.rb +3 -3
- metadata +4 -2
- data/TODO.md +0 -1
|
@@ -1,49 +1,51 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rake
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
13
|
+
def create(username, password, otp: nil)
|
|
14
|
+
require "net/http"
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
request = build_request(username, password, otp)
|
|
17
|
+
response = http_client.request(request)
|
|
18
|
+
parse_response(response)
|
|
19
|
+
end
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
private
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|