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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d42874e3ef31b2b7ecdad0cdd3acd28a62a2d38c7b3a6476c5a5d449dc27311b
|
|
4
|
+
data.tar.gz: 639dc539b8171640bd2f2f91d193d8c2f2278724d20a1594f6dafa050bfb9942
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 29e0b353656610e5adcbc79395753e4f7b2476218681da112d73070bd9dd2db2d53f7fb657225c5c11cd0760ca6a77f8942bda25f0931c7ba5cc3d32612207c4
|
|
7
|
+
data.tar.gz: b8a4daca35ca2dd5c5de72d2b1ff2d288ced5989125368049376aaee6df9ef5e31b5aedb7457667f6d43c248d0cf2337db23071d225b2a5b11633e5288975b59
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module GemKit
|
|
6
|
+
module Release
|
|
7
|
+
# A parser and linter for CHANGELOG.md, which follows
|
|
8
|
+
# [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
9
|
+
#
|
|
10
|
+
# The changelog is the one release artefact nothing else can regenerate, so
|
|
11
|
+
# it is the one most easily forgotten. Making it machine-checkable turns
|
|
12
|
+
# "did anyone write the changelog?" into a gate: `gem_kit-release check`
|
|
13
|
+
# validates the format, and refuses to release a version that has no
|
|
14
|
+
# section of its own.
|
|
15
|
+
#
|
|
16
|
+
# changelog = GemKit::Release::Changelog.load
|
|
17
|
+
# changelog.problems # => [] when the format is clean
|
|
18
|
+
# changelog.release_problems("4.1.0")
|
|
19
|
+
#
|
|
20
|
+
# The shape it expects:
|
|
21
|
+
#
|
|
22
|
+
# # Changelog
|
|
23
|
+
#
|
|
24
|
+
# ## [Unreleased]
|
|
25
|
+
#
|
|
26
|
+
# ## [4.1.0] - 2026-08-20
|
|
27
|
+
#
|
|
28
|
+
# ### Added
|
|
29
|
+
#
|
|
30
|
+
# - Something that happened.
|
|
31
|
+
#
|
|
32
|
+
class Changelog
|
|
33
|
+
# The six change types Keep a Changelog defines. Anything else under a
|
|
34
|
+
# version is a typo or an invention, and both are worth catching.
|
|
35
|
+
SECTIONS = %w[Added Changed Deprecated Removed Fixed Security].freeze
|
|
36
|
+
|
|
37
|
+
UNRELEASED = "Unreleased"
|
|
38
|
+
HEADING = /\A##\s+\[([^\]]+)\](?:\s+-\s+(.*))?\s*\z/
|
|
39
|
+
SUBHEADING = /\A###\s+(.*?)\s*\z/
|
|
40
|
+
BULLET = /\A[-*]\s+\S/
|
|
41
|
+
DATE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
42
|
+
|
|
43
|
+
# One `## [...]` section of the file.
|
|
44
|
+
Release = Struct.new(:version, :date, :line, :subsections, keyword_init: true) do
|
|
45
|
+
def unreleased? = version == UNRELEASED
|
|
46
|
+
def empty? = subsections.empty? || subsections.values.all?(&:empty?)
|
|
47
|
+
def to_s = unreleased? ? "[#{version}]" : "[#{version}] - #{date}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Load the changelog sitting beside the gem (../../CHANGELOG.md).
|
|
51
|
+
def self.load(path = File.join(Dir.pwd, "CHANGELOG.md"))
|
|
52
|
+
new(File.exist?(path) ? File.read(path) : nil, path: path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
attr_reader :path, :releases
|
|
56
|
+
|
|
57
|
+
# @parameter text [String, nil] the file's contents; nil means "no file".
|
|
58
|
+
def initialize(text, path: "CHANGELOG.md")
|
|
59
|
+
@path = path
|
|
60
|
+
@text = text
|
|
61
|
+
@releases = text ? parse(text) : []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def missing? = @text.nil?
|
|
65
|
+
|
|
66
|
+
def unreleased = releases.find(&:unreleased?)
|
|
67
|
+
|
|
68
|
+
def released = releases.reject(&:unreleased?)
|
|
69
|
+
|
|
70
|
+
def find(version)
|
|
71
|
+
target = Gem::Version.new(version.to_s)
|
|
72
|
+
released.find { |release| Gem::Version.new(release.version) == target rescue false }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Everything wrong with the file's *format*, as a list of human-readable
|
|
76
|
+
# problems. Empty means it lints clean.
|
|
77
|
+
def problems
|
|
78
|
+
return ["#{path} does not exist"] if missing?
|
|
79
|
+
|
|
80
|
+
[*header_problems, *heading_problems, *ordering_problems, *content_problems]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Everything standing between this changelog and releasing `version`.
|
|
84
|
+
# Format problems count: a file nobody can parse is not documentation.
|
|
85
|
+
def release_problems(version)
|
|
86
|
+
return problems unless problems.empty?
|
|
87
|
+
|
|
88
|
+
release = find(version)
|
|
89
|
+
return ["#{path} has no section for #{version} — run gem_kit-release changelog"] if release.nil?
|
|
90
|
+
return ["#{path} section for #{version} is empty"] if release.empty?
|
|
91
|
+
|
|
92
|
+
newest = released.first
|
|
93
|
+
if newest && newest.version != release.version
|
|
94
|
+
return ["#{path} lists #{newest.version} above #{version}; the release being cut must come first"]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
[]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Split the file into `## [...]` sections, recording each one's
|
|
103
|
+
# `### Type` subsections and their top-level bullets.
|
|
104
|
+
def parse(text)
|
|
105
|
+
found = []
|
|
106
|
+
current = nil
|
|
107
|
+
heading = nil
|
|
108
|
+
|
|
109
|
+
text.each_line.with_index(1) do |line, number|
|
|
110
|
+
case line
|
|
111
|
+
when HEADING
|
|
112
|
+
heading = nil
|
|
113
|
+
current = Release.new(version: $1, date: $2&.strip, line: number, subsections: {})
|
|
114
|
+
found << current
|
|
115
|
+
when SUBHEADING
|
|
116
|
+
next unless current
|
|
117
|
+
|
|
118
|
+
heading = $1
|
|
119
|
+
(current.subsections[heading] ||= [])
|
|
120
|
+
when BULLET
|
|
121
|
+
current.subsections[heading] << line.strip if current && heading
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
found
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def header_problems
|
|
129
|
+
first = @text.each_line.find { |line| !line.strip.empty? }
|
|
130
|
+
return [] if first&.strip == "# Changelog"
|
|
131
|
+
|
|
132
|
+
["#{path}:1 must start with the title `# Changelog`"]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def heading_problems
|
|
136
|
+
problems = []
|
|
137
|
+
|
|
138
|
+
@text.each_line.with_index(1) do |line, number|
|
|
139
|
+
next unless line.start_with?("## ") && !line.start_with?("###")
|
|
140
|
+
|
|
141
|
+
unless line =~ HEADING
|
|
142
|
+
problems << "#{path}:#{number} malformed version heading: #{line.strip.inspect} " \
|
|
143
|
+
"(expected `## [Unreleased]` or `## [1.2.3] - YYYY-MM-DD`)"
|
|
144
|
+
next
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
version, date = $1, $2&.strip
|
|
148
|
+
next if version == UNRELEASED
|
|
149
|
+
|
|
150
|
+
problems << "#{path}:#{number} #{version} has no date (expected `## [#{version}] - YYYY-MM-DD`)" if date.nil? || date.empty?
|
|
151
|
+
problems << "#{path}:#{number} #{version} has a malformed date: #{date.inspect}" if date && !date.empty? && date !~ DATE
|
|
152
|
+
problems << "#{path}:#{number} #{version} is not a valid version number" unless valid_version?(version)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
problems + subheading_problems
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def subheading_problems
|
|
159
|
+
@text.each_line.with_index(1).filter_map do |line, number|
|
|
160
|
+
next unless line.start_with?("### ")
|
|
161
|
+
next if line =~ SUBHEADING && SECTIONS.include?($1)
|
|
162
|
+
|
|
163
|
+
"#{path}:#{number} unknown section #{line.sub("###", "").strip.inspect} " \
|
|
164
|
+
"(expected one of: #{SECTIONS.join(", ")})"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def ordering_problems
|
|
169
|
+
problems = []
|
|
170
|
+
|
|
171
|
+
if unreleased && releases.first&.unreleased? == false
|
|
172
|
+
problems << "#{path}:#{unreleased.line} [Unreleased] must be the first section"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
seen = {}
|
|
176
|
+
released.each do |release|
|
|
177
|
+
if (first = seen[release.version])
|
|
178
|
+
problems << "#{path}:#{release.line} duplicate section for #{release.version} (also at line #{first})"
|
|
179
|
+
end
|
|
180
|
+
seen[release.version] ||= release.line
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
versions = released.select { |release| valid_version?(release.version) }
|
|
184
|
+
versions.each_cons(2) do |newer, older|
|
|
185
|
+
next if Gem::Version.new(newer.version) > Gem::Version.new(older.version)
|
|
186
|
+
|
|
187
|
+
problems << "#{path}:#{older.line} #{older.version} is listed below #{newer.version}; " \
|
|
188
|
+
"releases must run newest to oldest"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
problems
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def content_problems
|
|
195
|
+
released.flat_map do |release|
|
|
196
|
+
if release.subsections.empty?
|
|
197
|
+
["#{path}:#{release.line} #{release} has no #{SECTIONS.join("/")} section"]
|
|
198
|
+
else
|
|
199
|
+
release.subsections.filter_map do |heading, bullets|
|
|
200
|
+
"#{path}:#{release.line} #{release} has an empty `### #{heading}` section" if bullets.empty?
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def valid_version?(version)
|
|
207
|
+
Gem::Version.correct?(version)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
__END__
|
|
214
|
+
|
|
215
|
+
describe "gem_kit/release/changelog" do
|
|
216
|
+
# Build a changelog from a body, with the standard title already in place.
|
|
217
|
+
changelog = lambda do |body|
|
|
218
|
+
GemKit::Release::Changelog.new("# Changelog\n\n#{body}")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
good = <<~MD
|
|
222
|
+
## [Unreleased]
|
|
223
|
+
|
|
224
|
+
## [4.1.0] - 2026-08-20
|
|
225
|
+
|
|
226
|
+
### Added
|
|
227
|
+
|
|
228
|
+
- A thing.
|
|
229
|
+
|
|
230
|
+
## [4.0.0] - 2026-08-01
|
|
231
|
+
|
|
232
|
+
### Removed
|
|
233
|
+
|
|
234
|
+
- An older thing.
|
|
235
|
+
MD
|
|
236
|
+
|
|
237
|
+
it "parses sections, dates and bullets" do
|
|
238
|
+
log = changelog.call(good)
|
|
239
|
+
|
|
240
|
+
log.releases.map(&:version).should == ["Unreleased", "4.1.0", "4.0.0"]
|
|
241
|
+
log.unreleased.should.be.kind_of?(GemKit::Release::Changelog::Release)
|
|
242
|
+
log.released.first.date.should == "2026-08-20"
|
|
243
|
+
log.released.first.subsections["Added"].should == ["- A thing."]
|
|
244
|
+
log.find("4.0.0").subsections["Removed"].size.should == 1
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it "lints a well-formed file clean and clears it for release" do
|
|
248
|
+
log = changelog.call(good)
|
|
249
|
+
|
|
250
|
+
log.problems.should == []
|
|
251
|
+
log.release_problems("4.1.0").should == []
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it "requires the `# Changelog` title" do
|
|
255
|
+
GemKit::Release::Changelog.new("## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n")
|
|
256
|
+
.problems.first.should.match(/must start with the title/)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it "reports a missing file" do
|
|
260
|
+
log = GemKit::Release::Changelog.new(nil, path: "nope.md")
|
|
261
|
+
log.missing?.should.be.true
|
|
262
|
+
log.problems.first.should.match(/does not exist/)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
it "rejects a malformed version heading" do
|
|
266
|
+
changelog.call("## 1.0.0\n").problems.first.should.match(/malformed version heading/)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it "rejects an undated or badly dated release" do
|
|
270
|
+
changelog.call("## [1.0.0]\n\n### Added\n\n- x\n").problems.first.should.match(/has no date/)
|
|
271
|
+
changelog.call("## [1.0.0] - 20260101\n\n### Added\n\n- x\n").problems.first.should.match(/malformed date/)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it "rejects an unknown section type" do
|
|
275
|
+
changelog.call("## [1.0.0] - 2026-01-01\n\n### Improved\n\n- x\n")
|
|
276
|
+
.problems.first.should.match(/unknown section "Improved"/)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it "rejects an empty release and an empty subsection" do
|
|
280
|
+
changelog.call("## [1.0.0] - 2026-01-01\n").problems.first.should.match(/has no Added/)
|
|
281
|
+
changelog.call("## [1.0.0] - 2026-01-01\n\n### Added\n\n## [0.9.0] - 2026-01-01\n\n### Added\n\n- x\n")
|
|
282
|
+
.problems.first.should.match(/empty `### Added` section/)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
it "rejects duplicates and out-of-order releases" do
|
|
286
|
+
body = "## [1.0.0] - 2026-01-02\n\n### Added\n\n- x\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n- y\n"
|
|
287
|
+
changelog.call(body).problems.first.should.match(/duplicate section for 1\.0\.0/)
|
|
288
|
+
|
|
289
|
+
body = "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n\n## [2.0.0] - 2026-01-02\n\n### Added\n\n- y\n"
|
|
290
|
+
changelog.call(body).problems.first.should.match(/must run newest to oldest/)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it "requires [Unreleased] to come first" do
|
|
294
|
+
body = "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n\n## [Unreleased]\n"
|
|
295
|
+
changelog.call(body).problems.first.should.match(/\[Unreleased\] must be the first section/)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it "blocks a release with no section of its own" do
|
|
299
|
+
changelog.call(good).release_problems("9.9.9").first.should.match(/no section for 9\.9\.9/)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it "blocks a release that is not the newest section" do
|
|
303
|
+
changelog.call(good).release_problems("4.0.0").first.should.match(/lists 4\.1\.0 above 4\.0\.0/)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
it "reports format problems ahead of release problems" do
|
|
307
|
+
changelog.call("## nonsense\n").release_problems("4.1.0").first.should.match(/malformed version heading/)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
it "loads the repo's own CHANGELOG.md" do
|
|
311
|
+
GemKit::Release::Changelog.load.missing?.should.be.false
|
|
312
|
+
end
|
|
313
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
5
|
+
module GemKit
|
|
6
|
+
module Release
|
|
7
|
+
module CLI
|
|
8
|
+
# The deadline check lives here rather than at release time because the
|
|
9
|
+
# bump is the last moment anyone can be stopped: once the version file
|
|
10
|
+
# says 5.0.0, every promise to disappear "in 5.0" is already broken.
|
|
11
|
+
class Bump < Command
|
|
12
|
+
desc "Move the gem version, refusing to bump onto a deprecation deadline"
|
|
13
|
+
|
|
14
|
+
argument :segment,
|
|
15
|
+
type: :string, required: false, default: "patch",
|
|
16
|
+
values: %w[major minor patch],
|
|
17
|
+
desc: "Which segment of the version to move"
|
|
18
|
+
|
|
19
|
+
option :force,
|
|
20
|
+
type: :boolean, default: false,
|
|
21
|
+
desc: "Bump even when a deprecation comes due"
|
|
22
|
+
|
|
23
|
+
example [
|
|
24
|
+
"minor # 4.1.0 -> 4.2.0",
|
|
25
|
+
"major # refused while a 5.0 deprecation is still in the tree",
|
|
26
|
+
"major --force # bump anyway, saying what it overrode",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
def call(segment: "patch", **options)
|
|
30
|
+
current = project.version.to_s
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
target = VersionFile.bump(current, segment)
|
|
34
|
+
rescue VersionFile::Error => error
|
|
35
|
+
fail_with(error.message)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
problems = gate.bump_problems(target)
|
|
39
|
+
unless problems.empty?
|
|
40
|
+
unless options[:force]
|
|
41
|
+
refuse("Refusing to bump #{current} -> #{target}:",
|
|
42
|
+
problems + ["", "Remove them, then bump. Override with --force."])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
say("--force given; bumping past #{problems.size} deprecation(s) anyway.")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
version_file.write(target)
|
|
49
|
+
say("#{current} -> #{target}")
|
|
50
|
+
|
|
51
|
+
upcoming = gate.upcoming_deprecations(target)
|
|
52
|
+
unless upcoming.empty?
|
|
53
|
+
say("#{upcoming.size} deprecation(s) still outstanding (none due in #{target}).")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The changelog is the one release artefact nothing regenerates, and
|
|
57
|
+
# the one most easily forgotten — so say so loudly.
|
|
58
|
+
say
|
|
59
|
+
say("#{RED}now run: gem kit changelog --write#{RESET}")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
__END__
|
|
67
|
+
|
|
68
|
+
describe "gem_kit/release/cli/bump" do
|
|
69
|
+
require_relative "../../../../spec/support/gem_kit_release_spec"
|
|
70
|
+
extend GemKitReleaseSpec
|
|
71
|
+
|
|
72
|
+
it "moves the version and points at the changelog" do
|
|
73
|
+
with_gem do |dir|
|
|
74
|
+
status, out, _err = invoke(["bump", "minor"], dir)
|
|
75
|
+
|
|
76
|
+
status.should == 0
|
|
77
|
+
out.should.match(/1\.2\.3 -> 1\.3\.0/)
|
|
78
|
+
out.should.match(/now run: gem kit changelog --write/)
|
|
79
|
+
File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"1\.3\.0"/)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "defaults to a patch bump" do
|
|
84
|
+
with_gem do |dir|
|
|
85
|
+
invoke(["bump"], dir)
|
|
86
|
+
File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"1\.2\.4"/)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "refuses to bump onto a deadline, leaving the version file alone" do
|
|
91
|
+
with_gem do |dir|
|
|
92
|
+
status, _out, err = invoke(["bump", "major"], dir, deprecations: [["Old", "New", "2.0"]])
|
|
93
|
+
|
|
94
|
+
status.should == 1
|
|
95
|
+
err.should.match(/Refusing to bump 1\.2\.3 -> 2\.0\.0/)
|
|
96
|
+
err.should.match(/Old -> New/)
|
|
97
|
+
File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"1\.2\.3"/)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "--force bumps past a deadline and says so" do
|
|
102
|
+
with_gem do |dir|
|
|
103
|
+
status, out, _err = invoke(["bump", "major", "--force"], dir, deprecations: [["Old", "New", "2.0"]])
|
|
104
|
+
|
|
105
|
+
status.should == 0
|
|
106
|
+
out.should.match(/--force given; bumping past 1 deprecation/)
|
|
107
|
+
File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"2\.0\.0"/)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "mentions deprecations outstanding but not yet due" do
|
|
112
|
+
with_gem do |dir|
|
|
113
|
+
_status, out, _err = invoke(["bump", "minor"], dir, deprecations: [["Old", "New", "9.0"]])
|
|
114
|
+
out.should.match(/1 deprecation\(s\) still outstanding \(none due in 1\.3\.0\)/)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "rejects a segment that is not major, minor or patch" do
|
|
119
|
+
with_gem do |dir|
|
|
120
|
+
status, _out, _err = invoke(["bump", "epoch"], dir)
|
|
121
|
+
status.should.not == 0
|
|
122
|
+
File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"1\.2\.3"/)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "reports a directory with no gemspec" do
|
|
127
|
+
Dir.mktmpdir do |dir|
|
|
128
|
+
status, _out, err = invoke(["bump", "minor"], dir)
|
|
129
|
+
status.should == 1
|
|
130
|
+
err.should.match(/no \.gemspec/)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
5
|
+
module GemKit
|
|
6
|
+
module Release
|
|
7
|
+
module CLI
|
|
8
|
+
# Linting is the default because it is the safe, read-only half. Writing
|
|
9
|
+
# edits a file and costs money, so it asks for the flag.
|
|
10
|
+
class Changelog < Command
|
|
11
|
+
desc "Lint CHANGELOG.md, or have an AI CLI write this version's entry"
|
|
12
|
+
|
|
13
|
+
argument :version,
|
|
14
|
+
type: :string, required: false,
|
|
15
|
+
desc: "Check that this version is ready to release (default: format only)"
|
|
16
|
+
|
|
17
|
+
option :write,
|
|
18
|
+
type: :boolean, default: false,
|
|
19
|
+
desc: "Ask the configured AI CLI to write this version's entry"
|
|
20
|
+
|
|
21
|
+
example [
|
|
22
|
+
" # lint the format",
|
|
23
|
+
"4.2.0 # ...and check 4.2.0 is ready to release",
|
|
24
|
+
"--write # hand the entry to the configured AI CLI",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def call(version: nil, **options)
|
|
28
|
+
return write_entry if options[:write]
|
|
29
|
+
|
|
30
|
+
problems = version ? gate.release_problems(version) : gate.changelog_problems
|
|
31
|
+
|
|
32
|
+
if problems.empty?
|
|
33
|
+
say("#{relative(project.changelog_path)} is #{version ? "ready to release #{version}" : "clean"}.")
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
refuse("#{problems.size} problem(s) in #{relative(project.changelog_path)}:", problems)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def write_entry
|
|
43
|
+
writer = project.changelog_writer
|
|
44
|
+
unless system("command -v #{writer} >/dev/null 2>&1")
|
|
45
|
+
fail_with("#{writer} is not on PATH (set config.changelog_writer)")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
say("Writing #{relative(project.changelog_path)} for #{project.version}…")
|
|
49
|
+
fail_with("#{writer} failed") unless system(writer, prompt)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def prompt
|
|
53
|
+
<<~PROMPT
|
|
54
|
+
Update #{File.basename(project.changelog_path)} for the release of version #{project.version}.
|
|
55
|
+
|
|
56
|
+
The changes to document are the commits since the last tag plus any
|
|
57
|
+
uncommitted work. Read `git log`, `git status` and `git diff` first.
|
|
58
|
+
|
|
59
|
+
Rules:
|
|
60
|
+
- Follow Keep a Changelog (https://keepachangelog.com/en/1.1.0/) exactly.
|
|
61
|
+
The file must pass `gem kit changelog #{project.version}`; run it when you
|
|
62
|
+
are done and fix anything it reports.
|
|
63
|
+
- Add one `## [#{project.version}] - #{Time.now.strftime("%Y-%m-%d")}` section
|
|
64
|
+
directly below `## [Unreleased]`, and move anything already under
|
|
65
|
+
[Unreleased] that shipped in this version into it. Leave [Unreleased] in
|
|
66
|
+
place, empty.
|
|
67
|
+
- Group entries only under: Added, Changed, Deprecated, Removed, Fixed,
|
|
68
|
+
Security. Omit the groups with nothing in them.
|
|
69
|
+
- Write for someone upgrading the gem: what changed in the public API, what
|
|
70
|
+
they must now do differently. Name the constants and methods involved.
|
|
71
|
+
Skip internal refactors, test-only changes and typo fixes.
|
|
72
|
+
- Anything deprecated this cycle goes under Deprecated, naming the
|
|
73
|
+
replacement and the version it will be removed in (see
|
|
74
|
+
`gem kit deprecations`).
|
|
75
|
+
- Match the voice and level of detail of the existing entries.
|
|
76
|
+
- Edit #{File.basename(project.changelog_path)} only. Do not touch any other
|
|
77
|
+
file, and do not commit.
|
|
78
|
+
PROMPT
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
__END__
|
|
86
|
+
|
|
87
|
+
describe "gem_kit/release/cli/changelog" do
|
|
88
|
+
require_relative "../../../../spec/support/gem_kit_release_spec"
|
|
89
|
+
extend GemKitReleaseSpec
|
|
90
|
+
|
|
91
|
+
it "lints the format when given no version" do
|
|
92
|
+
with_gem do |dir|
|
|
93
|
+
status, out, _err = invoke(["changelog"], dir)
|
|
94
|
+
status.should == 0
|
|
95
|
+
out.should.match(/CHANGELOG\.md is clean/)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "checks a version is ready to release" do
|
|
100
|
+
with_gem do |dir|
|
|
101
|
+
invoke(["changelog", "1.2.3"], dir).first.should == 0
|
|
102
|
+
|
|
103
|
+
status, _out, err = invoke(["changelog", "9.9.9"], dir)
|
|
104
|
+
status.should == 1
|
|
105
|
+
err.should.match(/no section for 9\.9\.9/)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "reports a malformed changelog with line numbers" do
|
|
110
|
+
with_gem(changelog: :none) do |dir|
|
|
111
|
+
File.write(File.join(dir, "CHANGELOG.md"), "# Changelog\n\n## 1.0.0\n")
|
|
112
|
+
|
|
113
|
+
status, _out, err = invoke(["changelog"], dir)
|
|
114
|
+
status.should == 1
|
|
115
|
+
err.should.match(/malformed version heading/)
|
|
116
|
+
err.should.match(/CHANGELOG\.md:3/)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "reports a changelog that does not exist" do
|
|
121
|
+
with_gem(changelog: :none) do |dir|
|
|
122
|
+
status, _out, err = invoke(["changelog"], dir)
|
|
123
|
+
status.should == 1
|
|
124
|
+
err.should.match(/does not exist/)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "--write fails clearly when the configured writer is not on PATH" do
|
|
129
|
+
with_gem do |dir|
|
|
130
|
+
GemKit::Release.configure { |config| config.changelog_writer = "definitely-not-a-command" }
|
|
131
|
+
begin
|
|
132
|
+
status, _out, err = invoke(["changelog", "--write"], dir)
|
|
133
|
+
status.should == 1
|
|
134
|
+
err.should.match(/definitely-not-a-command is not on PATH/)
|
|
135
|
+
ensure
|
|
136
|
+
GemKit::Release.reset!
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "--write invokes the configured writer" do
|
|
142
|
+
with_gem do |dir|
|
|
143
|
+
# `true` accepts and ignores its argument, so this exercises the whole
|
|
144
|
+
# path without spending anything.
|
|
145
|
+
GemKit::Release.configure { |config| config.changelog_writer = "true" }
|
|
146
|
+
begin
|
|
147
|
+
status, out, _err = invoke(["changelog", "--write"], dir)
|
|
148
|
+
status.should == 0
|
|
149
|
+
out.should.match(/Writing CHANGELOG\.md for 1\.2\.3/)
|
|
150
|
+
ensure
|
|
151
|
+
GemKit::Release.reset!
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "is reachable by its alias" do
|
|
157
|
+
with_gem do |dir|
|
|
158
|
+
invoke(["log"], dir).first.should == 0
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
|
|
5
|
+
require_relative "../../release"
|
|
6
|
+
|
|
7
|
+
module GemKit
|
|
8
|
+
module Release
|
|
9
|
+
module CLI
|
|
10
|
+
# Raised instead of exiting, so the caller decides what a failure means:
|
|
11
|
+
# the `gem kit` bridge turns it into terminate_interaction, and the specs
|
|
12
|
+
# turn it into a status.
|
|
13
|
+
class Failure < StandardError; end
|
|
14
|
+
|
|
15
|
+
# Shared base for the subcommands. Each is thin — declare arguments and
|
|
16
|
+
# options so dry-cli can render the help page, ask a library object a
|
|
17
|
+
# question, report. The work stays in Gate, VersionFile, Changelog and
|
|
18
|
+
# Deprecate.
|
|
19
|
+
class Command < Dry::CLI::Command
|
|
20
|
+
RED = "\e[0;31m"
|
|
21
|
+
RESET = "\e[0m"
|
|
22
|
+
|
|
23
|
+
# The gem in the working directory. Everything is inferred from its
|
|
24
|
+
# .gemspec, so there is nothing to configure in the common case.
|
|
25
|
+
def project
|
|
26
|
+
@project ||= Project.detect
|
|
27
|
+
rescue Project::NotFound => error
|
|
28
|
+
fail_with(error.message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def gate
|
|
32
|
+
@gate ||= Gate.new(project)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def version_file
|
|
36
|
+
VersionFile.new(project.version_file, template: project.version_template)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def say(message = "") = $stdout.puts(message)
|
|
40
|
+
|
|
41
|
+
def fail_with(message)
|
|
42
|
+
raise Failure, message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Report a problem list under a heading and stop. One message — a
|
|
46
|
+
# refusal is not half output and half diagnostics.
|
|
47
|
+
def refuse(heading, problems)
|
|
48
|
+
fail_with([heading, "", *problems.map { |problem| " #{problem}" }].join("\n"))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def relative(path) = path.sub("#{project.root}/", "")
|
|
52
|
+
|
|
53
|
+
def render(entries)
|
|
54
|
+
lines(entries).each { |line| say(" #{line}") }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Deprecation entries as display lines: the deadline, the rename, and
|
|
58
|
+
# where it was declared.
|
|
59
|
+
def lines(entries)
|
|
60
|
+
entries.sort_by { |entry| [entry.removed_in, entry.name] }.flat_map do |entry|
|
|
61
|
+
line = "#{entry.removed_in.to_s.ljust(8)} #{entry}"
|
|
62
|
+
entry.declared_at ? [line, "#{" " * 8} #{entry.declared_at}"] : [line]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|