kettle-dev 1.0.8 → 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/ancient.yml +2 -4
- data/.github/workflows/coverage.yml +5 -7
- data/.github/workflows/current.yml +2 -4
- data/.github/workflows/heads.yml +2 -4
- data/.github/workflows/jruby.yml +2 -4
- data/.github/workflows/legacy.yml +2 -4
- data/.github/workflows/locked_deps.yml +1 -4
- data/.github/workflows/style.yml +2 -4
- data/.github/workflows/supported.yml +2 -4
- data/.github/workflows/truffle.yml +2 -4
- data/.github/workflows/unlocked_deps.yml +1 -4
- data/.github/workflows/unsupported.yml +2 -4
- data/.junie/guidelines.md +4 -3
- data/.simplecov +5 -1
- data/Appraisals +3 -0
- data/CHANGELOG.md +50 -3
- data/CHANGELOG.md.example +47 -0
- data/CONTRIBUTING.md +6 -0
- data/README.md +23 -5
- data/Rakefile +43 -54
- data/exe/kettle-commit-msg +8 -140
- data/exe/kettle-readme-backers +6 -348
- data/exe/kettle-release +8 -549
- 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 -343
- 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 -454
- 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 +18 -8
- 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 +56 -5
- metadata.gz.sig +4 -2
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,562 +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
|
-
run_cmd!("bin/setup")
|
55
|
-
run_cmd!("bin/rake")
|
56
|
-
|
57
|
-
version = detect_version
|
58
|
-
puts "Detected version: #{version.inspect}"
|
59
|
-
puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
|
60
|
-
print("> ")
|
61
|
-
ans = $stdin.gets&.strip
|
62
|
-
abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
|
63
|
-
|
64
|
-
# Re-run checks (and refresh Gemfile.lock)
|
65
|
-
run_cmd!("bin/setup")
|
66
|
-
run_cmd!("bin/rake")
|
67
|
-
|
68
|
-
# Update Appraisal gemfiles if Appraisals file is present
|
69
|
-
appraisals_path = File.join(@root, "Appraisals")
|
70
|
-
if File.file?(appraisals_path)
|
71
|
-
puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
|
72
|
-
run_cmd!("bin/rake appraisal:update")
|
73
|
-
else
|
74
|
-
puts "No Appraisals file found; skipping appraisal:update"
|
75
|
-
end
|
76
|
-
|
77
|
-
ensure_git_user!
|
78
|
-
commit_release_prep!(version)
|
79
|
-
|
80
|
-
trunk = detect_trunk_branch
|
81
|
-
feature = current_branch
|
82
|
-
puts "Trunk branch detected: #{trunk}"
|
83
|
-
ensure_trunk_synced_before_push!(trunk, feature)
|
84
|
-
|
85
|
-
push!
|
86
|
-
|
87
|
-
# After pushing, ensure the CI workflows for this project are passing
|
88
|
-
monitor_workflows_after_push!
|
89
|
-
|
90
|
-
# If all workflows are passing, merge the feature branch into trunk and push trunk
|
91
|
-
merge_feature_into_trunk_and_push!(trunk, feature)
|
92
|
-
|
93
|
-
# Ensure we are on trunk for the remaining steps
|
94
|
-
checkout!(trunk)
|
95
|
-
pull!(trunk)
|
96
|
-
|
97
|
-
ensure_signing_setup_or_skip!
|
98
|
-
# Build: expect PEM password prompt unless SKIP_GEM_SIGNING
|
99
|
-
puts "Running build (you may be prompted for the signing key password)..."
|
100
|
-
run_cmd!("bundle exec rake build")
|
101
|
-
|
102
|
-
# Checksums (commits, but does not push)
|
103
|
-
run_cmd!("bin/gem_checksums")
|
104
|
-
validate_checksums!(version, stage: "after build + gem_checksums")
|
105
|
-
|
106
|
-
# Release: expect PEM password + RubyGems MFA OTP
|
107
|
-
puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
|
108
|
-
run_cmd!("bundle exec rake release")
|
109
|
-
# Some release tasks rebuild the gem; re-validate to ensure reproducibility
|
110
|
-
validate_checksums!(version, stage: "after release")
|
111
|
-
|
112
|
-
puts "\nRelease complete. Don't forget to push the checksums commit if needed."
|
113
|
-
end
|
114
|
-
|
115
|
-
private
|
116
|
-
|
117
|
-
# Monitor GitHub Actions workflows discovered by ci:act logic.
|
118
|
-
# Checks one workflow per second in a round-robin loop until all pass, or any fails.
|
119
|
-
def monitor_workflows_after_push!
|
120
|
-
root = Kettle::Dev::CIHelpers.project_root
|
121
|
-
workflows = Kettle::Dev::CIHelpers.workflows_list(root)
|
122
|
-
gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
|
123
|
-
|
124
|
-
branch = Kettle::Dev::CIHelpers.current_branch
|
125
|
-
abort("Could not determine current branch for CI checks.") unless branch
|
126
|
-
|
127
|
-
# Prefer an explicit GitHub remote if available; fall back to origin repo_info
|
128
|
-
gh_remote = preferred_github_remote
|
129
|
-
gh_owner = nil
|
130
|
-
gh_repo = nil
|
131
|
-
if gh_remote && !workflows.empty?
|
132
|
-
url = remote_url(gh_remote)
|
133
|
-
gh_owner, gh_repo = parse_github_owner_repo(url)
|
134
|
-
end
|
135
|
-
|
136
|
-
checks_any = false
|
137
|
-
|
138
|
-
if gh_owner && gh_repo && !workflows.empty?
|
139
|
-
checks_any = true
|
140
|
-
total = workflows.size
|
141
|
-
abort("No GitHub workflows found under .github/workflows; aborting.") if total.zero?
|
142
|
-
|
143
|
-
passed = {}
|
144
|
-
idx = 0
|
145
|
-
puts "Ensuring GitHub Actions workflows pass on #{branch} (#{gh_owner}/#{gh_repo}) via remote '#{gh_remote}'"
|
146
|
-
pbar = ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
|
147
|
-
|
148
|
-
loop do
|
149
|
-
wf = workflows[idx]
|
150
|
-
run = Kettle::Dev::CIHelpers.latest_run(owner: gh_owner, repo: gh_repo, workflow_file: wf, branch: branch)
|
151
|
-
if run
|
152
|
-
if Kettle::Dev::CIHelpers.success?(run)
|
153
|
-
unless passed[wf]
|
154
|
-
passed[wf] = true
|
155
|
-
pbar.increment
|
156
|
-
end
|
157
|
-
elsif Kettle::Dev::CIHelpers.failed?(run)
|
158
|
-
puts
|
159
|
-
url = run["html_url"] || "https://github.com/#{gh_owner}/#{gh_repo}/actions/workflows/#{wf}"
|
160
|
-
abort("Workflow failed: #{wf} -> #{url}")
|
161
|
-
end
|
162
|
-
end
|
163
|
-
break if passed.size == total
|
164
|
-
idx = (idx + 1) % total
|
165
|
-
sleep(1)
|
166
|
-
end
|
167
|
-
pbar.finish unless pbar.finished?
|
168
|
-
puts "\nAll GitHub workflows passing (#{passed.size}/#{total})."
|
169
|
-
end
|
170
|
-
|
171
|
-
# Additionally, check GitLab if configured
|
172
|
-
gl_remote = gitlab_remote_candidates.first
|
173
|
-
if gitlab_ci && gl_remote
|
174
|
-
owner, repo = Kettle::Dev::CIHelpers.repo_info_gitlab
|
175
|
-
if owner && repo
|
176
|
-
checks_any = true
|
177
|
-
puts "Ensuring GitLab pipeline passes on #{branch} (#{owner}/#{repo}) via remote '#{gl_remote}'"
|
178
|
-
pbar = ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
|
179
|
-
loop do
|
180
|
-
pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
|
181
|
-
if pipe
|
182
|
-
if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
|
183
|
-
pbar.increment unless pbar.finished?
|
184
|
-
break
|
185
|
-
elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
|
186
|
-
puts
|
187
|
-
url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
|
188
|
-
abort("Pipeline failed: #{url}")
|
189
|
-
end
|
190
|
-
end
|
191
|
-
sleep(1)
|
192
|
-
end
|
193
|
-
pbar.finish unless pbar.finished?
|
194
|
-
puts "\nGitLab pipeline passing."
|
195
|
-
end
|
196
|
-
end
|
197
|
-
|
198
|
-
abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") unless checks_any
|
199
|
-
end
|
200
|
-
|
201
|
-
def run_cmd!(cmd)
|
202
|
-
puts "$ #{cmd}"
|
203
|
-
# Execute commands with the current environment
|
204
|
-
success = system(ENV, cmd)
|
205
|
-
abort("Command failed: #{cmd}") unless success
|
206
|
-
end
|
207
|
-
|
208
|
-
def git_output(args)
|
209
|
-
out, status = Open3.capture2("git", *args)
|
210
|
-
[out.strip, status.success?]
|
211
|
-
end
|
212
|
-
|
213
|
-
def check_git_clean!
|
214
|
-
out, ok = git_output(["status", "--porcelain"])
|
215
|
-
abort("Git working tree is not clean. Commit/stash changes before releasing.\n\n#{out}") unless ok && out.empty?
|
216
|
-
end
|
217
|
-
|
218
|
-
def ensure_git_user!
|
219
|
-
name, ok1 = git_output(["config", "user.name"])
|
220
|
-
email, ok2 = git_output(["config", "user.email"])
|
221
|
-
abort("Git user.name or user.email not configured.") unless ok1 && ok2 && !name.empty? && !email.empty?
|
222
|
-
end
|
223
|
-
|
224
|
-
def ensure_bundler_2_7_plus!
|
225
|
-
begin
|
226
|
-
require "bundler"
|
227
|
-
rescue LoadError
|
228
|
-
abort("Bundler is required. Please install bundler >= 2.7.0 and try again.")
|
229
|
-
end
|
230
|
-
ver = Gem::Version.new(Bundler::VERSION)
|
231
|
-
min = Gem::Version.new("2.7.0")
|
232
|
-
if ver < min
|
233
|
-
abort("kettle-release requires Bundler >= 2.7.0 for reproducible builds by default. Current: #{Bundler::VERSION}. Please upgrade bundler.")
|
234
|
-
end
|
235
|
-
end
|
236
|
-
|
237
|
-
def detect_version
|
238
|
-
# Look for lib/**/version.rb and extract VERSION constant string
|
239
|
-
candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
|
240
|
-
abort("Could not find version.rb under lib/**.") if candidates.empty?
|
241
|
-
path = candidates.min
|
242
|
-
content = File.read(path)
|
243
|
-
m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
|
244
|
-
abort("VERSION constant not found in #{path}.") unless m
|
245
|
-
m[2]
|
246
|
-
end
|
247
|
-
|
248
|
-
def commit_release_prep!(version)
|
249
|
-
msg = "🔖 Prepare release v#{version}"
|
250
|
-
# Only commit if there are changes (version/changelog)
|
251
|
-
out, _ = git_output(["status", "--porcelain"])
|
252
|
-
if out.empty?
|
253
|
-
puts "No changes to commit for release prep (continuing)."
|
254
|
-
else
|
255
|
-
run_cmd!(%(git commit -am #{Shellwords.escape(msg)}))
|
256
|
-
end
|
257
|
-
end
|
258
|
-
|
259
|
-
def push!
|
260
|
-
branch = current_branch
|
261
|
-
abort("Could not determine current branch to push.") unless branch
|
262
|
-
|
263
|
-
if has_remote?("all")
|
264
|
-
puts "$ git push all #{branch}"
|
265
|
-
success = system("git push all #{Shellwords.escape(branch)}")
|
266
|
-
unless success
|
267
|
-
warn("Normal push to 'all' failed; retrying with force push...")
|
268
|
-
run_cmd!("git push -f all #{Shellwords.escape(branch)}")
|
269
|
-
end
|
270
|
-
return
|
271
|
-
end
|
272
|
-
|
273
|
-
# Build the list of remotes to push to
|
274
|
-
remotes = []
|
275
|
-
remotes << "origin" if has_remote?("origin")
|
276
|
-
remotes |= github_remote_candidates
|
277
|
-
remotes |= gitlab_remote_candidates
|
278
|
-
remotes |= codeberg_remote_candidates
|
279
|
-
remotes.uniq!
|
280
|
-
|
281
|
-
if remotes.empty?
|
282
|
-
# Fallback to default behavior if we couldn't detect any remotes
|
283
|
-
puts "$ git push #{branch}"
|
284
|
-
success = system("git push #{Shellwords.escape(branch)}")
|
285
|
-
unless success
|
286
|
-
warn("Normal push failed; retrying with force push...")
|
287
|
-
run_cmd!("git push -f #{Shellwords.escape(branch)}")
|
288
|
-
end
|
289
|
-
return
|
290
|
-
end
|
291
|
-
|
292
|
-
remotes.each do |remote|
|
293
|
-
puts "$ git push #{remote} #{branch}"
|
294
|
-
success = system("git push #{Shellwords.escape(remote)} #{Shellwords.escape(branch)}")
|
295
|
-
unless success
|
296
|
-
warn("Push to #{remote} failed; retrying with force push...")
|
297
|
-
run_cmd!("git push -f #{Shellwords.escape(remote)} #{Shellwords.escape(branch)}")
|
298
|
-
end
|
299
|
-
end
|
300
|
-
end
|
301
|
-
|
302
|
-
def detect_trunk_branch
|
303
|
-
out, ok = git_output(["remote", "show", "origin"])
|
304
|
-
abort("Failed to get origin remote info.") unless ok
|
305
|
-
m = out.lines.find { |l| l.include?("HEAD branch") }
|
306
|
-
abort("Unable to detect trunk branch from origin.") unless m
|
307
|
-
m.split.last
|
308
|
-
end
|
309
|
-
|
310
|
-
def checkout!(branch)
|
311
|
-
run_cmd!("git checkout #{Shellwords.escape(branch)}")
|
312
|
-
end
|
313
|
-
|
314
|
-
def pull!(branch)
|
315
|
-
run_cmd!("git pull origin #{Shellwords.escape(branch)}")
|
316
|
-
end
|
317
|
-
|
318
|
-
def current_branch
|
319
|
-
out, ok = git_output(["rev-parse", "--abbrev-ref", "HEAD"])
|
320
|
-
ok ? out : nil
|
321
|
-
end
|
322
|
-
|
323
|
-
def list_remotes
|
324
|
-
out, ok = git_output(["remote"])
|
325
|
-
ok ? out.split(/\s+/).reject(&:empty?) : []
|
326
|
-
end
|
327
|
-
|
328
|
-
def remotes_with_urls
|
329
|
-
out, ok = git_output(["remote", "-v"])
|
330
|
-
return {} unless ok
|
331
|
-
urls = {}
|
332
|
-
out.each_line do |line|
|
333
|
-
if line =~ /(\S+)\s+(\S+)\s+\((fetch|push)\)/
|
334
|
-
name = Regexp.last_match(1)
|
335
|
-
url = Regexp.last_match(2)
|
336
|
-
kind = Regexp.last_match(3)
|
337
|
-
# prefer fetch URL when available
|
338
|
-
urls[name] = url if kind == "fetch" || !urls.key?(name)
|
339
|
-
end
|
340
|
-
end
|
341
|
-
urls
|
342
|
-
end
|
343
|
-
|
344
|
-
def remote_url(name)
|
345
|
-
remotes_with_urls[name]
|
346
|
-
end
|
347
|
-
|
348
|
-
def github_remote_candidates
|
349
|
-
remotes_with_urls.select { |n, u| u.include?("github.com") }.keys
|
350
|
-
end
|
351
|
-
|
352
|
-
def gitlab_remote_candidates
|
353
|
-
remotes_with_urls.select { |n, u| u.include?("gitlab.com") }.keys
|
354
|
-
end
|
355
|
-
|
356
|
-
def codeberg_remote_candidates
|
357
|
-
remotes_with_urls.select { |n, u| u.include?("codeberg.org") }.keys
|
358
|
-
end
|
359
|
-
|
360
|
-
def preferred_github_remote
|
361
|
-
cands = github_remote_candidates
|
362
|
-
return if cands.empty?
|
363
|
-
# Prefer a remote literally named 'github', otherwise the first
|
364
|
-
cands.find { |n| n == "github" } || cands.first
|
365
|
-
end
|
366
|
-
|
367
|
-
def parse_github_owner_repo(url)
|
368
|
-
return [nil, nil] unless url
|
369
|
-
if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
|
370
|
-
[Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
|
371
|
-
elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
|
372
|
-
[Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
|
373
|
-
else
|
374
|
-
[nil, nil]
|
375
|
-
end
|
376
|
-
end
|
377
|
-
|
378
|
-
def has_remote?(name)
|
379
|
-
list_remotes.include?(name)
|
380
|
-
end
|
381
|
-
|
382
|
-
def remote_branch_exists?(remote, branch)
|
383
|
-
_out, ok = git_output(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote}/#{branch}"])
|
384
|
-
ok
|
385
|
-
end
|
386
|
-
|
387
|
-
def ahead_behind_counts(local_ref, remote_ref)
|
388
|
-
out, ok = git_output(["rev-list", "--left-right", "--count", "#{local_ref}...#{remote_ref}"])
|
389
|
-
return [0, 0] unless ok && !out.empty?
|
390
|
-
parts = out.split
|
391
|
-
left = parts[0].to_i
|
392
|
-
right = parts[1].to_i
|
393
|
-
[left, right]
|
394
|
-
end
|
395
|
-
|
396
|
-
def trunk_behind_remote?(trunk, remote)
|
397
|
-
# If the remote branch doesn't exist, treat as not behind
|
398
|
-
return false unless remote_branch_exists?(remote, trunk)
|
399
|
-
_ahead, behind = ahead_behind_counts(trunk, "#{remote}/#{trunk}")
|
400
|
-
behind.positive?
|
401
|
-
end
|
402
|
-
|
403
|
-
def ensure_trunk_synced_before_push!(trunk, feature)
|
404
|
-
if has_remote?("all")
|
405
|
-
puts "Remote 'all' detected. Fetching from all remotes and enforcing strict trunk parity..."
|
406
|
-
run_cmd!("git fetch --all")
|
407
|
-
remotes = list_remotes
|
408
|
-
missing_from = []
|
409
|
-
remotes.each do |r|
|
410
|
-
next if r == "all"
|
411
|
-
if remote_branch_exists?(r, trunk)
|
412
|
-
_ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
|
413
|
-
missing_from << r if behind.positive?
|
414
|
-
end
|
415
|
-
end
|
416
|
-
unless missing_from.empty?
|
417
|
-
abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
|
418
|
-
end
|
419
|
-
puts "Local #{trunk} has all commits from remotes: #{(remotes - ["all"]).join(", ")}"
|
420
|
-
return
|
421
|
-
end
|
422
|
-
|
423
|
-
# Ensure local trunk is in sync with origin/trunk
|
424
|
-
run_cmd!("git fetch origin #{Shellwords.escape(trunk)}")
|
425
|
-
if trunk_behind_remote?(trunk, "origin")
|
426
|
-
puts "Local #{trunk} is behind origin/#{trunk}. Rebasing..."
|
427
|
-
cur = current_branch
|
428
|
-
checkout!(trunk) unless cur == trunk
|
429
|
-
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
430
|
-
checkout!(feature) unless feature.nil? || feature == trunk
|
431
|
-
run_cmd!("git rebase #{Shellwords.escape(trunk)}")
|
432
|
-
puts "Rebase complete. Will push updated branch next."
|
433
|
-
else
|
434
|
-
puts "Local #{trunk} is up to date with origin/#{trunk}."
|
435
|
-
end
|
436
|
-
|
437
|
-
# If there is a GitHub remote that is not origin, ensure origin/#{trunk} incorporates it
|
438
|
-
gh_remote = preferred_github_remote
|
439
|
-
if gh_remote && gh_remote != "origin"
|
440
|
-
puts "GitHub remote detected: #{gh_remote}. Fetching #{trunk}..."
|
441
|
-
run_cmd!("git fetch #{gh_remote} #{Shellwords.escape(trunk)}")
|
442
|
-
|
443
|
-
# Compare origin/trunk vs github/trunk to see if they differ
|
444
|
-
left, right = ahead_behind_counts("origin/#{trunk}", "#{gh_remote}/#{trunk}")
|
445
|
-
if left.zero? && right.zero?
|
446
|
-
puts "origin/#{trunk} and #{gh_remote}/#{trunk} are already in sync."
|
447
|
-
return
|
448
|
-
end
|
449
|
-
|
450
|
-
checkout!(trunk)
|
451
|
-
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
452
|
-
|
453
|
-
if left.positive? && right.positive?
|
454
|
-
# Histories have diverged -> let user choose
|
455
|
-
puts "origin/#{trunk} and #{gh_remote}/#{trunk} have diverged (#{left} ahead of GH, #{right} behind GH)."
|
456
|
-
puts "Choose how to reconcile:"
|
457
|
-
puts " [r] Rebase local/#{trunk} on top of #{gh_remote}/#{trunk} (push to origin)"
|
458
|
-
puts " [m] Merge --no-ff #{gh_remote}/#{trunk} into #{trunk} (push to origin and #{gh_remote})"
|
459
|
-
puts " [a] Abort"
|
460
|
-
print("> ")
|
461
|
-
choice = $stdin.gets&.strip&.downcase
|
462
|
-
case choice
|
463
|
-
when "r"
|
464
|
-
run_cmd!("git rebase #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
|
465
|
-
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
466
|
-
puts "Rebased #{trunk} onto #{gh_remote}/#{trunk} and pushed to origin."
|
467
|
-
when "m"
|
468
|
-
run_cmd!("git merge --no-ff #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
|
469
|
-
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
470
|
-
run_cmd!("git push #{Shellwords.escape(gh_remote)} #{Shellwords.escape(trunk)}")
|
471
|
-
puts "Merged #{gh_remote}/#{trunk} into #{trunk} and pushed to origin and #{gh_remote}."
|
472
|
-
else
|
473
|
-
abort("Aborted by user. Please reconcile trunks and re-run.")
|
474
|
-
end
|
475
|
-
elsif right.positive? && left.zero?
|
476
|
-
# One side can be fast-forwarded
|
477
|
-
puts "Fast-forwarding #{trunk} to include #{gh_remote}/#{trunk}..."
|
478
|
-
run_cmd!("git merge --ff-only #{Shellwords.escape("#{gh_remote}/#{trunk}")}")
|
479
|
-
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
480
|
-
# origin is behind GH -> fast-forward merge
|
481
|
-
elsif left.positive? && right.zero?
|
482
|
-
# origin ahead of GH -> nothing required for origin, optionally inform user
|
483
|
-
puts "origin/#{trunk} is ahead of #{gh_remote}/#{trunk}; no action required before push."
|
484
|
-
end
|
485
|
-
end
|
486
|
-
end
|
487
|
-
|
488
|
-
def merge_feature_into_trunk_and_push!(trunk, feature)
|
489
|
-
return if feature.nil? || feature == trunk
|
490
|
-
puts "Merging #{feature} into #{trunk} (after CI success)..."
|
491
|
-
checkout!(trunk)
|
492
|
-
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
493
|
-
run_cmd!("git merge #{Shellwords.escape(feature)}")
|
494
|
-
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
495
|
-
puts "Merged #{feature} into #{trunk} and pushed. The PR (if any) should auto-close."
|
496
|
-
end
|
497
|
-
|
498
|
-
def ensure_signing_setup_or_skip!
|
499
|
-
return if ENV.key?("SKIP_GEM_SIGNING")
|
500
|
-
|
501
|
-
user = ENV.fetch("GEM_CERT_USER", ENV["USER"])
|
502
|
-
cert_path = File.join(@root, "certs", "#{user}.pem")
|
503
|
-
unless File.exist?(cert_path)
|
504
|
-
abort(<<~MSG)
|
505
|
-
Gem signing appears enabled but no public cert found at:
|
506
|
-
#{cert_path}
|
507
|
-
Add your public key to certs/<USER>.pem (or set GEM_CERT_USER), or set SKIP_GEM_SIGNING to build unsigned.
|
508
|
-
MSG
|
509
|
-
end
|
510
|
-
puts "Found signing cert: #{cert_path}"
|
511
|
-
puts "When prompted during build/release, enter the PEM password for ~/.ssh/gem-private_key.pem"
|
512
|
-
end
|
513
|
-
|
514
|
-
# --- Checksum validation ---
|
515
|
-
# Validate that the sha256 of the built gem in pkg/ matches the recorded
|
516
|
-
# checksum stored under checksums/<gem>.gem.sha256. Abort with guidance if not.
|
517
|
-
# @param version [String]
|
518
|
-
# @param stage [String] human-readable context (e.g., "after release")
|
519
|
-
def validate_checksums!(version, stage: "")
|
520
|
-
gem_path = gem_file_for_version(version)
|
521
|
-
unless gem_path && File.file?(gem_path)
|
522
|
-
abort("Unable to locate built gem for version #{version} in pkg/. Did the build succeed?")
|
523
|
-
end
|
524
|
-
actual = compute_sha256(gem_path)
|
525
|
-
checks_path = File.join(@root, "checksums", "#{File.basename(gem_path)}.sha256")
|
526
|
-
unless File.file?(checks_path)
|
527
|
-
abort("Expected checksum file not found: #{checks_path}. Did bin/gem_checksums run?")
|
528
|
-
end
|
529
|
-
expected = File.read(checks_path).strip
|
530
|
-
if actual != expected
|
531
|
-
abort(<<~MSG)
|
532
|
-
SHA256 mismatch #{stage}:
|
533
|
-
gem: #{gem_path}
|
534
|
-
sha256sum: #{actual}
|
535
|
-
file: #{checks_path}
|
536
|
-
file: #{expected}
|
537
|
-
The artifact being released must match the checksummed artifact exactly.
|
538
|
-
Retry locally: bundle exec rake build && bin/gem_checksums && bundle exec rake release
|
539
|
-
MSG
|
540
|
-
else
|
541
|
-
puts "Checksum OK #{stage}: #{File.basename(gem_path)}"
|
542
|
-
end
|
543
|
-
end
|
544
|
-
|
545
|
-
# Find the gem file in pkg/ that matches the given version
|
546
|
-
def gem_file_for_version(version)
|
547
|
-
pkg = File.join(@root, "pkg")
|
548
|
-
pattern = File.join(pkg, "*.gem")
|
549
|
-
gems = Dir[pattern].select { |p| File.basename(p).include?("-#{version}.gem") }
|
550
|
-
gems.sort.last
|
551
|
-
end
|
552
|
-
|
553
|
-
# Compute sha256 using system utilities (sha256sum or shasum -a 256),
|
554
|
-
# falling back to Ruby Digest if neither is available.
|
555
|
-
def compute_sha256(path)
|
556
|
-
if system("which sha256sum > /dev/null 2>&1")
|
557
|
-
out, _ = Open3.capture2e("sha256sum", path)
|
558
|
-
out.split.first
|
559
|
-
elsif system("which shasum > /dev/null 2>&1")
|
560
|
-
out, _ = Open3.capture2e("shasum", "-a", "256", path)
|
561
|
-
out.split.first
|
562
|
-
else
|
563
|
-
require "digest"
|
564
|
-
Digest::SHA256.file(path).hexdigest
|
565
|
-
end
|
566
|
-
end
|
567
|
-
end
|
568
|
-
end
|
569
|
-
end
|
570
|
-
|
571
30
|
# Always execute when this file is loaded (e.g., via a Bundler binstub).
|
572
31
|
# Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
|
573
32
|
if ARGV.include?("-h") || ARGV.include?("--help")
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Branch rule enforcement and commit message footer support for commit-msg hook.
|
4
|
+
# Provides a lib entrypoint so the exe wrapper can be minimal.
|
5
|
+
|
6
|
+
module Kettle
|
7
|
+
module Dev
|
8
|
+
module CommitMsg
|
9
|
+
module_function
|
10
|
+
|
11
|
+
BRANCH_RULES = {
|
12
|
+
"jira" => /^(?<story_type>(hotfix)|(bug)|(feature)|(candy))\/(?<story_id>\d{8,})-.+\Z/,
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
# Enforce branch rule by appending [type][id] to the commit message when missing.
|
16
|
+
# @param path [String] path to commit message file (ARGV[0] from git)
|
17
|
+
def enforce_branch_rule!(path)
|
18
|
+
validate = ENV.fetch("GIT_HOOK_BRANCH_VALIDATE", "false")
|
19
|
+
branch_rule_type = (!validate.casecmp("false").zero? && validate) || nil
|
20
|
+
return unless branch_rule_type
|
21
|
+
branch_rule = BRANCH_RULES[branch_rule_type]
|
22
|
+
return unless branch_rule
|
23
|
+
|
24
|
+
branch = %x(git branch 2> /dev/null | grep -e ^* | awk '{print $2}')
|
25
|
+
match_data = branch.match(branch_rule)
|
26
|
+
return unless match_data
|
27
|
+
|
28
|
+
commit_msg = File.read(path)
|
29
|
+
unless commit_msg.include?(match_data[:story_id])
|
30
|
+
commit_msg = <<~EOS
|
31
|
+
#{commit_msg.strip}
|
32
|
+
[#{match_data[:story_type]}][#{match_data[:story_id]}]
|
33
|
+
EOS
|
34
|
+
File.open(path, "w") { |file| file.print(commit_msg) }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kettle
|
4
|
+
module Dev
|
5
|
+
# Exit/abort indirection layer to allow controllable behavior in tests.
|
6
|
+
#
|
7
|
+
# Production/default behavior delegates to Kernel.abort / Kernel.exit,
|
8
|
+
# which raise SystemExit. Specs can stub these methods to avoid terminating
|
9
|
+
# the process or to assert on arguments without coupling to Kernel.
|
10
|
+
#
|
11
|
+
# Example (RSpec):
|
12
|
+
# allow(Kettle::Dev::ExitAdapter).to receive(:abort).and_raise(SystemExit.new(1))
|
13
|
+
#
|
14
|
+
# This adapter mirrors the "mockable adapter" approach used for GitAdapter.
|
15
|
+
module ExitAdapter
|
16
|
+
module_function
|
17
|
+
|
18
|
+
# Abort the current execution with a message. By default this calls Kernel.abort,
|
19
|
+
# which raises SystemExit after printing the message to STDERR.
|
20
|
+
#
|
21
|
+
# @param msg [String]
|
22
|
+
# @return [void]
|
23
|
+
def abort(msg)
|
24
|
+
Kernel.abort(msg)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Exit the current process with a given status code. By default this calls Kernel.exit.
|
28
|
+
#
|
29
|
+
# @param status [Integer]
|
30
|
+
# @return [void]
|
31
|
+
def exit(status = 0)
|
32
|
+
Kernel.exit(status)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|