gem_kit-release 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c85c52c48c2f4ef893784a7e3f5cd6f1dd35fbf6e74e46de4eef4bc7c205821
4
- data.tar.gz: 994048430d10430d4d3f47ca3a57a214200459a75205685adc921cc63e6d58cf
3
+ metadata.gz: d9d6b16c56ad134e56b75cb00642a0a5f01ba96c6d7b620854736b871444b5aa
4
+ data.tar.gz: c3b718404bb08947a3df23e822a3edb5dc9ae7470da3605272b9d3e6f87b1dda
5
5
  SHA512:
6
- metadata.gz: 58349a775e00a64b0e023f6cd53016527bcc3ed440c9143ef53321da8dd03a8e5b2009ad90cf8d8d84827e7158f639943a544cf15446095c59b0407bc00dabe6
7
- data.tar.gz: 5014510b87f87df7047b62f288182586fae3278133fd92d1f50a24f93b6294ed986e92a67c3fb3da15c9bb86c47b8d1ef6231b886edcd4d422df7885aba7cdc6
6
+ metadata.gz: ff045009cdcf213639c10607dfc0bffbc3e03f49e00ae2c34d2e2e50f26b13ffdcd2910d2a00f0b1311ea73ddfcf7318fc75196ff3b5a0960be9be63d3484bf9
7
+ data.tar.gz: 4a16c794925df5faaa54e1a0fe3b2429ba3ceddc578e3da8b45fadbbdead1b947eb29c308d50ae27db1a5e0ef9fb7a7e281dbde3762ef8d923df649bf92636a3
@@ -38,6 +38,23 @@ module GemKit
38
38
  # Each command here is a thin front for a plain object under CLI::; the
39
39
  # work itself is in Gate, VersionFile, Changelog and Deprecate.
40
40
  class CLI < Thor
41
+ # The order the commands go in — printed above the command listing so
42
+ # nobody has to guess when to commit or when to write the changelog. The
43
+ # question the process is easy to get wrong on is exactly the one this
44
+ # answers: the changelog is written *after* the bump, then the two are
45
+ # committed together, and only then does release run.
46
+ WORKFLOW = <<~TXT
47
+ Release, in order:
48
+
49
+ 1. bin/test # green suite
50
+ 2. gem kit bump [SEGMENT] # move the version, relock the Gemfile
51
+ 3. gem kit changelog --write # AI CLI drafts this version's entry
52
+ 4. gem kit changelog [VERSION] # lint the entry, confirm ready to release
53
+ 5. git commit -am "Release ..." # bump + lockfile + entry, one commit
54
+ 6. gem kit release # gate, build, push, tag (also pushes the tag)
55
+ 7. git push # publish the release commit
56
+ TXT
57
+
41
58
  class_option :gem, type: :string,
42
59
  desc: "Which gem, in a repository holding more than one gemspec"
43
60
 
@@ -49,6 +66,15 @@ module GemKit
49
66
  "gem kit #{command.usage}"
50
67
  end
51
68
 
69
+ # Prepend the workflow to the top-level `gem kit` help. `gem kit help
70
+ # <command>` sets subcommand=true and lands here too; that page already
71
+ # has its own long_desc, so leave it alone.
72
+ def self.help(shell, subcommand = false)
73
+ shell.say(WORKFLOW) unless subcommand
74
+ shell.say unless subcommand
75
+ super
76
+ end
77
+
52
78
  # Short forms for the three typed most often.
53
79
  map "log" => :changelog,
54
80
  "deps" => :deprecations,
@@ -210,6 +236,33 @@ describe "gem_kit/release/cli" do
210
236
  end
211
237
  end
212
238
 
239
+ # The order between `bump` and `release` — when to write the changelog and
240
+ # when to commit — is exactly the question the numbered list answers, so
241
+ # the top-level help has to print it.
242
+ it "prints the numbered workflow above the command listing" do
243
+ with_gem do |dir|
244
+ _status, out, _err = invoke([], dir)
245
+
246
+ out.should.match(/Release, in order:/)
247
+ out.should.match(/1\. bin\/test/)
248
+ out.should.match(/2\. gem kit bump/)
249
+ out.should.match(/5\. git commit/)
250
+ out.should.match(/6\. gem kit release/)
251
+ out.should.match(/7\. git push/)
252
+ # The workflow lands above "Commands:", not swallowed by it.
253
+ out.index("Release, in order:").should < out.index("Commands:")
254
+ end
255
+ end
256
+
257
+ # `gem kit help <command>` already has its own long_desc; the workflow
258
+ # belongs on the top-level page only.
259
+ it "does not repeat the workflow on a per-command help page" do
260
+ with_gem do |dir|
261
+ _status, out, _err = invoke(["help", "bump"], dir)
262
+ out.should.not.match(/Release, in order:/)
263
+ end
264
+ end
265
+
213
266
  it "names the commands `gem kit <command>`, not the running program" do
214
267
  with_gem do |dir|
215
268
  _status, out, _err = invoke([], dir)
@@ -35,6 +35,8 @@ module GemKit
35
35
  version_file.write(target)
36
36
  say("#{current} -> #{target}")
37
37
 
38
+ relock(target)
39
+
38
40
  upcoming = gate.upcoming_deprecations(target)
39
41
  unless upcoming.empty?
40
42
  say("#{upcoming.size} deprecation(s) still outstanding (none due in #{target}).")
@@ -45,6 +47,35 @@ module GemKit
45
47
  say
46
48
  say("#{RED}now run: gem kit changelog --write#{RESET}")
47
49
  end
50
+
51
+ private
52
+
53
+ # A Gemfile that says `gemspec` or `path: "."` records this gem's own
54
+ # version in Gemfile.lock, so a bump that only rewrites the version
55
+ # file leaves the two disagreeing — and bundler refuses outright
56
+ # under frozen mode, which is what bundlerEnv sets. Relock, so the
57
+ # bump is one coherent change rather than a trap for the next
58
+ # command anyone runs.
59
+ #
60
+ # `bundle lock` rather than `bundle install`: nothing needs
61
+ # installing, only the lockfile needs to agree.
62
+ def relock(target)
63
+ return unless project.self_locked?
64
+
65
+ say("Relocking #{File.basename(project.lockfile_path)}…")
66
+
67
+ ok = Dir.chdir(project.root) do
68
+ # BUNDLE_FROZEN is exactly what stops this, and inheriting it
69
+ # from an ambient devshell would defeat the point.
70
+ system({"BUNDLE_FROZEN" => "false", "BUNDLE_GEMFILE" => nil},
71
+ "bundle", "lock", out: File::NULL, err: File::NULL)
72
+ end
73
+
74
+ return if ok
75
+
76
+ say("#{RED}could not relock — run `bundle lock` before committing " \
77
+ "#{target}#{RESET}")
78
+ end
48
79
  end
49
80
  end
50
81
  end
@@ -95,6 +126,44 @@ describe "gem_kit/release/commands/bump" do
95
126
  end
96
127
  end
97
128
 
129
+ # The version lives in two files when a gem is in its own bundle, and a bump
130
+ # that moves one of them leaves bundler refusing to do anything.
131
+ it "relocks a lockfile that records this gem's own version" do
132
+ with_gem do |dir|
133
+ File.write(File.join(dir, "Gemfile"), %(source "https://rubygems.org"\ngemspec\n))
134
+ File.write(File.join(dir, "Gemfile.lock"), <<~LOCK)
135
+ PATH
136
+ remote: .
137
+ specs:
138
+ demo (1.2.3)
139
+
140
+ GEM
141
+ remote: https://rubygems.org/
142
+
143
+ PLATFORMS
144
+ ruby
145
+
146
+ DEPENDENCIES
147
+ demo!
148
+
149
+ BUNDLED WITH
150
+ 2.7.2
151
+ LOCK
152
+
153
+ _status, out, _err = invoke(["bump", "minor"], dir)
154
+
155
+ out.should.match(/Relocking Gemfile\.lock/)
156
+ File.read(File.join(dir, "Gemfile.lock")).should.match(/demo \(1\.3\.0\)/)
157
+ end
158
+ end
159
+
160
+ it "says nothing about a lockfile that does not record this gem" do
161
+ with_gem do |dir|
162
+ _status, out, _err = invoke(["bump", "minor"], dir)
163
+ out.should.not.match(/Relocking/)
164
+ end
165
+ end
166
+
98
167
  it "mentions deprecations outstanding but not yet due" do
99
168
  with_gem do |dir|
100
169
  _status, out, _err = invoke(["bump", "minor"], dir, deprecations: [["Old", "New", "9.0"]])
@@ -98,6 +98,9 @@ __END__
98
98
 
99
99
  describe "gem_kit/release/gate" do
100
100
  require "tmpdir"
101
+ # These specs ask the gate whether a temp directory is a repository, so the
102
+ # walk up out of Dir.tmpdir has to be stopped or the answer is the machine's.
103
+ require_relative "../../../spec/support/git_isolation"
101
104
 
102
105
  # A Project stub: the Gate only asks it for a changelog path and a version,
103
106
  # and to load the library (a no-op here — the specs drive the registry).
@@ -105,6 +105,21 @@ module GemKit
105
105
  multi_gem? ? "CHANGELOG-#{name}.md" : "CHANGELOG.md"
106
106
  end
107
107
 
108
+ def lockfile_path
109
+ File.join(root, "Gemfile.lock")
110
+ end
111
+
112
+ # A Gemfile that says `gemspec` or `path: "."` puts this gem in its own
113
+ # bundle, and the lockfile then records this gem's version. Bumping
114
+ # without relocking leaves the two disagreeing — which bundler reports as
115
+ # "the gemspecs for path gems changed", and refuses outright under
116
+ # frozen mode, as bundlerEnv sets.
117
+ def self_locked?
118
+ return false unless File.exist?(lockfile_path)
119
+
120
+ File.read(lockfile_path).match?(/^\s{4}#{Regexp.escape(name)} \(\d/)
121
+ end
122
+
108
123
  # `v1.2.3` says which version but not which gem, which is fine until a
109
124
  # repository holds two of them at the same version.
110
125
  def tag_prefix
@@ -114,7 +129,10 @@ module GemKit
114
129
  # lib/gem_kit/release/version.rb for "gem_kit-release", lib/brute/version.rb
115
130
  # for "brute" — the convention every `bundle gem` project follows.
116
131
  def version_file
117
- File.expand_path(config.version_file || File.join("lib", *name.split("-"), "version.rb"), root)
132
+ File.expand_path(
133
+ config.version_file || File.join("lib", *library_segments, "version.rb"),
134
+ root,
135
+ )
118
136
  end
119
137
 
120
138
  # An ERB template beside the version file, if the project generates it.
@@ -126,7 +144,36 @@ module GemKit
126
144
  # What to require so that deprecation declarations register themselves.
127
145
  # "gem_kit-release" -> "gem_kit/release".
128
146
  def require_path
129
- config.require_path || name.tr("-", "/")
147
+ config.require_path || library_segments.join("/")
148
+ end
149
+
150
+ # Where the library sits under lib/, as path segments.
151
+ #
152
+ # `bundle gem gem_kit-release` NESTS, treating the hyphen as a directory
153
+ # separator: lib/gem_kit/release.rb, module GemKit::Release. That is the
154
+ # common case and the one assumed here first.
155
+ #
156
+ # But a hyphenated name whose library is a SINGLE module does not nest.
157
+ # `ag-ui` is lib/ag_ui.rb holding `module AgUi` — hyphen as underscore —
158
+ # and the nested form names lib/ag/ui.rb, a file that was never there.
159
+ # Both layouts are conventional and the name alone cannot tell them
160
+ # apart, so ask the tree: whichever exists is what this project is.
161
+ #
162
+ # When neither exists the nested form wins, because that is what
163
+ # `bundle gem` would have produced and a project with no library yet is
164
+ # about to become one.
165
+ def library_segments
166
+ nested = name.split("-")
167
+ if exists_under_lib?(nested)
168
+ nested
169
+ else
170
+ flat = [name.tr("-", "_")]
171
+ if exists_under_lib?(flat)
172
+ flat
173
+ else
174
+ nested
175
+ end
176
+ end
130
177
  end
131
178
 
132
179
  # Load the library, so Deprecate's registry reflects this project.
@@ -143,6 +190,15 @@ module GemKit
143
190
  def test_command = config.test_command || "bin/test"
144
191
 
145
192
  def changelog_writer = config.changelog_writer || "claude"
193
+
194
+ private
195
+
196
+ # A layout is present if the entry file is there, or the directory it
197
+ # would hold — a gem is free to ship lib/foo/ without lib/foo.rb.
198
+ def exists_under_lib?(segments)
199
+ base = File.join(root, "lib", *segments)
200
+ File.exist?("#{base}.rb") || File.directory?(base)
201
+ end
146
202
  end
147
203
  end
148
204
  end
@@ -152,10 +208,16 @@ __END__
152
208
  describe "gem_kit/release/project" do
153
209
  require "tmpdir"
154
210
 
155
- # A throwaway gem laid out the conventional way.
156
- with_project = lambda do |name: "demo", version: "1.2.3", &block|
211
+ # A throwaway gem. `layout:` picks between the two conventions a hyphenated
212
+ # name can follow: :nested is `bundle gem`'s lib/gem_kit/release, :flat is a
213
+ # single-module gem's lib/ag_ui.
214
+ with_project = lambda do |name: "demo", version: "1.2.3", layout: :nested, &block|
157
215
  Dir.mktmpdir do |dir|
158
- path = name.split("-")
216
+ if layout == :flat
217
+ path = [name.tr("-", "_")]
218
+ else
219
+ path = name.split("-")
220
+ end
159
221
  FileUtils.mkdir_p(File.join(dir, "lib", *path))
160
222
  File.write(File.join(dir, "lib", *path, "version.rb"), <<~RUBY)
161
223
  module #{path.map { |p| p.split("_").map(&:capitalize).join }.join("::")}
@@ -203,6 +265,48 @@ describe "gem_kit/release/project" do
203
265
  end
204
266
  end
205
267
 
268
+ # A hyphenated name whose library is one module: lib/ag_ui.rb, not
269
+ # lib/ag/ui.rb. Deriving the path from the name alone named a file that was
270
+ # never there, so `gem kit bump` could not find the version and deprecations
271
+ # went undetected.
272
+ it "reads an underscored single-module layout off the tree" do
273
+ with_project.call(name: "ag-ui", layout: :flat) do |dir|
274
+ project = GemKit::Release::Project.detect(dir)
275
+ project.require_path.should == "ag_ui"
276
+ project.version_file.should == File.join(dir, "lib/ag_ui/version.rb")
277
+ end
278
+ end
279
+
280
+ # Nesting stays the default: it is what `bundle gem` produces, so it is what
281
+ # a project with no library yet is about to become.
282
+ it "falls back to the nested layout when neither is on disk" do
283
+ Dir.mktmpdir do |dir|
284
+ File.write(File.join(dir, "ag-ui.gemspec"), <<~RUBY)
285
+ Gem::Specification.new do |spec|
286
+ spec.name = "ag-ui"
287
+ spec.version = "0.1.0"
288
+ spec.authors = ["x"]
289
+ spec.summary = "x"
290
+ spec.files = []
291
+ end
292
+ RUBY
293
+ GemKit::Release::Project.detect(dir).require_path.should == "ag/ui"
294
+ end
295
+ end
296
+
297
+ # An explicit setting still wins over anything read off the tree.
298
+ it "prefers a configured require path and version file" do
299
+ with_project.call(name: "ag-ui", layout: :flat) do |dir|
300
+ config = GemKit::Release::Project::Config.new(
301
+ require_path: "somewhere/else",
302
+ version_file: "lib/custom.rb",
303
+ )
304
+ project = GemKit::Release::Project.detect(dir, config: config)
305
+ project.require_path.should == "somewhere/else"
306
+ project.version_file.should == File.join(dir, "lib/custom.rb")
307
+ end
308
+ end
309
+
206
310
  it "defaults the changelog to CHANGELOG.md in the project root" do
207
311
  with_project.call do |dir|
208
312
  project = GemKit::Release::Project.detect(dir)
@@ -252,6 +356,38 @@ describe "gem_kit/release/project" do
252
356
  end
253
357
  end
254
358
 
359
+ # A gem whose own Gemfile pulls it in has its version written in the lockfile
360
+ # as well as the version file, and both have to move together.
361
+ it "knows whether the lockfile records this gem's own version" do
362
+ with_project.call do |dir|
363
+ GemKit::Release::Project.detect(dir).self_locked?.should.be.false
364
+
365
+ File.write(File.join(dir, "Gemfile.lock"), <<~LOCK)
366
+ PATH
367
+ remote: .
368
+ specs:
369
+ demo (1.2.3)
370
+
371
+ GEM
372
+ remote: https://rubygems.org/
373
+ LOCK
374
+ GemKit::Release::Project.detect(dir).self_locked?.should.be.true
375
+ end
376
+ end
377
+
378
+ it "does not mistake another gem's line in the lockfile for its own" do
379
+ with_project.call do |dir|
380
+ File.write(File.join(dir, "Gemfile.lock"), <<~LOCK)
381
+ GEM
382
+ remote: https://rubygems.org/
383
+ specs:
384
+ other-demo (1.0.0)
385
+ demo-helper (2.0.0)
386
+ LOCK
387
+ GemKit::Release::Project.detect(dir).self_locked?.should.be.false
388
+ end
389
+ end
390
+
255
391
  it "finds an ERB version template when the project generates its version file" do
256
392
  with_project.call do |dir|
257
393
  GemKit::Release::Project.detect(dir).version_template.should.be.nil
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GemKit
4
4
  module Release
5
- VERSION = "0.3.0"
5
+ VERSION = "0.3.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gem_kit-release
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Kidd