gem_kit-release 0.1.0
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 +7 -0
- data/lib/gem_kit/release/changelog.rb +313 -0
- data/lib/gem_kit/release/cli/bump.rb +133 -0
- data/lib/gem_kit/release/cli/changelog.rb +161 -0
- data/lib/gem_kit/release/cli/command.rb +68 -0
- data/lib/gem_kit/release/cli/deprecations.rb +102 -0
- data/lib/gem_kit/release/cli/release.rb +98 -0
- data/lib/gem_kit/release/cli/setup.rb +136 -0
- data/lib/gem_kit/release/cli/tag.rb +97 -0
- data/lib/gem_kit/release/cli.rb +121 -0
- data/lib/gem_kit/release/deprecate.rb +285 -0
- data/lib/gem_kit/release/gate.rb +178 -0
- data/lib/gem_kit/release/project.rb +188 -0
- data/lib/gem_kit/release/templates/DEPRECATIONS.md.erb +173 -0
- data/lib/gem_kit/release/templates/RELEASE.md.erb +159 -0
- data/lib/gem_kit/release/version.rb +7 -0
- data/lib/gem_kit/release/version_file.rb +133 -0
- data/lib/gem_kit/release.rb +77 -0
- data/lib/rubygems/commands/kit_command.rb +111 -0
- data/lib/rubygems_plugin.rb +11 -0
- metadata +77 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/deprecate"
|
|
4
|
+
|
|
5
|
+
module GemKit
|
|
6
|
+
module Release
|
|
7
|
+
# A deprecation is a dated promise: it names the replacement *and* the
|
|
8
|
+
# version the old name stops existing in. Built on Gem::Deprecate, which
|
|
9
|
+
# gets the message format and the skip_during escape hatch right, plus one
|
|
10
|
+
# addition — a registry, so the set of outstanding promises is data the
|
|
11
|
+
# release tooling can enforce rather than prose someone has to remember.
|
|
12
|
+
#
|
|
13
|
+
# Deprecate a method:
|
|
14
|
+
#
|
|
15
|
+
# class Session
|
|
16
|
+
# extend GemKit::Release::Deprecate
|
|
17
|
+
#
|
|
18
|
+
# def old_reset = new_reset
|
|
19
|
+
# deprecate :old_reset, "Session#new_reset", "5.0"
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Deprecate a whole constant that has moved or been renamed — leave the old
|
|
23
|
+
# name in place as a subclass of the new one, then declare it:
|
|
24
|
+
#
|
|
25
|
+
# class Completion < Brute::Completion::OpenRouter
|
|
26
|
+
# extend GemKit::Release::Deprecate
|
|
27
|
+
# superseded_by "Brute::Completion::OpenRouter", "5.0"
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# Both warn on use, naming the caller. Gem::Deprecate.skip_during silences
|
|
31
|
+
# them, so a test suite can exercise the old path in quiet.
|
|
32
|
+
module Deprecate
|
|
33
|
+
extend Gem::Deprecate
|
|
34
|
+
|
|
35
|
+
# One outstanding deprecation. `removed_in` is the deadline the release
|
|
36
|
+
# gate reads.
|
|
37
|
+
Entry = Struct.new(:name, :replacement, :removed_in, :declared_at, keyword_init: true) do
|
|
38
|
+
def to_s
|
|
39
|
+
"#{name} -> #{replacement == :none ? "(no replacement)" : replacement}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# Every deprecation declared in the loaded library, in declaration order.
|
|
45
|
+
def registry
|
|
46
|
+
@registry ||= []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def register(name:, replacement:, removed_in:, declared_at: nil)
|
|
50
|
+
entry = Entry.new(
|
|
51
|
+
name: name.to_s,
|
|
52
|
+
replacement: replacement,
|
|
53
|
+
removed_in: Gem::Version.new(removed_in.to_s),
|
|
54
|
+
declared_at: declared_at || location(1),
|
|
55
|
+
)
|
|
56
|
+
registry << entry
|
|
57
|
+
entry
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The deprecations that come due at `version` — every deadline that has
|
|
61
|
+
# arrived or passed. Releasing `version` with any of these still in the
|
|
62
|
+
# tree breaks the promise the warning made.
|
|
63
|
+
def pending(version)
|
|
64
|
+
target = Gem::Version.new(version.to_s)
|
|
65
|
+
registry.select { |entry| entry.removed_in <= target }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Deprecations still inside their grace period at `version`.
|
|
69
|
+
def upcoming(version)
|
|
70
|
+
target = Gem::Version.new(version.to_s)
|
|
71
|
+
registry.reject { |entry| entry.removed_in <= target }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Single funnel for every warning: Gem::Deprecate.skip_during works
|
|
75
|
+
# across all of them, and specs have one place to listen.
|
|
76
|
+
def warn(message)
|
|
77
|
+
Kernel.warn(message) unless Gem::Deprecate.skip
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The Gem::Deprecate-shaped message. `origin` must be computed at the
|
|
81
|
+
# call site — one frame deeper and it names this file rather than the
|
|
82
|
+
# code that needs changing.
|
|
83
|
+
def message(target, replacement, removed_in, origin)
|
|
84
|
+
[
|
|
85
|
+
"NOTE: #{target} is deprecated",
|
|
86
|
+
replacement == :none ? " with no replacement" : "; use #{replacement} instead",
|
|
87
|
+
". It will be removed in #{removed_in}",
|
|
88
|
+
"\n#{target} called from #{origin}.",
|
|
89
|
+
].join
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def location(depth)
|
|
93
|
+
caller_locations(depth + 1, 1)&.first&.then { |l| "#{l.path}:#{l.lineno}" }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Deprecate one method. Mirrors Gem::Deprecate#rubygems_deprecate, but the
|
|
98
|
+
# deadline is explicit — a deprecation added late in a cycle usually wants
|
|
99
|
+
# the major after next, and guessing that is not the tool's business.
|
|
100
|
+
def deprecate(name, replacement, removed_in)
|
|
101
|
+
label = singleton_class? ? "#{attached_object}.#{name}" : "#{self}##{name}"
|
|
102
|
+
Deprecate.register(name: label, replacement: replacement, removed_in: removed_in,
|
|
103
|
+
declared_at: Deprecate.location(1))
|
|
104
|
+
|
|
105
|
+
class_eval do
|
|
106
|
+
old = "_deprecated_#{name}"
|
|
107
|
+
alias_method old, name
|
|
108
|
+
define_method name do |*args, &block|
|
|
109
|
+
target = is_a?(Module) ? "#{self}.#{name}" : "#{self.class}##{name}"
|
|
110
|
+
origin = Gem.location_of_caller.join(":")
|
|
111
|
+
Deprecate.warn(Deprecate.message(target, replacement, removed_in, origin))
|
|
112
|
+
send(old, *args, &block)
|
|
113
|
+
end
|
|
114
|
+
ruby2_keywords name if respond_to?(:ruby2_keywords, true)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Deprecate the constant this is called in — the renamed-or-moved case.
|
|
119
|
+
# Named `superseded_by` rather than `deprecate_constant` because Module
|
|
120
|
+
# already has a method by that name and shadowing it would be rude.
|
|
121
|
+
def superseded_by(replacement, removed_in)
|
|
122
|
+
Deprecate.register(name: name || to_s, replacement: replacement, removed_in: removed_in,
|
|
123
|
+
declared_at: Deprecate.location(1))
|
|
124
|
+
|
|
125
|
+
return unless respond_to?(:new)
|
|
126
|
+
|
|
127
|
+
define_singleton_method(:new) do |*args, **options, &block|
|
|
128
|
+
origin = Gem.location_of_caller.join(":")
|
|
129
|
+
Deprecate.warn(Deprecate.message(name || to_s, replacement, removed_in, origin))
|
|
130
|
+
super(*args, **options, &block)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
__END__
|
|
138
|
+
|
|
139
|
+
describe "gem_kit/release/deprecate" do
|
|
140
|
+
Deprecate = GemKit::Release::Deprecate unless defined?(Deprecate)
|
|
141
|
+
|
|
142
|
+
captured = []
|
|
143
|
+
# Capture what Deprecate.warn emits and keep the shared registry clean —
|
|
144
|
+
# these specs declare throwaway deprecations.
|
|
145
|
+
isolated = lambda do |&block|
|
|
146
|
+
saved = Deprecate.registry.dup
|
|
147
|
+
original = Deprecate.method(:warn)
|
|
148
|
+
captured.clear
|
|
149
|
+
Deprecate.define_singleton_method(:warn) { |message| captured << message }
|
|
150
|
+
begin
|
|
151
|
+
block.call
|
|
152
|
+
ensure
|
|
153
|
+
Deprecate.define_singleton_method(:warn, original)
|
|
154
|
+
Deprecate.registry.replace(saved)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "warns on a deprecated method, naming replacement, version and caller" do
|
|
159
|
+
isolated.call do
|
|
160
|
+
klass = Class.new do
|
|
161
|
+
extend Deprecate
|
|
162
|
+
def new_name = :result
|
|
163
|
+
def old_name = new_name
|
|
164
|
+
deprecate :old_name, "Thing#new_name", "9.0"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
klass.new.old_name.should == :result # still works
|
|
168
|
+
captured.size.should == 1
|
|
169
|
+
captured.first.should.match(/is deprecated/)
|
|
170
|
+
captured.first.should.match(/use Thing#new_name instead/)
|
|
171
|
+
captured.first.should.match(/removed in 9\.0/)
|
|
172
|
+
captured.first.should.match(/called from /)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "names the caller, not the deprecation machinery" do
|
|
177
|
+
isolated.call do
|
|
178
|
+
klass = Class.new do
|
|
179
|
+
extend Deprecate
|
|
180
|
+
def old_name = :result
|
|
181
|
+
deprecate :old_name, "Thing#new_name", "9.0"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# These specs live in this file's __END__, so "the caller" is a line in
|
|
185
|
+
# deprecate.rb either way — pin the exact line to tell them apart.
|
|
186
|
+
klass.new.old_name; call_line = __LINE__
|
|
187
|
+
captured.first.should.match(/called from .*deprecate\.rb:#{call_line}\./)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it "labels a class-method deprecation by the class, not its singleton" do
|
|
192
|
+
isolated.call do
|
|
193
|
+
Class.new do
|
|
194
|
+
def self.to_s = "Demo"
|
|
195
|
+
def self.old_thing = :ok
|
|
196
|
+
class << self
|
|
197
|
+
extend Deprecate
|
|
198
|
+
deprecate :old_thing, "Other.new_thing", "9.0"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
Deprecate.registry.last.name.should == "Demo.old_thing"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "warns on a superseded constant but keeps it working" do
|
|
207
|
+
isolated.call do
|
|
208
|
+
modern = Class.new { def initialize(x); @x = x; end; attr_reader :x }
|
|
209
|
+
legacy = Class.new(modern) do
|
|
210
|
+
extend Deprecate
|
|
211
|
+
def self.name = "Old::Name"
|
|
212
|
+
superseded_by "New::Name", "9.0"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
legacy.new(42).x.should == 42 # still works
|
|
216
|
+
captured.size.should == 1
|
|
217
|
+
captured.first.should.match(/Old::Name is deprecated; use New::Name instead/)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "supports :none for a deprecation with no replacement" do
|
|
222
|
+
isolated.call do
|
|
223
|
+
klass = Class.new do
|
|
224
|
+
extend Deprecate
|
|
225
|
+
def gone = :ok
|
|
226
|
+
deprecate :gone, :none, "9.0"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
klass.new.gone
|
|
230
|
+
captured.first.should.match(/with no replacement/)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "registers each declaration with its deadline and source" do
|
|
235
|
+
isolated.call do
|
|
236
|
+
Class.new do
|
|
237
|
+
extend Deprecate
|
|
238
|
+
def gone = nil
|
|
239
|
+
deprecate :gone, "Other#kept", "9.0"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
entry = Deprecate.registry.last
|
|
243
|
+
entry.replacement.should == "Other#kept"
|
|
244
|
+
entry.removed_in.should == Gem::Version.new("9.0")
|
|
245
|
+
entry.declared_at.should.match(/deprecate\.rb:\d+/)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "splits the registry into pending and upcoming at a version" do
|
|
250
|
+
isolated.call do
|
|
251
|
+
Deprecate.registry.clear
|
|
252
|
+
Deprecate.register(name: "A", replacement: "A2", removed_in: "5.0")
|
|
253
|
+
Deprecate.register(name: "B", replacement: "B2", removed_in: "6.0")
|
|
254
|
+
|
|
255
|
+
Deprecate.pending("5.0.0").map(&:name).should == ["A"]
|
|
256
|
+
Deprecate.upcoming("5.0.0").map(&:name).should == ["B"]
|
|
257
|
+
Deprecate.pending("4.9.0").should.be.empty
|
|
258
|
+
Deprecate.pending("6.1.0").map(&:name).should == ["A", "B"]
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
it "stays quiet inside Gem::Deprecate.skip_during" do
|
|
263
|
+
saved = Deprecate.registry.dup
|
|
264
|
+
begin
|
|
265
|
+
klass = Class.new do
|
|
266
|
+
extend Deprecate
|
|
267
|
+
def quiet = :ok
|
|
268
|
+
deprecate :quiet, "Other#loud", "9.0"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
warned = []
|
|
272
|
+
original = Kernel.method(:warn)
|
|
273
|
+
Kernel.define_singleton_method(:warn) { |*args| warned << args.join }
|
|
274
|
+
begin
|
|
275
|
+
Gem::Deprecate.skip_during { klass.new.quiet.should == :ok }
|
|
276
|
+
ensure
|
|
277
|
+
Kernel.define_singleton_method(:warn, original)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
warned.should.be.empty
|
|
281
|
+
ensure
|
|
282
|
+
Deprecate.registry.replace(saved)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemKit
|
|
4
|
+
module Release
|
|
5
|
+
# The checks, in one place. Both the bump and the release ask the same two
|
|
6
|
+
# questions of a target version, and neither should be hand-rolling them:
|
|
7
|
+
#
|
|
8
|
+
# Is anything promised to disappear in this version still here?
|
|
9
|
+
# Does the changelog document this version?
|
|
10
|
+
#
|
|
11
|
+
# Every method returns a list of human-readable problems. Empty means pass,
|
|
12
|
+
# which makes the callers trivial and lets CI use the same object.
|
|
13
|
+
class Gate
|
|
14
|
+
attr_reader :project
|
|
15
|
+
|
|
16
|
+
def initialize(project)
|
|
17
|
+
@project = project
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Deprecations whose deadline has arrived at `version`. This is the check
|
|
21
|
+
# that has to run *before* a bump: bumping onto a deadline is what breaks
|
|
22
|
+
# the promise, so the bump is the last moment anyone can be stopped.
|
|
23
|
+
def deprecation_problems(version)
|
|
24
|
+
project.load!
|
|
25
|
+
|
|
26
|
+
Deprecate.pending(version).map do |entry|
|
|
27
|
+
"#{entry.removed_in.to_s.ljust(8)} #{entry}#{entry.declared_at ? "\n#{" " * 9}#{entry.declared_at}" : ""}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Deprecations still inside their grace period — worth printing on the
|
|
32
|
+
# way past, not worth blocking on.
|
|
33
|
+
def upcoming_deprecations(version)
|
|
34
|
+
project.load!
|
|
35
|
+
Deprecate.upcoming(version)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Changelog format, plus "is there an entry for this version?" when one
|
|
39
|
+
# is given. Without a version this is the format check alone, which is
|
|
40
|
+
# the useful thing to run on every push.
|
|
41
|
+
def changelog_problems(version = nil)
|
|
42
|
+
changelog = Changelog.new(
|
|
43
|
+
File.exist?(project.changelog_path) ? File.read(project.changelog_path) : nil,
|
|
44
|
+
path: project.changelog_path,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
version ? changelog.release_problems(version) : changelog.problems
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Everything standing between the project and releasing `version`.
|
|
51
|
+
def release_problems(version = project.version)
|
|
52
|
+
problems = []
|
|
53
|
+
|
|
54
|
+
changelog_problems(version).each { |problem| problems << problem }
|
|
55
|
+
deprecation_problems(version).each do |problem|
|
|
56
|
+
problems << "deprecation due in #{version}: #{problem}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
problems
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Everything standing between the project and *bumping to* `version`.
|
|
63
|
+
# Only the deprecation deadline applies — the changelog for a version
|
|
64
|
+
# cannot exist before the version does.
|
|
65
|
+
def bump_problems(version)
|
|
66
|
+
deprecation_problems(version).map { |problem| "deprecation due in #{version}: #{problem}" }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
__END__
|
|
73
|
+
|
|
74
|
+
describe "gem_kit/release/gate" do
|
|
75
|
+
require "tmpdir"
|
|
76
|
+
|
|
77
|
+
# A Project stub: the Gate only asks it for a changelog path and a version,
|
|
78
|
+
# and to load the library (a no-op here — the specs drive the registry).
|
|
79
|
+
stub_project = lambda do |changelog_path, version: "1.0.0"|
|
|
80
|
+
Struct.new(:changelog_path, :version) do
|
|
81
|
+
def load! = true
|
|
82
|
+
end.new(changelog_path, Gem::Version.new(version))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
clean_changelog = <<~MD
|
|
86
|
+
# Changelog
|
|
87
|
+
|
|
88
|
+
## [Unreleased]
|
|
89
|
+
|
|
90
|
+
## [1.0.0] - 2026-01-01
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
|
|
94
|
+
- A thing.
|
|
95
|
+
MD
|
|
96
|
+
|
|
97
|
+
# Run a block with the deprecation registry isolated.
|
|
98
|
+
isolated = lambda do |&block|
|
|
99
|
+
saved = GemKit::Release::Deprecate.registry.dup
|
|
100
|
+
GemKit::Release::Deprecate.registry.clear
|
|
101
|
+
begin
|
|
102
|
+
block.call
|
|
103
|
+
ensure
|
|
104
|
+
GemKit::Release::Deprecate.registry.replace(saved)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "passes when the changelog documents the version and nothing is due" do
|
|
109
|
+
Dir.mktmpdir do |dir|
|
|
110
|
+
path = File.join(dir, "CHANGELOG.md")
|
|
111
|
+
File.write(path, clean_changelog)
|
|
112
|
+
|
|
113
|
+
isolated.call do
|
|
114
|
+
GemKit::Release::Gate.new(stub_project.call(path)).release_problems("1.0.0").should == []
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "reports a missing changelog section for the version" do
|
|
120
|
+
Dir.mktmpdir do |dir|
|
|
121
|
+
path = File.join(dir, "CHANGELOG.md")
|
|
122
|
+
File.write(path, clean_changelog)
|
|
123
|
+
|
|
124
|
+
isolated.call do
|
|
125
|
+
problems = GemKit::Release::Gate.new(stub_project.call(path)).release_problems("2.0.0")
|
|
126
|
+
problems.first.should.match(/no section for 2\.0\.0/)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "reports a deprecation whose deadline has arrived" do
|
|
132
|
+
Dir.mktmpdir do |dir|
|
|
133
|
+
path = File.join(dir, "CHANGELOG.md")
|
|
134
|
+
File.write(path, clean_changelog)
|
|
135
|
+
|
|
136
|
+
isolated.call do
|
|
137
|
+
GemKit::Release::Deprecate.register(name: "Old", replacement: "New", removed_in: "1.0")
|
|
138
|
+
gate = GemKit::Release::Gate.new(stub_project.call(path))
|
|
139
|
+
|
|
140
|
+
gate.release_problems("1.0.0").first.should.match(/deprecation due in 1\.0\.0: .*Old -> New/)
|
|
141
|
+
gate.bump_problems("1.0.0").size.should == 1
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "does not block a bump on a deadline that has not arrived" do
|
|
147
|
+
Dir.mktmpdir do |dir|
|
|
148
|
+
isolated.call do
|
|
149
|
+
GemKit::Release::Deprecate.register(name: "Old", replacement: "New", removed_in: "2.0")
|
|
150
|
+
gate = GemKit::Release::Gate.new(stub_project.call(File.join(dir, "CHANGELOG.md")))
|
|
151
|
+
|
|
152
|
+
gate.bump_problems("1.1.0").should == []
|
|
153
|
+
gate.upcoming_deprecations("1.1.0").map(&:name).should == ["Old"]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "checks changelog format alone when given no version" do
|
|
159
|
+
Dir.mktmpdir do |dir|
|
|
160
|
+
path = File.join(dir, "CHANGELOG.md")
|
|
161
|
+
File.write(path, "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n")
|
|
162
|
+
|
|
163
|
+
isolated.call do
|
|
164
|
+
GemKit::Release::Gate.new(stub_project.call(path)).changelog_problems
|
|
165
|
+
.first.should.match(/must start with the title/)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it "reports a changelog that does not exist" do
|
|
171
|
+
Dir.mktmpdir do |dir|
|
|
172
|
+
isolated.call do
|
|
173
|
+
GemKit::Release::Gate.new(stub_project.call(File.join(dir, "CHANGELOG.md")))
|
|
174
|
+
.changelog_problems.first.should.match(/does not exist/)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems"
|
|
4
|
+
|
|
5
|
+
module GemKit
|
|
6
|
+
module Release
|
|
7
|
+
# What the tooling needs to know about the project it is running in.
|
|
8
|
+
#
|
|
9
|
+
# Almost all of it is already declared in the project's .gemspec — name,
|
|
10
|
+
# version, and (through `require_relative`) where the version constant
|
|
11
|
+
# lives. So the normal case is no configuration at all: point it at a
|
|
12
|
+
# directory and it works out the rest.
|
|
13
|
+
#
|
|
14
|
+
# project = GemKit::Release::Project.detect
|
|
15
|
+
# project.name # => "brute"
|
|
16
|
+
# project.version # => Gem::Version.new("4.1.0")
|
|
17
|
+
# project.version_file # => ".../lib/brute/version.rb"
|
|
18
|
+
#
|
|
19
|
+
# Anything that cannot be inferred is an override:
|
|
20
|
+
#
|
|
21
|
+
# GemKit::Release.configure do |config|
|
|
22
|
+
# config.changelog = "HISTORY.md"
|
|
23
|
+
# config.test_command = "bin/test"
|
|
24
|
+
# end
|
|
25
|
+
class Project
|
|
26
|
+
class NotFound < StandardError; end
|
|
27
|
+
|
|
28
|
+
# Overridable settings. Each is nil until set, and nil means "infer".
|
|
29
|
+
Config = Struct.new(:changelog, :version_file, :require_path, :test_command,
|
|
30
|
+
:changelog_writer, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
def self.detect(dir = Dir.pwd, config: GemKit::Release.config)
|
|
33
|
+
gemspec = Dir[File.join(dir, "*.gemspec")].sort.first
|
|
34
|
+
raise NotFound, "no .gemspec in #{dir}" if gemspec.nil?
|
|
35
|
+
|
|
36
|
+
new(gemspec, config: config)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :root, :gemspec_path, :config
|
|
40
|
+
|
|
41
|
+
def initialize(gemspec_path, config: GemKit::Release.config)
|
|
42
|
+
@gemspec_path = File.expand_path(gemspec_path)
|
|
43
|
+
@root = File.dirname(@gemspec_path)
|
|
44
|
+
@config = config
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The evaluated gemspec. Loaded from the project root, because a gemspec
|
|
48
|
+
# typically require_relative's its own version file.
|
|
49
|
+
def spec
|
|
50
|
+
@spec ||= Dir.chdir(root) { Gem::Specification.load(gemspec_path) } or
|
|
51
|
+
raise NotFound, "could not load #{gemspec_path}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def name = spec.name
|
|
55
|
+
|
|
56
|
+
def version = Gem::Version.new(spec.version.to_s)
|
|
57
|
+
|
|
58
|
+
# The next major version — the default deadline for a deprecation.
|
|
59
|
+
def next_major_version
|
|
60
|
+
Gem::Version.new(version.segments.first.to_s).bump.to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def changelog_path
|
|
64
|
+
File.expand_path(config.changelog || "CHANGELOG.md", root)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# lib/gem_kit/release/version.rb for "gem_kit-release", lib/brute/version.rb
|
|
68
|
+
# for "brute" — the convention every `bundle gem` project follows.
|
|
69
|
+
def version_file
|
|
70
|
+
File.expand_path(config.version_file || File.join("lib", *name.split("-"), "version.rb"), root)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# An ERB template beside the version file, if the project generates it.
|
|
74
|
+
def version_template
|
|
75
|
+
candidate = "#{version_file}.erb"
|
|
76
|
+
candidate if File.exist?(candidate)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# What to require so that deprecation declarations register themselves.
|
|
80
|
+
# "gem_kit-release" -> "gem_kit/release".
|
|
81
|
+
def require_path
|
|
82
|
+
config.require_path || name.tr("-", "/")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Load the library, so Deprecate's registry reflects this project.
|
|
86
|
+
def load!
|
|
87
|
+
$LOAD_PATH.unshift(File.join(root, "lib")) unless $LOAD_PATH.include?(File.join(root, "lib"))
|
|
88
|
+
require require_path
|
|
89
|
+
true
|
|
90
|
+
rescue LoadError => error
|
|
91
|
+
warn "gem_kit-release: could not require #{require_path.inspect} (#{error.message});" \
|
|
92
|
+
" deprecations will not be detected"
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_command = config.test_command || "bin/test"
|
|
97
|
+
|
|
98
|
+
def changelog_writer = config.changelog_writer || "claude"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
__END__
|
|
104
|
+
|
|
105
|
+
describe "gem_kit/release/project" do
|
|
106
|
+
require "tmpdir"
|
|
107
|
+
|
|
108
|
+
# A throwaway gem laid out the conventional way.
|
|
109
|
+
with_project = lambda do |name: "demo", version: "1.2.3", &block|
|
|
110
|
+
Dir.mktmpdir do |dir|
|
|
111
|
+
path = name.split("-")
|
|
112
|
+
FileUtils.mkdir_p(File.join(dir, "lib", *path))
|
|
113
|
+
File.write(File.join(dir, "lib", *path, "version.rb"), <<~RUBY)
|
|
114
|
+
module #{path.map { |p| p.split("_").map(&:capitalize).join }.join("::")}
|
|
115
|
+
VERSION = "#{version}"
|
|
116
|
+
end
|
|
117
|
+
RUBY
|
|
118
|
+
File.write(File.join(dir, "#{name}.gemspec"), <<~RUBY)
|
|
119
|
+
require_relative "lib/#{path.join("/")}/version"
|
|
120
|
+
Gem::Specification.new do |spec|
|
|
121
|
+
spec.name = "#{name}"
|
|
122
|
+
spec.version = "#{version}"
|
|
123
|
+
spec.authors = ["x"]
|
|
124
|
+
spec.summary = "x"
|
|
125
|
+
spec.files = []
|
|
126
|
+
end
|
|
127
|
+
RUBY
|
|
128
|
+
block.call(dir)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "detects the gemspec and reads name and version from it" do
|
|
133
|
+
with_project.call do |dir|
|
|
134
|
+
project = GemKit::Release::Project.detect(dir)
|
|
135
|
+
project.name.should == "demo"
|
|
136
|
+
project.version.should == Gem::Version.new("1.2.3")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "raises when there is no gemspec" do
|
|
141
|
+
Dir.mktmpdir do |dir|
|
|
142
|
+
lambda { GemKit::Release::Project.detect(dir) }.should.raise(GemKit::Release::Project::NotFound)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "infers the version file from the gem name, hyphens as directories" do
|
|
147
|
+
with_project.call(name: "gem_kit-release") do |dir|
|
|
148
|
+
GemKit::Release::Project.detect(dir).version_file
|
|
149
|
+
.should == File.join(dir, "lib/gem_kit/release/version.rb")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "infers the require path from the gem name" do
|
|
154
|
+
with_project.call(name: "gem_kit-release") do |dir|
|
|
155
|
+
GemKit::Release::Project.detect(dir).require_path.should == "gem_kit/release"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "defaults the changelog to CHANGELOG.md in the project root" do
|
|
160
|
+
with_project.call do |dir|
|
|
161
|
+
GemKit::Release::Project.detect(dir).changelog_path.should == File.join(dir, "CHANGELOG.md")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "bumps to the next major for the default deprecation deadline" do
|
|
166
|
+
with_project.call(version: "4.1.0") do |dir|
|
|
167
|
+
GemKit::Release::Project.detect(dir).next_major_version.should == "5"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "finds an ERB version template when the project generates its version file" do
|
|
172
|
+
with_project.call do |dir|
|
|
173
|
+
GemKit::Release::Project.detect(dir).version_template.should.be.nil
|
|
174
|
+
File.write(File.join(dir, "lib/demo/version.rb.erb"), "x")
|
|
175
|
+
GemKit::Release::Project.detect(dir).version_template
|
|
176
|
+
.should == File.join(dir, "lib/demo/version.rb.erb")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it "takes overrides from the config" do
|
|
181
|
+
with_project.call do |dir|
|
|
182
|
+
config = GemKit::Release::Project::Config.new(changelog: "HISTORY.md", test_command: "rake")
|
|
183
|
+
project = GemKit::Release::Project.detect(dir, config: config)
|
|
184
|
+
project.changelog_path.should == File.join(dir, "HISTORY.md")
|
|
185
|
+
project.test_command.should == "rake"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|