rake-gem-maintenance 0.1.4 → 0.1.6

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.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Rake
6
+ module GemMaintenance
7
+ # Pushes a single gem file to one repository, retrying with a renewed API key on auth failure.
8
+ class GemPush
9
+ Result = Struct.new(:success, :error)
10
+
11
+ def initialize(gem_file, repository, otp_provider)
12
+ @gem_file = gem_file
13
+ @repository = repository
14
+ @otp_provider = otp_provider
15
+ end
16
+
17
+ def attempt
18
+ out_err, status = Open3.capture2e(env, command)
19
+ return Result.new(true, nil) if status.success?
20
+ return retry_with_renewed_key if auth_failure?(out_err)
21
+
22
+ Result.new(false, out_err)
23
+ end
24
+
25
+ private
26
+
27
+ def command
28
+ cmd = "gem push #{@gem_file} --host #{@repository[:url]}"
29
+ otp = @otp_provider.otp_for(@repository[:name], otp_seed_env_var: @repository[:otp_seed_env_var])
30
+ cmd += " --otp #{otp}" if otp
31
+ cmd
32
+ end
33
+
34
+ def env
35
+ env_var = @repository[:api_key_env_var]
36
+ return {} unless env_var
37
+
38
+ key = ENV.fetch(env_var, nil)
39
+ return {} if key.nil? || key.empty?
40
+
41
+ { "GEM_HOST_API_KEY" => key }
42
+ end
43
+
44
+ def auth_failure?(output)
45
+ output.match?(/unauthorized|api.key|forbidden/i) ||
46
+ output.include?("401") || output.include?("403")
47
+ end
48
+
49
+ def retry_with_renewed_key
50
+ new_key = ApiKeyRenewer.new(otp_provider: @otp_provider).renew(@repository)
51
+ return Result.new(false, "Auth failed and renewal credentials unavailable.") unless new_key
52
+
53
+ _out_err, status = Open3.capture2e(env.merge("GEM_HOST_API_KEY" => new_key), command)
54
+ if status.success?
55
+ Result.new(true, nil)
56
+ else
57
+ Result.new(false, "Push failed after key renewal.")
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -2,5 +2,13 @@
2
2
 
3
3
  require_relative "../maintenance"
4
4
 
5
+ Rake::GemMaintenance::Repos.rubygems_api_key_env_var = "GEM_HOST_API_KEY"
6
+ Rake::GemMaintenance::Repos.rubygems_otp_seed_env_var = "RUBYGEMS_OTP_SEED"
7
+
5
8
  Rake::GemMaintenance::UpgradeTask.new
6
9
  Rake::GemMaintenance::VersionBumpTask.new
10
+
11
+ Rake::GemMaintenance::CredentialStore.new.apply_to_env(
12
+ username_env_var: "RUBYGEMS_USERNAME",
13
+ api_key_env_var: "GEM_HOST_API_KEY"
14
+ )
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rake
4
+ module GemMaintenance
5
+ # Resolves a 2FA OTP code for gem push, either from the environment or interactively.
6
+ # Resolution order for otp_for:
7
+ # 1. RUBYGEMS_OTP env var set → use raw code (works in CI and locally)
8
+ # 2. otp_seed_env_var provided and env var set → generate TOTP code (works in CI and locally)
9
+ # 3. CI environment → nil (gate only interactive prompt)
10
+ # 4. Interactive prompt
11
+ class OtpProvider
12
+ def initialize(ci_environment: CIEnvironment, input: $stdin)
13
+ @ci_environment = ci_environment
14
+ @input = input
15
+ end
16
+
17
+ def otp_for(repository_name, otp_seed_env_var: nil)
18
+ env_otp = ENV.fetch("RUBYGEMS_OTP", nil)
19
+ return env_otp if env_otp && !env_otp.empty?
20
+
21
+ if otp_seed_env_var
22
+ seed = ENV.fetch(otp_seed_env_var, nil)
23
+ return generate_totp(seed) if seed && !seed.empty?
24
+ end
25
+
26
+ return nil if @ci_environment.ci?
27
+
28
+ prompt_for_otp(repository_name)
29
+ end
30
+
31
+ private
32
+
33
+ def generate_totp(seed)
34
+ require "rotp"
35
+ ::ROTP::TOTP.new(seed).now
36
+ end
37
+
38
+ def prompt_for_otp(repository_name)
39
+ print "Enter OTP for #{repository_name} (blank to skip): "
40
+ value = @input.gets&.chomp
41
+ value&.empty? ? nil : value
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require "rake/tasklib"
5
+ require_relative "credential_store"
6
+
7
+ module Rake
8
+ module GemMaintenance
9
+ # Generates a new rubygems.org API key via the rubygems.org API and stores
10
+ # it in a Woodpecker CI org-level secret. Intended for local developer use only.
11
+ #
12
+ # Creates: <namespace>:renew_api_key
13
+ #
14
+ # Reads WOODPECKER_SERVER and WOODPECKER_TOKEN (or ~/.config/woodpecker/token)
15
+ # from the environment.
16
+ class RenewApiKeyTask < ::Rake::TaskLib
17
+ attr_accessor :namespace_name, :host, :api_key_env_var, :ci_environment,
18
+ :woodpecker_server, :woodpecker_org, :woodpecker_secret_name,
19
+ :username_env_var, :password_env_var, :credential_store
20
+
21
+ def initialize(namespace_name = :upgrade)
22
+ super()
23
+ apply_defaults(namespace_name)
24
+ define_tasks
25
+ end
26
+
27
+ private
28
+
29
+ def apply_defaults(namespace_name)
30
+ @namespace_name = namespace_name
31
+ @host = "https://rubygems.org"
32
+ @api_key_env_var = "GEM_HOST_API_KEY"
33
+ @ci_environment = CIEnvironment
34
+ @woodpecker_server = ENV.fetch("WOODPECKER_SERVER", nil)
35
+ @woodpecker_org = ENV.fetch("WOODPECKER_ORG", "cbp-org")
36
+ @woodpecker_secret_name = "rubygems_api_key"
37
+ @username_env_var = "RUBYGEMS_USERNAME"
38
+ @password_env_var = "RUBYGEMS_PASSWORD"
39
+ @credential_store = CredentialStore.new
40
+ end
41
+
42
+ def define_tasks
43
+ task_instance = self
44
+ namespace namespace_name do
45
+ desc "Generate a new rubygems.org API key and store it in Woodpecker CI"
46
+ task(:renew_api_key) { task_instance.send(:run_renewal) }
47
+ end
48
+ end
49
+
50
+ def run_renewal
51
+ credential_store.apply_to_env(username_env_var: username_env_var, api_key_env_var: api_key_env_var)
52
+ abort_if_ci
53
+ username, password = prompt_credentials
54
+ prompt_otp_seed_if_missing
55
+ api_key = generate_api_key(username, password)
56
+ save_and_distribute(username, api_key)
57
+ end
58
+
59
+ def generate_api_key(username, password)
60
+ otp = OtpProvider.new.otp_for("rubygems")
61
+ RubyGemsApiKeyCreator.new(host: host).create(username, password, otp: otp)
62
+ end
63
+
64
+ def save_and_distribute(username, api_key)
65
+ puts "\n[INFO] New API key generated."
66
+ credential_store.update(username: username, api_key: api_key, api_key_env_var: api_key_env_var)
67
+ store_in_woodpecker(api_key)
68
+ end
69
+
70
+ def abort_if_ci
71
+ return unless ci_environment.ci?
72
+
73
+ missing = [username_env_var, password_env_var].select { |v| env_credential(v).nil? }
74
+ return if missing.empty?
75
+
76
+ abort "[ERROR] Set #{missing.join(' and ')} CI secrets to run renewal unattended."
77
+ end
78
+
79
+ def prompt_otp_seed_if_missing
80
+ return if (seed = ENV.fetch("RUBYGEMS_OTP_SEED", nil)) && !seed.empty?
81
+
82
+ print "rubygems.org OTP seed (TOTP secret, not a code): "
83
+ seed = $stdin.gets&.chomp
84
+ ENV["RUBYGEMS_OTP_SEED"] = seed if seed && !seed.empty?
85
+ end
86
+
87
+ def prompt_credentials
88
+ username = env_credential(username_env_var) || prompt_username
89
+ password = env_credential(password_env_var) || read_password("rubygems.org password: ")
90
+ [username, password]
91
+ end
92
+
93
+ def env_credential(env_var)
94
+ value = ENV.fetch(env_var, nil)
95
+ value&.empty? ? nil : value
96
+ end
97
+
98
+ def prompt_username
99
+ print "rubygems.org username: "
100
+ value = $stdin.gets&.chomp
101
+ abort "[ERROR] No username provided." if value.nil? || value.empty?
102
+ value
103
+ end
104
+
105
+ def read_password(prompt)
106
+ require "io/console"
107
+ print prompt
108
+ password = $stdin.noecho(&:gets)&.chomp
109
+ puts
110
+ password
111
+ rescue LoadError
112
+ print prompt
113
+ $stdin.gets&.chomp
114
+ end
115
+
116
+ def store_in_woodpecker(api_key)
117
+ unless woodpecker_server
118
+ puts "[INFO] Set WOODPECKER_SERVER to auto-store the key in Woodpecker CI."
119
+ puts "[INFO] New API key (store as secret '#{woodpecker_secret_name}'): #{api_key}"
120
+ return
121
+ end
122
+
123
+ token = read_woodpecker_token
124
+ abort "[ERROR] No Woodpecker token. Set WOODPECKER_TOKEN or run woodpecker-cli setup." unless token
125
+
126
+ WoodpeckerSecretStore.new(server: woodpecker_server, org: woodpecker_org, token: token)
127
+ .store(woodpecker_secret_name, api_key)
128
+ puts "[SUCCESS] API key stored in Woodpecker secret '#{woodpecker_secret_name}'."
129
+ end
130
+
131
+ def read_woodpecker_token
132
+ ENV.fetch("WOODPECKER_TOKEN", nil) ||
133
+ File.read(File.expand_path("~/.config/woodpecker/token")).strip
134
+ rescue Errno::ENOENT
135
+ nil
136
+ end
137
+ end
138
+ end
139
+ end
@@ -14,35 +14,74 @@ module Rake
14
14
  # t.gem_repositories = Rake::GemMaintenance::Repos.all
15
15
  # end
16
16
  #
17
+ # @example Use local geminabox only
18
+ # Rake::GemMaintenance::GeminaboxUpgradeTask.new
19
+ #
20
+ # @example Dual publishing: geminabox + rubygems.org
21
+ # Rake::GemMaintenance::UpgradeTask.new do |t|
22
+ # t.gem_repositories = Rake::GemMaintenance::Repos.geminabox +
23
+ # Rake::GemMaintenance::Repos.rubygems
24
+ # end
25
+ #
17
26
  # @example Reconfigure internal URL
18
27
  # Rake::GemMaintenance::Repos.internal_url = "https://my-internal-gem.example.com"
28
+ #
29
+ # @example Configure API key and TOTP seed env vars
30
+ # Rake::GemMaintenance::Repos.rubygems_api_key_env_var = "GEM_HOST_API_KEY"
31
+ # Rake::GemMaintenance::Repos.rubygems_otp_seed_env_var = "RUBYGEMS_OTP_SEED"
32
+ # Rake::GemMaintenance::Repos.geminabox_url = "http://localhost:9292"
19
33
  module Repos
20
34
  @internal_url = "https://gems.cbp-org.internal"
21
35
  @rubygems_url = "https://rubygems.org"
36
+ @geminabox_url = "http://localhost:9292"
37
+
38
+ @rubygems_api_key_env_var = nil
39
+ @internal_api_key_env_var = nil
40
+ @geminabox_api_key_env_var = nil
41
+
42
+ @rubygems_otp_seed_env_var = nil
43
+ @internal_otp_seed_env_var = nil
44
+ @geminabox_otp_seed_env_var = nil
22
45
 
23
46
  class << self
24
- attr_accessor :internal_url, :rubygems_url
47
+ attr_accessor :internal_url, :rubygems_url, :geminabox_url,
48
+ :rubygems_api_key_env_var, :internal_api_key_env_var,
49
+ :geminabox_api_key_env_var,
50
+ :rubygems_otp_seed_env_var, :internal_otp_seed_env_var,
51
+ :geminabox_otp_seed_env_var
25
52
  end
26
53
 
27
54
  # Publish only to internal repository
28
55
  # @return [Array<Hash>] repository configuration
29
56
  def self.internal
30
- [{ name: "cbp-org", url: internal_url }]
57
+ base = { name: "cbp-org", url: internal_url }
58
+ base[:api_key_env_var] = internal_api_key_env_var if internal_api_key_env_var
59
+ base[:otp_seed_env_var] = internal_otp_seed_env_var if internal_otp_seed_env_var
60
+ [base]
31
61
  end
32
62
 
33
63
  # Publish to both rubygems.org and internal repository
34
64
  # @return [Array<Hash>] repository configuration
35
65
  def self.all
36
- [
37
- { name: "rubygems", url: rubygems_url },
38
- { name: "cbp-org", url: internal_url }
39
- ]
66
+ rubygems + internal
40
67
  end
41
68
 
42
69
  # Publish only to rubygems.org (the default)
43
70
  # @return [Array<Hash>] repository configuration
44
71
  def self.rubygems
45
- [{ name: "rubygems", url: rubygems_url }]
72
+ base = { name: "rubygems", url: rubygems_url }
73
+ base[:api_key_env_var] = rubygems_api_key_env_var if rubygems_api_key_env_var
74
+ base[:otp_seed_env_var] = rubygems_otp_seed_env_var if rubygems_otp_seed_env_var
75
+ [base]
76
+ end
77
+
78
+ # Publish only to a local geminabox instance
79
+ # @return [Array<Hash>] repository configuration
80
+ def self.geminabox
81
+ base = { name: "geminabox", url: geminabox_url }
82
+ base[:api_key_env_var] = geminabox_api_key_env_var if geminabox_api_key_env_var
83
+ base[:otp_seed_env_var] = geminabox_otp_seed_env_var if geminabox_otp_seed_env_var
84
+ [base]
46
85
  end
47
86
 
48
87
  # Default configuration: rubygems.org only
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
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
11
+
12
+ def create(username, password, otp: nil)
13
+ require "net/http"
14
+
15
+ request = build_request(username, password, otp)
16
+ response = http_client.request(request)
17
+ parse_response(response)
18
+ end
19
+
20
+ private
21
+
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
32
+
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
39
+
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}"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -3,6 +3,9 @@
3
3
  require "net/http"
4
4
  require "rake"
5
5
  require "rake/tasklib"
6
+ require_relative "ci_environment"
7
+ require_relative "otp_provider"
8
+ require_relative "renew_api_key_task"
6
9
  require_relative "gem_publisher"
7
10
  require_relative "repos"
8
11
 
@@ -20,6 +23,12 @@ module Rake
20
23
  :run_bundle_audit, :auto_pipeline, :gem_repositories,
21
24
  :gem_publisher_class, :gem_name, :gem_version
22
25
 
26
+ attr_writer :renew_api_key_task_class
27
+
28
+ def renew_api_key_task_class
29
+ @renew_api_key_task_class || RenewApiKeyTask
30
+ end
31
+
23
32
  def initialize(name = :upgrade)
24
33
  super()
25
34
  apply_default_configuration(name)
@@ -85,6 +94,7 @@ module Rake
85
94
  define_gems_task
86
95
  define_commit_task
87
96
  define_push_task
97
+ define_renew_api_key_task
88
98
  end
89
99
 
90
100
  def define_info_tasks
@@ -186,6 +196,10 @@ module Rake
186
196
  end
187
197
  end
188
198
 
199
+ def define_renew_api_key_task
200
+ renew_api_key_task_class.new(name)
201
+ end
202
+
189
203
  def pipeline_tasks
190
204
  return auto_pipeline if auto_pipeline
191
205
 
@@ -294,5 +308,14 @@ module Rake
294
308
  @gem_repositories = Repos.all
295
309
  end
296
310
  end
311
+
312
+ # Upgrades gems and publishes to a local geminabox instance only.
313
+ # Uses Repos.geminabox as default repositories.
314
+ class GeminaboxUpgradeTask < UpgradeTask
315
+ def apply_default_configuration(name)
316
+ super
317
+ @gem_repositories = Repos.geminabox
318
+ end
319
+ end
297
320
  end
298
321
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rake
4
4
  module GemMaintenance
5
- VERSION = "0.1.4"
5
+ VERSION = "0.1.6"
6
6
  end
7
7
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rake
4
+ module GemMaintenance
5
+ # Creates or updates an org-level secret in a Woodpecker CI server.
6
+ # SSL verification is disabled because Woodpecker is typically served
7
+ # on an internal network with a private CA.
8
+ class WoodpeckerSecretStore
9
+ def initialize(server:, org:, token:)
10
+ @server = server
11
+ @org = org
12
+ @token = token
13
+ end
14
+
15
+ def store(secret_name, value, events: %w[push tag manual])
16
+ org_id = find_org_id
17
+ abort "[ERROR] Woodpecker org '#{@org}' not found on #{@server}." unless org_id
18
+
19
+ if secret_exists?(org_id, secret_name)
20
+ patch("/api/orgs/#{org_id}/secrets/#{secret_name}",
21
+ { value: value, events: events, images: [] })
22
+ else
23
+ post("/api/orgs/#{org_id}/secrets",
24
+ { name: secret_name, value: value, events: events, images: [] })
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def find_org_id
31
+ orgs = get("/api/orgs")
32
+ orgs&.find { |o| o["name"] == @org }&.fetch("id", nil)
33
+ end
34
+
35
+ def secret_exists?(org_id, secret_name)
36
+ secrets = get("/api/orgs/#{org_id}/secrets")
37
+ secrets&.any? { |s| s["name"] == secret_name }
38
+ end
39
+
40
+ def get(path)
41
+ require "json"
42
+ response = request(Net::HTTP::Get, path)
43
+ JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
44
+ end
45
+
46
+ def post(path, body)
47
+ request(Net::HTTP::Post, path, body)
48
+ end
49
+
50
+ def patch(path, body)
51
+ request(Net::HTTP::Patch, path, body)
52
+ end
53
+
54
+ def request(klass, path, body = nil)
55
+ require "net/http"
56
+ require "json"
57
+ require "openssl"
58
+
59
+ uri = URI("#{@server}#{path}")
60
+ http.request(build_req(klass, uri, body))
61
+ end
62
+
63
+ def http
64
+ uri = URI(@server)
65
+ h = Net::HTTP.new(uri.hostname, uri.port)
66
+ h.use_ssl = (uri.scheme == "https")
67
+ h.verify_mode = OpenSSL::SSL::VERIFY_NONE
68
+ h
69
+ end
70
+
71
+ def build_req(klass, uri, body)
72
+ req = klass.new(uri)
73
+ req["Authorization"] = "Bearer #{@token}"
74
+ if body
75
+ req["Content-Type"] = "application/json"
76
+ req.body = JSON.generate(body)
77
+ end
78
+ req
79
+ end
80
+
81
+ def build_http(uri)
82
+ http = Net::HTTP.new(uri.hostname, uri.port)
83
+ http.use_ssl = (uri.scheme == "https")
84
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
85
+ http
86
+ end
87
+ end
88
+ end
89
+ end
@@ -2,6 +2,14 @@
2
2
 
3
3
  require_relative "maintenance/version"
4
4
  require_relative "maintenance/version_bump_task"
5
+ require_relative "maintenance/ci_environment"
6
+ require_relative "maintenance/credential_store"
7
+ require_relative "maintenance/otp_provider"
8
+ require_relative "maintenance/ruby_gems_api_key_creator"
9
+ require_relative "maintenance/woodpecker_secret_store"
10
+ require_relative "maintenance/api_key_renewer"
11
+ require_relative "maintenance/renew_api_key_task"
12
+ require_relative "maintenance/gem_push"
5
13
  require_relative "maintenance/gem_publisher"
6
14
  require_relative "maintenance/repos"
7
15
  require_relative "maintenance/upgrade_task"
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ci-publish-rubygems.rb — build and push this gem to rubygems.org
4
+ #
5
+ # Required env:
6
+ # GEM_HOST_API_KEY rubygems.org API key (from Woodpecker secret rubygems_api_key)
7
+ # RUBYGEMS_OTP_SEED base32 TOTP seed (from Woodpecker secret rubygems_otp_seed)
8
+
9
+ $LOAD_PATH.unshift File.join(__dir__, "..", "lib")
10
+ require "rake/gem/maintenance"
11
+
12
+ Rake::GemMaintenance::Repos.rubygems_api_key_env_var = "GEM_HOST_API_KEY"
13
+ Rake::GemMaintenance::Repos.rubygems_otp_seed_env_var = "RUBYGEMS_OTP_SEED"
14
+
15
+ gemspec_file = Dir["*.gemspec"].first
16
+ abort "ERROR: No gemspec found in #{Dir.pwd}" unless gemspec_file
17
+
18
+ system("gem build #{gemspec_file}") or abort "ERROR: gem build failed"
19
+
20
+ gem_file = Dir["*.gem"].max_by { |f| File.mtime(f) }
21
+ abort "ERROR: No .gem file found after build" unless gem_file
22
+
23
+ puts "Publishing #{gem_file} to rubygems.org..."
24
+ publisher = Rake::GemMaintenance::GemPublisher.new(Rake::GemMaintenance::Repos.rubygems)
25
+ publisher.publish(gem_file)
26
+
27
+ abort "ERROR: Failed to publish #{gem_file} to rubygems.org" if publisher.successful_repos.empty?
28
+
29
+ puts "Published #{gem_file} successfully to: #{publisher.successful_repos.join(', ')}"