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
data/exe/kettle-release
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
|
+
# vim: set syntax=ruby
|
5
|
+
|
4
6
|
# kettle-release: Automate release steps from CONTRIBUTING.md
|
5
7
|
# - Runs sanity checks
|
6
8
|
# - Ensures version/changelog updated (with confirmation)
|
@@ -12,715 +14,19 @@
|
|
12
14
|
# - Runs bin/gem_checksums
|
13
15
|
# - Runs `bundle exec rake release` (expects PEM password and RubyGems MFA OTP)
|
14
16
|
|
17
|
+
# Immediate, unbuffered output
|
15
18
|
$stdout.sync = true
|
16
|
-
|
17
|
-
|
18
|
-
begin
|
19
|
-
require "bundler/setup"
|
20
|
-
rescue LoadError
|
21
|
-
# Allow running outside of Bundler; runtime deps should still be available via rubygems
|
22
|
-
end
|
19
|
+
# Depending library or project must be using bundler
|
20
|
+
require "bundler/setup"
|
23
21
|
|
24
22
|
begin
|
25
|
-
require "
|
26
|
-
require "shellwords"
|
27
|
-
require "time"
|
28
|
-
require "fileutils"
|
29
|
-
require "net/http"
|
30
|
-
require "json"
|
31
|
-
require "uri"
|
32
|
-
require "kettle/dev/ci_helpers"
|
33
|
-
require "ruby-progressbar"
|
23
|
+
require "kettle/dev"
|
34
24
|
rescue LoadError => e
|
35
|
-
warn("kettle
|
36
|
-
warn("Hint: Ensure the host project includes kettle-dev and
|
37
|
-
warn(e.backtrace.join("\n")) if ENV["DEBUG"]
|
25
|
+
warn("kettle/dev: failed to load: #{e.message}")
|
26
|
+
warn("Hint: Ensure the host project includes kettle-dev and run bundle install.")
|
38
27
|
exit(1)
|
39
28
|
end
|
40
29
|
|
41
|
-
module Kettle
|
42
|
-
module Dev
|
43
|
-
class ReleaseCLI
|
44
|
-
def initialize
|
45
|
-
# Use the host project's root, not the installed gem's directory
|
46
|
-
@root = Kettle::Dev::CIHelpers.project_root
|
47
|
-
end
|
48
|
-
|
49
|
-
def run
|
50
|
-
puts "== kettle-release =="
|
51
|
-
|
52
|
-
ensure_bundler_2_7_plus!
|
53
|
-
|
54
|
-
version = detect_version
|
55
|
-
puts "Detected version: #{version.inspect}"
|
56
|
-
|
57
|
-
# Sanity check against latest released gem version (RubyGems)
|
58
|
-
begin
|
59
|
-
gem_name = detect_gem_name
|
60
|
-
latest_overall, latest_for_series = latest_released_versions(gem_name, version)
|
61
|
-
if latest_overall
|
62
|
-
msg = "Latest released: #{latest_overall}"
|
63
|
-
if latest_for_series && latest_for_series != latest_overall
|
64
|
-
msg += " | Latest for series #{Gem::Version.new(version).segments[0, 2].join(".")}.x: #{latest_for_series}"
|
65
|
-
elsif latest_for_series
|
66
|
-
msg += " (matches current series)"
|
67
|
-
end
|
68
|
-
puts msg
|
69
|
-
|
70
|
-
# Choose the comparison target:
|
71
|
-
# - If current series (MAJOR.MINOR) is behind overall latest, compare against the latest for the current series.
|
72
|
-
# - Otherwise (same or ahead), compare against overall latest.
|
73
|
-
cur = Gem::Version.new(version)
|
74
|
-
overall = Gem::Version.new(latest_overall)
|
75
|
-
cur_series = cur.segments[0, 2]
|
76
|
-
overall_series = overall.segments[0, 2]
|
77
|
-
target = if (cur_series <=> overall_series) == -1
|
78
|
-
latest_for_series # may be nil if no prior release in this series
|
79
|
-
else
|
80
|
-
latest_overall
|
81
|
-
end
|
82
|
-
if target && Gem::Version.new(version) <= Gem::Version.new(target)
|
83
|
-
series = cur_series.join(".")
|
84
|
-
warn("version.rb (#{version}) must be greater than the latest released version for series #{series}. Latest for series: #{target}.")
|
85
|
-
warn("Tip: bump PATCH for a stable branch release, or bump MINOR/MAJOR when on trunk.")
|
86
|
-
abort("Aborting: version bump required.")
|
87
|
-
end
|
88
|
-
else
|
89
|
-
puts "Could not determine latest released version from RubyGems (offline?). Proceeding without sanity check."
|
90
|
-
end
|
91
|
-
rescue StandardError => e
|
92
|
-
warn("Warning: failed to check RubyGems for latest version (#{e.class}: #{e.message}). Proceeding.")
|
93
|
-
end
|
94
|
-
|
95
|
-
puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
|
96
|
-
print("> ")
|
97
|
-
ans = $stdin.gets&.strip
|
98
|
-
abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
|
99
|
-
|
100
|
-
# Re-run checks (and refresh Gemfile.lock)
|
101
|
-
run_cmd!("bin/setup")
|
102
|
-
run_cmd!("bin/rake")
|
103
|
-
|
104
|
-
# Update Appraisal gemfiles if Appraisals file is present
|
105
|
-
appraisals_path = File.join(@root, "Appraisals")
|
106
|
-
if File.file?(appraisals_path)
|
107
|
-
puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
|
108
|
-
run_cmd!("bin/rake appraisal:update")
|
109
|
-
else
|
110
|
-
puts "No Appraisals file found; skipping appraisal:update"
|
111
|
-
end
|
112
|
-
|
113
|
-
ensure_git_user!
|
114
|
-
committed = commit_release_prep!(version)
|
115
|
-
|
116
|
-
# Optional: run a local CI check with 'act' prior to pushing
|
117
|
-
maybe_run_local_ci_before_push!(committed)
|
118
|
-
|
119
|
-
trunk = detect_trunk_branch
|
120
|
-
feature = current_branch
|
121
|
-
puts "Trunk branch detected: #{trunk}"
|
122
|
-
ensure_trunk_synced_before_push!(trunk, feature)
|
123
|
-
|
124
|
-
push!
|
125
|
-
|
126
|
-
# After pushing, ensure the CI workflows for this project are passing
|
127
|
-
monitor_workflows_after_push!
|
128
|
-
|
129
|
-
# If all workflows are passing, merge the feature branch into trunk and push trunk
|
130
|
-
merge_feature_into_trunk_and_push!(trunk, feature)
|
131
|
-
|
132
|
-
# Ensure we are on trunk for the remaining steps
|
133
|
-
checkout!(trunk)
|
134
|
-
pull!(trunk)
|
135
|
-
|
136
|
-
ensure_signing_setup_or_skip!
|
137
|
-
# Build: expect PEM password prompt unless SKIP_GEM_SIGNING
|
138
|
-
puts "Running build (you may be prompted for the signing key password)..."
|
139
|
-
run_cmd!("bundle exec rake build")
|
140
|
-
|
141
|
-
# Checksums (commits, but does not push)
|
142
|
-
run_cmd!("bin/gem_checksums")
|
143
|
-
validate_checksums!(version, stage: "after build + gem_checksums")
|
144
|
-
|
145
|
-
# Release: expect PEM password + RubyGems MFA OTP
|
146
|
-
puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
|
147
|
-
run_cmd!("bundle exec rake release")
|
148
|
-
# Some release tasks rebuild the gem; re-validate to ensure reproducibility
|
149
|
-
validate_checksums!(version, stage: "after release")
|
150
|
-
|
151
|
-
puts "\nRelease complete. Don't forget to push the checksums commit if needed."
|
152
|
-
end
|
153
|
-
|
154
|
-
private
|
155
|
-
|
156
|
-
# Monitor GitHub Actions workflows discovered by ci:act logic.
|
157
|
-
# Checks one workflow per second in a round-robin loop until all pass, or any fails.
|
158
|
-
def monitor_workflows_after_push!
|
159
|
-
root = Kettle::Dev::CIHelpers.project_root
|
160
|
-
workflows = Kettle::Dev::CIHelpers.workflows_list(root)
|
161
|
-
gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
|
162
|
-
|
163
|
-
branch = Kettle::Dev::CIHelpers.current_branch
|
164
|
-
abort("Could not determine current branch for CI checks.") unless branch
|
165
|
-
|
166
|
-
# Prefer an explicit GitHub remote if available; fall back to origin repo_info
|
167
|
-
gh_remote = preferred_github_remote
|
168
|
-
gh_owner = nil
|
169
|
-
gh_repo = nil
|
170
|
-
if gh_remote && !workflows.empty?
|
171
|
-
url = remote_url(gh_remote)
|
172
|
-
gh_owner, gh_repo = parse_github_owner_repo(url)
|
173
|
-
end
|
174
|
-
|
175
|
-
checks_any = false
|
176
|
-
|
177
|
-
if gh_owner && gh_repo && !workflows.empty?
|
178
|
-
checks_any = true
|
179
|
-
total = workflows.size
|
180
|
-
abort("No GitHub workflows found under .github/workflows; aborting.") if total.zero?
|
181
|
-
|
182
|
-
passed = {}
|
183
|
-
idx = 0
|
184
|
-
puts "Ensuring GitHub Actions workflows pass on #{branch} (#{gh_owner}/#{gh_repo}) via remote '#{gh_remote}'"
|
185
|
-
pbar = ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
|
186
|
-
|
187
|
-
loop do
|
188
|
-
wf = workflows[idx]
|
189
|
-
run = Kettle::Dev::CIHelpers.latest_run(owner: gh_owner, repo: gh_repo, workflow_file: wf, branch: branch)
|
190
|
-
if run
|
191
|
-
if Kettle::Dev::CIHelpers.success?(run)
|
192
|
-
unless passed[wf]
|
193
|
-
passed[wf] = true
|
194
|
-
pbar.increment
|
195
|
-
end
|
196
|
-
elsif Kettle::Dev::CIHelpers.failed?(run)
|
197
|
-
puts
|
198
|
-
url = run["html_url"] || "https://github.com/#{gh_owner}/#{gh_repo}/actions/workflows/#{wf}"
|
199
|
-
abort("Workflow failed: #{wf} -> #{url}")
|
200
|
-
end
|
201
|
-
end
|
202
|
-
break if passed.size == total
|
203
|
-
idx = (idx + 1) % total
|
204
|
-
sleep(1)
|
205
|
-
end
|
206
|
-
pbar.finish unless pbar.finished?
|
207
|
-
puts "\nAll GitHub workflows passing (#{passed.size}/#{total})."
|
208
|
-
end
|
209
|
-
|
210
|
-
# Additionally, check GitLab if configured
|
211
|
-
gl_remote = gitlab_remote_candidates.first
|
212
|
-
if gitlab_ci && gl_remote
|
213
|
-
owner, repo = Kettle::Dev::CIHelpers.repo_info_gitlab
|
214
|
-
if owner && repo
|
215
|
-
checks_any = true
|
216
|
-
puts "Ensuring GitLab pipeline passes on #{branch} (#{owner}/#{repo}) via remote '#{gl_remote}'"
|
217
|
-
pbar = ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
|
218
|
-
loop do
|
219
|
-
pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
|
220
|
-
if pipe
|
221
|
-
if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
|
222
|
-
pbar.increment unless pbar.finished?
|
223
|
-
break
|
224
|
-
elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
|
225
|
-
puts
|
226
|
-
url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
|
227
|
-
abort("Pipeline failed: #{url}")
|
228
|
-
end
|
229
|
-
end
|
230
|
-
sleep(1)
|
231
|
-
end
|
232
|
-
pbar.finish unless pbar.finished?
|
233
|
-
puts "\nGitLab pipeline passing."
|
234
|
-
end
|
235
|
-
end
|
236
|
-
|
237
|
-
abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") unless checks_any
|
238
|
-
end
|
239
|
-
|
240
|
-
def run_cmd!(cmd)
|
241
|
-
puts "$ #{cmd}"
|
242
|
-
# Execute commands with the current environment
|
243
|
-
success = system(ENV, cmd)
|
244
|
-
abort("Command failed: #{cmd}") unless success
|
245
|
-
end
|
246
|
-
|
247
|
-
def git_output(args)
|
248
|
-
out, status = Open3.capture2("git", *args)
|
249
|
-
[out.strip, status.success?]
|
250
|
-
end
|
251
|
-
|
252
|
-
def check_git_clean!
|
253
|
-
out, ok = git_output(["status", "--porcelain"])
|
254
|
-
abort("Git working tree is not clean. Commit/stash changes before releasing.\n\n#{out}") unless ok && out.empty?
|
255
|
-
end
|
256
|
-
|
257
|
-
def ensure_git_user!
|
258
|
-
name, ok1 = git_output(["config", "user.name"])
|
259
|
-
email, ok2 = git_output(["config", "user.email"])
|
260
|
-
abort("Git user.name or user.email not configured.") unless ok1 && ok2 && !name.empty? && !email.empty?
|
261
|
-
end
|
262
|
-
|
263
|
-
def ensure_bundler_2_7_plus!
|
264
|
-
begin
|
265
|
-
require "bundler"
|
266
|
-
rescue LoadError
|
267
|
-
abort("Bundler is required. Please install bundler >= 2.7.0 and try again.")
|
268
|
-
end
|
269
|
-
ver = Gem::Version.new(Bundler::VERSION)
|
270
|
-
min = Gem::Version.new("2.7.0")
|
271
|
-
if ver < min
|
272
|
-
abort("kettle-release requires Bundler >= 2.7.0 for reproducible builds by default. Current: #{Bundler::VERSION}. Please upgrade bundler.")
|
273
|
-
end
|
274
|
-
end
|
275
|
-
|
276
|
-
# Run a local CI workflow using 'act' before pushing the release prep commit, if enabled.
|
277
|
-
# Controlled by env var K_RELEASE_LOCAL_CI:
|
278
|
-
# - "true" => run without prompt
|
279
|
-
# - "ask" => prompt user [Y/n]
|
280
|
-
# - anything else or unset => skip
|
281
|
-
# Workflow selection:
|
282
|
-
# - If .github/workflows/locked_deps.yml (or .yaml) exists, default to it.
|
283
|
-
# - Else, use the first workflow from CIHelpers.workflows_list unless K_RELEASE_LOCAL_CI_WORKFLOW points to another file.
|
284
|
-
# On failure, soft reset the last commit if one was created, then abort.
|
285
|
-
def maybe_run_local_ci_before_push!(committed)
|
286
|
-
mode = (ENV["K_RELEASE_LOCAL_CI"] || "").strip.downcase
|
287
|
-
run_it = case mode
|
288
|
-
when "true", "1", "yes", "y" then true
|
289
|
-
when "ask"
|
290
|
-
print("Run local CI with 'act' before pushing? [Y/n] ")
|
291
|
-
ans = $stdin.gets&.strip
|
292
|
-
ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
|
293
|
-
else
|
294
|
-
false
|
295
|
-
end
|
296
|
-
return unless run_it
|
297
|
-
|
298
|
-
# Check for 'act'
|
299
|
-
act_ok = begin
|
300
|
-
system("act", "--version", out: File::NULL, err: File::NULL)
|
301
|
-
rescue StandardError
|
302
|
-
false
|
303
|
-
end
|
304
|
-
unless act_ok
|
305
|
-
puts "Skipping local CI: 'act' command not found. Install https://github.com/nektos/act to enable."
|
306
|
-
return
|
307
|
-
end
|
308
|
-
|
309
|
-
root = Kettle::Dev::CIHelpers.project_root
|
310
|
-
workflows_dir = File.join(root, ".github", "workflows")
|
311
|
-
candidates = Kettle::Dev::CIHelpers.workflows_list(root)
|
312
|
-
|
313
|
-
# Explicit override via env
|
314
|
-
chosen = (ENV["K_RELEASE_LOCAL_CI_WORKFLOW"] || "").strip
|
315
|
-
if !chosen.empty?
|
316
|
-
# Normalize to a basename with extension
|
317
|
-
if chosen !~ /\.ya?ml\z/
|
318
|
-
chosen = "#{chosen}.yml"
|
319
|
-
end
|
320
|
-
else
|
321
|
-
# Default to locked_deps if present
|
322
|
-
chosen = if candidates.include?("locked_deps.yml")
|
323
|
-
"locked_deps.yml"
|
324
|
-
elsif candidates.include?("locked_deps.yaml")
|
325
|
-
"locked_deps.yaml"
|
326
|
-
else
|
327
|
-
candidates.first
|
328
|
-
end
|
329
|
-
end
|
330
|
-
|
331
|
-
unless chosen
|
332
|
-
puts "Skipping local CI: no workflows found under .github/workflows."
|
333
|
-
return
|
334
|
-
end
|
335
|
-
|
336
|
-
file_path = File.join(workflows_dir, chosen)
|
337
|
-
unless File.file?(file_path)
|
338
|
-
puts "Skipping local CI: selected workflow not found: #{file_path}"
|
339
|
-
return
|
340
|
-
end
|
341
|
-
|
342
|
-
puts "== Running local CI with act on #{chosen} =="
|
343
|
-
ok = system("act", "-W", file_path)
|
344
|
-
if ok
|
345
|
-
puts "Local CI succeeded for #{chosen}."
|
346
|
-
else
|
347
|
-
puts "Local CI failed for #{chosen}."
|
348
|
-
if committed
|
349
|
-
puts "Rolling back release prep commit (soft reset)..."
|
350
|
-
system("git", "reset", "--soft", "HEAD^")
|
351
|
-
end
|
352
|
-
abort("Aborting due to local CI failure.")
|
353
|
-
end
|
354
|
-
end
|
355
|
-
|
356
|
-
def detect_version
|
357
|
-
# Look for lib/**/version.rb and extract VERSION constant string
|
358
|
-
candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
|
359
|
-
abort("Could not find version.rb under lib/**.") if candidates.empty?
|
360
|
-
path = candidates.min
|
361
|
-
content = File.read(path)
|
362
|
-
m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
|
363
|
-
abort("VERSION constant not found in #{path}.") unless m
|
364
|
-
m[2]
|
365
|
-
end
|
366
|
-
|
367
|
-
def detect_gem_name
|
368
|
-
# Find a .gemspec in project root and extract spec.name
|
369
|
-
gemspecs = Dir[File.join(@root, "*.gemspec")]
|
370
|
-
abort("Could not find a .gemspec in project root.") if gemspecs.empty?
|
371
|
-
path = gemspecs.min
|
372
|
-
content = File.read(path)
|
373
|
-
m = content.match(/spec\.name\s*=\s*(["'])([^"']+)\1/)
|
374
|
-
abort("Could not determine gem name from #{path}.") unless m
|
375
|
-
m[2]
|
376
|
-
end
|
377
|
-
|
378
|
-
# Returns [latest_overall, latest_for_series] as strings (or nils)
|
379
|
-
def latest_released_versions(gem_name, current_version)
|
380
|
-
uri = URI("https://rubygems.org/api/v1/versions/#{gem_name}.json")
|
381
|
-
res = Net::HTTP.get_response(uri)
|
382
|
-
return [nil, nil] unless res.is_a?(Net::HTTPSuccess)
|
383
|
-
data = JSON.parse(res.body)
|
384
|
-
versions = data.map { |h| h["number"] }.compact
|
385
|
-
# Drop pre-releases if API indicates; fall back to simple filter by '-' pattern
|
386
|
-
versions.reject! { |v| v.to_s.include?("-pre") || v.to_s.include?(".pre") || v.to_s =~ /[a-zA-Z]/ }
|
387
|
-
gversions = versions.map { |s| Gem::Version.new(s) }.sort
|
388
|
-
latest_overall = gversions.last&.to_s
|
389
|
-
|
390
|
-
cur = Gem::Version.new(current_version)
|
391
|
-
series = cur.segments[0, 2]
|
392
|
-
series_versions = gversions.select { |gv| gv.segments[0, 2] == series }
|
393
|
-
latest_series = series_versions.last&.to_s
|
394
|
-
[latest_overall, latest_series]
|
395
|
-
rescue StandardError
|
396
|
-
[nil, nil]
|
397
|
-
end
|
398
|
-
|
399
|
-
def commit_release_prep!(version)
|
400
|
-
msg = "🔖 Prepare release v#{version}"
|
401
|
-
# Only commit if there are changes (version/changelog)
|
402
|
-
out, _ = git_output(["status", "--porcelain"])
|
403
|
-
if out.empty?
|
404
|
-
puts "No changes to commit for release prep (continuing)."
|
405
|
-
false
|
406
|
-
else
|
407
|
-
run_cmd!(%(git commit -am #{Shellwords.escape(msg)}))
|
408
|
-
true
|
409
|
-
end
|
410
|
-
end
|
411
|
-
|
412
|
-
def push!
|
413
|
-
branch = current_branch
|
414
|
-
abort("Could not determine current branch to push.") unless branch
|
415
|
-
|
416
|
-
if has_remote?("all")
|
417
|
-
puts "$ git push all #{branch}"
|
418
|
-
success = system("git push all #{Shellwords.escape(branch)}")
|
419
|
-
unless success
|
420
|
-
warn("Normal push to 'all' failed; retrying with force push...")
|
421
|
-
run_cmd!("git push -f all #{Shellwords.escape(branch)}")
|
422
|
-
end
|
423
|
-
return
|
424
|
-
end
|
425
|
-
|
426
|
-
# Build the list of remotes to push to
|
427
|
-
remotes = []
|
428
|
-
remotes << "origin" if has_remote?("origin")
|
429
|
-
remotes |= github_remote_candidates
|
430
|
-
remotes |= gitlab_remote_candidates
|
431
|
-
remotes |= codeberg_remote_candidates
|
432
|
-
remotes.uniq!
|
433
|
-
|
434
|
-
if remotes.empty?
|
435
|
-
# Fallback to default behavior if we couldn't detect any remotes
|
436
|
-
puts "$ git push #{branch}"
|
437
|
-
success = system("git push #{Shellwords.escape(branch)}")
|
438
|
-
unless success
|
439
|
-
warn("Normal push failed; retrying with force push...")
|
440
|
-
run_cmd!("git push -f #{Shellwords.escape(branch)}")
|
441
|
-
end
|
442
|
-
return
|
443
|
-
end
|
444
|
-
|
445
|
-
remotes.each do |remote|
|
446
|
-
puts "$ git push #{remote} #{branch}"
|
447
|
-
success = system("git push #{Shellwords.escape(remote)} #{Shellwords.escape(branch)}")
|
448
|
-
unless success
|
449
|
-
warn("Push to #{remote} failed; retrying with force push...")
|
450
|
-
run_cmd!("git push -f #{Shellwords.escape(remote)} #{Shellwords.escape(branch)}")
|
451
|
-
end
|
452
|
-
end
|
453
|
-
end
|
454
|
-
|
455
|
-
def detect_trunk_branch
|
456
|
-
out, ok = git_output(["remote", "show", "origin"])
|
457
|
-
abort("Failed to get origin remote info.") unless ok
|
458
|
-
m = out.lines.find { |l| l.include?("HEAD branch") }
|
459
|
-
abort("Unable to detect trunk branch from origin.") unless m
|
460
|
-
m.split.last
|
461
|
-
end
|
462
|
-
|
463
|
-
def checkout!(branch)
|
464
|
-
run_cmd!("git checkout #{Shellwords.escape(branch)}")
|
465
|
-
end
|
466
|
-
|
467
|
-
def pull!(branch)
|
468
|
-
run_cmd!("git pull origin #{Shellwords.escape(branch)}")
|
469
|
-
end
|
470
|
-
|
471
|
-
def current_branch
|
472
|
-
out, ok = git_output(["rev-parse", "--abbrev-ref", "HEAD"])
|
473
|
-
ok ? out : nil
|
474
|
-
end
|
475
|
-
|
476
|
-
def list_remotes
|
477
|
-
out, ok = git_output(["remote"])
|
478
|
-
ok ? out.split(/\s+/).reject(&:empty?) : []
|
479
|
-
end
|
480
|
-
|
481
|
-
def remotes_with_urls
|
482
|
-
out, ok = git_output(["remote", "-v"])
|
483
|
-
return {} unless ok
|
484
|
-
urls = {}
|
485
|
-
out.each_line do |line|
|
486
|
-
if line =~ /(\S+)\s+(\S+)\s+\((fetch|push)\)/
|
487
|
-
name = Regexp.last_match(1)
|
488
|
-
url = Regexp.last_match(2)
|
489
|
-
kind = Regexp.last_match(3)
|
490
|
-
# prefer fetch URL when available
|
491
|
-
urls[name] = url if kind == "fetch" || !urls.key?(name)
|
492
|
-
end
|
493
|
-
end
|
494
|
-
urls
|
495
|
-
end
|
496
|
-
|
497
|
-
def remote_url(name)
|
498
|
-
remotes_with_urls[name]
|
499
|
-
end
|
500
|
-
|
501
|
-
def github_remote_candidates
|
502
|
-
remotes_with_urls.select { |n, u| u.include?("github.com") }.keys
|
503
|
-
end
|
504
|
-
|
505
|
-
def gitlab_remote_candidates
|
506
|
-
remotes_with_urls.select { |n, u| u.include?("gitlab.com") }.keys
|
507
|
-
end
|
508
|
-
|
509
|
-
def codeberg_remote_candidates
|
510
|
-
remotes_with_urls.select { |n, u| u.include?("codeberg.org") }.keys
|
511
|
-
end
|
512
|
-
|
513
|
-
def preferred_github_remote
|
514
|
-
cands = github_remote_candidates
|
515
|
-
return if cands.empty?
|
516
|
-
# Prefer a remote literally named 'github', otherwise the first
|
517
|
-
cands.find { |n| n == "github" } || cands.first
|
518
|
-
end
|
519
|
-
|
520
|
-
def parse_github_owner_repo(url)
|
521
|
-
return [nil, nil] unless url
|
522
|
-
if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
|
523
|
-
[Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
|
524
|
-
elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
|
525
|
-
[Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
|
526
|
-
else
|
527
|
-
[nil, nil]
|
528
|
-
end
|
529
|
-
end
|
530
|
-
|
531
|
-
def has_remote?(name)
|
532
|
-
list_remotes.include?(name)
|
533
|
-
end
|
534
|
-
|
535
|
-
def remote_branch_exists?(remote, branch)
|
536
|
-
_out, ok = git_output(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote}/#{branch}"])
|
537
|
-
ok
|
538
|
-
end
|
539
|
-
|
540
|
-
def ahead_behind_counts(local_ref, remote_ref)
|
541
|
-
out, ok = git_output(["rev-list", "--left-right", "--count", "#{local_ref}...#{remote_ref}"])
|
542
|
-
return [0, 0] unless ok && !out.empty?
|
543
|
-
parts = out.split
|
544
|
-
left = parts[0].to_i
|
545
|
-
right = parts[1].to_i
|
546
|
-
[left, right]
|
547
|
-
end
|
548
|
-
|
549
|
-
def trunk_behind_remote?(trunk, remote)
|
550
|
-
# If the remote branch doesn't exist, treat as not behind
|
551
|
-
return false unless remote_branch_exists?(remote, trunk)
|
552
|
-
_ahead, behind = ahead_behind_counts(trunk, "#{remote}/#{trunk}")
|
553
|
-
behind.positive?
|
554
|
-
end
|
555
|
-
|
556
|
-
def ensure_trunk_synced_before_push!(trunk, feature)
|
557
|
-
if has_remote?("all")
|
558
|
-
puts "Remote 'all' detected. Fetching from all remotes and enforcing strict trunk parity..."
|
559
|
-
run_cmd!("git fetch --all")
|
560
|
-
remotes = list_remotes
|
561
|
-
missing_from = []
|
562
|
-
remotes.each do |r|
|
563
|
-
next if r == "all"
|
564
|
-
if remote_branch_exists?(r, trunk)
|
565
|
-
_ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
|
566
|
-
missing_from << r if behind.positive?
|
567
|
-
end
|
568
|
-
end
|
569
|
-
unless missing_from.empty?
|
570
|
-
abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
|
571
|
-
end
|
572
|
-
puts "Local #{trunk} has all commits from remotes: #{(remotes - ["all"]).join(", ")}"
|
573
|
-
return
|
574
|
-
end
|
575
|
-
|
576
|
-
# Ensure local trunk is in sync with origin/trunk
|
577
|
-
run_cmd!("git fetch origin #{Shellwords.escape(trunk)}")
|
578
|
-
if trunk_behind_remote?(trunk, "origin")
|
579
|
-
puts "Local #{trunk} is behind origin/#{trunk}. Rebasing..."
|
580
|
-
cur = current_branch
|
581
|
-
checkout!(trunk) unless cur == trunk
|
582
|
-
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
583
|
-
checkout!(feature) unless feature.nil? || feature == trunk
|
584
|
-
run_cmd!("git rebase #{Shellwords.escape(trunk)}")
|
585
|
-
puts "Rebase complete. Will push updated branch next."
|
586
|
-
else
|
587
|
-
puts "Local #{trunk} is up to date with origin/#{trunk}."
|
588
|
-
end
|
589
|
-
|
590
|
-
# If there is a GitHub remote that is not origin, ensure origin/#{trunk} incorporates it
|
591
|
-
gh_remote = preferred_github_remote
|
592
|
-
if gh_remote && gh_remote != "origin"
|
593
|
-
puts "GitHub remote detected: #{gh_remote}. Fetching #{trunk}..."
|
594
|
-
run_cmd!("git fetch #{gh_remote} #{Shellwords.escape(trunk)}")
|
595
|
-
|
596
|
-
# Compare origin/trunk vs github/trunk to see if they differ
|
597
|
-
left, right = ahead_behind_counts("origin/#{trunk}", "#{gh_remote}/#{trunk}")
|
598
|
-
if left.zero? && right.zero?
|
599
|
-
puts "origin/#{trunk} and #{gh_remote}/#{trunk} are already in sync."
|
600
|
-
return
|
601
|
-
end
|
602
|
-
|
603
|
-
checkout!(trunk)
|
604
|
-
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
605
|
-
|
606
|
-
if left.positive? && right.positive?
|
607
|
-
# Histories have diverged -> let user choose
|
608
|
-
puts "origin/#{trunk} and #{gh_remote}/#{trunk} have diverged (#{left} ahead of GH, #{right} behind GH)."
|
609
|
-
puts "Choose how to reconcile:"
|
610
|
-
puts " [r] Rebase local/#{trunk} on top of #{gh_remote}/#{trunk} (push to origin)"
|
611
|
-
puts " [m] Merge --no-ff #{gh_remote}/#{trunk} into #{trunk} (push to origin and #{gh_remote})"
|
612
|
-
puts " [a] Abort"
|
613
|
-
print("> ")
|
614
|
-
choice = $stdin.gets&.strip&.downcase
|
615
|
-
case choice
|
616
|
-
when "r"
|
617
|
-
run_cmd!("git rebase #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
|
618
|
-
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
619
|
-
puts "Rebased #{trunk} onto #{gh_remote}/#{trunk} and pushed to origin."
|
620
|
-
when "m"
|
621
|
-
run_cmd!("git merge --no-ff #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
|
622
|
-
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
623
|
-
run_cmd!("git push #{Shellwords.escape(gh_remote)} #{Shellwords.escape(trunk)}")
|
624
|
-
puts "Merged #{gh_remote}/#{trunk} into #{trunk} and pushed to origin and #{gh_remote}."
|
625
|
-
else
|
626
|
-
abort("Aborted by user. Please reconcile trunks and re-run.")
|
627
|
-
end
|
628
|
-
elsif right.positive? && left.zero?
|
629
|
-
# One side can be fast-forwarded
|
630
|
-
puts "Fast-forwarding #{trunk} to include #{gh_remote}/#{trunk}..."
|
631
|
-
run_cmd!("git merge --ff-only #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
|
632
|
-
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
633
|
-
# origin is behind GH -> fast-forward merge
|
634
|
-
elsif left.positive? && right.zero?
|
635
|
-
# origin ahead of GH -> nothing required for origin, optionally inform user
|
636
|
-
puts "origin/#{trunk} is ahead of #{gh_remote}/#{trunk}; no action required before push."
|
637
|
-
end
|
638
|
-
end
|
639
|
-
end
|
640
|
-
|
641
|
-
def merge_feature_into_trunk_and_push!(trunk, feature)
|
642
|
-
return if feature.nil? || feature == trunk
|
643
|
-
puts "Merging #{feature} into #{trunk} (after CI success)..."
|
644
|
-
checkout!(trunk)
|
645
|
-
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
646
|
-
run_cmd!("git merge #{Shellwords.escape(feature)}")
|
647
|
-
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
648
|
-
puts "Merged #{feature} into #{trunk} and pushed. The PR (if any) should auto-close."
|
649
|
-
end
|
650
|
-
|
651
|
-
def ensure_signing_setup_or_skip!
|
652
|
-
return if ENV.key?("SKIP_GEM_SIGNING")
|
653
|
-
|
654
|
-
user = ENV.fetch("GEM_CERT_USER", ENV["USER"])
|
655
|
-
cert_path = File.join(@root, "certs", "#{user}.pem")
|
656
|
-
unless File.exist?(cert_path)
|
657
|
-
abort(<<~MSG)
|
658
|
-
Gem signing appears enabled but no public cert found at:
|
659
|
-
#{cert_path}
|
660
|
-
Add your public key to certs/<USER>.pem (or set GEM_CERT_USER), or set SKIP_GEM_SIGNING to build unsigned.
|
661
|
-
MSG
|
662
|
-
end
|
663
|
-
puts "Found signing cert: #{cert_path}"
|
664
|
-
puts "When prompted during build/release, enter the PEM password for ~/.ssh/gem-private_key.pem"
|
665
|
-
end
|
666
|
-
|
667
|
-
# --- Checksum validation ---
|
668
|
-
# Validate that the sha256 of the built gem in pkg/ matches the recorded
|
669
|
-
# checksum stored under checksums/<gem>.gem.sha256. Abort with guidance if not.
|
670
|
-
# @param version [String]
|
671
|
-
# @param stage [String] human-readable context (e.g., "after release")
|
672
|
-
def validate_checksums!(version, stage: "")
|
673
|
-
gem_path = gem_file_for_version(version)
|
674
|
-
unless gem_path && File.file?(gem_path)
|
675
|
-
abort("Unable to locate built gem for version #{version} in pkg/. Did the build succeed?")
|
676
|
-
end
|
677
|
-
actual = compute_sha256(gem_path)
|
678
|
-
checks_path = File.join(@root, "checksums", "#{File.basename(gem_path)}.sha256")
|
679
|
-
unless File.file?(checks_path)
|
680
|
-
abort("Expected checksum file not found: #{checks_path}. Did bin/gem_checksums run?")
|
681
|
-
end
|
682
|
-
expected = File.read(checks_path).strip
|
683
|
-
if actual != expected
|
684
|
-
abort(<<~MSG)
|
685
|
-
SHA256 mismatch #{stage}:
|
686
|
-
gem: #{gem_path}
|
687
|
-
sha256sum: #{actual}
|
688
|
-
file: #{checks_path}
|
689
|
-
file: #{expected}
|
690
|
-
The artifact being released must match the checksummed artifact exactly.
|
691
|
-
Retry locally: bundle exec rake build && bin/gem_checksums && bundle exec rake release
|
692
|
-
MSG
|
693
|
-
else
|
694
|
-
puts "Checksum OK #{stage}: #{File.basename(gem_path)}"
|
695
|
-
end
|
696
|
-
end
|
697
|
-
|
698
|
-
# Find the gem file in pkg/ that matches the given version
|
699
|
-
def gem_file_for_version(version)
|
700
|
-
pkg = File.join(@root, "pkg")
|
701
|
-
pattern = File.join(pkg, "*.gem")
|
702
|
-
gems = Dir[pattern].select { |p| File.basename(p).include?("-#{version}.gem") }
|
703
|
-
gems.sort.last
|
704
|
-
end
|
705
|
-
|
706
|
-
# Compute sha256 using system utilities (sha256sum or shasum -a 256),
|
707
|
-
# falling back to Ruby Digest if neither is available.
|
708
|
-
def compute_sha256(path)
|
709
|
-
if system("which sha256sum > /dev/null 2>&1")
|
710
|
-
out, _ = Open3.capture2e("sha256sum", path)
|
711
|
-
out.split.first
|
712
|
-
elsif system("which shasum > /dev/null 2>&1")
|
713
|
-
out, _ = Open3.capture2e("shasum", "-a", "256", path)
|
714
|
-
out.split.first
|
715
|
-
else
|
716
|
-
require "digest"
|
717
|
-
Digest::SHA256.file(path).hexdigest
|
718
|
-
end
|
719
|
-
end
|
720
|
-
end
|
721
|
-
end
|
722
|
-
end
|
723
|
-
|
724
30
|
# Always execute when this file is loaded (e.g., via a Bundler binstub).
|
725
31
|
# Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
|
726
32
|
if ARGV.include?("-h") || ARGV.include?("--help")
|