kettle-dev 1.0.10 → 1.0.12
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 +1 -1
- data/.github/workflows/coverage.yml +2 -2
- data/.github/workflows/coverage.yml.example +127 -0
- data/.github/workflows/discord-notifier.yml +2 -1
- data/.github/workflows/truffle.yml +0 -8
- data/.junie/guidelines.md +1 -0
- data/Appraisals +4 -1
- data/Appraisals.example +104 -0
- data/CHANGELOG.md +88 -29
- data/CHANGELOG.md.example +4 -4
- data/CONTRIBUTING.md +37 -1
- data/Gemfile +3 -0
- data/Gemfile.example +35 -0
- data/README.md +48 -10
- data/README.md.example +515 -0
- data/{Rakefile → Rakefile.example} +13 -27
- data/exe/kettle-changelog +404 -0
- data/exe/kettle-commit-msg +2 -0
- data/exe/kettle-readme-backers +2 -0
- data/exe/kettle-release +10 -9
- data/gemfiles/modular/optional.gemfile +1 -0
- data/lib/kettle/dev/ci_helpers.rb +19 -0
- data/lib/kettle/dev/ci_monitor.rb +192 -0
- data/lib/kettle/dev/git_adapter.rb +98 -33
- data/lib/kettle/dev/git_commit_footer.rb +1 -1
- data/lib/kettle/dev/input_adapter.rb +44 -0
- data/lib/kettle/dev/release_cli.rb +154 -177
- data/lib/kettle/dev/tasks/ci_task.rb +22 -1
- data/lib/kettle/dev/tasks/install_task.rb +313 -95
- data/lib/kettle/dev/tasks/template_task.rb +176 -74
- data/lib/kettle/dev/template_helpers.rb +61 -8
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev/versioning.rb +68 -0
- data/sig/kettle/dev/ci_helpers.rbs +1 -1
- data/sig/kettle/dev/ci_monitor.rbs +8 -0
- data/sig/kettle/dev/input_adapter.rbs +8 -0
- data/sig/kettle/dev/release_cli.rbs +1 -1
- data/sig/kettle/dev/template_helpers.rbs +3 -1
- data.tar.gz.sig +0 -0
- metadata +24 -22
- metadata.gz.sig +0 -0
- data/.gitlab-ci.yml +0 -45
@@ -0,0 +1,404 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# vim: set syntax=ruby
|
5
|
+
|
6
|
+
# kettle-changelog: Generate a CHANGELOG.md entry for the current VERSION.
|
7
|
+
# - Reads VERSION from lib/**/version.rb (must be unique across files)
|
8
|
+
# - Moves entries from the "Unreleased" section into a new versioned section
|
9
|
+
# - Prepends 4 heading lines:
|
10
|
+
# - TAG
|
11
|
+
# - COVERAGE (line coverage)
|
12
|
+
# - BRANCH COVERAGE (branch coverage)
|
13
|
+
# - percent documented (parsed from `bin/yard` output)
|
14
|
+
# - Updates bottom link references to GitHub style, converts any existing
|
15
|
+
# GitLab links to GitHub links, and appends the new [X.Y.Z] and [X.Y.Zt] links.
|
16
|
+
#
|
17
|
+
# Notes:
|
18
|
+
# - Expects a JSON coverage report at coverage/coverage.json. If missing,
|
19
|
+
# it will instruct you to run: K_SOUP_COV_FORMATTERS="json" bin/rspec
|
20
|
+
# - Expects bin/yard to be available via Bundler.
|
21
|
+
|
22
|
+
$stdout.sync = true
|
23
|
+
|
24
|
+
# Depending library or project must be using bundler
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
require "json"
|
28
|
+
require "time"
|
29
|
+
require "open3"
|
30
|
+
require "shellwords"
|
31
|
+
|
32
|
+
begin
|
33
|
+
require "kettle/dev"
|
34
|
+
rescue LoadError => e
|
35
|
+
warn("kettle/dev: failed to load: #{e.message}")
|
36
|
+
warn("Hint: Ensure the host project includes kettle-dev and run bundle install.")
|
37
|
+
exit(1)
|
38
|
+
end
|
39
|
+
|
40
|
+
require "kettle/dev/versioning"
|
41
|
+
|
42
|
+
puts "== kettle-changelog v#{Kettle::Dev::Version::VERSION} =="
|
43
|
+
|
44
|
+
module Kettle
|
45
|
+
module Dev
|
46
|
+
class ChangelogCLI
|
47
|
+
def initialize
|
48
|
+
@root = Kettle::Dev::CIHelpers.project_root
|
49
|
+
@changelog_path = File.join(@root, "CHANGELOG.md")
|
50
|
+
@coverage_path = File.join(@root, "coverage", "coverage.json")
|
51
|
+
end
|
52
|
+
|
53
|
+
def run
|
54
|
+
version = Kettle::Dev::Versioning.detect_version(@root)
|
55
|
+
today = Time.now.strftime("%Y-%m-%d")
|
56
|
+
owner, repo = Kettle::Dev::CIHelpers.repo_info
|
57
|
+
unless owner && repo
|
58
|
+
warn("Could not determine GitHub owner/repo from origin remote.")
|
59
|
+
warn("Make sure 'origin' points to github.com. Alternatively, set origin or update links manually afterward.")
|
60
|
+
end
|
61
|
+
|
62
|
+
line_cov_line, branch_cov_line = coverage_lines
|
63
|
+
yard_line = yard_percent_documented
|
64
|
+
|
65
|
+
changelog = File.read(@changelog_path)
|
66
|
+
|
67
|
+
# If the detected version already exists in the changelog, abort to avoid duplicates
|
68
|
+
if changelog =~ /^## \[#{Regexp.escape(version)}\]/
|
69
|
+
abort("CHANGELOG.md already has a section for version #{version}. Bump version.rb or remove the duplicate.")
|
70
|
+
end
|
71
|
+
|
72
|
+
unreleased_block, before, after = extract_unreleased(changelog)
|
73
|
+
if unreleased_block.nil?
|
74
|
+
abort("Could not find '## [Unreleased]' section in CHANGELOG.md")
|
75
|
+
end
|
76
|
+
|
77
|
+
if unreleased_block.strip.empty?
|
78
|
+
warn("No entries found under Unreleased. Creating an empty version section anyway.")
|
79
|
+
end
|
80
|
+
|
81
|
+
prev_version = detect_previous_version(after)
|
82
|
+
|
83
|
+
new_section = +""
|
84
|
+
new_section << "## [#{version}] - #{today}\n"
|
85
|
+
new_section << "- TAG: [v#{version}][#{version}t]\n"
|
86
|
+
new_section << "- #{line_cov_line}\n" if line_cov_line
|
87
|
+
new_section << "- #{branch_cov_line}\n" if branch_cov_line
|
88
|
+
new_section << "- #{yard_line}\n" if yard_line
|
89
|
+
new_section << filter_unreleased_sections(unreleased_block)
|
90
|
+
# Ensure exactly one blank line separates this new section from the next section
|
91
|
+
new_section.rstrip!
|
92
|
+
new_section << "\n\n"
|
93
|
+
|
94
|
+
# Reset the Unreleased section to empty category headings
|
95
|
+
unreleased_reset = <<~MD
|
96
|
+
## [Unreleased]
|
97
|
+
### Added
|
98
|
+
### Changed
|
99
|
+
### Deprecated
|
100
|
+
### Removed
|
101
|
+
### Fixed
|
102
|
+
### Security
|
103
|
+
MD
|
104
|
+
|
105
|
+
updated = before + unreleased_reset + "\n" + new_section + after
|
106
|
+
|
107
|
+
updated = update_link_refs(updated, owner, repo, prev_version, version)
|
108
|
+
|
109
|
+
# Ensure exactly one trailing newline at EOF
|
110
|
+
updated = updated.rstrip + "\n"
|
111
|
+
|
112
|
+
File.write(@changelog_path, updated)
|
113
|
+
puts "CHANGELOG.md updated with v#{version} section."
|
114
|
+
end
|
115
|
+
|
116
|
+
private
|
117
|
+
|
118
|
+
def abort(msg)
|
119
|
+
Kettle::Dev::ExitAdapter.abort(msg)
|
120
|
+
rescue NameError
|
121
|
+
Kernel.abort(msg)
|
122
|
+
end
|
123
|
+
|
124
|
+
def detect_version
|
125
|
+
candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
|
126
|
+
abort("Could not find version.rb under lib/**.") if candidates.empty?
|
127
|
+
versions = candidates.map do |path|
|
128
|
+
content = File.read(path)
|
129
|
+
m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
|
130
|
+
next unless m
|
131
|
+
m[2]
|
132
|
+
end.compact
|
133
|
+
abort("VERSION constant not found in #{@root}/lib/**/version.rb") if versions.none?
|
134
|
+
abort("Multiple VERSION constants found to be out of sync (#{versions.inspect}) in #{@root}/lib/**/version.rb") unless versions.uniq.length == 1
|
135
|
+
versions.first
|
136
|
+
end
|
137
|
+
|
138
|
+
def extract_unreleased(content)
|
139
|
+
lines = content.lines
|
140
|
+
start_i = lines.index { |l| l.start_with?("## [Unreleased]") }
|
141
|
+
return [nil, nil, nil] unless start_i
|
142
|
+
# Find the next version heading after Unreleased
|
143
|
+
next_i = (start_i + 1)
|
144
|
+
while next_i < lines.length && !lines[next_i].start_with?("## [")
|
145
|
+
next_i += 1
|
146
|
+
end
|
147
|
+
# Now next_i points to the next section heading or EOF
|
148
|
+
before = lines[0..(start_i - 1)].join
|
149
|
+
unreleased_block = lines[(start_i + 1)..(next_i - 1)].join
|
150
|
+
after = lines[next_i..-1]&.join || ""
|
151
|
+
[unreleased_block, before, after]
|
152
|
+
end
|
153
|
+
|
154
|
+
def detect_previous_version(after_text)
|
155
|
+
# after_text begins with the first released section following Unreleased
|
156
|
+
m = after_text.match(/^## \[(\d+\.\d+\.\d+)\]/)
|
157
|
+
return m[1] if m
|
158
|
+
nil
|
159
|
+
end
|
160
|
+
|
161
|
+
# From the Unreleased block, keep only sections that have content.
|
162
|
+
# We detect sections as lines starting with '### '. A section has content if there is at least
|
163
|
+
# one non-empty, non-heading line under it before the next '###' or '##'. Typically these are list items.
|
164
|
+
# Returns a string that includes only the non-empty sections with their content.
|
165
|
+
def filter_unreleased_sections(unreleased_block)
|
166
|
+
lines = unreleased_block.lines
|
167
|
+
out = []
|
168
|
+
i = 0
|
169
|
+
while i < lines.length
|
170
|
+
line = lines[i]
|
171
|
+
if line.start_with?("### ")
|
172
|
+
header = line
|
173
|
+
i += 1
|
174
|
+
chunk = []
|
175
|
+
while i < lines.length && !lines[i].start_with?("### ") && !lines[i].start_with?("## ")
|
176
|
+
chunk << lines[i]
|
177
|
+
i += 1
|
178
|
+
end
|
179
|
+
# Determine if chunk has any content (non-blank)
|
180
|
+
content_present = chunk.any? { |l| l.strip != "" }
|
181
|
+
if content_present
|
182
|
+
# Trim trailing blank lines
|
183
|
+
while chunk.any? && chunk.last.strip == ""
|
184
|
+
chunk.pop
|
185
|
+
end
|
186
|
+
out << header
|
187
|
+
out.concat(chunk)
|
188
|
+
out << "\n" unless out.last&.end_with?("\n")
|
189
|
+
end
|
190
|
+
next
|
191
|
+
else
|
192
|
+
# Lines outside sections are ignored for released sections
|
193
|
+
i += 1
|
194
|
+
end
|
195
|
+
end
|
196
|
+
out.join
|
197
|
+
end
|
198
|
+
|
199
|
+
def coverage_lines
|
200
|
+
unless File.file?(@coverage_path)
|
201
|
+
warn("Coverage JSON not found at #{@coverage_path}.")
|
202
|
+
warn("Run: K_SOUP_COV_FORMATTERS=\"json\" bin/rspec")
|
203
|
+
return [nil, nil]
|
204
|
+
end
|
205
|
+
data = JSON.parse(File.read(@coverage_path))
|
206
|
+
files = data["coverage"] || {}
|
207
|
+
file_count = 0
|
208
|
+
total_lines = 0
|
209
|
+
covered_lines = 0
|
210
|
+
total_branches = 0
|
211
|
+
covered_branches = 0
|
212
|
+
files.each_value do |h|
|
213
|
+
lines = h["lines"] || []
|
214
|
+
line_relevant = lines.count { |x| x.is_a?(Integer) }
|
215
|
+
line_covered = lines.count { |x| x.is_a?(Integer) && x > 0 }
|
216
|
+
if line_relevant > 0
|
217
|
+
file_count += 1
|
218
|
+
total_lines += line_relevant
|
219
|
+
covered_lines += line_covered
|
220
|
+
end
|
221
|
+
branches = h["branches"] || []
|
222
|
+
branches.each do |b|
|
223
|
+
next unless b.is_a?(Hash)
|
224
|
+
cov = b["coverage"]
|
225
|
+
next unless cov.is_a?(Numeric)
|
226
|
+
total_branches += 1
|
227
|
+
covered_branches += 1 if cov > 0
|
228
|
+
end
|
229
|
+
end
|
230
|
+
line_pct = (total_lines > 0) ? ((covered_lines.to_f / total_lines) * 100.0) : 0.0
|
231
|
+
branch_pct = (total_branches > 0) ? ((covered_branches.to_f / total_branches) * 100.0) : 0.0
|
232
|
+
line_str = format("COVERAGE: %.2f%% -- %d/%d lines in %d files", line_pct, covered_lines, total_lines, file_count)
|
233
|
+
branch_str = format("BRANCH COVERAGE: %.2f%% -- %d/%d branches in %d files", branch_pct, covered_branches, total_branches, file_count)
|
234
|
+
[line_str, branch_str]
|
235
|
+
rescue StandardError => e
|
236
|
+
warn("Failed to parse coverage: #{e.class}: #{e.message}")
|
237
|
+
[nil, nil]
|
238
|
+
end
|
239
|
+
|
240
|
+
def yard_percent_documented
|
241
|
+
cmd = File.join(@root, "bin", "yard")
|
242
|
+
unless File.executable?(cmd)
|
243
|
+
warn("bin/yard not found or not executable; ensure yard is installed via bundler")
|
244
|
+
return
|
245
|
+
end
|
246
|
+
out, _ = Open3.capture2(cmd)
|
247
|
+
# Look for a line containing e.g., "95.35% documented"
|
248
|
+
line = out.lines.find { |l| l =~ /\d+(?:\.\d+)?%\s+documented/ }
|
249
|
+
if line
|
250
|
+
line = line.strip
|
251
|
+
# Return exactly as requested: e.g. "95.35% documented"
|
252
|
+
line
|
253
|
+
else
|
254
|
+
warn("Could not find documented percentage in bin/yard output.")
|
255
|
+
nil
|
256
|
+
end
|
257
|
+
rescue StandardError => e
|
258
|
+
warn("Failed to run bin/yard: #{e.class}: #{e.message}")
|
259
|
+
nil
|
260
|
+
end
|
261
|
+
|
262
|
+
def update_link_refs(content, owner, repo, prev_version, new_version)
|
263
|
+
# Convert any GitLab links to GitHub
|
264
|
+
content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/compare/([^\.]+)\.\.\.([^\s]+)}) do
|
265
|
+
o = owner || Regexp.last_match(1)
|
266
|
+
r = repo || Regexp.last_match(2)
|
267
|
+
from = Regexp.last_match(3)
|
268
|
+
to = Regexp.last_match(4)
|
269
|
+
"https://github.com/#{o}/#{r}/compare/#{from}...#{to}"
|
270
|
+
end
|
271
|
+
content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/tags/(v[^\s\]]+)}) do
|
272
|
+
o = owner || Regexp.last_match(1)
|
273
|
+
r = repo || Regexp.last_match(2)
|
274
|
+
tag = Regexp.last_match(3)
|
275
|
+
"https://github.com/#{o}/#{r}/releases/tag/#{tag}"
|
276
|
+
end
|
277
|
+
|
278
|
+
# Append or update the bottom reference links
|
279
|
+
lines = content.lines
|
280
|
+
|
281
|
+
# Find the index of the Unreleased heading; only manipulate refs after this point
|
282
|
+
unreleased_idx = lines.index { |l| l.start_with?("## [Unreleased]") } || -1
|
283
|
+
|
284
|
+
# Find the first link-ref line (e.g., "[Unreleased]: http...") AFTER Unreleased
|
285
|
+
first_ref = nil
|
286
|
+
lines.each_with_index do |l, i|
|
287
|
+
if l =~ /^\[[^\]]+\]:\s+http/ && i > unreleased_idx
|
288
|
+
first_ref = i
|
289
|
+
break
|
290
|
+
end
|
291
|
+
end
|
292
|
+
unless first_ref
|
293
|
+
# Append at end if no ref block after Unreleased
|
294
|
+
first_ref = lines.length
|
295
|
+
lines << "\n"
|
296
|
+
end
|
297
|
+
|
298
|
+
# Ensure Unreleased points to GitHub compare from new tag to HEAD
|
299
|
+
if owner && repo
|
300
|
+
unreleased_ref = "[Unreleased]: https://github.com/#{owner}/#{repo}/compare/v#{new_version}...HEAD\n"
|
301
|
+
# Update an existing Unreleased ref only if it appears after Unreleased heading; otherwise append
|
302
|
+
idx = nil
|
303
|
+
lines.each_with_index do |l, i|
|
304
|
+
if l.start_with?("[Unreleased]:") && i >= first_ref
|
305
|
+
idx = i
|
306
|
+
break
|
307
|
+
end
|
308
|
+
end
|
309
|
+
if idx
|
310
|
+
lines[idx] = unreleased_ref
|
311
|
+
else
|
312
|
+
lines << unreleased_ref
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
if owner && repo
|
317
|
+
# Add compare link for the new version
|
318
|
+
from = prev_version ? "v#{prev_version}" : detect_initial_compare_base(lines)
|
319
|
+
new_compare = "[#{new_version}]: https://github.com/#{owner}/#{repo}/compare/#{from}...v#{new_version}\n"
|
320
|
+
unless lines.any? { |l| l.start_with?("[#{new_version}]:") }
|
321
|
+
lines << new_compare
|
322
|
+
end
|
323
|
+
# Add tag link for the new version
|
324
|
+
new_tag = "[#{new_version}t]: https://github.com/#{owner}/#{repo}/releases/tag/v#{new_version}\n"
|
325
|
+
unless lines.any? { |l| l.start_with?("[#{new_version}t]:") }
|
326
|
+
lines << new_tag
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
# Rebuild and sort the reference block so newest is at the bottom, preserving everything above first_ref
|
331
|
+
ref_lines = lines[first_ref..-1].select { |l| l =~ /^\[[^\]]+\]:\s+http/ }
|
332
|
+
# Deduplicate by key (text inside the square brackets)
|
333
|
+
by_key = {}
|
334
|
+
ref_lines.each do |l|
|
335
|
+
if l =~ /^\[([^\]]+)\]:\s+/
|
336
|
+
by_key[$1] = l
|
337
|
+
end
|
338
|
+
end
|
339
|
+
unreleased_line = by_key.delete("Unreleased")
|
340
|
+
# Separate version compare and tag links
|
341
|
+
compares = {}
|
342
|
+
tags = {}
|
343
|
+
by_key.each do |k, v|
|
344
|
+
if k =~ /^(\d+\.\d+\.\d+)$/
|
345
|
+
compares[$1] = v
|
346
|
+
elsif k =~ /^(\d+\.\d+\.\d+)t$/
|
347
|
+
tags[$1] = v
|
348
|
+
end
|
349
|
+
end
|
350
|
+
# Sort versions ascending so newest at bottom
|
351
|
+
sorted_versions = compares.keys.map { |s| Gem::Version.new(s) }.sort.map(&:to_s)
|
352
|
+
# In case some versions only have tags or only compares, include them as well
|
353
|
+
(tags.keys - compares.keys).each { |s| sorted_versions |= [s] }
|
354
|
+
sorted_versions = sorted_versions.map { |s| Gem::Version.new(s) }.sort.map(&:to_s)
|
355
|
+
|
356
|
+
new_ref_block = []
|
357
|
+
new_ref_block << unreleased_line if unreleased_line
|
358
|
+
sorted_versions.each do |v|
|
359
|
+
new_ref_block << compares[v] if compares[v]
|
360
|
+
new_ref_block << tags[v] if tags[v]
|
361
|
+
end
|
362
|
+
# Replace the old block
|
363
|
+
rebuilt = lines[0...first_ref] + new_ref_block + ["\n"]
|
364
|
+
rebuilt.join
|
365
|
+
end
|
366
|
+
|
367
|
+
def detect_initial_compare_base(lines)
|
368
|
+
# Fallback when prev_version is unknown: try to find the first compare base used historically
|
369
|
+
# e.g., for 1.0.0 it may be a commit SHA instead of a tag
|
370
|
+
ref = lines.find { |l| l =~ /^\[1\.0\.0\]:\s+https:\/\/github\.com\// }
|
371
|
+
if ref && (m = ref.match(%r{compare/([^\.]+)\.\.\.v\d+})).is_a?(MatchData)
|
372
|
+
m[1]
|
373
|
+
else
|
374
|
+
# Default to previous tag name if none found (unlikely to be correct, but better than empty)
|
375
|
+
"HEAD^"
|
376
|
+
end
|
377
|
+
end
|
378
|
+
end
|
379
|
+
end
|
380
|
+
end
|
381
|
+
|
382
|
+
begin
|
383
|
+
if ARGV.include?("-h") || ARGV.include?("--help")
|
384
|
+
puts <<~USAGE
|
385
|
+
Usage: kettle-changelog
|
386
|
+
|
387
|
+
Generates a new CHANGELOG.md entry for the current version detected from lib/**/version.rb.
|
388
|
+
Moves entries from [Unreleased] into the new section, adds coverage and documentation stats,
|
389
|
+
and updates bottom link references to GitHub style, adding new compare/tag links.
|
390
|
+
|
391
|
+
Prerequisites:
|
392
|
+
- coverage/coverage.json present (run: K_SOUP_COV_FORMATTERS="json" bin/rspec)
|
393
|
+
- yard installed and available via bin/yard
|
394
|
+
USAGE
|
395
|
+
exit(0)
|
396
|
+
end
|
397
|
+
Kettle::Dev::ChangelogCLI.new.run
|
398
|
+
rescue SystemExit
|
399
|
+
raise
|
400
|
+
rescue StandardError => e
|
401
|
+
warn("kettle-changelog: unexpected error: #{e.class}: #{e.message}")
|
402
|
+
warn(e.backtrace.join("\n")) if ENV["DEBUG"]
|
403
|
+
exit(1)
|
404
|
+
end
|
data/exe/kettle-commit-msg
CHANGED
@@ -13,6 +13,8 @@ require "erb"
|
|
13
13
|
|
14
14
|
require "kettle/dev"
|
15
15
|
|
16
|
+
puts "== kettle-commit-msg v#{Kettle::Dev::Version::VERSION} =="
|
17
|
+
|
16
18
|
# ENV variable control (set in .envrc, or .env.local)
|
17
19
|
# BRANCH_RULE_TYPE = jira, or another type of branch rule validation, or false to disable
|
18
20
|
# FOOTER_APPEND = true/false append commit message footer
|
data/exe/kettle-readme-backers
CHANGED
data/exe/kettle-release
CHANGED
@@ -19,21 +19,18 @@ $stdout.sync = true
|
|
19
19
|
# Depending library or project must be using bundler
|
20
20
|
require "bundler/setup"
|
21
21
|
|
22
|
-
|
23
|
-
require "kettle/dev"
|
24
|
-
rescue LoadError => e
|
25
|
-
warn("kettle/dev: failed to load: #{e.message}")
|
26
|
-
warn("Hint: Ensure the host project includes kettle-dev and run bundle install.")
|
27
|
-
exit(1)
|
28
|
-
end
|
22
|
+
require "kettle/dev"
|
29
23
|
|
30
24
|
# Always execute when this file is loaded (e.g., via a Bundler binstub).
|
31
25
|
# Do not guard with __FILE__ == $PROGRAM_NAME because binstubs use Kernel.load.
|
32
26
|
if ARGV.include?("-h") || ARGV.include?("--help")
|
33
27
|
puts <<~USAGE
|
34
|
-
Usage: kettle-release
|
28
|
+
Usage: kettle-release [start_step=<number>]
|
35
29
|
|
36
30
|
Automates the release flow for a Ruby gem in the host project:
|
31
|
+
|
32
|
+
Options:
|
33
|
+
start_step=<number> # skip directly to the numbered step (e.g., 10 for CI validation)
|
37
34
|
- Runs bin/setup and bin/rake sanity checks
|
38
35
|
- Prompts to confirm version and changelog updates
|
39
36
|
- Commits a release prep change
|
@@ -51,8 +48,12 @@ if ARGV.include?("-h") || ARGV.include?("--help")
|
|
51
48
|
exit 0
|
52
49
|
end
|
53
50
|
|
51
|
+
puts "== kettle-release v#{Kettle::Dev::Version::VERSION} =="
|
52
|
+
# Parse start_step=<n> from ARGV
|
53
|
+
start_step_arg = ARGV.find { |a| a.start_with?("start_step=") }
|
54
|
+
start_step = start_step_arg ? start_step_arg.split("=", 2)[1].to_i : 1
|
54
55
|
begin
|
55
|
-
Kettle::Dev::ReleaseCLI.new.run
|
56
|
+
Kettle::Dev::ReleaseCLI.new(start_step: start_step).run
|
56
57
|
rescue LoadError => e
|
57
58
|
warn("kettle-release: could not load dependency: #{e.message}")
|
58
59
|
warn(e.backtrace.join("\n")) if ENV["DEBUG"]
|
@@ -0,0 +1 @@
|
|
1
|
+
# Optional dependencies are not dependended on directly, but may be used if present.
|
@@ -177,10 +177,29 @@ module Kettle
|
|
177
177
|
data = JSON.parse(res.body)
|
178
178
|
pipe = data&.first
|
179
179
|
return unless pipe
|
180
|
+
# Attempt to enrich with failure_reason by querying the single pipeline endpoint
|
181
|
+
begin
|
182
|
+
if pipe["id"]
|
183
|
+
detail_uri = URI("https://#{host}/api/v4/projects/#{project}/pipelines/#{pipe["id"]}")
|
184
|
+
dreq = Net::HTTP::Get.new(detail_uri)
|
185
|
+
dreq["User-Agent"] = "kettle-dev/ci-helpers"
|
186
|
+
dreq["PRIVATE-TOKEN"] = token if token && !token.empty?
|
187
|
+
dres = Net::HTTP.start(detail_uri.hostname, detail_uri.port, use_ssl: true) { |http| http.request(dreq) }
|
188
|
+
if dres.is_a?(Net::HTTPSuccess)
|
189
|
+
det = JSON.parse(dres.body)
|
190
|
+
pipe["failure_reason"] = det["failure_reason"] if det.is_a?(Hash)
|
191
|
+
pipe["status"] = det["status"] if det["status"]
|
192
|
+
pipe["web_url"] = det["web_url"] if det["web_url"]
|
193
|
+
end
|
194
|
+
end
|
195
|
+
rescue StandardError
|
196
|
+
# ignore enrichment errors; fall back to basic fields
|
197
|
+
end
|
180
198
|
{
|
181
199
|
"status" => pipe["status"],
|
182
200
|
"web_url" => pipe["web_url"],
|
183
201
|
"id" => pipe["id"],
|
202
|
+
"failure_reason" => pipe["failure_reason"],
|
184
203
|
}
|
185
204
|
rescue StandardError
|
186
205
|
nil
|
@@ -0,0 +1,192 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# External stdlib
|
4
|
+
require "uri"
|
5
|
+
require "json"
|
6
|
+
require "net/http"
|
7
|
+
|
8
|
+
# Internal
|
9
|
+
require "kettle/dev/ci_helpers"
|
10
|
+
require "kettle/dev/exit_adapter"
|
11
|
+
require "kettle/dev/git_adapter"
|
12
|
+
|
13
|
+
module Kettle
|
14
|
+
module Dev
|
15
|
+
# CIMonitor centralizes CI monitoring logic (GitHub Actions and GitLab pipelines)
|
16
|
+
# so it can be reused by both kettle-release and Rake tasks (e.g., ci:act).
|
17
|
+
#
|
18
|
+
# Public API is intentionally small and based on environment/project introspection
|
19
|
+
# via CIHelpers, matching the behavior historically implemented in ReleaseCLI.
|
20
|
+
module CIMonitor
|
21
|
+
module_function
|
22
|
+
|
23
|
+
# Abort helper (delegates through ExitAdapter so specs can trap exits)
|
24
|
+
def abort(msg)
|
25
|
+
Kettle::Dev::ExitAdapter.abort(msg)
|
26
|
+
end
|
27
|
+
module_function :abort
|
28
|
+
|
29
|
+
# Monitor both GitHub and GitLab CI for the current project/branch.
|
30
|
+
# This mirrors ReleaseCLI behavior.
|
31
|
+
#
|
32
|
+
# @param restart_hint [String] guidance command shown on failure
|
33
|
+
# @return [void]
|
34
|
+
def monitor_all!(restart_hint: "bundle exec kettle-release start_step=10")
|
35
|
+
checks_any = false
|
36
|
+
checks_any |= monitor_github_internal!(restart_hint: restart_hint)
|
37
|
+
checks_any |= monitor_gitlab_internal!(restart_hint: restart_hint)
|
38
|
+
abort("CI configuration not detected (GitHub or GitLab). Ensure CI is configured and remotes point to the correct hosts.") unless checks_any
|
39
|
+
end
|
40
|
+
|
41
|
+
# Monitor only the GitLab pipeline for current project/branch.
|
42
|
+
# Used by ci:act after running 'act'.
|
43
|
+
#
|
44
|
+
# @param restart_hint [String] guidance command shown on failure
|
45
|
+
# @return [Boolean] true if check performed (gitlab configured), false otherwise
|
46
|
+
def monitor_gitlab!(restart_hint: "bundle exec rake ci:act")
|
47
|
+
monitor_gitlab_internal!(restart_hint: restart_hint)
|
48
|
+
end
|
49
|
+
|
50
|
+
# -- internals --
|
51
|
+
|
52
|
+
def monitor_github_internal!(restart_hint:)
|
53
|
+
root = Kettle::Dev::CIHelpers.project_root
|
54
|
+
workflows = Kettle::Dev::CIHelpers.workflows_list(root)
|
55
|
+
gh_remote = preferred_github_remote
|
56
|
+
return false unless gh_remote && !workflows.empty?
|
57
|
+
|
58
|
+
branch = Kettle::Dev::CIHelpers.current_branch
|
59
|
+
abort("Could not determine current branch for CI checks.") unless branch
|
60
|
+
|
61
|
+
url = remote_url(gh_remote)
|
62
|
+
owner, repo = parse_github_owner_repo(url)
|
63
|
+
return false unless owner && repo
|
64
|
+
|
65
|
+
total = workflows.size
|
66
|
+
abort("No GitHub workflows found under .github/workflows; aborting.") if total.zero?
|
67
|
+
|
68
|
+
passed = {}
|
69
|
+
puts "Ensuring GitHub Actions workflows pass on #{branch} (#{owner}/#{repo}) via remote '#{gh_remote}'"
|
70
|
+
pbar = if defined?(ProgressBar)
|
71
|
+
ProgressBar.create(title: "CI", total: total, format: "%t %b %c/%C", length: 30)
|
72
|
+
end
|
73
|
+
idx = 0
|
74
|
+
loop do
|
75
|
+
wf = workflows[idx]
|
76
|
+
run = Kettle::Dev::CIHelpers.latest_run(owner: owner, repo: repo, workflow_file: wf, branch: branch)
|
77
|
+
if run
|
78
|
+
if Kettle::Dev::CIHelpers.success?(run)
|
79
|
+
unless passed[wf]
|
80
|
+
passed[wf] = true
|
81
|
+
pbar&.increment
|
82
|
+
end
|
83
|
+
elsif Kettle::Dev::CIHelpers.failed?(run)
|
84
|
+
puts
|
85
|
+
wf_url = run["html_url"] || "https://github.com/#{owner}/#{repo}/actions/workflows/#{wf}"
|
86
|
+
abort("Workflow failed: #{wf} -> #{wf_url} Fix the workflow, then restart this tool from CI validation with: #{restart_hint}")
|
87
|
+
end
|
88
|
+
end
|
89
|
+
break if passed.size == total
|
90
|
+
idx = (idx + 1) % total
|
91
|
+
sleep(1)
|
92
|
+
end
|
93
|
+
pbar&.finish unless pbar&.finished?
|
94
|
+
puts "\nAll GitHub workflows passing (#{passed.size}/#{total})."
|
95
|
+
true
|
96
|
+
end
|
97
|
+
module_function :monitor_github_internal!
|
98
|
+
|
99
|
+
def monitor_gitlab_internal!(restart_hint:)
|
100
|
+
root = Kettle::Dev::CIHelpers.project_root
|
101
|
+
gitlab_ci = File.exist?(File.join(root, ".gitlab-ci.yml"))
|
102
|
+
gl_remote = gitlab_remote_candidates.first
|
103
|
+
return false unless gitlab_ci && gl_remote
|
104
|
+
|
105
|
+
branch = Kettle::Dev::CIHelpers.current_branch
|
106
|
+
abort("Could not determine current branch for CI checks.") unless branch
|
107
|
+
|
108
|
+
owner, repo = Kettle::Dev::CIHelpers.repo_info_gitlab
|
109
|
+
return false unless owner && repo
|
110
|
+
|
111
|
+
puts "Ensuring GitLab pipeline passes on #{branch} (#{owner}/#{repo}) via remote '#{gl_remote}'"
|
112
|
+
pbar = if defined?(ProgressBar)
|
113
|
+
ProgressBar.create(title: "CI", total: 1, format: "%t %b %c/%C", length: 30)
|
114
|
+
end
|
115
|
+
loop do
|
116
|
+
pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
|
117
|
+
if pipe
|
118
|
+
if Kettle::Dev::CIHelpers.gitlab_success?(pipe)
|
119
|
+
pbar&.increment unless pbar&.finished?
|
120
|
+
break
|
121
|
+
elsif Kettle::Dev::CIHelpers.gitlab_failed?(pipe)
|
122
|
+
# Special-case: if failure is due to exhausted minutes/insufficient quota, treat as unknown and continue
|
123
|
+
reason = (pipe["failure_reason"] || "").to_s
|
124
|
+
if reason =~ /insufficient|quota|minute/i
|
125
|
+
puts "\nGitLab reports pipeline cannot run due to quota/minutes exhaustion. Result is unknown; continuing."
|
126
|
+
pbar&.finish unless pbar&.finished?
|
127
|
+
break
|
128
|
+
else
|
129
|
+
puts
|
130
|
+
url = pipe["web_url"] || "https://gitlab.com/#{owner}/#{repo}/-/pipelines"
|
131
|
+
abort("Pipeline failed: #{url} Fix the pipeline, then restart this tool from CI validation with: #{restart_hint}")
|
132
|
+
end
|
133
|
+
elsif pipe["status"] == "blocked"
|
134
|
+
# Blocked pipeline (e.g., awaiting approvals) — treat as unknown and continue
|
135
|
+
puts "\nGitLab pipeline is blocked. Result is unknown; continuing."
|
136
|
+
pbar&.finish unless pbar&.finished?
|
137
|
+
break
|
138
|
+
end
|
139
|
+
end
|
140
|
+
sleep(1)
|
141
|
+
end
|
142
|
+
pbar&.finish unless pbar&.finished?
|
143
|
+
puts "\nGitLab pipeline passing."
|
144
|
+
true
|
145
|
+
end
|
146
|
+
module_function :monitor_gitlab_internal!
|
147
|
+
|
148
|
+
# -- tiny wrappers around GitAdapter-like helpers used by ReleaseCLI --
|
149
|
+
def remotes_with_urls
|
150
|
+
Kettle::Dev::GitAdapter.new.remotes_with_urls
|
151
|
+
end
|
152
|
+
module_function :remotes_with_urls
|
153
|
+
|
154
|
+
def remote_url(name)
|
155
|
+
Kettle::Dev::GitAdapter.new.remote_url(name)
|
156
|
+
end
|
157
|
+
module_function :remote_url
|
158
|
+
|
159
|
+
def github_remote_candidates
|
160
|
+
remotes_with_urls.select { |n, u| u.include?("github.com") }.keys
|
161
|
+
end
|
162
|
+
module_function :github_remote_candidates
|
163
|
+
|
164
|
+
def gitlab_remote_candidates
|
165
|
+
remotes_with_urls.select { |n, u| u.include?("gitlab.com") }.keys
|
166
|
+
end
|
167
|
+
module_function :gitlab_remote_candidates
|
168
|
+
|
169
|
+
def preferred_github_remote
|
170
|
+
cands = github_remote_candidates
|
171
|
+
return if cands.empty?
|
172
|
+
explicit = cands.find { |n| n == "github" } || cands.find { |n| n == "gh" }
|
173
|
+
return explicit if explicit
|
174
|
+
return "origin" if cands.include?("origin")
|
175
|
+
cands.first
|
176
|
+
end
|
177
|
+
module_function :preferred_github_remote
|
178
|
+
|
179
|
+
def parse_github_owner_repo(url)
|
180
|
+
return [nil, nil] unless url
|
181
|
+
if url =~ %r{git@github.com:(.+?)/(.+?)(\.git)?$}
|
182
|
+
[Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
|
183
|
+
elsif url =~ %r{https://github.com/(.+?)/(.+?)(\.git)?$}
|
184
|
+
[Regexp.last_match(1), Regexp.last_match(2).sub(/\.git\z/, "")]
|
185
|
+
else
|
186
|
+
[nil, nil]
|
187
|
+
end
|
188
|
+
end
|
189
|
+
module_function :parse_github_owner_repo
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|