secupdater 0.2.0alpha4 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72d1d0d9d634e7b741faf88079cd487319fdd9b9d623b6f21871c57144492849
4
- data.tar.gz: c08e421af618d820ac2c361844bab4d8d6a96bff6c676f418bce2d979479a09f
3
+ metadata.gz: f3e2a3d2fd687a912cd17a2dab8216d5c7187693c5239ccfdeac56ce87a825ac
4
+ data.tar.gz: 6db3dd9e3f87b76f96e75b3849c9d447ac24a1f0ea31972d24a37cdc20437ee6
5
5
  SHA512:
6
- metadata.gz: b63b7f2d374a1262822179eff166c111f902303defaf335b6d49047f2427d39544c2a02b506b77dc534f990b95971e9391e48cde0bd04a464dab0a7e7a535a7d
7
- data.tar.gz: ad07d210bd3c346f361440f33bdea10c1b2b925df568550ba46c0946dc1ee80b0827ef7b1850139cec37ef36035275397763903cd2ec6c1c808b38bbeafd3d57
6
+ metadata.gz: 66aabee38119a94edf9f609ba05d6d8c59581a7994f3f8c3a089f836c8460af124a5a34b08f44418dd9588439b590204864970fad3faf492e05a885f605ae329
7
+ data.tar.gz: 3f8607eca5779819b70d6346365dfa70ec158af70bc9f5c8a3d6c2c3ea9bef3a53839708c9eee388ff942369cc5303c03af958db07013dfb9d85ec04d162155a
@@ -35,7 +35,7 @@ module Secupdater
35
35
  end
36
36
 
37
37
  def run_update(names, level)
38
- output, status = Open3.capture2e(bundle_env, "bundle", "lock", "--update", *Array(names), "--conservative", "--#{level}", "--strict")
38
+ output, status = Open3.capture2e(bundle_env, "bundle", *bundler_version_arg, "lock", "--update", *Array(names), "--conservative", "--#{level}", "--strict")
39
39
 
40
40
  if status.exitstatus == FROZEN_EXIT_CODE
41
41
  raise Secupdater::Error, "bundle lock can't change Gemfile.lock (is BUNDLE_DEPLOYMENT set?)\n#{output}"
@@ -79,6 +79,24 @@ module Secupdater
79
79
 
80
80
  private
81
81
 
82
+ def bundler_version_arg
83
+ version = Bundler::LockfileParser.new(Bundler.read_file("Gemfile.lock")).bundler_version
84
+ return [] unless version
85
+
86
+ install_bundler(version)
87
+ ["_#{version}_"]
88
+ end
89
+
90
+ def install_bundler(version)
91
+ @installed_bundlers ||= Set.new
92
+ return if @installed_bundlers.include?(version)
93
+
94
+ output, status = Open3.capture2e(bundle_env, "gem", "install", "bundler", "--version", version.to_s, "--conservative", "--no-document")
95
+ raise Secupdater::Error, "Couldn't install Bundler #{version}, which Gemfile.lock was bundled with\n#{output}" unless status.success?
96
+
97
+ @installed_bundlers << version
98
+ end
99
+
82
100
  # When secupdater itself runs under `bundle exec` (e.g. an image whose
83
101
  # entrypoint is `bundle exec`), undo what that set up so bundle lock works on
84
102
  # the Gemfile and Gemfile.lock here. PATH is left alone, as callers may have
@@ -11,6 +11,7 @@ module Secupdater
11
11
  @project_id = project_id
12
12
  @target_branch = target_branch
13
13
  @start_sha = start_sha
14
+ @current_merge_requests = {}
14
15
  end
15
16
 
16
17
  # Added to every MR secupdater opens. Closing one declines that version of
@@ -36,13 +37,39 @@ module Secupdater
36
37
  commit(http)
37
38
  end
38
39
 
39
- return [:up_to_date, nil] if open_merge_request && up_to_date
40
+ if open_merge_request
41
+ remember(open_merge_request)
42
+ return [:up_to_date, nil] if up_to_date
43
+ else
44
+ remember(JSON.parse(create_merge_request(http).body))
45
+ end
40
46
 
41
- create_merge_request(http) unless open_merge_request
42
47
  [:published, nil]
43
48
  end
44
49
  end
45
50
 
51
+ # Closes open secupdater MRs for a dependency that this run opened or kept a
52
+ # different MR for, or that no longer has any advisories. MRs for
53
+ # dependencies this run couldn't update are left alone.
54
+ def finish(vulnerable_names)
55
+ Net::HTTP.start("gitlab.com", 443, use_ssl: true) do |http|
56
+ find_open_secupdater_merge_requests(http).filter_map do |merge_request|
57
+ name = merge_request["source_branch"][REFERENCE_PATTERN, :name]
58
+ current = @current_merge_requests[name]
59
+
60
+ reason = if current && current != merge_request["iid"]
61
+ "Superseded by !#{current}."
62
+ elsif name && !vulnerable_names.include?(name)
63
+ "#{name} no longer has any security advisories on #{target_branch}, so this is no longer needed."
64
+ end
65
+ next unless reason
66
+
67
+ close_merge_request(http, merge_request["iid"], reason)
68
+ "closed !#{merge_request["iid"]} (#{merge_request["source_branch"]}): #{reason}"
69
+ end
70
+ end
71
+ end
72
+
46
73
  private
47
74
 
48
75
  def commit(http, force: false)
@@ -57,6 +84,20 @@ module Secupdater
57
84
  request(http, Net::HTTP::Post, "repository/commits", data)
58
85
  end
59
86
 
87
+ def remember(merge_request)
88
+ @current_merge_requests[bump.dependency.name] = merge_request["iid"]
89
+ end
90
+
91
+ def find_open_secupdater_merge_requests(http)
92
+ query = URI.encode_www_form(state: "opened", labels: LABEL, target_branch: target_branch, per_page: 100)
93
+ JSON.parse(request(http, Net::HTTP::Get, "merge_requests?#{query}").body)
94
+ end
95
+
96
+ def close_merge_request(http, iid, reason)
97
+ request(http, Net::HTTP::Post, "merge_requests/#{iid}/notes", { body: reason })
98
+ request(http, Net::HTTP::Put, "merge_requests/#{iid}", { state_event: "close" })
99
+ end
100
+
60
101
  def create_merge_request(http)
61
102
  data = {
62
103
  source_branch: reference,
@@ -17,6 +17,13 @@ module Secupdater
17
17
  run
18
18
  end
19
19
 
20
+ # Called once every dependency has been through #call, with the names of all
21
+ # the dependencies that have advisories. Returns messages about anything it
22
+ # tidied up.
23
+ def finish(vulnerable_names) = []
24
+
25
+ REFERENCE_PATTERN = %r{\Asecurity/(?<name>.+)-(?<version>[^-]+)\z}
26
+
20
27
  def reference = "security/#{bump.dependency.name}-#{bump.to}"
21
28
 
22
29
  def title = "Upgrade #{ecosystem.ecosystem_name} dependency #{bump.dependency.name} to version #{bump.to}"
@@ -16,9 +16,13 @@ module Secupdater
16
16
  missing = deps_needing_checking.keys - original.keys
17
17
  raise Secupdater::Error, "Advisories for dependencies not in the lockfile: #{missing.join(', ')}" if missing.any?
18
18
 
19
- deps_needing_checking.to_h do |name, dep_advisories|
19
+ results = deps_needing_checking.to_h do |name, dep_advisories|
20
20
  [name, update_dependency(original[name], dep_advisories)]
21
21
  end
22
+
23
+ publisher&.finish(deps_needing_checking.keys)&.each { logger.info it }
24
+
25
+ results
22
26
  end
23
27
 
24
28
  def update_dependency(dep, advisories)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Secupdater
4
- VERSION = "0.2.0alpha4"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -54,6 +54,8 @@ RSpec.describe "secupdater end to end" do
54
54
  FileUtils.mkdir("bin")
55
55
  File.write("bin/bundle", "#!/bin/sh\ncp Gemfile.lock.after Gemfile.lock\n")
56
56
  File.chmod(0o755, "bin/bundle")
57
+ File.write("bin/gem", "#!/bin/sh\n")
58
+ File.chmod(0o755, "bin/gem")
57
59
 
58
60
  system("git init -q && git add Gemfile.lock && git -c user.name=test -c user.email=test@example.com commit -qm init", exception: true)
59
61
 
@@ -70,7 +72,14 @@ RSpec.describe "secupdater end to end" do
70
72
  let(:env) { { "SECUPDATER_GITLAB_TOKEN" => "token", "CI_PROJECT_ID" => "123", "CI_DEFAULT_BRANCH" => "main" } }
71
73
 
72
74
  let!(:find_mr_stub) do
73
- stub_request(:get, %r{\Ahttps://gitlab.com/api/v4/projects/123/merge_requests\?})
75
+ stub_request(:get, "https://gitlab.com/api/v4/projects/123/merge_requests")
76
+ .with(query: hash_including(source_branch: "security/css_parser-1.1.0"))
77
+ .to_return(status: 200, body: "[]")
78
+ end
79
+
80
+ let!(:find_open_secupdater_mrs_stub) do
81
+ stub_request(:get, "https://gitlab.com/api/v4/projects/123/merge_requests")
82
+ .with(query: hash_including(state: "opened", labels: "secupdater"))
74
83
  .to_return(status: 200, body: "[]")
75
84
  end
76
85
 
@@ -99,6 +108,7 @@ RSpec.describe "secupdater end to end" do
99
108
  expect { run_cli }.not_to raise_error
100
109
 
101
110
  expect(find_mr_stub).to have_been_requested.once
111
+ expect(find_open_secupdater_mrs_stub).to have_been_requested.once
102
112
  expect(create_commit_stub).to have_been_requested.once
103
113
  expect(create_mr_stub).to have_been_requested.once
104
114
  expect(File.read("Gemfile.lock")).to eq(lockfile("1.0.0"))
@@ -145,11 +145,65 @@ RSpec.describe Secupdater::Ecosystem::Rubygems do
145
145
  }
146
146
  end
147
147
 
148
- before { allow(Bundler).to receive(:original_env).and_return(original_env) }
148
+ let(:install_status) { instance_double(Process::Status, success?: true, exitstatus: 0) }
149
+
150
+ before do
151
+ allow(Bundler).to receive(:original_env).and_return(original_env)
152
+ allow(Open3).to receive(:capture2e).with(anything, "gem", any_args).and_return(["", install_status])
153
+ end
154
+
155
+ context "lockfile bundled with a Bundler other than the one running" do
156
+ before { allow(Open3).to receive(:capture2e).with(anything, "bundle", any_args).and_return(["", status]) }
157
+
158
+ it "installs that Bundler if it's missing, then runs bundle lock with it" do
159
+ rubygems.try_update("css_parser", level: :patch)
160
+
161
+ expect(Open3).to have_received(:capture2e).with(bundle_env, "gem", "install", "bundler", "--version", "2.5.0", "--conservative", "--no-document").ordered
162
+ expect(Open3).to have_received(:capture2e).with(bundle_env, "bundle", "_2.5.0_", "lock", any_args).ordered
163
+ end
164
+
165
+ it "only installs it once per run" do
166
+ rubygems.try_update("css_parser", level: :patch)
167
+ rubygems.try_update("css_parser", level: :minor)
168
+
169
+ expect(Open3).to have_received(:capture2e).with(anything, "gem", any_args).once
170
+ end
171
+ end
172
+
173
+ context "lockfile without BUNDLED WITH" do
174
+ before do
175
+ File.write("Gemfile.lock", lockfile("1.0.0").sub(/\nBUNDLED WITH\n.*\n\z/, ""))
176
+ allow(Open3).to receive(:capture2e).with(anything, "bundle", any_args).and_return(["", status])
177
+ end
178
+
179
+ it "runs bundle lock with whichever Bundler RubyGems picks, without installing one" do
180
+ rubygems.try_update("css_parser", level: :patch)
181
+
182
+ expect(Open3).not_to have_received(:capture2e).with(anything, "gem", any_args)
183
+ expect(Open3).to have_received(:capture2e).with(bundle_env, "bundle", "lock", "--update", "css_parser", "--conservative", "--patch", "--strict")
184
+ end
185
+ end
186
+
187
+ context "locked Bundler can't be installed" do
188
+ let(:install_status) { instance_double(Process::Status, success?: false, exitstatus: 2) }
189
+
190
+ before do
191
+ allow(Open3).to receive(:capture2e).with(anything, "gem", any_args).and_return(["Could not find a valid gem 'bundler' (= 2.5.0)", install_status])
192
+ allow(Open3).to receive(:capture2e).with(anything, "bundle", any_args)
193
+ end
194
+
195
+ it "raises Secupdater::Error rather than UpdateCommandFailed, so the run stops, and doesn't run bundle lock" do
196
+ expect { rubygems.try_update("css_parser", level: :patch) }.to raise_error(Secupdater::Error) do
197
+ expect(it).not_to be_a(Secupdater::UpdateCommandFailed)
198
+ expect(it.message).to include("Bundler 2.5.0", "Could not find a valid gem")
199
+ end
200
+ expect(Open3).not_to have_received(:capture2e).with(anything, "bundle", any_args)
201
+ end
202
+ end
149
203
 
150
204
  context "command succeeds" do
151
205
  before do
152
- allow(Open3).to receive(:capture2e) do
206
+ allow(Open3).to receive(:capture2e).with(anything, "bundle", any_args) do
153
207
  File.write("Gemfile.lock", lockfile("1.1.0"))
154
208
  ["Bundle updated!", status]
155
209
  end
@@ -158,7 +212,7 @@ RSpec.describe Secupdater::Ecosystem::Rubygems do
158
212
  it "runs a strict, conservative, unfrozen bundle lock --update at the given level" do
159
213
  rubygems.try_update("css_parser", level: :minor)
160
214
 
161
- expect(Open3).to have_received(:capture2e).with(bundle_env, "bundle", "lock", "--update", "css_parser", "--conservative", "--minor", "--strict")
215
+ expect(Open3).to have_received(:capture2e).with(bundle_env, "bundle", "_2.5.0_", "lock", "--update", "css_parser", "--conservative", "--minor", "--strict")
162
216
  end
163
217
 
164
218
  it "returns the lockfile change with its new and original content" do
@@ -169,7 +223,7 @@ RSpec.describe Secupdater::Ecosystem::Rubygems do
169
223
  end
170
224
 
171
225
  context "command succeeds without changing anything" do
172
- before { allow(Open3).to receive(:capture2e).and_return(["Bundle updated!", status]) }
226
+ before { allow(Open3).to receive(:capture2e).with(anything, "bundle", any_args).and_return(["Bundle updated!", status]) }
173
227
 
174
228
  it "returns no file changes" do
175
229
  expect(rubygems.try_update("css_parser", level: :patch)).to eq([])
@@ -177,12 +231,12 @@ RSpec.describe Secupdater::Ecosystem::Rubygems do
177
231
  end
178
232
 
179
233
  context "gem name containing shell metacharacters" do
180
- before { allow(Open3).to receive(:capture2e).and_return(["", status]) }
234
+ before { allow(Open3).to receive(:capture2e).with(anything, "bundle", any_args).and_return(["", status]) }
181
235
 
182
236
  it "passes it as a single argument without going through a shell" do
183
237
  rubygems.try_update("css_parser; touch pwned", level: :patch)
184
238
 
185
- expect(Open3).to have_received(:capture2e).with(bundle_env, "bundle", "lock", "--update", "css_parser; touch pwned", "--conservative", "--patch", "--strict")
239
+ expect(Open3).to have_received(:capture2e).with(bundle_env, "bundle", "_2.5.0_", "lock", "--update", "css_parser; touch pwned", "--conservative", "--patch", "--strict")
186
240
  end
187
241
  end
188
242
 
@@ -196,14 +250,14 @@ RSpec.describe Secupdater::Ecosystem::Rubygems do
196
250
  }
197
251
  end
198
252
 
199
- before { allow(Open3).to receive(:capture2e).and_return(["", status]) }
253
+ before { allow(Open3).to receive(:capture2e).with(anything, "bundle", any_args).and_return(["", status]) }
200
254
 
201
255
  it "runs bundle lock with Bundler's settings from before bundle exec, on the Gemfile in the working directory" do
202
256
  rubygems.try_update("css_parser", level: :patch)
203
257
 
204
258
  expect(Open3).to have_received(:capture2e).with(
205
259
  bundle_env.merge("GEM_HOME" => "/usr/local/bundle", "RUBYOPT" => "-W0"),
206
- "bundle", "lock", "--update", "css_parser", "--conservative", "--patch", "--strict"
260
+ "bundle", "_2.5.0_", "lock", "--update", "css_parser", "--conservative", "--patch", "--strict"
207
261
  )
208
262
  end
209
263
  end
@@ -211,7 +265,7 @@ RSpec.describe Secupdater::Ecosystem::Rubygems do
211
265
  context "command exits 1" do
212
266
  let(:status) { instance_double(Process::Status, success?: false, exitstatus: 1) }
213
267
 
214
- before { allow(Open3).to receive(:capture2e).and_return(["Could not find gem 'css_parser'", status]) }
268
+ before { allow(Open3).to receive(:capture2e).with(anything, "bundle", any_args).and_return(["Could not find gem 'css_parser'", status]) }
215
269
 
216
270
  it "raises a retryable UpdateCommandFailed with the integer exit code and combined output" do
217
271
  expect { rubygems.try_update("css_parser", level: :patch) }.to raise_error(Secupdater::UpdateCommandFailed) do
@@ -233,7 +287,7 @@ RSpec.describe Secupdater::Ecosystem::Rubygems do
233
287
  context "command exits #{exit_code} (#{reason})" do
234
288
  let(:status) { instance_double(Process::Status, success?: false, exitstatus: exit_code) }
235
289
 
236
- before { allow(Open3).to receive(:capture2e).and_return([reason, status]) }
290
+ before { allow(Open3).to receive(:capture2e).with(anything, "bundle", any_args).and_return([reason, status]) }
237
291
 
238
292
  it "raises UnretryableUpdateFailed with the exit code and output" do
239
293
  expect { rubygems.try_update("css_parser", level: :patch) }.to raise_error(Secupdater::UnretryableUpdateFailed) do
@@ -247,7 +301,7 @@ RSpec.describe Secupdater::Ecosystem::Rubygems do
247
301
  context "command exits 16 (frozen lockfile)" do
248
302
  let(:status) { instance_double(Process::Status, success?: false, exitstatus: 16) }
249
303
 
250
- before { allow(Open3).to receive(:capture2e).and_return(["frozen mode is set", status]) }
304
+ before { allow(Open3).to receive(:capture2e).with(anything, "bundle", any_args).and_return(["frozen mode is set", status]) }
251
305
 
252
306
  it "raises Secupdater::Error rather than UpdateCommandFailed, so the run stops" do
253
307
  expect { rubygems.try_update("css_parser", level: :patch) }.to raise_error(Secupdater::Error) do
@@ -86,7 +86,7 @@ RSpec.describe Secupdater::Publisher::Gitlab do
86
86
  title: "Upgrade RubyGems dependency css_parser to version 1.1.0",
87
87
  description: "Upgrade RubyGems dependency css_parser to version 1.1.0 to address the following security advisories:\n\n* cve/2024-0001\n",
88
88
  }, headers: headers)
89
- .to_return(status: 201, body: "{}")
89
+ .to_return(status: 201, body: { iid: 9 }.to_json)
90
90
  end
91
91
 
92
92
  def publish = gitlab.call(ecosystem, bump, advisories, changes)
@@ -243,4 +243,76 @@ RSpec.describe Secupdater::Publisher::Gitlab do
243
243
  end
244
244
  end
245
245
  end
246
+
247
+ describe "#finish(vulnerable_names)" do
248
+ let(:open_secupdater_merge_requests) { [] }
249
+
250
+ let!(:find_open_stub) do
251
+ stub_request(:get, "#{project_api}/merge_requests")
252
+ .with(query: { state: "opened", labels: "secupdater", target_branch: "main", per_page: "100" }, headers: headers)
253
+ .to_return(status: 200, body: open_secupdater_merge_requests.to_json)
254
+ end
255
+
256
+ let!(:note_stub) { stub_request(:post, %r{\A#{project_api}/merge_requests/\d+/notes\z}).to_return(status: 201, body: "{}") }
257
+ let!(:close_stub) do
258
+ stub_request(:put, %r{\A#{project_api}/merge_requests/\d+\z})
259
+ .with(body: { state_event: "close" })
260
+ .to_return(status: 200, body: "{}")
261
+ end
262
+
263
+ context "an older MR for a dependency this run opened a new MR for" do
264
+ let(:open_secupdater_merge_requests) do
265
+ [
266
+ { iid: 5, source_branch: "security/css_parser-1.0.5" },
267
+ { iid: 9, source_branch: "security/css_parser-1.1.0" }
268
+ ]
269
+ end
270
+
271
+ before { publish }
272
+
273
+ it "closes it, saying which MR superseded it" do
274
+ expect(gitlab.finish(["css_parser"])).to eq(["closed !5 (security/css_parser-1.0.5): Superseded by !9."])
275
+
276
+ expect(a_request(:post, "#{project_api}/merge_requests/5/notes").with(body: { body: "Superseded by !9." })).to have_been_made
277
+ expect(a_request(:put, "#{project_api}/merge_requests/5")).to have_been_made
278
+ expect(a_request(:put, //)).to have_been_made.once
279
+ end
280
+ end
281
+
282
+ context "an MR for a dependency that no longer has advisories" do
283
+ let(:open_secupdater_merge_requests) { [{ iid: 4, source_branch: "security/net-ssh-7.3.8" }] }
284
+
285
+ it "closes it, saying it's no longer needed" do
286
+ expect(gitlab.finish(["css_parser"])).to eq(
287
+ ["closed !4 (security/net-ssh-7.3.8): net-ssh no longer has any security advisories on main, so this is no longer needed."]
288
+ )
289
+
290
+ expect(a_request(:put, "#{project_api}/merge_requests/4")).to have_been_made
291
+ expect(a_request(:put, //)).to have_been_made.once
292
+ end
293
+ end
294
+
295
+ context "an MR for a dependency that still has advisories, but that this run didn't publish" do
296
+ let(:open_secupdater_merge_requests) { [{ iid: 4, source_branch: "security/net-ssh-7.3.8" }] }
297
+
298
+ it "leaves it alone" do
299
+ expect(gitlab.finish(["net-ssh"])).to eq([])
300
+
301
+ expect(a_request(:put, //)).not_to have_been_made
302
+ expect(a_request(:post, //)).not_to have_been_made
303
+ end
304
+ end
305
+
306
+ context "the MR this run opened or kept" do
307
+ let(:open_secupdater_merge_requests) { [{ iid: 9, source_branch: "security/css_parser-1.1.0" }] }
308
+
309
+ before { publish }
310
+
311
+ it "leaves it alone" do
312
+ expect(gitlab.finish(["css_parser"])).to eq([])
313
+
314
+ expect(a_request(:put, //)).not_to have_been_made
315
+ end
316
+ end
317
+ end
246
318
  end
@@ -85,6 +85,7 @@ RSpec.describe Secupdater::Update do
85
85
 
86
86
  before do
87
87
  allow(publisher).to receive(:call) { |*args| published << args; [:published, nil] } if publisher
88
+ allow(publisher).to receive(:finish).and_return([]) if publisher
88
89
  end
89
90
 
90
91
  describe "#call(advisories)" do
@@ -274,6 +275,30 @@ RSpec.describe Secupdater::Update do
274
275
  end
275
276
  end
276
277
 
278
+ context "once every gem has been through the publisher" do
279
+ let(:outcomes) { { "rack" => "2.1", "nokogiri" => :unchanged } }
280
+
281
+ before { allow(publisher).to receive(:finish).and_return(["closed !5 (security/rack-2.0): Superseded by !9."]) }
282
+
283
+ it "lets the publisher tidy up, with the names of every gem that has advisories" do
284
+ update.call([advisory("rack", "cve/1"), advisory("nokogiri", "cve/2")])
285
+
286
+ expect(publisher).to have_received(:finish).with(["rack", "nokogiri"])
287
+ end
288
+
289
+ it "logs what it tidied up" do
290
+ update.call([advisory("rack", "cve/1")])
291
+
292
+ expect(log.string).to match(%r{^INFO closed !5 \(security/rack-2\.0\): Superseded by !9\.})
293
+ end
294
+
295
+ it "does so even when there are no advisories, so MRs whose fixes have landed get closed" do
296
+ expect(update.call([])).to eq({})
297
+
298
+ expect(publisher).to have_received(:finish).with([])
299
+ end
300
+ end
301
+
277
302
  context "publishing fails for the first gem" do
278
303
  let(:outcomes) { { "rack" => "2.1", "nokogiri" => "2.0" } }
279
304
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: secupdater
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0alpha4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Anderson