kettle-dev 1.0.0 → 1.0.2
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/.devcontainer/devcontainer.json +26 -0
- data/.envrc +42 -0
- data/.git-hooks/commit-msg +41 -0
- data/.git-hooks/commit-subjects-goalie.txt +8 -0
- data/.git-hooks/footer-template.erb.txt +16 -0
- data/.git-hooks/prepare-commit-msg +20 -0
- data/.github/FUNDING.yml +13 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ancient.yml +80 -0
- data/.github/workflows/auto-assign.yml +21 -0
- data/.github/workflows/codeql-analysis.yml +70 -0
- data/.github/workflows/coverage.yml +130 -0
- data/.github/workflows/current.yml +88 -0
- data/.github/workflows/dependency-review.yml +20 -0
- data/.github/workflows/discord-notifier.yml +38 -0
- data/.github/workflows/heads.yml +87 -0
- data/.github/workflows/jruby.yml +79 -0
- data/.github/workflows/legacy.yml +70 -0
- data/.github/workflows/locked_deps.yml +88 -0
- data/.github/workflows/opencollective.yml +40 -0
- data/.github/workflows/style.yml +67 -0
- data/.github/workflows/supported.yml +85 -0
- data/.github/workflows/truffle.yml +78 -0
- data/.github/workflows/unlocked_deps.yml +87 -0
- data/.github/workflows/unsupported.yml +78 -0
- data/.gitignore +48 -0
- data/.gitlab-ci.yml +45 -0
- data/.junie/guidelines-rbs.md +49 -0
- data/.junie/guidelines.md +132 -0
- data/.opencollective.yml +3 -0
- data/.qlty/qlty.toml +79 -0
- data/.rspec +8 -0
- data/.rubocop.yml +13 -0
- data/.simplecov +7 -0
- data/.tool-versions +1 -0
- data/.yard_gfm_support.rb +22 -0
- data/.yardopts +11 -0
- data/Appraisal.root.gemfile +12 -0
- data/Appraisals +120 -0
- data/CHANGELOG.md +26 -5
- data/Gemfile +32 -0
- data/Rakefile +99 -0
- data/checksums/kettle-dev-1.0.1.gem.sha256 +1 -0
- data/checksums/kettle-dev-1.0.1.gem.sha512 +1 -0
- data/checksums/kettle-dev-1.0.2.gem.sha256 +1 -0
- data/checksums/kettle-dev-1.0.2.gem.sha512 +1 -0
- data/exe/kettle-commit-msg +185 -0
- data/exe/kettle-readme-backers +355 -0
- data/exe/kettle-release +327 -0
- data/gemfiles/modular/coverage.gemfile +6 -0
- data/gemfiles/modular/documentation.gemfile +11 -0
- data/gemfiles/modular/style.gemfile +16 -0
- data/lib/kettle/dev/rakelib/appraisal.rake +40 -0
- data/lib/kettle/dev/rakelib/bench.rake +58 -0
- data/lib/kettle/dev/rakelib/bundle_audit.rake +18 -0
- data/lib/kettle/dev/rakelib/ci.rake +348 -0
- data/lib/kettle/dev/rakelib/install.rake +304 -0
- data/lib/kettle/dev/rakelib/reek.rake +34 -0
- data/lib/kettle/dev/rakelib/require_bench.rake +7 -0
- data/lib/kettle/dev/rakelib/rubocop_gradual.rake +9 -0
- data/lib/kettle/dev/rakelib/spec_test.rake +42 -0
- data/lib/kettle/dev/rakelib/template.rake +413 -0
- data/lib/kettle/dev/rakelib/yard.rake +33 -0
- data/lib/kettle/dev/version.rb +1 -1
- data.tar.gz.sig +0 -0
- metadata +74 -5
- metadata.gz.sig +0 -0
@@ -0,0 +1,355 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# Updates README.md backers and sponsors sections using data from Open Collective
|
5
|
+
# backers.json and sponsors.json for the configured handle.
|
6
|
+
#
|
7
|
+
# Backers (individuals) section markers supported (first match wins):
|
8
|
+
# <!-- OPENCOLLECTIVE:START --> ... <!-- OPENCOLLECTIVE:END -->
|
9
|
+
# <!-- OPENCOLLECTIVE-INDIVIDUALS:START --> ... <!-- OPENCOLLECTIVE-INDIVIDUALS:END -->
|
10
|
+
# Sponsors (organizations) section markers:
|
11
|
+
# <!-- OPENCOLLECTIVE-ORGANIZATIONS:START --> ... <!-- OPENCOLLECTIVE-ORGANIZATIONS:END -->
|
12
|
+
#
|
13
|
+
# Handle resolution order:
|
14
|
+
# 1. ENV["OPENCOLLECTIVE_HANDLE"] if present
|
15
|
+
# 2. .opencollective.yml's `collective:` key in project root if present
|
16
|
+
# 3. Abort with error
|
17
|
+
#
|
18
|
+
# Usage:
|
19
|
+
# OPENCOLLECTIVE_HANDLE=kettle-rb exe/kettle-readme-backers
|
20
|
+
# # or ensure .opencollective.yml exists with collective: "kettle-rb"
|
21
|
+
|
22
|
+
require "rubygems"
|
23
|
+
require "bundler/setup"
|
24
|
+
|
25
|
+
require "yaml"
|
26
|
+
require "json"
|
27
|
+
require "uri"
|
28
|
+
require "net/http"
|
29
|
+
require "set"
|
30
|
+
|
31
|
+
module Kettle
|
32
|
+
module Dev
|
33
|
+
class ReadmeBackers
|
34
|
+
DEFAULT_AVATAR = "https://opencollective.com/static/images/default-avatar.png"
|
35
|
+
README_PATH = File.expand_path("../README.md", __dir__)
|
36
|
+
OC_YML_PATH = File.expand_path("../.opencollective.yml", __dir__)
|
37
|
+
README_OSC_TAG_DEFAULT = "OPENCOLLECTIVE"
|
38
|
+
COMMIT_SUBJECT_DEFAULT = "💸 Thanks 🙏 to our new backers 🎒 and subscribers 📜"
|
39
|
+
|
40
|
+
Backer = Struct.new(:name, :image, :website, :profile, keyword_init: true)
|
41
|
+
|
42
|
+
def initialize(handle: nil, readme_path: README_PATH)
|
43
|
+
@handle = handle || resolve_handle
|
44
|
+
@readme_path = readme_path
|
45
|
+
end
|
46
|
+
|
47
|
+
def run!
|
48
|
+
readme = File.read(@readme_path)
|
49
|
+
|
50
|
+
# Identify previous entries for diffing/mentions
|
51
|
+
b_start, b_end = detect_backer_tags(readme)
|
52
|
+
prev_backer_identities = extract_section_identities(readme, b_start, b_end)
|
53
|
+
s_start_prev, s_end_prev = detect_sponsor_tags(readme)
|
54
|
+
prev_sponsor_identities = extract_section_identities(readme, s_start_prev, s_end_prev)
|
55
|
+
|
56
|
+
# Backers (individuals)
|
57
|
+
backers = fetch_members("backers.json")
|
58
|
+
backers_md = generate_markdown(backers, empty_message: "No backers yet. Be the first!", default_name: "Backer")
|
59
|
+
updated = replace_between_tags(readme, b_start, b_end, backers_md)
|
60
|
+
case updated
|
61
|
+
when :not_found
|
62
|
+
# Do not exit yet; we may still update sponsors.
|
63
|
+
updated_readme = readme
|
64
|
+
backers_changed = false
|
65
|
+
new_backers = []
|
66
|
+
when :no_change
|
67
|
+
updated_readme = readme
|
68
|
+
backers_changed = false
|
69
|
+
new_backers = []
|
70
|
+
else
|
71
|
+
updated_readme = updated
|
72
|
+
backers_changed = true
|
73
|
+
new_backers = compute_new_members(prev_backer_identities, backers)
|
74
|
+
end
|
75
|
+
|
76
|
+
# Sponsors (organizations)
|
77
|
+
sponsors = fetch_members("sponsors.json")
|
78
|
+
sponsors_md = generate_markdown(sponsors, empty_message: "No sponsors yet. Be the first!", default_name: "Sponsor")
|
79
|
+
s_start, s_end = detect_sponsor_tags(updated_readme)
|
80
|
+
updated2 = replace_between_tags(updated_readme, s_start, s_end, sponsors_md)
|
81
|
+
case updated2
|
82
|
+
when :not_found
|
83
|
+
sponsors_changed = false
|
84
|
+
final = updated_readme
|
85
|
+
new_sponsors = []
|
86
|
+
when :no_change
|
87
|
+
sponsors_changed = false
|
88
|
+
final = updated_readme
|
89
|
+
new_sponsors = []
|
90
|
+
else
|
91
|
+
sponsors_changed = true
|
92
|
+
final = updated2
|
93
|
+
new_sponsors = compute_new_members(prev_sponsor_identities, sponsors)
|
94
|
+
end
|
95
|
+
|
96
|
+
if !backers_changed && !sponsors_changed
|
97
|
+
if b_start == :not_found && s_start == :not_found
|
98
|
+
ts = tag_strings
|
99
|
+
warn("No recognized Open Collective tags found in #{@readme_path}. Expected one or more of: " \
|
100
|
+
"#{ts[:generic_start]}/#{ts[:generic_end]}, #{ts[:individuals_start]}/#{ts[:individuals_end]}, #{ts[:orgs_start]}/#{ts[:orgs_end]}.")
|
101
|
+
exit(2)
|
102
|
+
end
|
103
|
+
puts "No changes to backers or sponsors sections in #{@readme_path}."
|
104
|
+
return
|
105
|
+
end
|
106
|
+
|
107
|
+
File.write(@readme_path, final)
|
108
|
+
msgs = []
|
109
|
+
msgs << "backers" if backers_changed
|
110
|
+
msgs << "sponsors" if sponsors_changed
|
111
|
+
puts "Updated #{msgs.join(" and ")} section#{{true => "s", false => ""}[msgs.size > 1]} in #{@readme_path}."
|
112
|
+
|
113
|
+
# Compose and perform commit with mentions if in a git repo
|
114
|
+
perform_git_commit(new_backers, new_sponsors) if git_repo? && (backers_changed || sponsors_changed)
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def readme_osc_tag
|
120
|
+
env = ENV["KETTLE_DEV_BACKER_README_OSC_TAG"].to_s
|
121
|
+
return env unless env.strip.empty?
|
122
|
+
if File.file?(OC_YML_PATH)
|
123
|
+
begin
|
124
|
+
yml = YAML.safe_load(File.read(OC_YML_PATH))
|
125
|
+
if yml.is_a?(Hash)
|
126
|
+
from_yml = yml["readme-osc-tag"] || yml[:"readme-osc-tag"]
|
127
|
+
from_yml = from_yml.to_s if from_yml
|
128
|
+
return from_yml unless from_yml.nil? || from_yml.strip.empty?
|
129
|
+
end
|
130
|
+
rescue StandardError
|
131
|
+
# ignore yaml errors and fall back
|
132
|
+
end
|
133
|
+
end
|
134
|
+
README_OSC_TAG_DEFAULT
|
135
|
+
end
|
136
|
+
|
137
|
+
def tag_strings
|
138
|
+
base = readme_osc_tag
|
139
|
+
{
|
140
|
+
generic_start: "<!-- #{base}:START -->",
|
141
|
+
generic_end: "<!-- #{base}:END -->",
|
142
|
+
individuals_start: "<!-- #{base}-INDIVIDUALS:START -->",
|
143
|
+
individuals_end: "<!-- #{base}-INDIVIDUALS:END -->",
|
144
|
+
orgs_start: "<!-- #{base}-ORGANIZATIONS:START -->",
|
145
|
+
orgs_end: "<!-- #{base}-ORGANIZATIONS:END -->",
|
146
|
+
}
|
147
|
+
end
|
148
|
+
|
149
|
+
def resolve_handle
|
150
|
+
env = ENV["OPENCOLLECTIVE_HANDLE"]
|
151
|
+
return env unless env.nil? || env.strip.empty?
|
152
|
+
if File.file?(OC_YML_PATH)
|
153
|
+
yml = YAML.safe_load(File.read(OC_YML_PATH))
|
154
|
+
handle = yml.is_a?(Hash) ? yml["collective"] || yml[:collective] : nil
|
155
|
+
return handle.to_s unless handle.nil? || handle.to_s.strip.empty?
|
156
|
+
end
|
157
|
+
abort("ERROR: Open Collective handle not provided. Set OPENCOLLECTIVE_HANDLE or add 'collective: <handle>' to .opencollective.yml.")
|
158
|
+
end
|
159
|
+
|
160
|
+
def fetch_members(path)
|
161
|
+
url = URI("https://opencollective.com/#{@handle}/#{path}")
|
162
|
+
response = Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == "https") do |conn|
|
163
|
+
conn.read_timeout = 10
|
164
|
+
conn.open_timeout = 5
|
165
|
+
req = Net::HTTP::Get.new(url)
|
166
|
+
req["User-Agent"] = "kettle-dev/README-backers"
|
167
|
+
conn.request(req)
|
168
|
+
end
|
169
|
+
return [] unless response.is_a?(Net::HTTPSuccess)
|
170
|
+
parsed = JSON.parse(response.body)
|
171
|
+
Array(parsed).map do |h|
|
172
|
+
Backer.new(
|
173
|
+
name: h["name"],
|
174
|
+
image: (h["image"].to_s.strip.empty? ? nil : h["image"]),
|
175
|
+
website: (h["website"].to_s.strip.empty? ? nil : h["website"]),
|
176
|
+
profile: (h["profile"].to_s.strip.empty? ? nil : h["profile"]),
|
177
|
+
)
|
178
|
+
end
|
179
|
+
rescue JSON::ParserError => e
|
180
|
+
warn("Error parsing #{path} JSON: #{e.message}")
|
181
|
+
[]
|
182
|
+
rescue StandardError => e
|
183
|
+
warn("Error fetching #{path}: #{e.class}: #{e.message}")
|
184
|
+
[]
|
185
|
+
end
|
186
|
+
|
187
|
+
def generate_markdown(members, empty_message:, default_name:)
|
188
|
+
return empty_message if members.nil? || members.empty?
|
189
|
+
members.map do |m|
|
190
|
+
image_url = m.image || DEFAULT_AVATAR
|
191
|
+
link = m.website || m.profile || "#"
|
192
|
+
name = (m.name && !m.name.strip.empty?) ? m.name : default_name
|
193
|
+
"[](#{link})"
|
194
|
+
end.join(" ")
|
195
|
+
end
|
196
|
+
|
197
|
+
def replace_between_tags(content, start_tag, end_tag, new_content)
|
198
|
+
return :not_found if start_tag == :not_found || end_tag == :not_found
|
199
|
+
start_index = content.index(start_tag)
|
200
|
+
end_index = content.index(end_tag)
|
201
|
+
return :not_found if start_index.nil? || end_index.nil? || end_index < start_index
|
202
|
+
before = content[0..start_index + start_tag.length - 1]
|
203
|
+
after = content[end_index..-1]
|
204
|
+
replacement = "#{start_tag}\n#{new_content}\n#{end_tag}"
|
205
|
+
current_block = content[start_index..end_index + end_tag.length - 1]
|
206
|
+
return :no_change if current_block == replacement
|
207
|
+
trailing = after[end_tag.length..-1] || ""
|
208
|
+
"#{before}\n#{new_content}\n#{end_tag}#{trailing}"
|
209
|
+
end
|
210
|
+
|
211
|
+
def detect_backer_tags(content)
|
212
|
+
ts = tag_strings
|
213
|
+
if content.include?(ts[:generic_start]) && content.include?(ts[:generic_end])
|
214
|
+
[ts[:generic_start], ts[:generic_end]]
|
215
|
+
elsif content.include?(ts[:individuals_start]) && content.include?(ts[:individuals_end])
|
216
|
+
[ts[:individuals_start], ts[:individuals_end]]
|
217
|
+
else
|
218
|
+
[:not_found, :not_found]
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
def detect_sponsor_tags(content)
|
223
|
+
ts = tag_strings
|
224
|
+
if content.include?(ts[:orgs_start]) && content.include?(ts[:orgs_end])
|
225
|
+
[ts[:orgs_start], ts[:orgs_end]]
|
226
|
+
else
|
227
|
+
[:not_found, :not_found]
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
# Extract identity tokens from the current README section between start/end tags
|
232
|
+
# Identity priority used for comparison:
|
233
|
+
# href (profile/website URL) downcased, else alt text (name) downcased
|
234
|
+
def extract_section_identities(content, start_tag, end_tag)
|
235
|
+
return Set.new unless start_tag && end_tag && start_tag != :not_found && end_tag != :not_found
|
236
|
+
start_index = content.index(start_tag)
|
237
|
+
end_index = content.index(end_tag)
|
238
|
+
return Set.new if start_index.nil? || end_index.nil? || end_index < start_index
|
239
|
+
block = content[(start_index + start_tag.length)...end_index]
|
240
|
+
identities = Set.new
|
241
|
+
# Match patterns like: [](link_url)
|
242
|
+
block.to_s.scan(/\[!\[[^\]]*\]\([^\)]*\)\]\(([^\)]+)\)/) do |m|
|
243
|
+
href = (m[0] || "").strip
|
244
|
+
identities << href.downcase unless href.empty?
|
245
|
+
end
|
246
|
+
# Also capture alt texts in case links are missing
|
247
|
+
block.to_s.scan(/\[!\[([^\]]*)\]\([^\)]*\)\]\([^\)]*\)/) do |m|
|
248
|
+
alt = (m[0] || "").strip
|
249
|
+
identities << alt.downcase unless alt.empty?
|
250
|
+
end
|
251
|
+
identities
|
252
|
+
end
|
253
|
+
|
254
|
+
def compute_new_members(previous_identities, members)
|
255
|
+
prev = previous_identities || Set.new
|
256
|
+
members.select do |m|
|
257
|
+
id = identity_for_member(m)
|
258
|
+
!prev.include?(id)
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
def identity_for_member(m)
|
263
|
+
if m.profile && !m.profile.strip.empty?
|
264
|
+
m.profile.strip.downcase
|
265
|
+
elsif m.website && !m.website.strip.empty?
|
266
|
+
m.website.strip.downcase
|
267
|
+
elsif m.name && !m.name.strip.empty?
|
268
|
+
m.name.strip.downcase
|
269
|
+
else
|
270
|
+
""
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
def mention_for_member(m, default_name: "Member")
|
275
|
+
handle = github_handle_from_urls(m.profile, m.website)
|
276
|
+
return "@#{handle}" if handle
|
277
|
+
name = (m.name && !m.name.strip.empty?) ? m.name.strip : default_name
|
278
|
+
name
|
279
|
+
end
|
280
|
+
|
281
|
+
def github_handle_from_urls(*urls)
|
282
|
+
urls.compact.each do |u|
|
283
|
+
begin
|
284
|
+
uri = URI.parse(u)
|
285
|
+
rescue URI::InvalidURIError
|
286
|
+
next
|
287
|
+
end
|
288
|
+
next unless uri&.host&.downcase&.end_with?("github.com")
|
289
|
+
path = (uri.path || "").sub(%r{^/}, "").sub(%r{/$}, "")
|
290
|
+
next if path.empty?
|
291
|
+
parts = path.split("/")
|
292
|
+
# github.com/sponsors/<handle> or github.com/<handle>/...
|
293
|
+
candidate = if parts[0].downcase == "sponsors" && parts[1]
|
294
|
+
parts[1]
|
295
|
+
else
|
296
|
+
parts[0]
|
297
|
+
end
|
298
|
+
candidate = candidate.gsub(%r{[^a-zA-Z0-9-]}, "")
|
299
|
+
return candidate unless candidate.empty?
|
300
|
+
end
|
301
|
+
nil
|
302
|
+
end
|
303
|
+
|
304
|
+
def perform_git_commit(new_backers, new_sponsors)
|
305
|
+
backer_mentions = new_backers.map { |m| mention_for_member(m, default_name: "Backer") }.uniq
|
306
|
+
sponsor_mentions = new_sponsors.map { |m| mention_for_member(m, default_name: "Subscriber") }.uniq
|
307
|
+
title = commit_subject
|
308
|
+
lines = [title]
|
309
|
+
lines << ""
|
310
|
+
lines << "Backers: #{backer_mentions.join(", ")}" unless backer_mentions.empty?
|
311
|
+
lines << "Subscribers: #{sponsor_mentions.join(", ")}" unless sponsor_mentions.empty?
|
312
|
+
message = lines.join("\n")
|
313
|
+
# Stage and commit README.md
|
314
|
+
system("git", "add", @readme_path)
|
315
|
+
# Only commit if README is staged/changed
|
316
|
+
if system("git", "diff", "--cached", "--quiet")
|
317
|
+
# nothing staged; skip commit
|
318
|
+
return
|
319
|
+
end
|
320
|
+
system("git", "commit", "-m", message)
|
321
|
+
end
|
322
|
+
|
323
|
+
def commit_subject
|
324
|
+
env = ENV["KETTLE_README_BACKERS_COMMIT_SUBJECT"].to_s
|
325
|
+
return env unless env.strip.empty?
|
326
|
+
# Fallback to .opencollective.yml key: readme-backers-commit-subject
|
327
|
+
if File.file?(OC_YML_PATH)
|
328
|
+
begin
|
329
|
+
yml = YAML.safe_load(File.read(OC_YML_PATH))
|
330
|
+
if yml.is_a?(Hash)
|
331
|
+
from_yml = yml["readme-backers-commit-subject"] || yml[:"readme-backers-commit-subject"]
|
332
|
+
from_yml = from_yml.to_s if from_yml
|
333
|
+
return from_yml unless from_yml.nil? || from_yml.strip.empty?
|
334
|
+
end
|
335
|
+
rescue StandardError
|
336
|
+
# ignore yaml read errors and fall back to default
|
337
|
+
end
|
338
|
+
end
|
339
|
+
COMMIT_SUBJECT_DEFAULT
|
340
|
+
end
|
341
|
+
|
342
|
+
def git_repo?
|
343
|
+
system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
|
344
|
+
end
|
345
|
+
|
346
|
+
def escape_text(text)
|
347
|
+
text.gsub("[", "\\[").gsub("]", "\\]")
|
348
|
+
end
|
349
|
+
end
|
350
|
+
end
|
351
|
+
end
|
352
|
+
|
353
|
+
if __FILE__ == $PROGRAM_NAME
|
354
|
+
Kettle::Dev::ReadmeBackers.new.run!
|
355
|
+
end
|
data/exe/kettle-release
ADDED
@@ -0,0 +1,327 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# kettle-release: Automate release steps from CONTRIBUTING.md
|
5
|
+
# - Runs sanity checks
|
6
|
+
# - Ensures version/changelog updated (with confirmation)
|
7
|
+
# - Commits and pushes a release prep commit
|
8
|
+
# - Ensures on trunk, up-to-date
|
9
|
+
# - Exports SOURCE_DATE_EPOCH for reproducible checksums
|
10
|
+
# - Runs `bundle exec rake build` (expects PEM password unless SKIP_GEM_SIGNING)
|
11
|
+
# - If signing not skipped and no public cert in certs/<user>.pem, aborts with guidance
|
12
|
+
# - Runs bin/gem_checksums
|
13
|
+
# - Runs `bundle exec rake release` (expects PEM password and RubyGems MFA OTP)
|
14
|
+
|
15
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
16
|
+
|
17
|
+
require "rubygems"
|
18
|
+
require "bundler/setup"
|
19
|
+
|
20
|
+
require "open3"
|
21
|
+
require "shellwords"
|
22
|
+
require "time"
|
23
|
+
require "fileutils"
|
24
|
+
require "net/http"
|
25
|
+
require "json"
|
26
|
+
require "uri"
|
27
|
+
require "kettle/dev/ci_helpers"
|
28
|
+
require "ruby-progressbar"
|
29
|
+
|
30
|
+
module Kettle
|
31
|
+
module Dev
|
32
|
+
class ReleaseCLI
|
33
|
+
def initialize
|
34
|
+
@root = File.expand_path("..", __dir__)
|
35
|
+
end
|
36
|
+
|
37
|
+
def run
|
38
|
+
puts "== kettle-release =="
|
39
|
+
|
40
|
+
run_cmd!("bin/setup")
|
41
|
+
run_cmd!("bin/rake")
|
42
|
+
|
43
|
+
version = detect_version
|
44
|
+
puts "Detected version: #{version.inspect}"
|
45
|
+
puts "Have you updated lib/**/version.rb and CHANGELOG.md for v#{version}? [y/N]"
|
46
|
+
print("> ")
|
47
|
+
ans = $stdin.gets&.strip
|
48
|
+
abort("Aborted: please update version.rb and CHANGELOG.md, then re-run.") unless ans&.downcase&.start_with?("y")
|
49
|
+
|
50
|
+
# Re-run checks (and refresh Gemfile.lock)
|
51
|
+
run_cmd!("bin/setup")
|
52
|
+
run_cmd!("bin/rake")
|
53
|
+
|
54
|
+
# Update Appraisal gemfiles if Appraisals file is present
|
55
|
+
appraisals_path = File.join(@root, "Appraisals")
|
56
|
+
if File.file?(appraisals_path)
|
57
|
+
puts "Appraisals detected at #{appraisals_path}. Running: bin/rake appraisal:update"
|
58
|
+
run_cmd!("bin/rake appraisal:update")
|
59
|
+
else
|
60
|
+
puts "No Appraisals file found; skipping appraisal:update"
|
61
|
+
end
|
62
|
+
|
63
|
+
ensure_git_user!
|
64
|
+
commit_release_prep!(version)
|
65
|
+
|
66
|
+
trunk = detect_trunk_branch
|
67
|
+
feature = current_branch
|
68
|
+
puts "Trunk branch detected: #{trunk}"
|
69
|
+
ensure_trunk_synced_before_push!(trunk, feature)
|
70
|
+
|
71
|
+
push!
|
72
|
+
|
73
|
+
# After pushing, ensure the CI workflows for this project are passing
|
74
|
+
monitor_workflows_after_push!
|
75
|
+
|
76
|
+
# If all workflows are passing, merge the feature branch into trunk and push trunk
|
77
|
+
merge_feature_into_trunk_and_push!(trunk, feature)
|
78
|
+
|
79
|
+
# Ensure we are on trunk for the remaining steps
|
80
|
+
checkout!(trunk)
|
81
|
+
pull!(trunk)
|
82
|
+
|
83
|
+
export_source_date_epoch!
|
84
|
+
|
85
|
+
ensure_signing_setup_or_skip!
|
86
|
+
# Build: expect PEM password prompt unless SKIP_GEM_SIGNING
|
87
|
+
puts "Running build (you may be prompted for the signing key password)..."
|
88
|
+
run_cmd!("bundle exec rake build")
|
89
|
+
|
90
|
+
# Checksums (commits, but does not push)
|
91
|
+
run_cmd!("bin/gem_checksums")
|
92
|
+
|
93
|
+
# Release: expect PEM password + RubyGems MFA OTP
|
94
|
+
puts "Running release (you may be prompted for signing key password and RubyGems MFA OTP)..."
|
95
|
+
run_cmd!("bundle exec rake release")
|
96
|
+
|
97
|
+
puts "\nRelease complete. Don't forget to push the checksums commit if needed."
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
# Monitor GitHub Actions workflows discovered by ci:act logic.
|
103
|
+
# Checks one workflow per second in a round-robin loop until all pass, or any fails.
|
104
|
+
def monitor_workflows_after_push!
|
105
|
+
root = Kettle::Dev::CIHelpers.project_root
|
106
|
+
workflows = Kettle::Dev::CIHelpers.workflows_list(root)
|
107
|
+
if workflows.empty?
|
108
|
+
puts "No workflows detected under .github/workflows; skipping CI checks."
|
109
|
+
return
|
110
|
+
end
|
111
|
+
|
112
|
+
owner, repo = Kettle::Dev::CIHelpers.repo_info
|
113
|
+
branch = Kettle::Dev::CIHelpers.current_branch
|
114
|
+
unless owner && repo && branch
|
115
|
+
puts "Unable to determine repository or branch; skipping CI checks."
|
116
|
+
return
|
117
|
+
end
|
118
|
+
|
119
|
+
total = workflows.size
|
120
|
+
passed = {}
|
121
|
+
idx = 0
|
122
|
+
puts "Ensuring CI workflows pass on branch #{branch} (#{owner}/#{repo})"
|
123
|
+
pbar = ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
|
124
|
+
|
125
|
+
loop do
|
126
|
+
wf = workflows[idx]
|
127
|
+
run = Kettle::Dev::CIHelpers.latest_run(owner: owner, repo: repo, workflow_file: wf, branch: branch)
|
128
|
+
if run
|
129
|
+
if Kettle::Dev::CIHelpers.success?(run)
|
130
|
+
unless passed[wf]
|
131
|
+
passed[wf] = true
|
132
|
+
pbar.increment
|
133
|
+
end
|
134
|
+
elsif Kettle::Dev::CIHelpers.failed?(run)
|
135
|
+
# Fail fast with link to the failed run
|
136
|
+
puts
|
137
|
+
url = run["html_url"] || "https://github.com/#{owner}/#{repo}/actions/workflows/#{wf}"
|
138
|
+
abort("Workflow failed: #{wf} -> #{url}")
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
break if passed.size == total
|
143
|
+
|
144
|
+
idx = (idx + 1) % total
|
145
|
+
sleep(1)
|
146
|
+
end
|
147
|
+
pbar.finish unless pbar.finished?
|
148
|
+
puts "\nAll workflows passing (#{passed.size}/#{total})."
|
149
|
+
end
|
150
|
+
|
151
|
+
def run_cmd!(cmd)
|
152
|
+
puts "$ #{cmd}"
|
153
|
+
success = system(cmd)
|
154
|
+
abort("Command failed: #{cmd}") unless success
|
155
|
+
end
|
156
|
+
|
157
|
+
def git_output(args)
|
158
|
+
out, status = Open3.capture2("git", *args)
|
159
|
+
[out.strip, status.success?]
|
160
|
+
end
|
161
|
+
|
162
|
+
def check_git_clean!
|
163
|
+
out, ok = git_output(["status", "--porcelain"])
|
164
|
+
abort("Git working tree is not clean. Commit/stash changes before releasing.\n\n#{out}") unless ok && out.empty?
|
165
|
+
end
|
166
|
+
|
167
|
+
def ensure_git_user!
|
168
|
+
name, ok1 = git_output(["config", "user.name"])
|
169
|
+
email, ok2 = git_output(["config", "user.email"])
|
170
|
+
abort("Git user.name or user.email not configured.") unless ok1 && ok2 && !name.empty? && !email.empty?
|
171
|
+
end
|
172
|
+
|
173
|
+
def detect_version
|
174
|
+
# Look for lib/**/version.rb and extract VERSION constant string
|
175
|
+
candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
|
176
|
+
abort("Could not find version.rb under lib/**.") if candidates.empty?
|
177
|
+
path = candidates.min
|
178
|
+
content = File.read(path)
|
179
|
+
m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
|
180
|
+
abort("VERSION constant not found in #{path}.") unless m
|
181
|
+
m[2]
|
182
|
+
end
|
183
|
+
|
184
|
+
def commit_release_prep!(version)
|
185
|
+
msg = "🔖 Prepare release v#{version}"
|
186
|
+
# Only commit if there are changes (version/changelog)
|
187
|
+
out, _ = git_output(["status", "--porcelain"])
|
188
|
+
if out.empty?
|
189
|
+
puts "No changes to commit for release prep (continuing)."
|
190
|
+
else
|
191
|
+
run_cmd!(%(git commit -am #{Shellwords.escape(msg)}))
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def push!
|
196
|
+
puts "$ git push"
|
197
|
+
success = system("git push")
|
198
|
+
unless success
|
199
|
+
warn("Normal push failed; retrying with force push...")
|
200
|
+
run_cmd!("git push -f")
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def detect_trunk_branch
|
205
|
+
out, ok = git_output(["remote", "show", "origin"])
|
206
|
+
abort("Failed to get origin remote info.") unless ok
|
207
|
+
m = out.lines.find { |l| l.include?("HEAD branch") }
|
208
|
+
abort("Unable to detect trunk branch from origin.") unless m
|
209
|
+
m.split.last
|
210
|
+
end
|
211
|
+
|
212
|
+
def checkout!(branch)
|
213
|
+
run_cmd!("git checkout #{Shellwords.escape(branch)}")
|
214
|
+
end
|
215
|
+
|
216
|
+
def pull!(branch)
|
217
|
+
run_cmd!("git pull origin #{Shellwords.escape(branch)}")
|
218
|
+
end
|
219
|
+
|
220
|
+
def current_branch
|
221
|
+
out, ok = git_output(["rev-parse", "--abbrev-ref", "HEAD"])
|
222
|
+
ok ? out : nil
|
223
|
+
end
|
224
|
+
|
225
|
+
def list_remotes
|
226
|
+
out, ok = git_output(["remote"])
|
227
|
+
ok ? out.split(/\s+/).reject(&:empty?) : []
|
228
|
+
end
|
229
|
+
|
230
|
+
def has_remote?(name)
|
231
|
+
list_remotes.include?(name)
|
232
|
+
end
|
233
|
+
|
234
|
+
def remote_branch_exists?(remote, branch)
|
235
|
+
_out, ok = git_output(["show-ref", "--verify", "--quiet", "refs/remotes/#{remote}/#{branch}"])
|
236
|
+
ok
|
237
|
+
end
|
238
|
+
|
239
|
+
def ahead_behind_counts(local_ref, remote_ref)
|
240
|
+
out, ok = git_output(["rev-list", "--left-right", "--count", "#{local_ref}...#{remote_ref}"])
|
241
|
+
return [0, 0] unless ok && !out.empty?
|
242
|
+
parts = out.split
|
243
|
+
left = parts[0].to_i
|
244
|
+
right = parts[1].to_i
|
245
|
+
[left, right]
|
246
|
+
end
|
247
|
+
|
248
|
+
def trunk_behind_remote?(trunk, remote)
|
249
|
+
# If the remote branch doesn't exist, treat as not behind
|
250
|
+
return false unless remote_branch_exists?(remote, trunk)
|
251
|
+
_ahead, behind = ahead_behind_counts(trunk, "#{remote}/#{trunk}")
|
252
|
+
behind.positive?
|
253
|
+
end
|
254
|
+
|
255
|
+
def ensure_trunk_synced_before_push!(trunk, feature)
|
256
|
+
if has_remote?("all")
|
257
|
+
puts "Remote 'all' detected. Fetching from all remotes and enforcing strict trunk parity..."
|
258
|
+
run_cmd!("git fetch --all")
|
259
|
+
remotes = list_remotes
|
260
|
+
missing_from = []
|
261
|
+
remotes.each do |r|
|
262
|
+
next if r == "all"
|
263
|
+
if remote_branch_exists?(r, trunk)
|
264
|
+
_ahead, behind = ahead_behind_counts(trunk, "#{r}/#{trunk}")
|
265
|
+
missing_from << r if behind.positive?
|
266
|
+
end
|
267
|
+
end
|
268
|
+
unless missing_from.empty?
|
269
|
+
abort("Local #{trunk} is missing commits present on: #{missing_from.join(", ")}. Please sync trunk first.")
|
270
|
+
end
|
271
|
+
puts "Local #{trunk} has all commits from remotes: #{(remotes - ["all"]).join(", ")}"
|
272
|
+
return
|
273
|
+
end
|
274
|
+
|
275
|
+
# Default behavior: ensure local trunk is not behind origin/trunk; if it is, rebase flows
|
276
|
+
run_cmd!("git fetch origin #{Shellwords.escape(trunk)}")
|
277
|
+
if trunk_behind_remote?(trunk, "origin")
|
278
|
+
puts "Local #{trunk} is behind origin/#{trunk}. Rebasing..."
|
279
|
+
cur = current_branch
|
280
|
+
checkout!(trunk) unless cur == trunk
|
281
|
+
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
282
|
+
checkout!(feature) unless feature.nil? || feature == trunk
|
283
|
+
run_cmd!("git rebase #{Shellwords.escape(trunk)}")
|
284
|
+
puts "Rebase complete. Will push updated branch next."
|
285
|
+
else
|
286
|
+
puts "Local #{trunk} is up to date with origin/#{trunk}."
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
def merge_feature_into_trunk_and_push!(trunk, feature)
|
291
|
+
return if feature.nil? || feature == trunk
|
292
|
+
puts "Merging #{feature} into #{trunk} (after CI success)..."
|
293
|
+
checkout!(trunk)
|
294
|
+
run_cmd!("git pull --rebase origin #{Shellwords.escape(trunk)}")
|
295
|
+
run_cmd!("git merge #{Shellwords.escape(feature)}")
|
296
|
+
run_cmd!("git push origin #{Shellwords.escape(trunk)}")
|
297
|
+
puts "Merged #{feature} into #{trunk} and pushed. The PR (if any) should auto-close."
|
298
|
+
end
|
299
|
+
|
300
|
+
def export_source_date_epoch!
|
301
|
+
epoch = Time.now.to_i
|
302
|
+
ENV["SOURCE_DATE_EPOCH"] = epoch.to_s
|
303
|
+
puts "Exported SOURCE_DATE_EPOCH=#{epoch}"
|
304
|
+
end
|
305
|
+
|
306
|
+
def ensure_signing_setup_or_skip!
|
307
|
+
return if ENV.key?("SKIP_GEM_SIGNING")
|
308
|
+
|
309
|
+
user = ENV.fetch("GEM_CERT_USER", ENV["USER"])
|
310
|
+
cert_path = File.join(@root, "certs", "#{user}.pem")
|
311
|
+
unless File.exist?(cert_path)
|
312
|
+
abort(<<~MSG)
|
313
|
+
Gem signing appears enabled but no public cert found at:
|
314
|
+
#{cert_path}
|
315
|
+
Add your public key to certs/<USER>.pem (or set GEM_CERT_USER), or set SKIP_GEM_SIGNING to build unsigned.
|
316
|
+
MSG
|
317
|
+
end
|
318
|
+
puts "Found signing cert: #{cert_path}"
|
319
|
+
puts "When prompted during build/release, enter the PEM password for ~/.ssh/gem-private_key.pem"
|
320
|
+
end
|
321
|
+
end
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
if __FILE__ == $PROGRAM_NAME
|
326
|
+
Kettle::Dev::ReleaseCLI.new.run
|
327
|
+
end
|