kettle-dev 2.4.5 → 2.5.0
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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +146 -1
- data/README.md +115 -1
- data/exe/kettle-changelog +4 -2
- data/exe/kettle-release +40 -2
- data/exe/kettle-reset +35 -0
- data/lib/kettle/dev/changelog_cli.rb +73 -34
- data/lib/kettle/dev/ci_monitor.rb +0 -2
- data/lib/kettle/dev/dvcs_cli.rb +3 -2
- data/lib/kettle/dev/executable_version.rb +15 -1
- data/lib/kettle/dev/git_adapter.rb +126 -18
- data/lib/kettle/dev/interactive_release_command.rb +125 -0
- data/lib/kettle/dev/lockfile_reset.rb +442 -0
- data/lib/kettle/dev/rakelib/spec_test.rake +8 -2
- data/lib/kettle/dev/rakelib/yard.rake +11 -0
- data/lib/kettle/dev/release_cli.rb +203 -149
- data/lib/kettle/dev/release_secrets.rb +153 -0
- data/lib/kettle/dev/reset_cli.rb +81 -0
- data/lib/kettle/dev/{gem_coop_versions.rb → ruby_gems_versions.rb} +6 -6
- data/lib/kettle/dev/version.rb +4 -1
- data/lib/kettle/dev.rb +11 -1
- data.tar.gz.sig +0 -0
- metadata +37 -11
- metadata.gz.sig +0 -0
|
@@ -27,7 +27,8 @@ module Kettle
|
|
|
27
27
|
# @param enforce_coverage_thresholds [Boolean] when true, fail strict coverage generation below project thresholds
|
|
28
28
|
# @param update_prep [Boolean] when true, update the most recent prepared release section in place
|
|
29
29
|
# @param version [String, nil] explicit version override for gems without a literal VERSION constant
|
|
30
|
-
|
|
30
|
+
# @param yes [Boolean] when true, approve the selected release plan without prompting
|
|
31
|
+
def initialize(strict: true, enforce_coverage_thresholds: true, update_prep: false, version: nil, root: Kettle::Dev::CIHelpers.project_root, refresh_cache: false, yes: false)
|
|
31
32
|
@root = root
|
|
32
33
|
@changelog_path = File.join(@root, "CHANGELOG.md")
|
|
33
34
|
@coverage_path = File.join(@root, "coverage", "coverage.json")
|
|
@@ -36,6 +37,7 @@ module Kettle
|
|
|
36
37
|
@update_prep = update_prep
|
|
37
38
|
@version_override = Kettle::Dev::Versioning.normalize_explicit_version(version)
|
|
38
39
|
@refresh_cache = refresh_cache
|
|
40
|
+
@yes = !!yes
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
# Main entry point to update CHANGELOG.md
|
|
@@ -250,8 +252,8 @@ module Kettle
|
|
|
250
252
|
branch = default_branch_ref
|
|
251
253
|
return nil unless tag && branch
|
|
252
254
|
|
|
253
|
-
stdout,
|
|
254
|
-
|
|
255
|
+
stdout, ok = git_capture(["rev-list", "--count", "#{tag}..#{branch}"])
|
|
256
|
+
ok ? stdout.to_i : nil
|
|
255
257
|
end
|
|
256
258
|
|
|
257
259
|
def release_tag_for_version(version)
|
|
@@ -261,15 +263,15 @@ module Kettle
|
|
|
261
263
|
end
|
|
262
264
|
|
|
263
265
|
def default_branch_ref
|
|
264
|
-
stdout,
|
|
265
|
-
return stdout.strip if
|
|
266
|
+
stdout, ok = git_capture(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])
|
|
267
|
+
return stdout.strip if ok && !stdout.strip.empty?
|
|
266
268
|
|
|
267
269
|
%w[main master HEAD].find { |ref| git_ref_exists?(ref) }
|
|
268
270
|
end
|
|
269
271
|
|
|
270
272
|
def git_ref_exists?(ref)
|
|
271
|
-
_stdout,
|
|
272
|
-
|
|
273
|
+
_stdout, ok = git_capture(["rev-parse", "--verify", "--quiet", ref])
|
|
274
|
+
ok
|
|
273
275
|
end
|
|
274
276
|
|
|
275
277
|
def detect_plan(changelog, version)
|
|
@@ -280,7 +282,7 @@ module Kettle
|
|
|
280
282
|
gem_name = detect_gem_name
|
|
281
283
|
latest_overall, latest_for_series = latest_released_versions(gem_name, version)
|
|
282
284
|
rescue => e
|
|
283
|
-
warn("[kettle-changelog]
|
|
285
|
+
warn("[kettle-changelog] RubyGems.org release check failed: #{e.class}: #{e.message}")
|
|
284
286
|
warn("Proceeding without live release info.")
|
|
285
287
|
end
|
|
286
288
|
|
|
@@ -345,6 +347,11 @@ module Kettle
|
|
|
345
347
|
puts " latest released for current series: #{plan.fetch(:latest_for_series) || "unknown"}"
|
|
346
348
|
puts " latest CHANGELOG.md release: #{plan.fetch(:latest_changelog_version) || "none"}"
|
|
347
349
|
puts " gem: #{plan.fetch(:gem_name) || "unknown"}"
|
|
350
|
+
if @yes
|
|
351
|
+
puts("Continue with this plan? [y/N]: y")
|
|
352
|
+
return
|
|
353
|
+
end
|
|
354
|
+
|
|
348
355
|
print("Continue with this plan? [y/N]: ")
|
|
349
356
|
ans = Kettle::Dev::InputAdapter.gets&.strip&.downcase
|
|
350
357
|
return if ans == "y" || ans == "yes"
|
|
@@ -400,7 +407,7 @@ module Kettle
|
|
|
400
407
|
end
|
|
401
408
|
|
|
402
409
|
def latest_released_versions(gem_name, current_version)
|
|
403
|
-
data = Kettle::Dev::
|
|
410
|
+
data = Kettle::Dev::RubyGemsVersions.fetch(gem_name, version_hint: current_version, refresh: @refresh_cache)
|
|
404
411
|
return [nil, nil, nil] unless data.is_a?(Array)
|
|
405
412
|
|
|
406
413
|
versions = data.map { |h| h["number"] }.compact
|
|
@@ -736,7 +743,7 @@ module Kettle
|
|
|
736
743
|
"K_SOUP_COV_DO" => "true",
|
|
737
744
|
"K_SOUP_COV_FORMATTERS" => "json",
|
|
738
745
|
"K_SOUP_COV_MIN_HARD" => @enforce_coverage_thresholds ? "true" : "false",
|
|
739
|
-
"K_SOUP_COV_MULTI_FORMATTERS" => "
|
|
746
|
+
"K_SOUP_COV_MULTI_FORMATTERS" => "false",
|
|
740
747
|
"K_SOUP_COV_OPEN_BIN" => ""
|
|
741
748
|
}
|
|
742
749
|
end
|
|
@@ -766,30 +773,59 @@ module Kettle
|
|
|
766
773
|
end
|
|
767
774
|
end
|
|
768
775
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
out << output
|
|
776
|
-
line = documented_percent_line(output)
|
|
777
|
-
return line if line
|
|
776
|
+
# Run the canonical docs task to get the documentation percentage.
|
|
777
|
+
commands.each do |command|
|
|
778
|
+
prepare_yard_fence_tmp_files if command == [File.join(@root, "bin", "yard")]
|
|
779
|
+
output, status = capture_yard_command(command)
|
|
780
|
+
unless command_successful?(status)
|
|
781
|
+
return handle_yard_documentation_failure(yard_command_failure_message(command, output, status))
|
|
778
782
|
end
|
|
783
|
+
line = documented_percent_line(output)
|
|
784
|
+
return line if line
|
|
785
|
+
end
|
|
779
786
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
787
|
+
handle_yard_documentation_failure("Could not find documented percentage in YARD output")
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def capture_yard_command(command)
|
|
791
|
+
Open3.capture2e(*command, {chdir: @root})
|
|
792
|
+
rescue => e
|
|
793
|
+
["#{e.class}: #{e.message}", false]
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def handle_yard_documentation_failure(message)
|
|
797
|
+
if @strict
|
|
798
|
+
raise message
|
|
799
|
+
else
|
|
800
|
+
warn(message)
|
|
801
|
+
nil
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def command_successful?(status)
|
|
806
|
+
return status if status == true || status == false
|
|
807
|
+
|
|
808
|
+
!status.respond_to?(:success?) || status.success?
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
def yard_command_failure_message(command, output, status)
|
|
812
|
+
exit_status = status.respond_to?(:exitstatus) ? status.exitstatus : nil
|
|
813
|
+
message = "Failed to run #{yard_command_label(command)}"
|
|
814
|
+
message = "#{message} (exit #{exit_status})" if exit_status
|
|
815
|
+
output = output.to_s.strip
|
|
816
|
+
message = "#{message}: #{output}" unless output.empty?
|
|
817
|
+
message
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def yard_command_label(command)
|
|
821
|
+
command = Array(command)
|
|
822
|
+
bin = command.first.to_s
|
|
823
|
+
if bin == File.join(@root, "bin", "rake")
|
|
824
|
+
"bin/rake #{command.drop(1).join(" ")}".strip
|
|
825
|
+
elsif bin == File.join(@root, "bin", "yard")
|
|
826
|
+
"bin/yard"
|
|
827
|
+
else
|
|
828
|
+
command.join(" ")
|
|
793
829
|
end
|
|
794
830
|
end
|
|
795
831
|
|
|
@@ -1111,13 +1147,16 @@ module Kettle
|
|
|
1111
1147
|
# Return the root commit SHA of the current repository, or nil on failure.
|
|
1112
1148
|
# Uses the generic GitAdapter#capture escape hatch so tests can stub it.
|
|
1113
1149
|
def git_root_commit
|
|
1114
|
-
|
|
1115
|
-
out, ok = adapter.capture(["rev-list", "--max-parents=0", "HEAD"])
|
|
1150
|
+
out, ok = git_capture(["rev-list", "--max-parents=0", "HEAD"])
|
|
1116
1151
|
sha = out.to_s.lines.last&.strip # take last line in case of multiple root commits
|
|
1117
1152
|
(ok && sha && !sha.empty?) ? sha : nil
|
|
1118
1153
|
rescue
|
|
1119
1154
|
nil
|
|
1120
1155
|
end
|
|
1156
|
+
|
|
1157
|
+
def git_capture(args)
|
|
1158
|
+
Kettle::Dev::GitAdapter.new(@root).capture(args)
|
|
1159
|
+
end
|
|
1121
1160
|
end
|
|
1122
1161
|
end
|
|
1123
1162
|
end
|
|
@@ -267,8 +267,6 @@ module Kettle
|
|
|
267
267
|
end
|
|
268
268
|
module_function :collect_gitlab
|
|
269
269
|
|
|
270
|
-
# -- internals (abort-on-failure legacy paths used elsewhere) --
|
|
271
|
-
|
|
272
270
|
def monitor_github_internal!(restart_hint:, workflows: nil)
|
|
273
271
|
root = Kettle::Dev::CIHelpers.project_root
|
|
274
272
|
workflows = Array(workflows).empty? ? Kettle::Dev::CIHelpers.workflows_list(root) : Array(workflows)
|
data/lib/kettle/dev/dvcs_cli.rb
CHANGED
|
@@ -22,10 +22,11 @@ module Kettle
|
|
|
22
22
|
# - Creates an "all" remote that fetches from origin only and pushes to all three forges
|
|
23
23
|
# - Attempts to fetch from each forge to determine availability and updates README federation summary
|
|
24
24
|
#
|
|
25
|
-
#
|
|
25
|
+
# Examples:
|
|
26
|
+
# Non-interactive run with defaults (origin: github, protocol: ssh)
|
|
26
27
|
# kettle-dvcs --force my-org my-repo
|
|
27
28
|
#
|
|
28
|
-
#
|
|
29
|
+
# Use GitLab as origin and HTTPS URLs
|
|
29
30
|
# kettle-dvcs --origin gitlab --protocol https my-org my-repo
|
|
30
31
|
class DvcsCLI
|
|
31
32
|
DEFAULTS = {
|
|
@@ -14,7 +14,9 @@ module Kettle
|
|
|
14
14
|
exit(0)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def print_header(script_basename)
|
|
17
|
+
def print_header(script_basename, argv = ARGV)
|
|
18
|
+
return unless consume_verbose!(argv)
|
|
19
|
+
|
|
18
20
|
puts header(script_basename)
|
|
19
21
|
end
|
|
20
22
|
|
|
@@ -22,6 +24,18 @@ module Kettle
|
|
|
22
24
|
"== #{script_basename} v#{Kettle::Dev::Version::VERSION} =="
|
|
23
25
|
end
|
|
24
26
|
|
|
27
|
+
def verbose?(argv)
|
|
28
|
+
argv.any? { |arg| arg == "--verbose" }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def consume_verbose!(argv)
|
|
32
|
+
index = argv.index("--verbose")
|
|
33
|
+
return false unless index
|
|
34
|
+
|
|
35
|
+
argv.delete_at(index)
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
25
39
|
def requested?(argv, value_option: false)
|
|
26
40
|
argv.each_with_index.any? do |arg, index|
|
|
27
41
|
arg == "-v" || bare_long_version?(argv, arg, index, value_option: value_option)
|
|
@@ -28,7 +28,7 @@ module Kettle
|
|
|
28
28
|
false
|
|
29
29
|
end
|
|
30
30
|
else
|
|
31
|
-
out, st =
|
|
31
|
+
out, st = git_capture2("status", "--porcelain")
|
|
32
32
|
st.success? && out.strip.empty?
|
|
33
33
|
end
|
|
34
34
|
rescue => e
|
|
@@ -43,16 +43,106 @@ module Kettle
|
|
|
43
43
|
# @param args [Array<String>]
|
|
44
44
|
# @return [Array<(String, Boolean)>] [output, success]
|
|
45
45
|
def capture(args)
|
|
46
|
-
out, status =
|
|
46
|
+
out, status = git_capture2(*args)
|
|
47
47
|
[out.strip, status.success?]
|
|
48
48
|
rescue => e
|
|
49
49
|
Kettle::Dev.debug_error(e, __method__)
|
|
50
50
|
["", false]
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# Stage all changes in the repository.
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def add_all
|
|
57
|
+
git_system("add", "-A")
|
|
58
|
+
rescue => e
|
|
59
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Stage selected paths.
|
|
64
|
+
#
|
|
65
|
+
# @param paths [Array<String>]
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def add_paths(paths)
|
|
68
|
+
git_system("add", "--", *Array(paths).map(&:to_s))
|
|
69
|
+
rescue => e
|
|
70
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Commit all staged/tracked changes with a message.
|
|
75
|
+
#
|
|
76
|
+
# @param message [String]
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def commit_all(message)
|
|
79
|
+
git_system("commit", "-am", message.to_s)
|
|
80
|
+
rescue => e
|
|
81
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Amend the current commit without changing its message.
|
|
86
|
+
#
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
def commit_amend_no_edit
|
|
89
|
+
git_system("commit", "--amend", "--no-edit")
|
|
90
|
+
rescue => e
|
|
91
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Return whether a tracked path has no unstaged changes.
|
|
96
|
+
#
|
|
97
|
+
# @param path [String]
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def diff_quiet?(path)
|
|
100
|
+
_out, status = git_capture2("diff", "--quiet", "--", path.to_s)
|
|
101
|
+
status.success?
|
|
102
|
+
rescue => e
|
|
103
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
104
|
+
false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Return whether a tracked path has no changes compared with HEAD.
|
|
108
|
+
#
|
|
109
|
+
# @param path [String]
|
|
110
|
+
# @return [Boolean]
|
|
111
|
+
def diff_head_quiet?(path)
|
|
112
|
+
_out, status = git_capture2("diff", "--quiet", "HEAD", "--", path.to_s)
|
|
113
|
+
status.success?
|
|
114
|
+
rescue => e
|
|
115
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Create an annotated tag.
|
|
120
|
+
#
|
|
121
|
+
# @param tag [String]
|
|
122
|
+
# @param message [String]
|
|
123
|
+
# @return [Boolean]
|
|
124
|
+
def tag_annotated(tag, message)
|
|
125
|
+
git_system("tag", "-a", tag.to_s, "-m", message.to_s)
|
|
126
|
+
rescue => e
|
|
127
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
128
|
+
false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Soft reset to a ref.
|
|
132
|
+
#
|
|
133
|
+
# @param ref [String]
|
|
134
|
+
# @return [Boolean]
|
|
135
|
+
def reset_soft(ref)
|
|
136
|
+
git_system("reset", "--soft", ref.to_s)
|
|
137
|
+
rescue => e
|
|
138
|
+
Kettle::Dev.debug_error(e, __method__)
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
53
142
|
# Create a new adapter rooted at the current working directory.
|
|
54
143
|
# @return [void]
|
|
55
|
-
def initialize
|
|
144
|
+
def initialize(root = Dir.pwd)
|
|
145
|
+
@root = File.expand_path(root.to_s)
|
|
56
146
|
# Allow users/CI to opt out of using the 'git' gem even when available.
|
|
57
147
|
# Set KETTLE_DEV_DISABLE_GIT_GEM to a truthy value ("1", "true", "yes") to force CLI backend.
|
|
58
148
|
env_val = ENV["KETTLE_DEV_DISABLE_GIT_GEM"]
|
|
@@ -63,7 +153,7 @@ module Kettle
|
|
|
63
153
|
else
|
|
64
154
|
Kernel.require "git"
|
|
65
155
|
@backend = :gem
|
|
66
|
-
@git = ::Git.open(
|
|
156
|
+
@git = ::Git.open(@root)
|
|
67
157
|
end
|
|
68
158
|
rescue LoadError => e
|
|
69
159
|
Kettle::Dev.debug_error(e, __method__, backtrace: false)
|
|
@@ -118,14 +208,14 @@ module Kettle
|
|
|
118
208
|
# The ruby-git gem does not expose a dedicated API for "--tags" consistently across versions.
|
|
119
209
|
# Use a shell fallback even when the gem backend is active. Tests should stub this method.
|
|
120
210
|
if remote && !remote.to_s.empty?
|
|
121
|
-
|
|
211
|
+
git_system("push", remote.to_s, "--tags")
|
|
122
212
|
else
|
|
123
|
-
|
|
213
|
+
git_system("push", "--tags")
|
|
124
214
|
end
|
|
125
215
|
elsif remote && !remote.to_s.empty?
|
|
126
|
-
|
|
216
|
+
git_system("push", remote.to_s, "--tags")
|
|
127
217
|
else
|
|
128
|
-
|
|
218
|
+
git_system("push", "--tags")
|
|
129
219
|
end
|
|
130
220
|
rescue => e
|
|
131
221
|
Kettle::Dev.debug_error(e, __method__)
|
|
@@ -137,7 +227,7 @@ module Kettle
|
|
|
137
227
|
if @backend == :gem
|
|
138
228
|
@git.current_branch
|
|
139
229
|
else
|
|
140
|
-
out, status =
|
|
230
|
+
out, status = git_capture2("rev-parse", "--abbrev-ref", "HEAD")
|
|
141
231
|
status.success? ? out.strip : nil
|
|
142
232
|
end
|
|
143
233
|
rescue => e
|
|
@@ -157,7 +247,7 @@ module Kettle
|
|
|
157
247
|
[]
|
|
158
248
|
end
|
|
159
249
|
else
|
|
160
|
-
out, status =
|
|
250
|
+
out, status = git_capture2("ls-files")
|
|
161
251
|
status.success? ? out.split(/\r?\n/).reject(&:empty?) : []
|
|
162
252
|
end
|
|
163
253
|
rescue => e
|
|
@@ -174,7 +264,7 @@ module Kettle
|
|
|
174
264
|
# @param path [String] path to the file, relative to the repository root
|
|
175
265
|
# @return [String] raw porcelain blame output, or "" on error / untracked file
|
|
176
266
|
def blame_porcelain(path)
|
|
177
|
-
out, status =
|
|
267
|
+
out, status = git_capture2("blame", "--porcelain", path.to_s)
|
|
178
268
|
status.success? ? out : ""
|
|
179
269
|
rescue => e
|
|
180
270
|
Kettle::Dev.debug_error(e, __method__)
|
|
@@ -186,7 +276,7 @@ module Kettle
|
|
|
186
276
|
if @backend == :gem
|
|
187
277
|
@git.remotes.map(&:name)
|
|
188
278
|
else
|
|
189
|
-
out, status =
|
|
279
|
+
out, status = git_capture2("remote")
|
|
190
280
|
status.success? ? out.split(/\r?\n/).map(&:strip).reject(&:empty?) : []
|
|
191
281
|
end
|
|
192
282
|
rescue => e
|
|
@@ -206,7 +296,7 @@ module Kettle
|
|
|
206
296
|
end
|
|
207
297
|
end
|
|
208
298
|
else
|
|
209
|
-
out, status =
|
|
299
|
+
out, status = git_capture2("remote", "-v")
|
|
210
300
|
return {} unless status.success?
|
|
211
301
|
|
|
212
302
|
urls = {}
|
|
@@ -230,7 +320,7 @@ module Kettle
|
|
|
230
320
|
r = @git.remotes.find { |x| x.name == name }
|
|
231
321
|
r&.url
|
|
232
322
|
else
|
|
233
|
-
out, status =
|
|
323
|
+
out, status = git_capture2("config", "--get", "remote.#{name}.url")
|
|
234
324
|
status.success? ? out.strip : nil
|
|
235
325
|
end
|
|
236
326
|
rescue => e
|
|
@@ -246,7 +336,7 @@ module Kettle
|
|
|
246
336
|
@git.checkout(branch)
|
|
247
337
|
true
|
|
248
338
|
else
|
|
249
|
-
|
|
339
|
+
git_system("checkout", branch.to_s)
|
|
250
340
|
end
|
|
251
341
|
rescue => e
|
|
252
342
|
Kettle::Dev.debug_error(e, __method__)
|
|
@@ -262,7 +352,7 @@ module Kettle
|
|
|
262
352
|
@git.pull(remote, branch)
|
|
263
353
|
true
|
|
264
354
|
else
|
|
265
|
-
|
|
355
|
+
git_system("pull", remote.to_s, branch.to_s)
|
|
266
356
|
end
|
|
267
357
|
rescue => e
|
|
268
358
|
Kettle::Dev.debug_error(e, __method__)
|
|
@@ -282,14 +372,32 @@ module Kettle
|
|
|
282
372
|
end
|
|
283
373
|
true
|
|
284
374
|
elsif ref
|
|
285
|
-
|
|
375
|
+
git_system("fetch", remote.to_s, ref.to_s)
|
|
286
376
|
else
|
|
287
|
-
|
|
377
|
+
git_system("fetch", remote.to_s)
|
|
288
378
|
end
|
|
289
379
|
rescue => e
|
|
290
380
|
Kettle::Dev.debug_error(e, __method__)
|
|
291
381
|
false
|
|
292
382
|
end
|
|
383
|
+
|
|
384
|
+
private
|
|
385
|
+
|
|
386
|
+
def git_capture2(*args)
|
|
387
|
+
if @root == Dir.pwd
|
|
388
|
+
Open3.capture2("git", *args)
|
|
389
|
+
else
|
|
390
|
+
Open3.capture2("git", *args, chdir: @root)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def git_system(*args)
|
|
395
|
+
if @root == Dir.pwd
|
|
396
|
+
system("git", *args)
|
|
397
|
+
else
|
|
398
|
+
system("git", *args, chdir: @root)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
293
401
|
end
|
|
294
402
|
end
|
|
295
403
|
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Kettle
|
|
6
|
+
module Dev
|
|
7
|
+
class InteractiveReleaseCommand
|
|
8
|
+
def initialize(secrets_provider:, input: $stdin, output: $stdout, error: $stderr)
|
|
9
|
+
@secrets_provider = secrets_provider
|
|
10
|
+
@input = input
|
|
11
|
+
@output = output
|
|
12
|
+
@error = error
|
|
13
|
+
@gem_signing_passphrase = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env, cmd)
|
|
17
|
+
return call_with_pty(env, cmd) if pty_available?
|
|
18
|
+
|
|
19
|
+
call_with_open3(env, cmd)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :secrets_provider
|
|
25
|
+
|
|
26
|
+
def call_with_pty(env, cmd)
|
|
27
|
+
stdout_str = +""
|
|
28
|
+
status = nil
|
|
29
|
+
PTY.spawn(env, cmd) do |output, input, pid|
|
|
30
|
+
begin
|
|
31
|
+
loop do
|
|
32
|
+
chunk = output.readpartial(1024)
|
|
33
|
+
stdout_str << chunk
|
|
34
|
+
@output.print(chunk)
|
|
35
|
+
handle_prompt(input, chunk)
|
|
36
|
+
end
|
|
37
|
+
rescue Errno::EIO
|
|
38
|
+
# PTY raises EIO when the child process exits after closing the slave.
|
|
39
|
+
rescue Kettle::Dev::Error
|
|
40
|
+
Process.kill("TERM", pid)
|
|
41
|
+
raise
|
|
42
|
+
ensure
|
|
43
|
+
_, status = Process.wait2(pid)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
[stdout_str, "", status]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def call_with_open3(env, cmd)
|
|
50
|
+
stdout_str = +""
|
|
51
|
+
stderr_str = +""
|
|
52
|
+
status = nil
|
|
53
|
+
Open3.popen3(env, cmd) do |input, output, error, wait_thread|
|
|
54
|
+
readers = [output, error]
|
|
55
|
+
until readers.empty?
|
|
56
|
+
ready = IO.select(readers)
|
|
57
|
+
ready.first.each do |reader|
|
|
58
|
+
begin
|
|
59
|
+
chunk = reader.readpartial(1024)
|
|
60
|
+
if reader.equal?(output)
|
|
61
|
+
stdout_str << chunk
|
|
62
|
+
@output.print(chunk)
|
|
63
|
+
else
|
|
64
|
+
stderr_str << chunk
|
|
65
|
+
@error.print(chunk)
|
|
66
|
+
end
|
|
67
|
+
handle_prompt(input, chunk)
|
|
68
|
+
rescue EOFError
|
|
69
|
+
readers.delete(reader)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
status = wait_thread.value
|
|
74
|
+
end
|
|
75
|
+
[stdout_str, stderr_str, status]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_prompt(input, chunk)
|
|
79
|
+
if otp_prompt?(chunk)
|
|
80
|
+
write_secret(input, secrets_provider.rubygems_otp, label: "RubyGems MFA code") if otp_response_prompt?(chunk)
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
write_secret(input, gem_signing_passphrase, label: "gem signing passphrase") if signing_password_prompt?(chunk)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def gem_signing_passphrase
|
|
88
|
+
@gem_signing_passphrase ||= secrets_provider.gem_signing_passphrase.to_s
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def write_secret(input, value, label:)
|
|
92
|
+
secret = value.to_s
|
|
93
|
+
if secret.empty?
|
|
94
|
+
raise Kettle::Dev::Error, "#{label} prompt reached, but the configured release secrets provider returned no value. Aborting because secret prompts are not allowed when a secrets provider is configured."
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@output.puts("#{label} loaded from configured secrets provider.")
|
|
98
|
+
input.write("#{secret}\n")
|
|
99
|
+
input.flush
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def otp_prompt?(chunk)
|
|
103
|
+
chunk.match?(/(?:multi-factor authentication|OTP code|one-time password|\bCode:\s*)/i)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def otp_response_prompt?(chunk)
|
|
107
|
+
chunk.match?(/(?:OTP code|one-time password|\bCode:\s*)/i)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def signing_password_prompt?(chunk)
|
|
111
|
+
chunk.match?(/(?:enter\s+)?(?:PEM\s+)?pass(?:\s|-)?phrase\s*(?:for\s+[^:]+)?[:?]\s*\z/i) ||
|
|
112
|
+
chunk.match?(/(?:PEM|private key) password\s*[:?]\s*\z/i)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def pty_available?
|
|
116
|
+
return false unless RUBY_ENGINE == "ruby"
|
|
117
|
+
|
|
118
|
+
require "pty"
|
|
119
|
+
true
|
|
120
|
+
rescue LoadError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|