gemvault 0.1.3 → 0.1.5

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.
@@ -0,0 +1,45 @@
1
+ require "rubygems/package"
2
+ require "tempfile"
3
+ require "stringio"
4
+ require_relative "vault"
5
+
6
+ module Gemvault
7
+ # Shared vault behavior: turn gem bytes (stored or on disk) into gemspecs and
8
+ # temporary files. Host classes must implement #gem_data(name, version,
9
+ # platform:) and #gem_entries.
10
+ module GemExtraction
11
+ def with_gem_file(name, version, platform: "ruby")
12
+ tmpfile = write_tempfile(gem_data(name, version, platform: platform))
13
+ begin
14
+ yield tmpfile.path
15
+ ensure
16
+ tmpfile.close unless tmpfile.closed?
17
+ tmpfile.unlink
18
+ end
19
+ end
20
+
21
+ def spec_from_blob(name, version, platform = "ruby")
22
+ Gem::Package.new(StringIO.new(gem_data(name, version, platform: platform))).spec
23
+ end
24
+
25
+ def specs
26
+ gem_entries.map { |entry| spec_from_blob(entry.name, entry.version, entry.platform) }
27
+ end
28
+
29
+ private
30
+
31
+ def spec_from_gem_file(gem_path)
32
+ Gem::Package.new(gem_path).spec
33
+ rescue StandardError => e
34
+ raise Vault::InvalidGemError, "Invalid gem file #{gem_path}: #{e.message}"
35
+ end
36
+
37
+ def write_tempfile(data)
38
+ tmpfile = Tempfile.new(["vault_gem", ".gem"])
39
+ tmpfile.binmode
40
+ tmpfile.write(data)
41
+ tmpfile.close
42
+ tmpfile
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,76 @@
1
+ require "json"
2
+ require "openssl"
3
+ require_relative "gem_entry"
4
+
5
+ module Gemvault
6
+ # The manifest.json stored as the first entry of a Tarvault. Records each
7
+ # gem's identity, timestamp, and SHA256 digest so listing and integrity
8
+ # checks never require reading every gem blob.
9
+ class Manifest
10
+ FILENAME = "manifest.json".freeze
11
+ FORMAT_VERSION = 2
12
+ DIGEST = "SHA256".freeze
13
+
14
+ # One gem's metadata inside a manifest.
15
+ Record = Struct.new(:name, :version, :platform, :created_at, :sha256, :encrypted, keyword_init: true) do
16
+ def to_gem_entry
17
+ GemEntry.new(name: name, version: version, platform: platform, created_at: created_at)
18
+ end
19
+
20
+ def filename
21
+ to_gem_entry.filename
22
+ end
23
+ end
24
+
25
+ def self.digest(bytes)
26
+ OpenSSL::Digest.new(DIGEST).hexdigest(bytes)
27
+ end
28
+
29
+ def self.empty(created_at:)
30
+ new(created_at: created_at, records: [])
31
+ end
32
+
33
+ def self.parse(json)
34
+ data = JSON.parse(json)
35
+ records = data.fetch("gems", []).map { |g| Record.new(**g.transform_keys(&:to_sym)) }
36
+ new(
37
+ created_at: data["created_at"],
38
+ records: records,
39
+ format_version: (data["vault_version"] || FORMAT_VERSION).to_i,
40
+ )
41
+ end
42
+
43
+ attr_reader :records, :created_at, :format_version
44
+
45
+ def initialize(created_at:, records:, format_version: FORMAT_VERSION)
46
+ @created_at = created_at
47
+ @records = records
48
+ @format_version = format_version
49
+ end
50
+
51
+ def with(record)
52
+ self.class.new(created_at: created_at, records: records + [record], format_version: format_version)
53
+ end
54
+
55
+ def without(dropped)
56
+ self.class.new(created_at: created_at, records: records - dropped, format_version: format_version)
57
+ end
58
+
59
+ def find(name, version, platform)
60
+ records.find { |r| r.name == name && r.version == version && r.platform == platform }
61
+ end
62
+
63
+ def gem_entries
64
+ records.sort_by { |r| [r.name, r.version] }.map(&:to_gem_entry)
65
+ end
66
+
67
+ def to_json(*_args)
68
+ JSON.pretty_generate(
69
+ "vault_version" => FORMAT_VERSION,
70
+ "format" => "tarvault",
71
+ "created_at" => created_at,
72
+ "gems" => records.map(&:to_h),
73
+ )
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,66 @@
1
+ require "rubygems/package"
2
+ require "tempfile"
3
+ require "fileutils"
4
+ require_relative "manifest"
5
+
6
+ module Gemvault
7
+ # The tar file backing a Tarvault. Reads named entries and rewrites the
8
+ # whole archive atomically: tar has no index and the leading manifest entry
9
+ # changes size on every mutation, so mutation means a full rewrite.
10
+ class TarArchive
11
+ def initialize(path)
12
+ @path = path
13
+ end
14
+
15
+ def read(name)
16
+ bytes = nil
17
+ each_entry { |entry| bytes = entry.read if entry.full_name == name }
18
+ bytes
19
+ end
20
+
21
+ def gem_pairs
22
+ pairs = []
23
+ each_entry do |entry|
24
+ pairs << [entry.full_name, entry.read] unless entry.full_name == Manifest::FILENAME
25
+ end
26
+ pairs
27
+ end
28
+
29
+ def write(pairs)
30
+ tmp = Tempfile.create(["tarvault", ".tar"], File.dirname(@path))
31
+ write_entries(tmp, pairs)
32
+ tmp.flush
33
+ tmp.fsync
34
+ tmp.close
35
+ File.rename(tmp.path, @path)
36
+ rescue StandardError
37
+ cleanup(tmp)
38
+ raise
39
+ end
40
+
41
+ private
42
+
43
+ def each_entry(&)
44
+ File.open(@path, "rb") do |io|
45
+ Gem::Package::TarReader.new(io) { |reader| reader.each_entry(&) }
46
+ end
47
+ end
48
+
49
+ def write_entries(io, pairs)
50
+ Gem::Package::TarWriter.new(io) do |writer|
51
+ pairs.each { |name, bytes| add_entry(writer, name, bytes) }
52
+ end
53
+ end
54
+
55
+ def add_entry(writer, name, bytes)
56
+ writer.add_file_simple(name, 0o644, bytes.bytesize) { |dest| dest.write(bytes) }
57
+ end
58
+
59
+ def cleanup(tmp)
60
+ return unless tmp
61
+
62
+ tmp.close unless tmp.closed?
63
+ FileUtils.rm_f(tmp.path)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,148 @@
1
+ require "time"
2
+ require "json"
3
+ require_relative "vault"
4
+ require_relative "vault_session"
5
+ require_relative "gem_extraction"
6
+ require_relative "manifest"
7
+ require_relative "tar_archive"
8
+ require_relative "gem_reference"
9
+
10
+ module Gemvault
11
+ # A Tarvault: a tarball whose first entry is manifest.json and whose
12
+ # remaining entries are .gem files. Portable, dependency-free storage.
13
+ class Tarvault
14
+ extend VaultSession
15
+ include GemExtraction
16
+
17
+ attr_reader :path
18
+
19
+ def initialize(path, create: false)
20
+ @path = File.expand_path(path)
21
+ @archive = TarArchive.new(@path)
22
+ @closed = false
23
+ create ? create_vault! : load_manifest!
24
+ end
25
+
26
+ def add(gem_path, created_at: nil)
27
+ gem_path = File.expand_path(gem_path)
28
+ raise Vault::NotFoundError, "Gem file not found: #{gem_path}" unless File.file?(gem_path)
29
+
30
+ spec = spec_from_gem_file(gem_path)
31
+ raise_if_duplicate(spec)
32
+ store(spec, File.binread(gem_path), created_at || timestamp)
33
+ end
34
+
35
+ def remove(reference)
36
+ dropped = matching_records(reference)
37
+ return 0 if dropped.empty?
38
+
39
+ @manifest = @manifest.without(dropped)
40
+ rewrite(survivors_excluding(dropped))
41
+ dropped.size
42
+ end
43
+
44
+ def gem_data(name, version, platform: "ruby")
45
+ record = @manifest.find(name, version, platform)
46
+ raise Vault::NotFoundError, "Gem not found: #{name}-#{version} (#{platform})" unless record
47
+
48
+ verify(@archive.read(record.filename), record)
49
+ end
50
+
51
+ def gem_entries
52
+ @manifest.gem_entries
53
+ end
54
+
55
+ def format_version
56
+ @manifest.format_version
57
+ end
58
+
59
+ def size
60
+ @manifest.records.size
61
+ end
62
+
63
+ def close
64
+ @closed = true
65
+ end
66
+
67
+ def closed?
68
+ @closed
69
+ end
70
+
71
+ private
72
+
73
+ def create_vault!
74
+ raise Vault::Error, "Vault already exists: #{@path}" if File.exist?(@path)
75
+
76
+ @manifest = Manifest.empty(created_at: timestamp)
77
+ rewrite([])
78
+ end
79
+
80
+ def load_manifest!
81
+ raise Vault::NotFoundError, "Vault not found: #{@path}" unless File.exist?(@path)
82
+
83
+ @manifest = Manifest.parse(read_manifest_json)
84
+ Vault.assert_readable!(@manifest.format_version, @path)
85
+ rescue JSON::ParserError, Gem::Package::TarInvalidError, ArgumentError, Errno::EINVAL
86
+ raise Vault::Error, "Not a valid Tarvault: #{@path}"
87
+ end
88
+
89
+ def read_manifest_json
90
+ json = @archive.read(Manifest::FILENAME)
91
+ raise Vault::Error, "Not a valid Tarvault (missing manifest): #{@path}" unless json
92
+
93
+ json
94
+ end
95
+
96
+ def store(spec, bytes, created_at)
97
+ record = build_record(spec, bytes, created_at)
98
+ @manifest = @manifest.with(record)
99
+ rewrite(@archive.gem_pairs + [[record.filename, bytes]])
100
+ end
101
+
102
+ def rewrite(gem_pairs)
103
+ @archive.write([[Manifest::FILENAME, @manifest.to_json]] + gem_pairs)
104
+ end
105
+
106
+ def survivors_excluding(dropped)
107
+ names = dropped.map(&:filename)
108
+ @archive.gem_pairs.reject { |pair| names.include?(pair.first) }
109
+ end
110
+
111
+ def build_record(spec, bytes, created_at)
112
+ Manifest::Record.new(
113
+ name: spec.name,
114
+ version: spec.version.to_s,
115
+ platform: spec.platform.to_s,
116
+ created_at: created_at,
117
+ sha256: Manifest.digest(bytes),
118
+ encrypted: false,
119
+ )
120
+ end
121
+
122
+ def matching_records(reference)
123
+ case reference
124
+ in GemReference::AnyVersion[name:]
125
+ @manifest.records.select { |r| r.name == name }
126
+ in GemReference::SpecificVersion[name:, version:]
127
+ @manifest.records.select { |r| r.name == name && r.version == version.to_s }
128
+ end
129
+ end
130
+
131
+ def raise_if_duplicate(spec)
132
+ return unless @manifest.find(spec.name, spec.version.to_s, spec.platform.to_s)
133
+
134
+ raise Vault::DuplicateGemError,
135
+ "Gem already in vault: #{spec.name}-#{spec.version} (#{spec.platform})"
136
+ end
137
+
138
+ def verify(bytes, record)
139
+ return bytes if Manifest.digest(bytes) == record.sha256
140
+
141
+ raise Vault::Error, "Integrity check failed for #{record.filename}"
142
+ end
143
+
144
+ def timestamp
145
+ Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
146
+ end
147
+ end
148
+ end
@@ -1,193 +1,87 @@
1
- require "sqlite3"
2
- require "rubygems/package"
3
- require "fileutils"
4
- require "tempfile"
5
- require_relative "gem_entry"
6
- require_relative "gem_reference"
1
+ require "forwardable"
2
+ require_relative "vault_session"
7
3
 
8
4
  module Gemvault
9
- # SQLite-backed archive of .gem blobs; supports add/remove/list/extract.
5
+ # The public vault interface. Delegates storage to a backend chosen by file
6
+ # format: a Dbvault (SQLite) for existing SQLite files, a Tarvault (tarball)
7
+ # otherwise. New vaults are Tarvaults. Only the selected backend is loaded,
8
+ # so the tar path never requires sqlite3.
10
9
  class Vault
10
+ extend VaultSession
11
+ extend Forwardable
12
+
11
13
  class Error < StandardError; end
12
14
  class NotFoundError < Error; end
13
15
  class DuplicateGemError < Error; end
14
16
  class InvalidGemError < Error; end
17
+ class UnsupportedVersionError < Error; end
18
+ class ReadOnlyError < Error; end
15
19
 
16
- SCHEMA_VERSION = "1".freeze
17
20
  SQLITE_MAGIC = "SQLite format 3#{0.chr}".freeze
21
+ TAR_MAGIC = "ustar".freeze
22
+ TAR_MAGIC_OFFSET = 257
18
23
 
19
- attr_reader :path
20
-
21
- def self.open(path, **opts, &block)
22
- raise ArgumentError, "#{name}.open requires a block" unless block
23
-
24
- vault = new(path, **opts)
25
- begin
26
- yield vault
27
- ensure
28
- vault.close
29
- end
30
- end
31
-
32
- def initialize(path, create: false)
33
- @path = File.expand_path(path)
34
- create ? create_vault! : open_vault!
35
- end
36
-
37
- def add(gem_path)
38
- gem_path = File.expand_path(gem_path)
39
- raise NotFoundError, "Gem file not found: #{gem_path}" unless File.file?(gem_path)
40
-
41
- spec = load_gem_spec(gem_path)
42
- raise_if_duplicate(spec)
43
- insert_gem(gem_path, spec)
44
- end
45
-
46
- def remove(reference)
47
- case reference
48
- in GemReference::AnyVersion[name:]
49
- @db.execute("DELETE FROM gems WHERE name = ?", [name])
50
- in GemReference::SpecificVersion[name:, version:]
51
- @db.execute(
52
- "DELETE FROM gems WHERE name = ? AND version = ?",
53
- [name, version.to_s],
54
- )
55
- end
56
- @db.changes
57
- end
58
-
59
- def gem_data(name, version, platform: "ruby")
60
- row = @db.execute(
61
- "SELECT data FROM gems WHERE name = ? AND version = ? AND platform = ?",
62
- [name, version, platform],
63
- ).first
64
-
65
- raise NotFoundError, "Gem not found: #{name}-#{version} (#{platform})" unless row
66
-
67
- row["data"]
68
- end
24
+ CURRENT_FORMAT = 2
25
+ MIN_READABLE_FORMAT = 1
69
26
 
70
- def specs
71
- gem_entries.map { |entry| spec_from_blob(entry.name, entry.version, entry.platform) }
72
- end
27
+ def_delegators :@backend,
28
+ :add, :remove, :gem_data, :gem_entries, :specs,
29
+ :spec_from_blob, :with_gem_file, :size, :close, :closed?,
30
+ :path, :format_version
73
31
 
74
- def gem_entries
75
- @db.execute(
76
- "SELECT name, version, platform, created_at FROM gems ORDER BY name, version",
77
- ).map { |row| GemEntry.new(**row.transform_keys(&:to_sym)) }
78
- end
32
+ def self.assert_readable!(version, path)
33
+ return if version.between?(MIN_READABLE_FORMAT, CURRENT_FORMAT)
79
34
 
80
- def size
81
- @db.execute("SELECT COUNT(*) AS count FROM gems").first["count"]
35
+ raise UnsupportedVersionError,
36
+ "Vault #{path} is format #{version}; this gemvault reads up to #{CURRENT_FORMAT}. Upgrade gemvault."
82
37
  end
83
38
 
84
- def close
85
- @db.close if @db && !@db.closed?
86
- end
39
+ def self.backend_for(path, create:)
40
+ return build_tarvault(path, create: true) if create
41
+ raise NotFoundError, "Vault not found: #{path}" unless File.exist?(path)
87
42
 
88
- def with_gem_file(name, version, platform: "ruby")
89
- data = gem_data(name, version, platform: platform)
90
- tmpfile = write_tempfile(data)
91
- begin
92
- yield tmpfile.path
93
- ensure
94
- tmpfile.close unless tmpfile.closed?
95
- tmpfile.unlink
43
+ case container_kind(path)
44
+ when :sqlite then build_dbvault(path)
45
+ when :tar then build_tarvault(path, create: false)
46
+ else
47
+ raise Error, "Unrecognized vault format: #{path} (not a Dbvault or Tarvault; it may require a newer gemvault)"
96
48
  end
97
49
  end
98
50
 
99
- def spec_from_blob(name, version, platform = "ruby")
100
- with_gem_file(name, version, platform: platform) do |path|
101
- Gem::Package.new(path).spec
102
- end
103
- end
104
-
105
- private
51
+ def self.container_kind(path)
52
+ return :sqlite if sqlite?(path)
53
+ return :tar if tar?(path)
106
54
 
107
- def create_vault!
108
- raise Error, "Vault already exists: #{@path}" if File.exist?(@path)
109
-
110
- @db = new_database
111
- create_schema
55
+ :unknown
112
56
  end
113
57
 
114
- def open_vault!
115
- raise NotFoundError, "Vault not found: #{@path}" unless File.exist?(@path)
116
-
117
- validate_sqlite!
118
- @db = new_database
58
+ def self.sqlite?(path)
59
+ File.exist?(path) && File.binread(path, SQLITE_MAGIC.bytesize) == SQLITE_MAGIC
119
60
  end
120
61
 
121
- def new_database
122
- db = SQLite3::Database.new(@path)
123
- db.results_as_hash = true
124
- db
125
- end
126
-
127
- def load_gem_spec(gem_path)
128
- Gem::Package.new(gem_path).spec
129
- rescue StandardError => e
130
- raise InvalidGemError, "Invalid gem file #{gem_path}: #{e.message}"
131
- end
132
-
133
- def raise_if_duplicate(spec)
134
- existing = @db.execute(
135
- "SELECT 1 FROM gems WHERE name = ? AND version = ? AND platform = ?",
136
- [spec.name, spec.version.to_s, spec.platform.to_s],
137
- )
138
- return if existing.empty?
62
+ def self.tar?(path)
63
+ return false unless File.exist?(path)
139
64
 
140
- raise DuplicateGemError,
141
- "Gem already in vault: #{spec.name}-#{spec.version} (#{spec.platform})"
65
+ header = File.binread(path, TAR_MAGIC_OFFSET + TAR_MAGIC.bytesize)
66
+ header.to_s[TAR_MAGIC_OFFSET, TAR_MAGIC.bytesize] == TAR_MAGIC
142
67
  end
143
68
 
144
- def insert_gem(gem_path, spec)
145
- data = File.binread(gem_path)
146
- @db.execute(
147
- "INSERT INTO gems (name, version, platform, data) VALUES (?, ?, ?, ?)",
148
- [spec.name, spec.version.to_s, spec.platform.to_s, SQLite3::Blob.new(data)],
149
- )
69
+ def self.build_dbvault(path)
70
+ require_relative "dbvault"
71
+ Dbvault.new(path)
72
+ rescue LoadError => e
73
+ raise Error,
74
+ "#{path} is a legacy SQLite vault; it needs the sqlite3 gem (#{e.message}). " \
75
+ "Install sqlite3, or upgrade the vault with a gemvault that includes it."
150
76
  end
151
77
 
152
- def write_tempfile(data)
153
- tmpfile = Tempfile.new(["vault_gem", ".gem"])
154
- tmpfile.binmode
155
- tmpfile.write(data)
156
- tmpfile.close
157
- tmpfile
78
+ def self.build_tarvault(path, create:)
79
+ require_relative "tarvault"
80
+ Tarvault.new(path, create: create)
158
81
  end
159
82
 
160
- def create_schema
161
- @db.execute_batch(<<~SQL)
162
- CREATE TABLE metadata (
163
- key TEXT PRIMARY KEY,
164
- value TEXT NOT NULL
165
- );
166
-
167
- CREATE TABLE gems (
168
- name TEXT NOT NULL,
169
- version TEXT NOT NULL,
170
- platform TEXT NOT NULL DEFAULT 'ruby',
171
- data BLOB NOT NULL,
172
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
173
- PRIMARY KEY (name, version, platform)
174
- );
175
- SQL
176
-
177
- @db.execute(
178
- "INSERT INTO metadata (key, value) VALUES (?, ?)",
179
- ["vault_version", SCHEMA_VERSION],
180
- )
181
- @db.execute(
182
- "INSERT INTO metadata (key, value) VALUES (?, ?)",
183
- ["created_at", Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")],
184
- )
185
- end
186
-
187
- def validate_sqlite!
188
- return if File.binread(@path, SQLITE_MAGIC.bytesize) == SQLITE_MAGIC
189
-
190
- raise Error, "Not a valid vault file (not SQLite): #{@path}"
83
+ def initialize(path, create: false)
84
+ @backend = self.class.backend_for(File.expand_path(path), create: create)
191
85
  end
192
86
  end
193
87
  end
@@ -0,0 +1,16 @@
1
+ module Gemvault
2
+ # Class-method mixin giving vault types a block-scoped `open` that news up
3
+ # an instance, yields it, and guarantees `close`.
4
+ module VaultSession
5
+ def open(path, **opts)
6
+ raise ArgumentError, "#{name}.open requires a block" unless block_given?
7
+
8
+ vault = new(path, **opts)
9
+ begin
10
+ yield vault
11
+ ensure
12
+ vault.close
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,87 @@
1
+ require "fileutils"
2
+ require_relative "vault"
3
+ require_relative "deprecation"
4
+
5
+ module Gemvault
6
+ # Migrates a vault to the current storage format by reading it through its
7
+ # existing backend and rewriting it through the current-format writer, then
8
+ # atomically swapping the file into place. Preserves each gem's created_at.
9
+ class VaultUpgrade
10
+ # The from/to/gem-count summary of an upgrade; also detects an
11
+ # already-current (no-op) vault.
12
+ Plan = Struct.new(:from_version, :to_version, :gem_count, keyword_init: true) do
13
+ def no_op?
14
+ from_version == to_version
15
+ end
16
+
17
+ def to_s
18
+ "format #{from_version} -> #{to_version}"
19
+ end
20
+ end
21
+
22
+ def initialize(path, backup: true)
23
+ @path = File.expand_path(path)
24
+ @backup = backup
25
+ end
26
+
27
+ def plan
28
+ Deprecation.silence do
29
+ Vault.open(@path) do |vault|
30
+ Plan.new(from_version: vault.format_version, to_version: Vault::CURRENT_FORMAT, gem_count: vault.size)
31
+ end
32
+ end
33
+ end
34
+
35
+ def call
36
+ summary = plan
37
+ return summary if summary.no_op?
38
+
39
+ backup! if @backup
40
+ rebuild(summary.gem_count)
41
+ summary
42
+ end
43
+
44
+ private
45
+
46
+ def backup!
47
+ backup_path = "#{@path}.bak"
48
+ if File.exist?(backup_path)
49
+ raise Vault::Error, "Backup already exists: #{backup_path} (remove it or pass --no-backup)"
50
+ end
51
+
52
+ FileUtils.cp(@path, backup_path)
53
+ end
54
+
55
+ def rebuild(expected_count)
56
+ tmp = "#{@path}.upgrading"
57
+ FileUtils.rm_f(tmp)
58
+ copy_current_format(tmp)
59
+ verify_count!(tmp, expected_count)
60
+ File.rename(tmp, @path)
61
+ end
62
+
63
+ def copy_current_format(tmp)
64
+ Deprecation.silence do
65
+ Vault.open(@path) do |old|
66
+ target = Vault.new(tmp, create: true)
67
+ old.gem_entries.each { |entry| copy_gem(old, target, entry) }
68
+ target.close
69
+ end
70
+ end
71
+ end
72
+
73
+ def copy_gem(old, target, entry)
74
+ old.with_gem_file(entry.name, entry.version, platform: entry.platform) do |gem_path|
75
+ target.add(gem_path, created_at: entry.created_at)
76
+ end
77
+ end
78
+
79
+ def verify_count!(tmp, expected_count)
80
+ actual = Vault.open(tmp, &:size)
81
+ return if actual == expected_count
82
+
83
+ FileUtils.rm_f(tmp)
84
+ raise Vault::Error, "Upgrade verification failed: expected #{expected_count} gems, got #{actual}"
85
+ end
86
+ end
87
+ end
@@ -1,3 +1,3 @@
1
1
  module Gemvault
2
- VERSION = "0.1.3".freeze
2
+ VERSION = "0.1.5".freeze
3
3
  end