kettle-dev 1.0.22 → 1.0.24
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/.github/workflows/style.yml +3 -3
- data/.github/workflows/truffle.yml +3 -3
- data/CHANGELOG.md +85 -45
- data/FUNDING.md +77 -0
- data/README.md +9 -12
- data/README.md.example +10 -6
- data/Rakefile.example +1 -1
- data/exe/kettle-changelog +23 -351
- data/exe/kettle-commit-msg +26 -5
- data/exe/kettle-readme-backers +25 -4
- data/exe/kettle-release +14 -5
- data/kettle-dev.gemspec.example +4 -3
- data/lib/kettle/dev/changelog_cli.rb +352 -0
- data/lib/kettle/dev/ci_monitor.rb +0 -5
- data/lib/kettle/dev/gem_spec_reader.rb +129 -0
- data/lib/kettle/dev/git_commit_footer.rb +0 -2
- data/lib/kettle/dev/readme_backers.rb +0 -1
- data/lib/kettle/dev/release_cli.rb +10 -11
- data/lib/kettle/dev/tasks/ci_task.rb +14 -27
- data/lib/kettle/dev/tasks/install_task.rb +80 -92
- data/lib/kettle/dev/tasks/template_task.rb +102 -8
- data/lib/kettle/dev/template_helpers.rb +10 -79
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev.rb +9 -3
- data/lib/kettle/emoji_regex.rb +1 -1
- data/sig/kettle/dev/gem_spec_reader.rbs +30 -0
- data/sig/kettle/dev/template_helpers.rbs +10 -0
- data.tar.gz.sig +0 -0
- metadata +9 -4
- metadata.gz.sig +0 -0
@@ -0,0 +1,352 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Kettle
|
4
|
+
module Dev
|
5
|
+
class ChangelogCLI
|
6
|
+
def initialize
|
7
|
+
@root = Kettle::Dev::CIHelpers.project_root
|
8
|
+
@changelog_path = File.join(@root, "CHANGELOG.md")
|
9
|
+
@coverage_path = File.join(@root, "coverage", "coverage.json")
|
10
|
+
end
|
11
|
+
|
12
|
+
def run
|
13
|
+
version = Kettle::Dev::Versioning.detect_version(@root)
|
14
|
+
today = Time.now.strftime("%Y-%m-%d")
|
15
|
+
owner, repo = Kettle::Dev::CIHelpers.repo_info
|
16
|
+
unless owner && repo
|
17
|
+
warn("Could not determine GitHub owner/repo from origin remote.")
|
18
|
+
warn("Make sure 'origin' points to github.com. Alternatively, set origin or update links manually afterward.")
|
19
|
+
end
|
20
|
+
|
21
|
+
line_cov_line, branch_cov_line = coverage_lines
|
22
|
+
yard_line = yard_percent_documented
|
23
|
+
|
24
|
+
changelog = File.read(@changelog_path)
|
25
|
+
|
26
|
+
# If the detected version already exists in the changelog, abort to avoid duplicates
|
27
|
+
if changelog =~ /^## \[#{Regexp.escape(version)}\]/
|
28
|
+
abort("CHANGELOG.md already has a section for version #{version}. Bump version.rb or remove the duplicate.")
|
29
|
+
end
|
30
|
+
|
31
|
+
unreleased_block, before, after = extract_unreleased(changelog)
|
32
|
+
if unreleased_block.nil?
|
33
|
+
abort("Could not find '## [Unreleased]' section in CHANGELOG.md")
|
34
|
+
end
|
35
|
+
|
36
|
+
if unreleased_block.strip.empty?
|
37
|
+
warn("No entries found under Unreleased. Creating an empty version section anyway.")
|
38
|
+
end
|
39
|
+
|
40
|
+
prev_version = detect_previous_version(after)
|
41
|
+
|
42
|
+
new_section = +""
|
43
|
+
new_section << "## [#{version}] - #{today}\n"
|
44
|
+
new_section << "- TAG: [v#{version}][#{version}t]\n"
|
45
|
+
new_section << "- #{line_cov_line}\n" if line_cov_line
|
46
|
+
new_section << "- #{branch_cov_line}\n" if branch_cov_line
|
47
|
+
new_section << "- #{yard_line}\n" if yard_line
|
48
|
+
new_section << filter_unreleased_sections(unreleased_block)
|
49
|
+
# Ensure exactly one blank line separates this new section from the next section
|
50
|
+
new_section.rstrip!
|
51
|
+
new_section << "\n\n"
|
52
|
+
|
53
|
+
# Reset the Unreleased section to empty category headings
|
54
|
+
unreleased_reset = <<~MD
|
55
|
+
## [Unreleased]
|
56
|
+
### Added
|
57
|
+
### Changed
|
58
|
+
### Deprecated
|
59
|
+
### Removed
|
60
|
+
### Fixed
|
61
|
+
### Security
|
62
|
+
MD
|
63
|
+
|
64
|
+
# Preserve everything from the first released section down to the line containing the [Unreleased] link ref.
|
65
|
+
# Many real-world changelogs intersperse stray link refs between sections; we should keep them.
|
66
|
+
updated = before + unreleased_reset + "\n" + new_section
|
67
|
+
# Find the [Unreleased]: link-ref line and append everything from the start of the first released section
|
68
|
+
# through to the end of the file, but if a [Unreleased]: ref exists, ensure we do not duplicate the
|
69
|
+
# section content above it.
|
70
|
+
if after && !after.empty?
|
71
|
+
# Split 'after' by lines so we can locate the first link-ref to Unreleased
|
72
|
+
after_lines = after.lines
|
73
|
+
unreleased_ref_idx = after_lines.index { |l| l.start_with?("[Unreleased]:") }
|
74
|
+
if unreleased_ref_idx
|
75
|
+
# Keep all content prior to the link-ref (older releases and interspersed refs)
|
76
|
+
preserved_body = after_lines[0...unreleased_ref_idx].join
|
77
|
+
# Then append the tail starting from the Unreleased link-ref line to preserve the footer refs
|
78
|
+
preserved_footer = after_lines[unreleased_ref_idx..-1].join
|
79
|
+
updated << preserved_body << preserved_footer
|
80
|
+
else
|
81
|
+
# No Unreleased ref found; just append the remainder as-is
|
82
|
+
updated << after
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
updated = update_link_refs(updated, owner, repo, prev_version, version)
|
87
|
+
|
88
|
+
# Ensure exactly one trailing newline at EOF
|
89
|
+
updated = updated.rstrip + "\n"
|
90
|
+
|
91
|
+
File.write(@changelog_path, updated)
|
92
|
+
puts "CHANGELOG.md updated with v#{version} section."
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def abort(msg)
|
98
|
+
Kettle::Dev::ExitAdapter.abort(msg)
|
99
|
+
rescue NameError
|
100
|
+
Kernel.abort(msg)
|
101
|
+
end
|
102
|
+
|
103
|
+
def detect_version
|
104
|
+
candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
|
105
|
+
abort("Could not find version.rb under lib/**.") if candidates.empty?
|
106
|
+
versions = candidates.map do |path|
|
107
|
+
content = File.read(path)
|
108
|
+
m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
|
109
|
+
next unless m
|
110
|
+
m[2]
|
111
|
+
end.compact
|
112
|
+
abort("VERSION constant not found in #{@root}/lib/**/version.rb") if versions.none?
|
113
|
+
abort("Multiple VERSION constants found to be out of sync (#{versions.inspect}) in #{@root}/lib/**/version.rb") unless versions.uniq.length == 1
|
114
|
+
versions.first
|
115
|
+
end
|
116
|
+
|
117
|
+
def extract_unreleased(content)
|
118
|
+
lines = content.lines
|
119
|
+
start_i = lines.index { |l| l.start_with?("## [Unreleased]") }
|
120
|
+
return [nil, nil, nil] unless start_i
|
121
|
+
# Find the next version heading after Unreleased
|
122
|
+
next_i = (start_i + 1)
|
123
|
+
while next_i < lines.length && !lines[next_i].start_with?("## [")
|
124
|
+
next_i += 1
|
125
|
+
end
|
126
|
+
# Now next_i points to the next section heading or EOF
|
127
|
+
before = lines[0..(start_i - 1)].join
|
128
|
+
unreleased_block = lines[(start_i + 1)..(next_i - 1)].join
|
129
|
+
after = lines[next_i..-1]&.join || ""
|
130
|
+
[unreleased_block, before, after]
|
131
|
+
end
|
132
|
+
|
133
|
+
def detect_previous_version(after_text)
|
134
|
+
# after_text begins with the first released section following Unreleased
|
135
|
+
m = after_text.match(/^## \[(\d+\.\d+\.\d+)\]/)
|
136
|
+
return m[1] if m
|
137
|
+
nil
|
138
|
+
end
|
139
|
+
|
140
|
+
# From the Unreleased block, keep only sections that have content.
|
141
|
+
# We detect sections as lines starting with '### '. A section has content if there is at least
|
142
|
+
# one non-empty, non-heading line under it before the next '###' or '##'. Typically these are list items.
|
143
|
+
# Returns a string that includes only the non-empty sections with their content.
|
144
|
+
def filter_unreleased_sections(unreleased_block)
|
145
|
+
lines = unreleased_block.lines
|
146
|
+
out = []
|
147
|
+
i = 0
|
148
|
+
while i < lines.length
|
149
|
+
line = lines[i]
|
150
|
+
if line.start_with?("### ")
|
151
|
+
header = line
|
152
|
+
i += 1
|
153
|
+
chunk = []
|
154
|
+
while i < lines.length && !lines[i].start_with?("### ") && !lines[i].start_with?("## ")
|
155
|
+
chunk << lines[i]
|
156
|
+
i += 1
|
157
|
+
end
|
158
|
+
# Determine if chunk has any content (non-blank)
|
159
|
+
content_present = chunk.any? { |l| l.strip != "" }
|
160
|
+
if content_present
|
161
|
+
# Trim trailing blank lines
|
162
|
+
while chunk.any? && chunk.last.strip == ""
|
163
|
+
chunk.pop
|
164
|
+
end
|
165
|
+
out << header
|
166
|
+
out.concat(chunk)
|
167
|
+
out << "\n" unless out.last&.end_with?("\n")
|
168
|
+
end
|
169
|
+
next
|
170
|
+
else
|
171
|
+
# Lines outside sections are ignored for released sections
|
172
|
+
i += 1
|
173
|
+
end
|
174
|
+
end
|
175
|
+
out.join
|
176
|
+
end
|
177
|
+
|
178
|
+
def coverage_lines
|
179
|
+
unless File.file?(@coverage_path)
|
180
|
+
warn("Coverage JSON not found at #{@coverage_path}.")
|
181
|
+
warn("Run: K_SOUP_COV_FORMATTERS=\"json\" bin/rspec")
|
182
|
+
return [nil, nil]
|
183
|
+
end
|
184
|
+
data = JSON.parse(File.read(@coverage_path))
|
185
|
+
files = data["coverage"] || {}
|
186
|
+
file_count = 0
|
187
|
+
total_lines = 0
|
188
|
+
covered_lines = 0
|
189
|
+
total_branches = 0
|
190
|
+
covered_branches = 0
|
191
|
+
files.each_value do |h|
|
192
|
+
lines = h["lines"] || []
|
193
|
+
line_relevant = lines.count { |x| x.is_a?(Integer) }
|
194
|
+
line_covered = lines.count { |x| x.is_a?(Integer) && x > 0 }
|
195
|
+
if line_relevant > 0
|
196
|
+
file_count += 1
|
197
|
+
total_lines += line_relevant
|
198
|
+
covered_lines += line_covered
|
199
|
+
end
|
200
|
+
branches = h["branches"] || []
|
201
|
+
branches.each do |b|
|
202
|
+
next unless b.is_a?(Hash)
|
203
|
+
cov = b["coverage"]
|
204
|
+
next unless cov.is_a?(Numeric)
|
205
|
+
total_branches += 1
|
206
|
+
covered_branches += 1 if cov > 0
|
207
|
+
end
|
208
|
+
end
|
209
|
+
line_pct = (total_lines > 0) ? ((covered_lines.to_f / total_lines) * 100.0) : 0.0
|
210
|
+
branch_pct = (total_branches > 0) ? ((covered_branches.to_f / total_branches) * 100.0) : 0.0
|
211
|
+
line_str = format("COVERAGE: %.2f%% -- %d/%d lines in %d files", line_pct, covered_lines, total_lines, file_count)
|
212
|
+
branch_str = format("BRANCH COVERAGE: %.2f%% -- %d/%d branches in %d files", branch_pct, covered_branches, total_branches, file_count)
|
213
|
+
[line_str, branch_str]
|
214
|
+
rescue StandardError => e
|
215
|
+
warn("Failed to parse coverage: #{e.class}: #{e.message}")
|
216
|
+
[nil, nil]
|
217
|
+
end
|
218
|
+
|
219
|
+
def yard_percent_documented
|
220
|
+
cmd = File.join(@root, "bin", "yard")
|
221
|
+
unless File.executable?(cmd)
|
222
|
+
warn("bin/yard not found or not executable; ensure yard is installed via bundler")
|
223
|
+
return
|
224
|
+
end
|
225
|
+
out, _ = Open3.capture2(cmd)
|
226
|
+
# Look for a line containing e.g., "95.35% documented"
|
227
|
+
line = out.lines.find { |l| l =~ /\d+(?:\.\d+)?%\s+documented/ }
|
228
|
+
if line
|
229
|
+
line = line.strip
|
230
|
+
# Return exactly as requested: e.g. "95.35% documented"
|
231
|
+
line
|
232
|
+
else
|
233
|
+
warn("Could not find documented percentage in bin/yard output.")
|
234
|
+
nil
|
235
|
+
end
|
236
|
+
rescue StandardError => e
|
237
|
+
warn("Failed to run bin/yard: #{e.class}: #{e.message}")
|
238
|
+
nil
|
239
|
+
end
|
240
|
+
|
241
|
+
def update_link_refs(content, owner, repo, prev_version, new_version)
|
242
|
+
# Convert any GitLab links to GitHub
|
243
|
+
content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/compare/([^\.]+)\.\.\.([^\s]+)}) do
|
244
|
+
o = owner || Regexp.last_match(1)
|
245
|
+
r = repo || Regexp.last_match(2)
|
246
|
+
from = Regexp.last_match(3)
|
247
|
+
to = Regexp.last_match(4)
|
248
|
+
"https://github.com/#{o}/#{r}/compare/#{from}...#{to}"
|
249
|
+
end
|
250
|
+
content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/tags/(v[^\s\]]+)}) do
|
251
|
+
o = owner || Regexp.last_match(1)
|
252
|
+
r = repo || Regexp.last_match(2)
|
253
|
+
tag = Regexp.last_match(3)
|
254
|
+
"https://github.com/#{o}/#{r}/releases/tag/#{tag}"
|
255
|
+
end
|
256
|
+
|
257
|
+
# Append or update the bottom reference links
|
258
|
+
lines = content.lines
|
259
|
+
|
260
|
+
# Identify the true start of the footer reference block: the line with the [Unreleased] link-ref.
|
261
|
+
# Do NOT assume the first link-ref after the Unreleased heading starts the footer, because
|
262
|
+
# some changelogs contain interspersed link-refs within section bodies.
|
263
|
+
unreleased_ref_idx = lines.index { |l| l.start_with?("[Unreleased]:") }
|
264
|
+
first_ref = if unreleased_ref_idx
|
265
|
+
unreleased_ref_idx
|
266
|
+
else
|
267
|
+
# If no [Unreleased]: ref is present, consider the reference block to start at EOF
|
268
|
+
lines.length
|
269
|
+
end
|
270
|
+
|
271
|
+
# Ensure Unreleased points to GitHub compare from new tag to HEAD
|
272
|
+
if owner && repo
|
273
|
+
unreleased_ref = "[Unreleased]: https://github.com/#{owner}/#{repo}/compare/v#{new_version}...HEAD\n"
|
274
|
+
# Update an existing Unreleased ref only if it appears after Unreleased heading; otherwise append
|
275
|
+
idx = nil
|
276
|
+
lines.each_with_index do |l, i|
|
277
|
+
if l.start_with?("[Unreleased]:") && i >= first_ref
|
278
|
+
idx = i
|
279
|
+
break
|
280
|
+
end
|
281
|
+
end
|
282
|
+
if idx
|
283
|
+
lines[idx] = unreleased_ref
|
284
|
+
else
|
285
|
+
lines << unreleased_ref
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
if owner && repo
|
290
|
+
# Add compare link for the new version
|
291
|
+
from = prev_version ? "v#{prev_version}" : detect_initial_compare_base(lines)
|
292
|
+
new_compare = "[#{new_version}]: https://github.com/#{owner}/#{repo}/compare/#{from}...v#{new_version}\n"
|
293
|
+
unless lines.any? { |l| l.start_with?("[#{new_version}]:") }
|
294
|
+
lines << new_compare
|
295
|
+
end
|
296
|
+
# Add tag link for the new version
|
297
|
+
new_tag = "[#{new_version}t]: https://github.com/#{owner}/#{repo}/releases/tag/v#{new_version}\n"
|
298
|
+
unless lines.any? { |l| l.start_with?("[#{new_version}t]:") }
|
299
|
+
lines << new_tag
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
# Rebuild and sort the reference block so Unreleased is first, then newest to oldest versions, preserving everything above first_ref
|
304
|
+
ref_lines = lines[first_ref..-1].select { |l| l =~ /^\[[^\]]+\]:\s+http/ }
|
305
|
+
# Deduplicate by key (text inside the square brackets)
|
306
|
+
by_key = {}
|
307
|
+
ref_lines.each do |l|
|
308
|
+
if l =~ /^\[([^\]]+)\]:\s+/
|
309
|
+
by_key[$1] = l
|
310
|
+
end
|
311
|
+
end
|
312
|
+
unreleased_line = by_key.delete("Unreleased")
|
313
|
+
# Separate version compare and tag links
|
314
|
+
compares = {}
|
315
|
+
tags = {}
|
316
|
+
by_key.each do |k, v|
|
317
|
+
if k =~ /^(\d+\.\d+\.\d+)$/
|
318
|
+
compares[$1] = v
|
319
|
+
elsif k =~ /^(\d+\.\d+\.\d+)t$/
|
320
|
+
tags[$1] = v
|
321
|
+
end
|
322
|
+
end
|
323
|
+
# Build a unified set of versions that appear in either compares or tags
|
324
|
+
version_keys = (compares.keys | tags.keys)
|
325
|
+
# Sort versions descending (newest to oldest)
|
326
|
+
sorted_versions = version_keys.map { |s| Gem::Version.new(s) }.sort.reverse.map(&:to_s)
|
327
|
+
|
328
|
+
new_ref_block = []
|
329
|
+
new_ref_block << unreleased_line if unreleased_line
|
330
|
+
sorted_versions.each do |v|
|
331
|
+
new_ref_block << compares[v] if compares[v]
|
332
|
+
new_ref_block << tags[v] if tags[v]
|
333
|
+
end
|
334
|
+
# Replace the old block
|
335
|
+
rebuilt = lines[0...first_ref] + new_ref_block + ["\n"]
|
336
|
+
rebuilt.join
|
337
|
+
end
|
338
|
+
|
339
|
+
def detect_initial_compare_base(lines)
|
340
|
+
# Fallback when prev_version is unknown: try to find the first compare base used historically
|
341
|
+
# e.g., for 1.0.0 it may be a commit SHA instead of a tag
|
342
|
+
ref = lines.find { |l| l =~ /^\[1\.0\.0\]:\s+https:\/\/github\.com\// }
|
343
|
+
if ref && (m = ref.match(%r{compare/([^\.]+)\.\.\.v\d+})).is_a?(MatchData)
|
344
|
+
m[1]
|
345
|
+
else
|
346
|
+
# Default to previous tag name if none found (unlikely to be correct, but better than empty)
|
347
|
+
"HEAD^"
|
348
|
+
end
|
349
|
+
end
|
350
|
+
end
|
351
|
+
end
|
352
|
+
end
|
@@ -5,11 +5,6 @@ require "uri"
|
|
5
5
|
require "json"
|
6
6
|
require "net/http"
|
7
7
|
|
8
|
-
# Internal
|
9
|
-
require "kettle/dev/ci_helpers"
|
10
|
-
require "kettle/dev/exit_adapter"
|
11
|
-
require "kettle/dev/git_adapter"
|
12
|
-
|
13
8
|
module Kettle
|
14
9
|
module Dev
|
15
10
|
# CIMonitor centralizes CI monitoring logic (GitHub Actions and GitLab pipelines)
|
@@ -0,0 +1,129 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rubygems"
|
4
|
+
|
5
|
+
module Kettle
|
6
|
+
module Dev
|
7
|
+
# Unified gemspec reader using RubyGems loader instead of regex parsing.
|
8
|
+
# Returns a Hash with all data used by this project from gemspecs.
|
9
|
+
# Cache within the process to avoid repeated loads.
|
10
|
+
class GemSpecReader
|
11
|
+
DEFAULT_MINIMUM_RUBY = Gem::Version.new("1.8").freeze
|
12
|
+
class << self
|
13
|
+
# Load gemspec data for the project at root.
|
14
|
+
# @param root [String]
|
15
|
+
# @return [Hash]
|
16
|
+
def load(root)
|
17
|
+
gemspec_path = Dir.glob(File.join(root.to_s, "*.gemspec")).first
|
18
|
+
spec = nil
|
19
|
+
if gemspec_path && File.file?(gemspec_path)
|
20
|
+
begin
|
21
|
+
spec = Gem::Specification.load(gemspec_path)
|
22
|
+
rescue StandardError
|
23
|
+
spec = nil
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
gem_name = spec&.name.to_s
|
28
|
+
# minimum ruby version: derived from spec.required_ruby_version
|
29
|
+
# Always an instance of Gem::Version
|
30
|
+
min_ruby =
|
31
|
+
begin
|
32
|
+
# irb(main):004> Gem::Requirement.parse(spec.required_ruby_version)
|
33
|
+
# => [">=", Gem::Version.new("2.3.0")]
|
34
|
+
requirement = spec&.required_ruby_version
|
35
|
+
if requirement
|
36
|
+
tuple = Gem::Requirement.parse(requirement)
|
37
|
+
tuple[1] # an instance of Gem::Version
|
38
|
+
else
|
39
|
+
# Default to a minimum of Ruby 1.8
|
40
|
+
puts "WARNING: Minimum Ruby not detected"
|
41
|
+
DEFAULT_MINIMUM_RUBY
|
42
|
+
end
|
43
|
+
rescue StandardError => e
|
44
|
+
# Default to a minimum of Ruby 1.8
|
45
|
+
puts "WARNING: Minimum Ruby detection failed: #{e.class}: #{e.message}"
|
46
|
+
DEFAULT_MINIMUM_RUBY
|
47
|
+
end
|
48
|
+
|
49
|
+
homepage_val = spec&.homepage.to_s
|
50
|
+
|
51
|
+
# Derive org/repo from homepage or git remote
|
52
|
+
forge_org = nil
|
53
|
+
gh_repo = nil
|
54
|
+
if homepage_val && !homepage_val.empty?
|
55
|
+
if (m = homepage_val.match(%r{github\.com[/:]([^/]+)/([^/]+)}i))
|
56
|
+
forge_org = m[1]
|
57
|
+
gh_repo = m[2].to_s.sub(/\.git\z/, "")
|
58
|
+
end
|
59
|
+
end
|
60
|
+
if forge_org.nil?
|
61
|
+
begin
|
62
|
+
origin_out = IO.popen(["git", "-C", root.to_s, "remote", "get-url", "origin"], &:read)
|
63
|
+
origin_out = origin_out.read if origin_out.respond_to?(:read)
|
64
|
+
origin_url = origin_out.to_s.strip
|
65
|
+
if (m = origin_url.match(%r{github\.com[/:]([^/]+)/([^/]+)}i))
|
66
|
+
forge_org = m[1]
|
67
|
+
gh_repo = m[2].to_s.sub(/\.git\z/, "")
|
68
|
+
end
|
69
|
+
rescue StandardError
|
70
|
+
# ignore
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
camel = lambda do |s|
|
75
|
+
s.to_s.split(/[_-]/).map { |p| p.gsub(/\b([a-z])/) { Regexp.last_match(1).upcase } }.join
|
76
|
+
end
|
77
|
+
namespace = gem_name.to_s.split("-").map { |seg| camel.call(seg) }.join("::")
|
78
|
+
namespace_shield = namespace.gsub("::", "%3A%3A")
|
79
|
+
entrypoint_require = gem_name.to_s.tr("-", "/")
|
80
|
+
gem_shield = gem_name.to_s.gsub("-", "--").gsub("_", "__")
|
81
|
+
|
82
|
+
# Funding org detection (ENV, .opencollective.yml, fallback to forge_org)
|
83
|
+
funding_org = ENV["FUNDING_ORG"].to_s.strip
|
84
|
+
funding_org = ENV["OPENCOLLECTIVE_ORG"].to_s.strip if funding_org.empty?
|
85
|
+
funding_org = ENV["OPENCOLLECTIVE_HANDLE"].to_s.strip if funding_org.empty?
|
86
|
+
if funding_org.empty?
|
87
|
+
begin
|
88
|
+
oc_path = File.join(root.to_s, ".opencollective.yml")
|
89
|
+
if File.file?(oc_path)
|
90
|
+
txt = File.read(oc_path)
|
91
|
+
if (m = txt.match(/\borg:\s*([\w\-]+)/i))
|
92
|
+
funding_org = m[1].to_s
|
93
|
+
end
|
94
|
+
end
|
95
|
+
rescue StandardError
|
96
|
+
# ignore
|
97
|
+
end
|
98
|
+
end
|
99
|
+
funding_org = forge_org.to_s if funding_org.to_s.empty?
|
100
|
+
|
101
|
+
{
|
102
|
+
gemspec_path: gemspec_path,
|
103
|
+
gem_name: gem_name,
|
104
|
+
min_ruby: min_ruby, # Gem::Version instance
|
105
|
+
homepage: homepage_val.to_s,
|
106
|
+
gh_org: forge_org, # Might allow divergence from forge_org someday
|
107
|
+
forge_org: forge_org,
|
108
|
+
funding_org: funding_org,
|
109
|
+
gh_repo: gh_repo,
|
110
|
+
namespace: namespace,
|
111
|
+
namespace_shield: namespace_shield,
|
112
|
+
entrypoint_require: entrypoint_require,
|
113
|
+
gem_shield: gem_shield,
|
114
|
+
# Additional fields sourced from the gemspec for templating carry-over
|
115
|
+
authors: Array(spec&.authors).compact.uniq,
|
116
|
+
email: Array(spec&.email).compact.uniq,
|
117
|
+
summary: spec&.summary.to_s,
|
118
|
+
description: spec&.description.to_s,
|
119
|
+
licenses: Array(spec&.licenses), # licenses will include any specified as license (singular)
|
120
|
+
required_ruby_version: spec&.required_ruby_version, # Gem::Requirement instance
|
121
|
+
require_paths: Array(spec&.require_paths),
|
122
|
+
bindir: (spec&.bindir || "").to_s,
|
123
|
+
executables: Array(spec&.executables),
|
124
|
+
}
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -13,12 +13,6 @@ require "uri"
|
|
13
13
|
# External gems
|
14
14
|
require "ruby-progressbar"
|
15
15
|
|
16
|
-
# Internal
|
17
|
-
require "kettle/dev/git_adapter"
|
18
|
-
require "kettle/dev/exit_adapter"
|
19
|
-
require "kettle/dev/input_adapter"
|
20
|
-
require "kettle/dev/versioning"
|
21
|
-
|
22
16
|
module Kettle
|
23
17
|
module Dev
|
24
18
|
class ReleaseCLI
|
@@ -57,7 +51,9 @@ module Kettle
|
|
57
51
|
gem_name = detect_gem_name
|
58
52
|
latest_overall, latest_for_series = latest_released_versions(gem_name, version)
|
59
53
|
rescue StandardError => e
|
60
|
-
warn("
|
54
|
+
warn("[kettle-release] RubyGems check failed: #{e.class}: #{e.message}")
|
55
|
+
warn(e.backtrace.first(3).map { |l| " " + l }.join("\n")) if ENV["DEBUG"]
|
56
|
+
warn("Proceeding without RubyGems latest version info.")
|
61
57
|
end
|
62
58
|
|
63
59
|
if latest_overall
|
@@ -460,7 +456,6 @@ module Kettle
|
|
460
456
|
|
461
457
|
def monitor_workflows_after_push!
|
462
458
|
# Delegate to shared CI monitor to keep logic DRY across release flow and rake tasks
|
463
|
-
require "kettle/dev/ci_monitor"
|
464
459
|
Kettle::Dev::CIMonitor.monitor_all!(restart_hint: "bundle exec kettle-release start_step=10")
|
465
460
|
end
|
466
461
|
|
@@ -923,8 +918,11 @@ module Kettle
|
|
923
918
|
# Title: v<version>
|
924
919
|
# Body: the CHANGELOG section for this version, followed by the two link references for this version.
|
925
920
|
def maybe_create_github_release!(version)
|
926
|
-
token = ENV.fetch("GITHUB_TOKEN", "").to_s
|
927
|
-
|
921
|
+
token = ENV.fetch("GITHUB_TOKEN", "").strip.to_s
|
922
|
+
if token.empty?
|
923
|
+
warn("GITHUB_TOKEN not set; skipping GitHub release creation. Set GITHUB_TOKEN with repo:public_repo (classic) or contents:write scope.")
|
924
|
+
return
|
925
|
+
end
|
928
926
|
|
929
927
|
gh_remote = preferred_github_remote
|
930
928
|
url = remote_url(gh_remote || "origin")
|
@@ -1002,7 +1000,8 @@ module Kettle
|
|
1002
1000
|
# Normalize: trim trailing whitespace but keep internal formatting
|
1003
1001
|
block = block.lstrip # drop leading newline/space
|
1004
1002
|
block.rstrip
|
1005
|
-
rescue StandardError
|
1003
|
+
rescue StandardError => e
|
1004
|
+
warn("[kettle-release] Failed to extract release notes footer from FUNDING.md: #{e.class}: #{e.message}")
|
1006
1005
|
nil
|
1007
1006
|
end
|
1008
1007
|
|
@@ -1,12 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
# External
|
4
|
-
require "kettle/dev/exit_adapter"
|
5
|
-
require "kettle/dev/input_adapter"
|
6
4
|
require "open3"
|
7
5
|
require "net/http"
|
8
6
|
require "json"
|
9
7
|
require "uri"
|
8
|
+
require "io/console"
|
10
9
|
|
11
10
|
module Kettle
|
12
11
|
module Dev
|
@@ -15,16 +14,17 @@ module Kettle
|
|
15
14
|
module_function
|
16
15
|
|
17
16
|
# Local abort indirection to enable mocking via ExitAdapter
|
18
|
-
def
|
19
|
-
|
17
|
+
def task_abort(msg)
|
18
|
+
if defined?(RSpec)
|
19
|
+
raise Kettle::Dev::Error, msg
|
20
|
+
else
|
21
|
+
Kettle::Dev::ExitAdapter.abort(msg)
|
22
|
+
end
|
20
23
|
end
|
21
|
-
module_function :abort
|
22
24
|
|
23
25
|
# Runs `act` for a selected workflow. Option can be a short code or workflow basename.
|
24
|
-
# Mirrors the behavior previously implemented in the ci:act rake task.
|
25
26
|
# @param opt [String, nil]
|
26
27
|
def act(opt = nil)
|
27
|
-
require "io/console"
|
28
28
|
choice = opt&.strip
|
29
29
|
|
30
30
|
root_dir = Kettle::Dev::CIHelpers.project_root
|
@@ -100,7 +100,7 @@ module Kettle
|
|
100
100
|
|
101
101
|
run_act_for = proc do |file_path|
|
102
102
|
ok = system("act", "-W", file_path)
|
103
|
-
|
103
|
+
task_abort("ci:act failed: 'act' command not found or exited with failure") unless ok
|
104
104
|
end
|
105
105
|
|
106
106
|
if choice && !choice.empty?
|
@@ -128,17 +128,10 @@ module Kettle
|
|
128
128
|
puts " (others) =>"
|
129
129
|
dynamic_files.each { |v| puts " #{v}" }
|
130
130
|
end
|
131
|
-
|
131
|
+
task_abort("ci:act aborted")
|
132
132
|
end
|
133
133
|
fetch_and_print_status.call(file)
|
134
134
|
run_act_for.call(file_path)
|
135
|
-
# After running locally, check upstream GitLab pipeline status if configured
|
136
|
-
begin
|
137
|
-
require "kettle/dev/ci_monitor"
|
138
|
-
Kettle::Dev::CIMonitor.monitor_gitlab!(restart_hint: "bundle exec rake ci:act")
|
139
|
-
rescue LoadError
|
140
|
-
# ignore if not available
|
141
|
-
end
|
142
135
|
return
|
143
136
|
end
|
144
137
|
|
@@ -312,13 +305,13 @@ module Kettle
|
|
312
305
|
end
|
313
306
|
|
314
307
|
input = selected
|
315
|
-
|
308
|
+
task_abort("ci:act aborted: no selection") if input.nil? || input.empty?
|
316
309
|
|
317
310
|
chosen_file = nil
|
318
311
|
if !!(/^\d+$/ =~ input)
|
319
312
|
idx = input.to_i - 1
|
320
313
|
if idx < 0 || idx >= options_with_quit.length
|
321
|
-
|
314
|
+
task_abort("ci:act aborted: invalid selection #{input}")
|
322
315
|
end
|
323
316
|
code, val = options_with_quit[idx]
|
324
317
|
if code == quit_code
|
@@ -334,20 +327,14 @@ module Kettle
|
|
334
327
|
return
|
335
328
|
end
|
336
329
|
chosen_file = mapping[code]
|
337
|
-
|
330
|
+
task_abort("ci:act aborted: unknown code '#{code}'") unless chosen_file
|
338
331
|
end
|
339
332
|
|
340
333
|
file_path = File.join(workflows_dir, chosen_file)
|
341
|
-
|
334
|
+
task_abort("ci:act aborted: workflow not found: #{file_path}") unless File.file?(file_path)
|
342
335
|
fetch_and_print_status.call(chosen_file)
|
343
336
|
run_act_for.call(file_path)
|
344
|
-
|
345
|
-
begin
|
346
|
-
require "kettle/dev/ci_monitor"
|
347
|
-
Kettle::Dev::CIMonitor.monitor_gitlab!(restart_hint: "bundle exec rake ci:act")
|
348
|
-
rescue LoadError
|
349
|
-
# ignore if not available
|
350
|
-
end
|
337
|
+
Kettle::Dev::CIMonitor.monitor_gitlab!(restart_hint: "bundle exec rake ci:act")
|
351
338
|
end
|
352
339
|
end
|
353
340
|
end
|