gem_kit-release 0.2.2 → 0.3.1

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: 7c57bae0a6fbc182e752ab68d4e5eaa6f1441354e9f906d9ee6d336c8c51c9e4
4
- data.tar.gz: 73cc9921720bad183c573a5cd7bdb077bd118ed6f3eb2d1a0f417f0c9a1bf9de
3
+ metadata.gz: dbdaca4c93bb73c92daa2e585aa8f6dbcef18a7717a6f9c3549f943272b13947
4
+ data.tar.gz: 8d41aaa5cd762b8c6660d6cffbb669d1d17ce5a604ea578d1673681eb6c39809
5
5
  SHA512:
6
- metadata.gz: 507b97ad08897e529ba1d471b696beeeb8af74fdd782e7067de2ac0f6ffa51f20010d782de808727083781b4e4b766fa7327cf122b300b284e88441e2d2899ef
7
- data.tar.gz: 1fc2b130909d685e609f30e54a59faa8ad8e047ec057be23a5cfbea5648d1919a4b28fbf2bf2076239a7af4a3523ccd9b49cdc899333b0d1cd7d1e8bab113080
6
+ metadata.gz: e431cf45975cc92e084d02988ed88a68053bf538f386bf734a167a9e0a6914de609a8d22555ba4e238c41ff6ae932bbfa44e5933fd665f19b0c4468336d60e8b
7
+ data.tar.gz: 852b384995fbbd1ee34b28200cdcd472eddb6c3460e99101b70df56da4435b45be91fc6805959f21eb942bfc7287f883000e70e1566f684e518102727a29f1d1
data/README.md CHANGED
@@ -65,7 +65,7 @@ gem kit changelog --write # have an AI CLI write the entry
65
65
  gem kit changelog # lint it
66
66
  gem kit deprecations # what is still outstanding
67
67
  gem kit release --dry-run # run the gates
68
- gem kit tag --push # tag it
68
+ gem kit release # ...then build, push and tag
69
69
  ```
70
70
 
71
71
  | Command | What it does |
@@ -105,21 +105,28 @@ module GemKit
105
105
  Commands::Deprecations.new(options).call(version)
106
106
  end
107
107
 
108
- desc "release", "Gate, build and push this gem"
108
+ desc "release", "Gate, build, push and tag this gem"
109
109
  long_desc <<~TXT
110
- Two gates, both before `gem build` runs:
110
+ Three gates, all before `gem build` runs:
111
111
 
112
112
  the changelog must have a non-empty, correctly formatted section for this
113
- version, sitting at the top of the released list; and nothing promised to
114
- disappear in this version may still be in the tree.
113
+ version, sitting at the top of the released list; nothing promised to
114
+ disappear in this version may still be in the tree; and the working tree
115
+ must be committed, because a gem built from uncommitted files is a gem
116
+ whose source exists nowhere.
117
+
118
+ Then builds the gemspec, pushes it, and tags the release — after the push,
119
+ so a tag never names a version that failed to publish. Requires RubyGems
120
+ push credentials.
115
121
 
116
- Then builds the gemspec and pushes it. Requires RubyGems push credentials.
117
122
  --dry-run runs the gates and stops, which is what belongs in CI.
118
123
  TXT
119
124
  method_option :dry_run, type: :boolean, default: false, aliases: "-n",
120
125
  desc: "Run the gates and stop"
121
- method_option :tag, type: :boolean, default: false, aliases: "-t",
122
- desc: "Tag the release after pushing"
126
+ method_option :tag, type: :boolean, default: true,
127
+ desc: "Tag the release and push the tag (--no-tag to skip)"
128
+ method_option :allow_dirty, type: :boolean, default: false,
129
+ desc: "Release even with uncommitted changes"
123
130
  def release
124
131
  Commands::Release.new(options).call
125
132
  end
@@ -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"]])
@@ -5,15 +5,15 @@ require_relative "command"
5
5
  module GemKit
6
6
  module Release
7
7
  module Commands
8
- # Gate, build, push. The gates run before anything is built, because a
9
- # half-done release is worse than a refused one.
8
+ # Gate, build, push, tag. The gates run before anything is built, because
9
+ # a half-done release is worse than a refused one.
10
10
  class Release < Command
11
11
 
12
12
 
13
13
 
14
14
  def call
15
15
  version = project.version.to_s
16
- problems = gate.release_problems(version)
16
+ problems = gate.release_problems(version, allow_dirty: options[:allow_dirty])
17
17
 
18
18
  refuse("Refusing to release #{project.name} #{version}:", problems) unless problems.empty?
19
19
 
@@ -35,7 +35,11 @@ module GemKit
35
35
 
36
36
  say("Released #{project.name} #{version}")
37
37
 
38
- Tag.new(options).call if options[:tag]
38
+ # Tagging is part of releasing, not a flag someone remembers. It runs
39
+ # after the push rather than before, so a tag never points at a
40
+ # version that failed to publish — and it pushes, because the gem is
41
+ # public by now and a tag only this machine has is half a release.
42
+ Tag.new(options.merge(push: true)).call unless options[:tag] == false
39
43
  end
40
44
  end
41
45
  end
@@ -56,6 +60,39 @@ describe "gem_kit/release/commands/release" do
56
60
  end
57
61
  end
58
62
 
63
+ # A gem built from an uncommitted tree matches nothing in git.
64
+ it "refuses while the working tree is dirty, before building anything" do
65
+ with_gem do |dir|
66
+ system("git init -q #{dir}")
67
+
68
+ status, _out, err = invoke(["release"], dir)
69
+
70
+ status.should == 1
71
+ err.should.match(/uncommitted change/)
72
+ err.should.match(/--allow-dirty/)
73
+ Dir[File.join(dir, "*.gem")].should.be.empty
74
+ end
75
+ end
76
+
77
+ it "--allow-dirty releases anyway" do
78
+ with_gem do |dir|
79
+ system("git init -q #{dir}")
80
+
81
+ # --dry-run stops before the push, which is as far as a spec should go.
82
+ invoke(["release", "--dry-run", "--allow-dirty"], dir).first.should == 0
83
+ end
84
+ end
85
+
86
+ it "is satisfied once the tree is committed" do
87
+ with_gem do |dir|
88
+ system("git init -q #{dir}")
89
+ system("git -C #{dir} add -A")
90
+ system("git -C #{dir} -c user.name=t -c user.email=t@t commit -q -m x")
91
+
92
+ invoke(["release", "--dry-run"], dir).first.should == 0
93
+ end
94
+ end
95
+
59
96
  it "refuses without a changelog entry, before building anything" do
60
97
  with_gem(changelog: :none) do |dir|
61
98
  status, _out, err = invoke(["release"], dir)
@@ -47,14 +47,39 @@ module GemKit
47
47
  version ? changelog.release_problems(version) : changelog.problems
48
48
  end
49
49
 
50
+ # What is in the working tree but not in git. A gem built from an
51
+ # uncommitted tree is a gem whose source exists nowhere — and `bump` and
52
+ # `changelog --write` leave exactly two such files behind, which is
53
+ # precisely the moment someone reaches for `release`.
54
+ #
55
+ # A directory that is not a git repository is not a problem: this gate
56
+ # has nothing to say about it.
57
+ def working_tree_problems
58
+ return [] unless git?
59
+
60
+ dirty = Dir.chdir(project.root) { `git status --porcelain`.lines.map(&:strip) }
61
+ return [] if dirty.empty?
62
+
63
+ ["#{dirty.size} uncommitted change(s) — the gem would match nothing in git:",
64
+ *dirty.first(10).map { |line| " #{line}" },
65
+ *(dirty.size > 10 ? [" … and #{dirty.size - 10} more"] : []),
66
+ "",
67
+ "The bump and the changelog belong in one commit. Or pass --allow-dirty."]
68
+ end
69
+
70
+ def git?
71
+ Dir.chdir(project.root) { system("git rev-parse --git-dir >/dev/null 2>&1") }
72
+ end
73
+
50
74
  # Everything standing between the project and releasing `version`.
51
- def release_problems(version = project.version)
75
+ def release_problems(version = project.version, allow_dirty: false)
52
76
  problems = []
53
77
 
54
78
  changelog_problems(version).each { |problem| problems << problem }
55
79
  deprecation_problems(version).each do |problem|
56
80
  problems << "deprecation due in #{version}: #{problem}"
57
81
  end
82
+ working_tree_problems.each { |problem| problems << problem } unless allow_dirty
58
83
 
59
84
  problems
60
85
  end
@@ -76,10 +101,10 @@ describe "gem_kit/release/gate" do
76
101
 
77
102
  # A Project stub: the Gate only asks it for a changelog path and a version,
78
103
  # 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
104
+ stub_project = lambda do |changelog_path, version: "1.0.0", root: nil|
105
+ Struct.new(:changelog_path, :version, :root) do
81
106
  def load! = true
82
- end.new(changelog_path, Gem::Version.new(version))
107
+ end.new(changelog_path, Gem::Version.new(version), root || File.dirname(changelog_path))
83
108
  end
84
109
 
85
110
  clean_changelog = <<~MD
@@ -155,6 +180,36 @@ describe "gem_kit/release/gate" do
155
180
  end
156
181
  end
157
182
 
183
+ # The gate that catches what `bump` and `changelog --write` leave behind.
184
+ it "reports an uncommitted working tree, and clears once it is committed" do
185
+ Dir.mktmpdir do |dir|
186
+ File.write(File.join(dir, "CHANGELOG.md"), clean_changelog)
187
+ system("git init -q #{dir}")
188
+
189
+ isolated.call do
190
+ gate = GemKit::Release::Gate.new(stub_project.call(File.join(dir, "CHANGELOG.md"), root: dir))
191
+
192
+ gate.release_problems("1.0.0").first.should.match(/uncommitted change/)
193
+ gate.release_problems("1.0.0", allow_dirty: true).should == []
194
+
195
+ system("git -C #{dir} add -A")
196
+ system("git -C #{dir} -c user.name=t -c user.email=t@t commit -q -m x")
197
+ gate.release_problems("1.0.0").should == []
198
+ end
199
+ end
200
+ end
201
+
202
+ it "says nothing about a directory that is not a git repository" do
203
+ Dir.mktmpdir do |dir|
204
+ File.write(File.join(dir, "CHANGELOG.md"), clean_changelog)
205
+
206
+ isolated.call do
207
+ GemKit::Release::Gate.new(stub_project.call(File.join(dir, "CHANGELOG.md"), root: dir))
208
+ .working_tree_problems.should == []
209
+ end
210
+ end
211
+ end
212
+
158
213
  it "checks changelog format alone when given no version" do
159
214
  Dir.mktmpdir do |dir|
160
215
  path = File.join(dir, "CHANGELOG.md")
@@ -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
@@ -252,6 +267,38 @@ describe "gem_kit/release/project" do
252
267
  end
253
268
  end
254
269
 
270
+ # A gem whose own Gemfile pulls it in has its version written in the lockfile
271
+ # as well as the version file, and both have to move together.
272
+ it "knows whether the lockfile records this gem's own version" do
273
+ with_project.call do |dir|
274
+ GemKit::Release::Project.detect(dir).self_locked?.should.be.false
275
+
276
+ File.write(File.join(dir, "Gemfile.lock"), <<~LOCK)
277
+ PATH
278
+ remote: .
279
+ specs:
280
+ demo (1.2.3)
281
+
282
+ GEM
283
+ remote: https://rubygems.org/
284
+ LOCK
285
+ GemKit::Release::Project.detect(dir).self_locked?.should.be.true
286
+ end
287
+ end
288
+
289
+ it "does not mistake another gem's line in the lockfile for its own" do
290
+ with_project.call do |dir|
291
+ File.write(File.join(dir, "Gemfile.lock"), <<~LOCK)
292
+ GEM
293
+ remote: https://rubygems.org/
294
+ specs:
295
+ other-demo (1.0.0)
296
+ demo-helper (2.0.0)
297
+ LOCK
298
+ GemKit::Release::Project.detect(dir).self_locked?.should.be.false
299
+ end
300
+ end
301
+
255
302
  it "finds an ERB version template when the project generates its version file" do
256
303
  with_project.call do |dir|
257
304
  GemKit::Release::Project.detect(dir).version_template.should.be.nil
@@ -8,8 +8,7 @@ The whole process, in order:
8
8
  <%= step("gem kit changelog#{gem_flag} --write") %># 3. write the entry
9
9
  <%= step("gem kit changelog#{gem_flag} <VERSION>") %># 4. check it
10
10
  <%= step(%(git commit -am "Release ...")) %># 5. commit the bump + changelog
11
- <%= step("gem kit release#{gem_flag}") %># 6. build and push
12
- <%= step("gem kit tag#{gem_flag} --push") %># 7. tag it
11
+ <%= step("gem kit release#{gem_flag}") %># 6. gate, build, push, tag
13
12
  ```
14
13
 
15
14
  Steps 2 and 6 are gates: they refuse to proceed when something is missing.
@@ -119,27 +118,30 @@ unwritten in git is the failure this process exists to prevent.
119
118
  ## 6. Release
120
119
 
121
120
  ```sh
122
- gem kit release<%= gem_flag %> # or: gem kit release<%= gem_flag %> --dry-run, which is the CI check
121
+ gem kit release<%= gem_flag %> # or --dry-run, which is the CI check
123
122
  ```
124
123
 
125
- Two gates, both before `gem build` runs:
124
+ Three gates, all before `gem build` runs:
126
125
 
127
126
  1. **Changelog** — this version needs its own non-empty, correctly formatted
128
127
  section, sitting at the top of the released list.
129
128
  2. **Deprecations** — nothing promised to disappear in this version may still
130
129
  be in the tree.
130
+ 3. **The working tree** — everything committed. A gem built from uncommitted
131
+ files is a gem whose source exists nowhere, and step 2 and step 3 above
132
+ leave exactly two such files behind. `--allow-dirty` overrides it.
131
133
 
132
- Then `gem build` and `gem push`. Requires RubyGems push credentials.
134
+ Then `gem build`, `gem push`, and the tag — `<%= tag_prefix %><%= version %>`,
135
+ created and pushed *after* the gem, so a tag never names a version that failed
136
+ to publish. `--no-tag` skips it.
133
137
 
134
- ## 7. Tag
138
+ Requires RubyGems push credentials.
135
139
 
136
- ```sh
137
- gem kit tag<%= gem_flag %> --push
138
- ```
140
+ The tag matters beyond bookkeeping: it is what the next
141
+ `gem kit changelog<%= gem_flag %> --write` uses to find the commit range, so a
142
+ missing one makes the following release's changelog harder to write.
139
143
 
140
- Creates `<%= tag_prefix %><%= version %>`, refusing if it already exists. The tag is also what
141
- the next `gem kit changelog<%= gem_flag %> --write` uses to find the commit range, so a missing
142
- one makes the following release's changelog harder to write.
144
+ `gem kit tag<%= gem_flag %>` still exists for tagging on its own.
143
145
 
144
146
  ## When something goes wrong
145
147
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GemKit
4
4
  module Release
5
- VERSION = "0.2.2"
5
+ VERSION = "0.3.1"
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.2.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Kidd