kettle-dev 1.0.9 → 1.0.10
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/.envrc +4 -3
- data/.github/workflows/coverage.yml +3 -3
- data/.junie/guidelines.md +4 -3
- data/.simplecov +5 -1
- data/Appraisals +3 -0
- data/CHANGELOG.md +22 -1
- data/CONTRIBUTING.md +6 -0
- data/README.md +18 -5
- data/Rakefile +7 -11
- data/exe/kettle-commit-msg +9 -143
- data/exe/kettle-readme-backers +7 -353
- data/exe/kettle-release +8 -702
- data/lib/kettle/dev/ci_helpers.rb +1 -0
- data/lib/kettle/dev/commit_msg.rb +39 -0
- data/lib/kettle/dev/exit_adapter.rb +36 -0
- data/lib/kettle/dev/git_adapter.rb +120 -0
- data/lib/kettle/dev/git_commit_footer.rb +130 -0
- data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
- data/lib/kettle/dev/rakelib/bench.rake +2 -7
- data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
- data/lib/kettle/dev/rakelib/ci.rake +4 -396
- data/lib/kettle/dev/rakelib/install.rake +1 -295
- data/lib/kettle/dev/rakelib/reek.rake +2 -0
- data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
- data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
- data/lib/kettle/dev/rakelib/template.rake +3 -465
- data/lib/kettle/dev/readme_backers.rb +340 -0
- data/lib/kettle/dev/release_cli.rb +672 -0
- data/lib/kettle/dev/tasks/ci_task.rb +334 -0
- data/lib/kettle/dev/tasks/install_task.rb +298 -0
- data/lib/kettle/dev/tasks/template_task.rb +491 -0
- data/lib/kettle/dev/template_helpers.rb +4 -4
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev.rb +30 -1
- data/lib/kettle-dev.rb +2 -3
- data/sig/kettle/dev/ci_helpers.rbs +8 -17
- data/sig/kettle/dev/commit_msg.rbs +8 -0
- data/sig/kettle/dev/exit_adapter.rbs +8 -0
- data/sig/kettle/dev/git_adapter.rbs +15 -0
- data/sig/kettle/dev/git_commit_footer.rbs +16 -0
- data/sig/kettle/dev/readme_backers.rbs +20 -0
- data/sig/kettle/dev/release_cli.rbs +8 -0
- data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
- data/sig/kettle/dev/tasks/install_task.rbs +10 -0
- data/sig/kettle/dev/tasks/template_task.rbs +10 -0
- data/sig/kettle/dev/tasks.rbs +0 -0
- data/sig/kettle/dev/version.rbs +0 -0
- data/sig/kettle/emoji_regex.rbs +5 -0
- data/sig/kettle-dev.rbs +0 -0
- data.tar.gz.sig +0 -0
- metadata +55 -5
- metadata.gz.sig +0 -0
@@ -0,0 +1,672 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# External stdlib
|
4
|
+
require "digest"
|
5
|
+
require "open3"
|
6
|
+
require "shellwords"
|
7
|
+
require "time"
|
8
|
+
require "fileutils"
|
9
|
+
require "net/http"
|
10
|
+
require "json"
|
11
|
+
require "uri"
|
12
|
+
|
13
|
+
# External gems
|
14
|
+
require "ruby-progressbar"
|
15
|
+
|
16
|
+
# Internal
|
17
|
+
require "kettle/dev/git_adapter"
|
18
|
+
require "kettle/dev/exit_adapter"
|
19
|
+
|
20
|
+
module Kettle
|
21
|
+
module Dev
|
22
|
+
class ReleaseCLI
|
23
|
+
private
|
24
|
+
|
25
|
+
def abort(msg)
|
26
|
+
Kettle::Dev::ExitAdapter.abort(msg)
|
27
|
+
end
|
28
|
+
|
29
|
+
public
|
30
|
+
|
31
|
+
def initialize
|
32
|
+
@root = Kettle::Dev::CIHelpers.project_root
|
33
|
+
@git = Kettle::Dev::GitAdapter.new
|
34
|
+
end
|
35
|
+
|
36
|
+
def run
|
37
|
+
puts "== kettle-release =="
|
38
|
+
|
39
|
+
ensure_bundler_2_7_plus!
|
40
|
+
|
41
|
+
version = detect_version
|
42
|
+
puts "Detected version: #{version.inspect}"
|
43
|
+
|
44
|
+
latest_overall = nil
|
45
|
+
latest_for_series = nil
|
46
|
+
begin
|
47
|
+
gem_name = detect_gem_name
|
48
|
+
latest_overall, latest_for_series = latest_released_versions(gem_name, version)
|
49
|
+
rescue StandardError => e
|
50
|
+
warn("Warning: failed to check RubyGems for latest version (#{e.class}: #{e.message}). Proceeding.")
|
51
|
+
end
|
52
|
+
|
53
|
+
if latest_overall
|
54
|
+
msg = "Latest released: #{latest_overall}"
|
55
|
+
if latest_for_series && latest_for_series != latest_overall
|
56
|
+
msg += " | Latest for series #{Gem::Version.new(version).segments[0, 2].join(".")}.x: #{latest_for_series}"
|
57
|
+
elsif latest_for_series
|
58
|
+
msg += " (matches current series)"
|
59
|
+
end
|
60
|
+
puts msg
|
61
|
+
|
62
|
+
cur = Gem::Version.new(version)
|
63
|
+
overall = Gem::Version.new(latest_overall)
|
64
|
+
cur_series = cur.segments[0, 2]
|
65
|
+
overall_series = overall.segments[0, 2]
|
66
|
+
target = if (cur_series <=> overall_series) == -1
|
67
|
+
latest_for_series
|
68
|
+
else
|
69
|
+
latest_overall
|
70
|
+
end
|
71
|
+
if target && Gem::Version.new(version) <= Gem::Version.new(target)
|
72
|
+
series = cur_series.join(".")
|
73
|
+
warn("version.rb (#{version}) must be greater than the latest released version for series #{series}. Latest for series: #{target}.")
|
74
|
+
warn("Tip: bump PATCH for a stable branch release, or bump MINOR/MAJOR when on trunk.")
|
75
|
+
abort("Aborting: version bump required.")
|
76
|
+
end
|
77
|
+
else
|
78
|
+
puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
|
79
|
+
end
|
80
|
+
|
81
|
+
puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
|
82
|
+
print("> ")
|
83
|
+
ans = $stdin.gets&.strip
|
84
|
+
abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
|
85
|
+
|
86
|
+
run_cmd!("bin/setup")
|
87
|
+
run_cmd!("bin/rake")
|
88
|
+
|
89
|
+
appraisals_path = File.join(@root, "Appraisals")
|
90
|
+
if File.file?(appraisals_path)
|
91
|
+
puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
|
92
|
+
run_cmd!("bin/rake appraisal:update")
|
93
|
+
else
|
94
|
+
puts "No Appraisals file found; skipping appraisal:update"
|
95
|
+
end
|
96
|
+
|
97
|
+
ensure_git_user!
|
98
|
+
committed = commit_release_prep!(version)
|
99
|
+
|
100
|
+
maybe_run_local_ci_before_push!(committed)
|
101
|
+
|
102
|
+
trunk = detect_trunk_branch
|
103
|
+
feature = current_branch
|
104
|
+
puts "Trunk branch detected: #{trunk}"
|
105
|
+
ensure_trunk_synced_before_push!(trunk, feature)
|
106
|
+
|
107
|
+
push!
|
108
|
+
|
109
|
+
monitor_workflows_after_push!
|
110
|
+
|
111
|
+
merge_feature_into_trunk_and_push!(trunk, feature)
|
112
|
+
|
113
|
+
checkout!(trunk)
|
114
|
+
pull!(trunk)
|
115
|
+
|
116
|
+
# Strong reminder for local runs: skip signing when testing a release flow
|
117
|
+
if ENV["SKIP_GEM_SIGNING"].to_s.strip == ""
|
118
|
+
puts "TIP: For local dry-runs or testing the release workflow, set SKIP_GEM_SIGNING=true to avoid PEM password prompts."
|
119
|
+
# Prompt on CI to allow an explicit abort when signing would otherwise hang
|
120
|
+
if ENV.fetch("CI", "false").casecmp("true").zero?
|
121
|
+
print("Proceed with signing enabled? This may hang waiting for a PEM password. [y/N]: ")
|
122
|
+
ans = $stdin.gets&.strip
|
123
|
+
unless ans&.downcase&.start_with?("y")
|
124
|
+
abort("Aborted. Re-run with SKIP_GEM_SIGNING=true bundle exec kettle-release (or set it in your environment).")
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
ensure_signing_setup_or_skip!
|
130
|
+
puts "Running build (you may be prompted for the signing key password)..."
|
131
|
+
run_cmd!("bundle exec rake build")
|
132
|
+
|
133
|
+
run_cmd!("bin/gem_checksums")
|
134
|
+
validate_checksums!(version, stage: "after build + gem_checksums")
|
135
|
+
|
136
|
+
puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
|
137
|
+
run_cmd!("bundle exec rake release")
|
138
|
+
validate_checksums!(version, stage: "after release")
|
139
|
+
|
140
|
+
puts "\nRelease complete. Don't forget to push the checksums commit if needed."
|
141
|
+
end
|
142
|
+
|
143
|
+
private
|
144
|
+
|
145
|
+
def monitor_workflows_after_push!
|
146
|
+
root = Kettle::Dev::CIHelpers.project_root
|
147
|
+
workflows = Kettle::Dev::CIHelpers.workflows_list(root)
|
148
|
+
gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
|
149
|
+
|
150
|
+
branch = Kettle::Dev::CIHelpers.current_branch
|
151
|
+
abort("Could not determine current branch for CI checks.") unless branch
|
152
|
+
|
153
|
+
gh_remote = preferred_github_remote
|
154
|
+
gh_owner = nil
|
155
|
+
gh_repo = nil
|
156
|
+
if gh_remote && !workflows.empty?
|
157
|
+
url = remote_url(gh_remote)
|
158
|
+
gh_owner, gh_repo = parse_github_owner_repo(url)
|
159
|
+
end
|
160
|
+
|
161
|
+
checks_any = false
|
162
|
+
|
163
|
+
if gh_owner && gh_repo && !workflows.empty?
|
164
|
+
checks_any = true
|
165
|
+
total = workflows.size
|
166
|
+
abort("No GitHub workflows found under .github/workflows; aborting.") if total.zero?
|
167
|
+
|
168
|
+
passed = {}
|
169
|
+
idx = 0
|
170
|
+
puts "Ensuring GitHub Actions workflows pass on #{branch} (#{gh_owner}/#{gh_repo}) via remote '#{gh_remote}'"
|
171
|
+
pbar = if defined?(ProgressBar)
|
172
|
+
ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
|
173
|
+
end
|
174
|
+
|
175
|
+
loop do
|
176
|
+
wf = workflows[idx]
|
177
|
+
run = Kettle::Dev::CIHelpers.latest_run(owner: gh_owner, repo: gh_repo, workflow_file: wf, branch: branch)
|
178
|
+
if run
|
179
|
+
if Kettle::Dev::CIHelpers.success?(run)
|
180
|
+
unless passed[wf]
|
181
|
+
passed[wf] = true
|
182
|
+
pbar&.increment
|
183
|
+
end
|
184
|
+
elsif Kettle::Dev::CIHelpers.failed?(run)
|
185
|
+
puts
|
186
|
+
url = run["html_url"] || "https://github.com/#{gh_owner}/#{gh_repo}/actions/workflows/#{wf}"
|
187
|
+
abort("Workflow failed: #{wf} -> #{url}")
|
188
|
+
end
|
189
|
+
end
|
190
|
+
break if passed.size == total
|
191
|
+
idx = (idx + 1) % total
|
192
|
+
sleep(1)
|
193
|
+
end
|
194
|
+
pbar&.finish unless pbar&.finished?
|
195
|
+
puts "\nAll GitHub workflows passing (#{passed.size}/#{total})."
|
196
|
+
end
|
197
|
+
|
198
|
+
gl_remote = gitlab_remote_candidates.first
|
199
|
+
if gitlab_ci && gl_remote
|
200
|
+
owner, repo = Kettle::Dev::CIHelpers.repo_info_gitlab
|
201
|
+
if owner && repo
|
202
|
+
checks_any = true
|
203
|
+
puts "Ensuring GitLab pipeline passes on #{branch} (#{owner}/#{repo}) via remote '#{gl_remote}'"
|
204
|
+
pbar = if defined?(ProgressBar)
|
205
|
+
ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
|
206
|
+
end
|
207
|
+
loop do
|
208
|
+
pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
|
209
|
+
if pipe
|
210
|
+
if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
|
211
|
+
pbar&.increment unless pbar&.finished?
|
212
|
+
break
|
213
|
+
elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
|
214
|
+
puts
|
215
|
+
url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
|
216
|
+
abort("Pipeline failed: #{url}")
|
217
|
+
end
|
218
|
+
end
|
219
|
+
sleep(1)
|
220
|
+
end
|
221
|
+
pbar&.finish unless pbar&.finished?
|
222
|
+
puts "\nGitLab pipeline passing."
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") unless checks_any
|
227
|
+
end
|
228
|
+
|
229
|
+
def run_cmd!(cmd)
|
230
|
+
# For Bundler-invoked build/release, explicitly prefix SKIP_GEM_SIGNING so
|
231
|
+
# the signing step is skipped even when Bundler scrubs ENV.
|
232
|
+
if ENV["SKIP_GEM_SIGNING"] && cmd =~ /\Abundle(\s+exec)?\s+rake\s+(build|release)\b/
|
233
|
+
cmd = "SKIP_GEM_SIGNING=true #{cmd}"
|
234
|
+
end
|
235
|
+
puts "$ #{cmd}"
|
236
|
+
# Pass a plain Hash for the environment to satisfy tests and avoid ENV object oddities
|
237
|
+
env_hash = ENV.respond_to?(:to_hash) ? ENV.to_hash : ENV.to_h
|
238
|
+
success = system(env_hash, cmd)
|
239
|
+
abort("Command failed: #{cmd}") unless success
|
240
|
+
end
|
241
|
+
|
242
|
+
def git_output(args)
|
243
|
+
out, status = Open3.capture2("git", *args)
|
244
|
+
[out.strip, status.success?]
|
245
|
+
end
|
246
|
+
|
247
|
+
def ensure_git_user!
|
248
|
+
name, ok1 = git_output(["config", "user.name"])
|
249
|
+
email, ok2 = git_output(["config", "user.email"])
|
250
|
+
abort("Git user.name or user.email not configured.") unless ok1 && ok2 && !name.empty? && !email.empty?
|
251
|
+
end
|
252
|
+
|
253
|
+
def ensure_bundler_2_7_plus!
|
254
|
+
begin
|
255
|
+
require "bundler"
|
256
|
+
rescue LoadError
|
257
|
+
abort("Bundler is required. Please install bundler >= 2.7.0 and try again.")
|
258
|
+
end
|
259
|
+
ver = Gem::Version.new(Bundler::VERSION)
|
260
|
+
min = Gem::Version.new("2.7.0")
|
261
|
+
if ver < min
|
262
|
+
abort("kettle-release requires Bundler >= 2.7.0 for reproducible builds by default. Current: #{Bundler::VERSION}. Please upgrade bundler.")
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
def maybe_run_local_ci_before_push!(committed)
|
267
|
+
mode = (ENV["K_RELEASE_LOCAL_CI"] || "").strip.downcase
|
268
|
+
run_it = case mode
|
269
|
+
when "true", "1", "yes", "y" then true
|
270
|
+
when "ask"
|
271
|
+
print("Run local CI with 'act' before pushing? [Y/n] ")
|
272
|
+
ans = $stdin.gets&.strip
|
273
|
+
ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
|
274
|
+
else
|
275
|
+
false
|
276
|
+
end
|
277
|
+
return unless run_it
|
278
|
+
|
279
|
+
act_ok = begin
|
280
|
+
system("act", "--version", out: File::NULL, err: File::NULL)
|
281
|
+
rescue StandardError
|
282
|
+
false
|
283
|
+
end
|
284
|
+
unless act_ok
|
285
|
+
puts "Skipping local CI: 'act' command not found. Install https://github.com/nektos/act to enable."
|
286
|
+
return
|
287
|
+
end
|
288
|
+
|
289
|
+
root = Kettle::Dev::CIHelpers.project_root
|
290
|
+
workflows_dir = File.join(root, ".github", "workflows")
|
291
|
+
candidates = Kettle::Dev::CIHelpers.workflows_list(root)
|
292
|
+
|
293
|
+
chosen = (ENV["K_RELEASE_LOCAL_CI_WORKFLOW"] || "").strip
|
294
|
+
if !chosen.empty?
|
295
|
+
chosen = "#{chosen}.yml" unless chosen =~ /\.ya?ml\z/
|
296
|
+
else
|
297
|
+
chosen = if candidates.include?("locked_deps.yml")
|
298
|
+
"locked_deps.yml"
|
299
|
+
elsif candidates.include?("locked_deps.yaml")
|
300
|
+
"locked_deps.yaml"
|
301
|
+
else
|
302
|
+
candidates.first
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
unless chosen
|
307
|
+
puts "Skipping local CI: no workflows found under .github/workflows."
|
308
|
+
return
|
309
|
+
end
|
310
|
+
|
311
|
+
file_path = File.join(workflows_dir, chosen)
|
312
|
+
unless File.file?(file_path)
|
313
|
+
puts "Skipping local CI: selected workflow not found: #{file_path}"
|
314
|
+
return
|
315
|
+
end
|
316
|
+
|
317
|
+
puts "== Running local CI with act on #{chosen} =="
|
318
|
+
ok = system("act", "-W", file_path)
|
319
|
+
if ok
|
320
|
+
puts "Local CI succeeded for #{chosen}."
|
321
|
+
else
|
322
|
+
puts "Local CI failed for #{chosen}."
|
323
|
+
if committed
|
324
|
+
puts "Rolling back release prep commit (soft reset)..."
|
325
|
+
system("git", "reset", "--soft", "HEAD^")
|
326
|
+
end
|
327
|
+
abort("Aborting due to local CI failure.")
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
def detect_version
|
332
|
+
candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
|
333
|
+
abort("Could not find version.rb under lib/**.") if candidates.empty?
|
334
|
+
versions = candidates.map do |path|
|
335
|
+
content = File.read(path)
|
336
|
+
m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
|
337
|
+
next unless m
|
338
|
+
m[2]
|
339
|
+
end.compact
|
340
|
+
abort("VERSION constant not found in #{@root}/lib/**/version.rb") if versions.none?
|
341
|
+
abort("Multiple VERSION constants found to be out of sync (#{versions.inspect}) in #{@root}/lib/**/version.rb") unless versions.uniq.length == 1
|
342
|
+
versions.first
|
343
|
+
end
|
344
|
+
|
345
|
+
def detect_gem_name
|
346
|
+
gemspecs = Dir[File.join(@root, "*.gemspec")]
|
347
|
+
abort("Could not find a .gemspec in project root.") if gemspecs.empty?
|
348
|
+
path = gemspecs.min
|
349
|
+
content = File.read(path)
|
350
|
+
m = content.match(/spec\.name\s*=\s*(["'])([^"']+)\1/)
|
351
|
+
abort("Could not determine gem name from #{path}.") unless m
|
352
|
+
m[2]
|
353
|
+
end
|
354
|
+
|
355
|
+
def latest_released_versions(gem_name, current_version)
|
356
|
+
uri = URI("https://rubygems.org/api/v1/versions/#{gem_name}.json")
|
357
|
+
res = Net::HTTP.get_response(uri)
|
358
|
+
return [nil, nil] unless res.is_a?(Net::HTTPSuccess)
|
359
|
+
data = JSON.parse(res.body)
|
360
|
+
versions = data.map { |h| h["number"] }.compact
|
361
|
+
versions.reject! { |v| v.to_s.include?("-pre") || v.to_s.include?(".pre") || v.to_s =~ /[a-zA-Z]/ }
|
362
|
+
gversions = versions.map { |s| Gem::Version.new(s) }.sort
|
363
|
+
latest_overall = gversions.last&.to_s
|
364
|
+
|
365
|
+
cur = Gem::Version.new(current_version)
|
366
|
+
series = cur.segments[0, 2]
|
367
|
+
series_versions = gversions.select { |gv| gv.segments[0, 2] == series }
|
368
|
+
latest_series = series_versions.last&.to_s
|
369
|
+
[latest_overall, latest_series]
|
370
|
+
rescue StandardError
|
371
|
+
[nil, nil]
|
372
|
+
end
|
373
|
+
|
374
|
+
def commit_release_prep!(version)
|
375
|
+
msg = "🔖 Prepare release v#{version}"
|
376
|
+
# Stage all changes (including new/untracked files) prior to committing
|
377
|
+
run_cmd!("git add -A")
|
378
|
+
out, _ = git_output(["status", "--porcelain"])
|
379
|
+
if out.empty?
|
380
|
+
puts "No changes to commit for release prep (continuing)."
|
381
|
+
false
|
382
|
+
else
|
383
|
+
run_cmd!(%(git commit -am #{Shellwords.escape(msg)}))
|
384
|
+
true
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
def push!
|
389
|
+
branch = current_branch
|
390
|
+
abort("Could not determine current branch to push.") unless branch
|
391
|
+
|
392
|
+
if has_remote?("all")
|
393
|
+
puts "$ git push all #{branch}"
|
394
|
+
success = @git.push("all", branch)
|
395
|
+
unless success
|
396
|
+
warn("Normal push to 'all' failed; retrying with force push...")
|
397
|
+
@git.push("all", branch, force: true)
|
398
|
+
end
|
399
|
+
return
|
400
|
+
end
|
401
|
+
|
402
|
+
remotes = []
|
403
|
+
remotes << "origin" if has_remote?("origin")
|
404
|
+
remotes |= github_remote_candidates
|
405
|
+
remotes |= gitlab_remote_candidates
|
406
|
+
remotes |= codeberg_remote_candidates
|
407
|
+
remotes.uniq!
|
408
|
+
|
409
|
+
if remotes.empty?
|
410
|
+
puts "$ git push #{branch}"
|
411
|
+
success = @git.push(nil, branch)
|
412
|
+
unless success
|
413
|
+
warn("Normal push failed; retrying with force push...")
|
414
|
+
@git.push(nil, branch, force: true)
|
415
|
+
end
|
416
|
+
return
|
417
|
+
end
|
418
|
+
|
419
|
+
remotes.each do |remote|
|
420
|
+
puts "$ git push #{remote} #{branch}"
|
421
|
+
success = @git.push(remote, branch)
|
422
|
+
unless success
|
423
|
+
warn("Push to #{remote} failed; retrying with force push...")
|
424
|
+
@git.push(remote, branch, force: true)
|
425
|
+
end
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
def detect_trunk_branch
|
430
|
+
out, ok = git_output(["remote", "show", "origin"])
|
431
|
+
abort("Failed to get origin remote info.") unless ok
|
432
|
+
m = out.lines.find { |l| l.include?("HEAD branch") }
|
433
|
+
abort("Unable to detect trunk branch from origin.") unless m
|
434
|
+
m.split.last
|
435
|
+
end
|
436
|
+
|
437
|
+
def checkout!(branch)
|
438
|
+
ok = @git.checkout(branch)
|
439
|
+
abort("Failed to checkout #{branch}") unless ok
|
440
|
+
end
|
441
|
+
|
442
|
+
def pull!(branch)
|
443
|
+
ok = @git.pull("origin", branch)
|
444
|
+
abort("Failed to pull origin #{branch}") unless ok
|
445
|
+
end
|
446
|
+
|
447
|
+
def current_branch
|
448
|
+
@git.current_branch
|
449
|
+
end
|
450
|
+
|
451
|
+
def list_remotes
|
452
|
+
@git.remotes
|
453
|
+
end
|
454
|
+
|
455
|
+
def remotes_with_urls
|
456
|
+
@git.remotes_with_urls
|
457
|
+
end
|
458
|
+
|
459
|
+
def remote_url(name)
|
460
|
+
@git.remote_url(name)
|
461
|
+
end
|
462
|
+
|
463
|
+
def github_remote_candidates
|
464
|
+
remotes_with_urls.select { |n, u| u.include?("github.com") }.keys
|
465
|
+
end
|
466
|
+
|
467
|
+
def gitlab_remote_candidates
|
468
|
+
remotes_with_urls.select { |n, u| u.include?("gitlab.com") }.keys
|
469
|
+
end
|
470
|
+
|
471
|
+
def codeberg_remote_candidates
|
472
|
+
remotes_with_urls.select { |n, u| u.include?("codeberg.org") }.keys
|
473
|
+
end
|
474
|
+
|
475
|
+
def preferred_github_remote
|
476
|
+
cands = github_remote_candidates
|
477
|
+
return if cands.empty?
|
478
|
+
# Prefer explicitly named GitHub remotes first, then origin (only if it points to GitHub), else the first candidate
|
479
|
+
explicit = cands.find { |n| n == "github" } || cands.find { |n| n == "gh" }
|
480
|
+
return explicit if explicit
|
481
|
+
return "origin" if cands.include?("origin")
|
482
|
+
cands.first
|
483
|
+
end
|
484
|
+
|
485
|
+
def parse_github_owner_repo(url)
|
486
|
+
return [nil, nil] unless url
|
487
|
+
if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
|
488
|
+
[Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
|
489
|
+
elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
|
490
|
+
[Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
|
491
|
+
else
|
492
|
+
[nil, nil]
|
493
|
+
end
|
494
|
+
end
|
495
|
+
|
496
|
+
def has_remote?(name)
|
497
|
+
list_remotes.include?(name)
|
498
|
+
end
|
499
|
+
|
500
|
+
def remote_branch_exists?(remote, branch)
|
501
|
+
_out, ok = git_output(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote}/#{branch}"])
|
502
|
+
ok
|
503
|
+
end
|
504
|
+
|
505
|
+
def ahead_behind_counts(local_ref, remote_ref)
|
506
|
+
out, ok = git_output(["rev-list", "--left-right", "--count", "#{local_ref}...#{remote_ref}"])
|
507
|
+
return [0, 0] unless ok && !out.empty?
|
508
|
+
parts = out.split
|
509
|
+
left = parts[0].to_i
|
510
|
+
right = parts[1].to_i
|
511
|
+
[left, right]
|
512
|
+
end
|
513
|
+
|
514
|
+
def trunk_behind_remote?(trunk, remote)
|
515
|
+
return false unless remote_branch_exists?(remote, trunk)
|
516
|
+
_ahead, behind = ahead_behind_counts(trunk, "#{remote}/#{trunk}")
|
517
|
+
behind.positive?
|
518
|
+
end
|
519
|
+
|
520
|
+
def ensure_trunk_synced_before_push!(trunk, feature)
|
521
|
+
if has_remote?("all")
|
522
|
+
puts "Remote 'all' detected. Fetching from all remotes and enforcing strict trunk parity..."
|
523
|
+
run_cmd!("git fetch --all")
|
524
|
+
remotes = list_remotes
|
525
|
+
missing_from = []
|
526
|
+
remotes.each do |r|
|
527
|
+
next if r == "all"
|
528
|
+
if remote_branch_exists?(r, trunk)
|
529
|
+
_ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
|
530
|
+
missing_from << r if behind.positive?
|
531
|
+
end
|
532
|
+
end
|
533
|
+
unless missing_from.empty?
|
534
|
+
abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
|
535
|
+
end
|
536
|
+
puts "Local #{trunk} has all commits from remotes: #{(remotes - ["all"]).join(", ")}"
|
537
|
+
return
|
538
|
+
end
|
539
|
+
|
540
|
+
run_cmd!("git fetch origin #{Shellwords.escape(trunk)}")
|
541
|
+
if trunk_behind_remote?(trunk, "origin")
|
542
|
+
puts "Local #{trunk} is behind origin/#{trunk}. Rebasing..."
|
543
|
+
cur = current_branch
|
544
|
+
checkout!(trunk) unless cur == trunk
|
545
|
+
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
546
|
+
checkout!(feature) unless feature.nil? || feature == trunk
|
547
|
+
run_cmd!("git rebase #{Shellwords.escape(trunk)}")
|
548
|
+
puts "Rebase complete. Will push updated branch next."
|
549
|
+
else
|
550
|
+
puts "Local #{trunk} is up to date with origin/#{trunk}."
|
551
|
+
end
|
552
|
+
|
553
|
+
gh_remote = preferred_github_remote
|
554
|
+
if gh_remote && gh_remote != "origin"
|
555
|
+
puts "GitHub remote detected: #{gh_remote}. Fetching #{trunk}..."
|
556
|
+
run_cmd!("git fetch #{gh_remote} #{Shellwords.escape(trunk)}")
|
557
|
+
|
558
|
+
left, right = ahead_behind_counts("origin/#{trunk}", "#{gh_remote}/#{trunk}")
|
559
|
+
if left.zero? && right.zero?
|
560
|
+
puts "origin/#{trunk} and #{gh_remote}/#{trunk} are already in sync."
|
561
|
+
return
|
562
|
+
end
|
563
|
+
|
564
|
+
checkout!(trunk)
|
565
|
+
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
566
|
+
|
567
|
+
if left.positive? && right.positive?
|
568
|
+
puts "origin/#{trunk} and #{gh_remote}/#{trunk} have diverged (#{left} ahead of GH, #{right} behind GH)."
|
569
|
+
puts "Choose how to reconcile:"
|
570
|
+
puts " [r] Rebase local/#{trunk} on top of #{gh_remote}/#{trunk} (push to origin)"
|
571
|
+
puts " [m] Merge --no-ff #{gh_remote}/#{trunk} into #{trunk} (push to origin and #{gh_remote})"
|
572
|
+
puts " [a] Abort"
|
573
|
+
print("> ")
|
574
|
+
choice = $stdin.gets&.strip&.downcase
|
575
|
+
case choice
|
576
|
+
when "r"
|
577
|
+
run_cmd!("git rebase #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
|
578
|
+
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
579
|
+
puts "Rebased #{trunk} onto #{gh_remote}/#{trunk} and pushed to origin."
|
580
|
+
when "m"
|
581
|
+
run_cmd!("git merge --no-ff #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
|
582
|
+
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
583
|
+
run_cmd!("git push #{Shellwords.escape(gh_remote)} #{Shellwords.escape(trunk)}")
|
584
|
+
puts "Merged #{gh_remote}/#{trunk} into #{trunk} and pushed to origin and #{gh_remote}."
|
585
|
+
else
|
586
|
+
abort("Aborted by user. Please reconcile trunks and re-run.")
|
587
|
+
end
|
588
|
+
elsif right.positive? && left.zero?
|
589
|
+
puts "Fast-forwarding #{trunk} to include #{gh_remote}/#{trunk}..."
|
590
|
+
run_cmd!("git merge --ff-only #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
|
591
|
+
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
592
|
+
elsif left.positive? && right.zero?
|
593
|
+
puts "origin/#{trunk} is ahead of #{gh_remote}/#{trunk}; no action required before push."
|
594
|
+
end
|
595
|
+
end
|
596
|
+
end
|
597
|
+
|
598
|
+
def merge_feature_into_trunk_and_push!(trunk, feature)
|
599
|
+
return if feature.nil? || feature == trunk
|
600
|
+
puts "Merging #{feature} into #{trunk} (after CI success)..."
|
601
|
+
checkout!(trunk)
|
602
|
+
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
603
|
+
run_cmd!("git merge #{Shellwords.escape(feature)}")
|
604
|
+
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
605
|
+
puts "Merged #{feature} into #{trunk} and pushed. The PR (if any) should auto-close."
|
606
|
+
end
|
607
|
+
|
608
|
+
def ensure_signing_setup_or_skip!
|
609
|
+
# Treat any non-empty value as an explicit skip signal (more robust across Ruby versions and ENV adapters)
|
610
|
+
return if ENV["SKIP_GEM_SIGNING"].to_s.strip != ""
|
611
|
+
|
612
|
+
user = ENV.fetch("GEM_CERT_USER", ENV["USER"])
|
613
|
+
cert_path = File.join(@root, "certs", "#{user}.pem")
|
614
|
+
unless File.exist?(cert_path)
|
615
|
+
abort(<<~MSG)
|
616
|
+
Gem signing appears enabled but no public cert found at:
|
617
|
+
#{cert_path}
|
618
|
+
Add your public key to certs/<USER>.pem (or set GEM_CERT_USER), or set SKIP_GEM_SIGNING to build unsigned.
|
619
|
+
MSG
|
620
|
+
end
|
621
|
+
puts "Found signing cert: #{cert_path}"
|
622
|
+
puts "When prompted during build/release, enter the PEM password for ~/.ssh/gem-private_key.pem"
|
623
|
+
end
|
624
|
+
|
625
|
+
def validate_checksums!(version, stage: "")
|
626
|
+
gem_path = gem_file_for_version(version)
|
627
|
+
unless gem_path && File.file?(gem_path)
|
628
|
+
abort("Unable to locate built gem for version #{version} in pkg/. Did the build succeed?")
|
629
|
+
end
|
630
|
+
actual = compute_sha256(gem_path)
|
631
|
+
checks_path = File.join(@root, "checksums", "#{File.basename(gem_path)}.sha256")
|
632
|
+
unless File.file?(checks_path)
|
633
|
+
abort("Expected checksum file not found: #{checks_path}. Did bin/gem_checksums run?")
|
634
|
+
end
|
635
|
+
expected = File.read(checks_path).strip
|
636
|
+
if actual != expected
|
637
|
+
abort(<<~MSG)
|
638
|
+
SHA256 mismatch #{stage}:
|
639
|
+
gem: #{gem_path}
|
640
|
+
sha256sum: #{actual}
|
641
|
+
file: #{checks_path}
|
642
|
+
file: #{expected}
|
643
|
+
The artifact being released must match the checksummed artifact exactly.
|
644
|
+
Retry locally: bundle exec rake build && bin/gem_checksums && bundle exec rake release
|
645
|
+
MSG
|
646
|
+
else
|
647
|
+
puts "Checksum OK #{stage}: #{File.basename(gem_path)}"
|
648
|
+
end
|
649
|
+
end
|
650
|
+
|
651
|
+
def gem_file_for_version(version)
|
652
|
+
pkg = File.join(@root, "pkg")
|
653
|
+
pattern = File.join(pkg, "*.gem")
|
654
|
+
gems = Dir[pattern].select { |p| File.basename(p).include?("-#{version}.gem") }
|
655
|
+
gems.sort.last
|
656
|
+
end
|
657
|
+
|
658
|
+
def compute_sha256(path)
|
659
|
+
if system("which sha256sum > /dev/null 2>&1")
|
660
|
+
out, _ = Open3.capture2e("sha256sum", path)
|
661
|
+
out.split.first
|
662
|
+
elsif system("which shasum > /dev/null 2>&1")
|
663
|
+
out, _ = Open3.capture2e("shasum", "-a", "256", path)
|
664
|
+
out.split.first
|
665
|
+
else
|
666
|
+
require "digest"
|
667
|
+
Digest::SHA256.file(path).hexdigest
|
668
|
+
end
|
669
|
+
end
|
670
|
+
end
|
671
|
+
end
|
672
|
+
end
|