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.
- checksums.yaml +4 -4
- data/.woodpecker/publish.yml +52 -0
- data/.woodpecker/renew_api_key.yml +47 -0
- data/.woodpecker/verify.yml +38 -0
- data/CLAUDE.md +14 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +53 -30
- data/README.md +75 -0
- data/lib/rake/gem/maintenance/api_key_renewer.rb +48 -0
- data/lib/rake/gem/maintenance/ci_environment.rb +12 -0
- data/lib/rake/gem/maintenance/credential_store.rb +65 -0
- data/lib/rake/gem/maintenance/gem_publisher.rb +18 -7
- data/lib/rake/gem/maintenance/gem_push.rb +62 -0
- data/lib/rake/gem/maintenance/install_tasks.rb +8 -0
- data/lib/rake/gem/maintenance/otp_provider.rb +45 -0
- data/lib/rake/gem/maintenance/renew_api_key_task.rb +139 -0
- data/lib/rake/gem/maintenance/repos.rb +46 -7
- data/lib/rake/gem/maintenance/ruby_gems_api_key_creator.rb +51 -0
- data/lib/rake/gem/maintenance/upgrade_task.rb +23 -0
- data/lib/rake/gem/maintenance/version.rb +1 -1
- data/lib/rake/gem/maintenance/woodpecker_secret_store.rb +89 -0
- data/lib/rake/gem/maintenance.rb +8 -0
- data/scripts/ci_publish_rubygems.rb +29 -0
- metadata +34 -9
- data/rake-gem-maintenance.gemspec +0 -32
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
data/lib/rake/gem/maintenance.rb
CHANGED
|
@@ -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(', ')}"
|