gemfile-audit 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f7075c7b895832615c7a6f71f7e9dcaeb697b6c077a4d1ba6cd6a453c9168f9b
4
+ data.tar.gz: 5bb001386eb6f57b5101d2794a675449d17295bdce54a1c81287008b0cba8075
5
+ SHA512:
6
+ metadata.gz: fd5c6848b704868311ff14a0d6c9db8d625784f2755d7035c35f47dd96c624efaec585d05eda4ad5f16b81e05c2e0259954973ee54030ff3b85398a7b2eea331
7
+ data.tar.gz: 5c1f1be103e1dc761dd7eefd9b0fb01eab94a936582cf97f211fca242a2d1e4fe9f99de1c4dd3eb951519dae17672d29a991e371ad87f0b63550a5009e314381
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gemfile.io, operated by ExtractMethod
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # gemfile-audit
2
+
3
+ ## What it does
4
+
5
+ gemfile-audit measures your Rails test run while it happens: how much of your code the tests cover and which deprecation warnings they raise. It sends those measurements to gemfile.io, which returns a private preview of an upgrade audit for your app.
6
+
7
+ ## What it sends
8
+
9
+ - file paths with their coverage percentages and line counts;
10
+ - deprecation messages, with file and line number;
11
+ - gem, Ruby, Bundler, Node, Redis and database versions, including your lockfile;
12
+ - test counts: run, passed, failed and skipped;
13
+ - the repository name, branch, commit and kind of run, so the preview can be labelled.
14
+
15
+ ## What it never sends
16
+
17
+ - source code or any line of it;
18
+ - test names;
19
+ - test output;
20
+ - environment variables;
21
+ - secrets;
22
+ - database contents.
23
+
24
+ ## GitHub Actions
25
+
26
+ Add these steps to your workflow. They must come after the step that installs Ruby:
27
+
28
+ ```yaml
29
+ - uses: ruby/setup-ruby@v1
30
+ with:
31
+ bundler-cache: true
32
+ - run: 'gem install gemfile-audit --no-document && gemfile-audit start || echo "gemfile-audit: could not start. Your build is not affected."'
33
+ - run: bin/rails test # your existing test step, unchanged
34
+ - run: gemfile-audit finish || true
35
+ if: always()
36
+ ```
37
+
38
+ The extra steps never fail your build.
39
+
40
+ ## GitLab
41
+
42
+ Put these lines after Ruby is available in the job:
43
+
44
+ ```yaml
45
+ test:
46
+ script:
47
+ - 'gem install gemfile-audit --no-document || echo "gemfile-audit: could not install the gem. Your build is not affected."'
48
+ - if command -v gemfile-audit >/dev/null 2>&1; then gemfile-audit run -- bundle exec rspec; else bundle exec rspec; fi
49
+ ```
50
+
51
+ ## CircleCI
52
+
53
+ Put these steps after Ruby is available in the job:
54
+
55
+ ```yaml
56
+ - run: 'gem install gemfile-audit --no-document || echo "gemfile-audit: could not install the gem. Your build is not affected."'
57
+ - run: if command -v gemfile-audit >/dev/null 2>&1; then gemfile-audit run -- bundle exec rspec; else bundle exec rspec; fi
58
+ ```
59
+
60
+ ## Bitbucket
61
+
62
+ Put these lines after Ruby is available in the step:
63
+
64
+ ```yaml
65
+ script:
66
+ - 'gem install gemfile-audit --no-document || echo "gemfile-audit: could not install the gem. Your build is not affected."'
67
+ - if command -v gemfile-audit >/dev/null 2>&1; then gemfile-audit run -- bundle exec rspec; else bundle exec rspec; fi
68
+ ```
69
+
70
+ ## Any other CI
71
+
72
+ Run these after Ruby is installed:
73
+
74
+ ```sh
75
+ gem install gemfile-audit --no-document || echo "gemfile-audit: could not install the gem. Your build is not affected."
76
+ if command -v gemfile-audit >/dev/null 2>&1; then gemfile-audit run -- bundle exec rspec; else bundle exec rspec; fi
77
+ ```
78
+
79
+ The command exits with the status of your test command, so a failing test still fails the build and a passing one still passes.
80
+
81
+ Merge request and pull request pipelines are not uploaded. A run there prints "not uploaded".
82
+
83
+ ## On your laptop
84
+
85
+ ```sh
86
+ gem install gemfile-audit
87
+ gemfile-audit run -- bundle exec rspec
88
+ ```
89
+
90
+ Run it from your app folder. The preview is labelled as a local run.
91
+
92
+ ## See what would be sent
93
+
94
+ Add `--dry-run` to print the upload instead of sending it:
95
+
96
+ ```sh
97
+ gemfile-audit run --dry-run -- bundle exec rspec
98
+ ```
99
+
100
+ ## Requirements
101
+
102
+ - Ruby 2.3 or newer.
103
+ - Rails 4.2 or newer.
104
+ - Tests that run with one command.
105
+
106
+ ## Privacy
107
+
108
+ There is no account and no access to your GitHub. Unpaid previews are deleted after 7 days. Paid reports are kept for 30 days and sent to you by email.
data/exe/gemfile-audit ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "gemfile_audit"
4
+
5
+ exit GemfileAudit::CLI.new(ARGV).call
@@ -0,0 +1,85 @@
1
+ module GemfileAudit
2
+ # Works out which repository, ref, commit and kind of run this is, from the
3
+ # variables a CI service sets or, on a laptop, from git.
4
+ module CI
5
+ Info = Struct.new(:repository, :ref, :sha, :event, :skip_reason)
6
+
7
+ PULL_REQUEST = "pull requests are not uploaded".freeze
8
+ NO_REMOTE = "no git remote named origin, so the run cannot be named".freeze
9
+ NO_COMMIT = "no git commit found, so the run cannot be named".freeze
10
+
11
+ def self.detect(env = ENV, root:)
12
+ if present?(env["GITHUB_ACTIONS"])
13
+ github(env)
14
+ elsif present?(env["GITLAB_CI"])
15
+ build(env["CI_PROJECT_PATH"], env["CI_COMMIT_REF_NAME"], env["CI_COMMIT_SHA"], "ci",
16
+ %w(merge_request_event external_pull_request_event).include?(env["CI_PIPELINE_SOURCE"]) ? PULL_REQUEST : nil)
17
+ elsif present?(env["CIRCLECI"])
18
+ build("#{env['CIRCLE_PROJECT_USERNAME']}/#{env['CIRCLE_PROJECT_REPONAME']}", env["CIRCLE_BRANCH"],
19
+ env["CIRCLE_SHA1"], "ci", present?(env["CIRCLE_PULL_REQUEST"]) ? PULL_REQUEST : nil)
20
+ elsif present?(env["BITBUCKET_BUILD_NUMBER"])
21
+ build(env["BITBUCKET_REPO_FULL_NAME"], env["BITBUCKET_BRANCH"], env["BITBUCKET_COMMIT"], "ci",
22
+ present?(env["BITBUCKET_PR_ID"]) ? PULL_REQUEST : nil)
23
+ elsif env["CI"].to_s.downcase == "true" || present?(env["JENKINS_URL"]) || present?(env["TF_BUILD"])
24
+ from_git(root, "ci")
25
+ else
26
+ from_git(root, "local")
27
+ end
28
+ end
29
+
30
+ def self.github(env)
31
+ event = env["GITHUB_EVENT_NAME"].to_s
32
+ skip = if event == "pull_request" || event == "pull_request_target"
33
+ PULL_REQUEST
34
+ elsif event != "push" && event != "workflow_dispatch"
35
+ "only pushes and manual runs are uploaded"
36
+ end
37
+ build(env["GITHUB_REPOSITORY"], env["GITHUB_REF"], env["GITHUB_SHA"], event, skip)
38
+ end
39
+
40
+ def self.build(repository, ref, sha, event, skip_reason)
41
+ name = normalise(repository)
42
+ skip_reason ||= "the repository name is not available" if name.nil?
43
+ Info.new(name, ref.to_s, sha.to_s, event, skip_reason)
44
+ end
45
+
46
+ def self.from_git(root, event)
47
+ url = git(root, "remote", "get-url", "origin")
48
+ repository = url && repository_from_url(url)
49
+ sha = git(root, "rev-parse", "HEAD")
50
+ ref = git(root, "rev-parse", "--abbrev-ref", "HEAD")
51
+ skip = if repository.nil?
52
+ NO_REMOTE
53
+ elsif sha.nil?
54
+ NO_COMMIT
55
+ end
56
+ Info.new(repository, ref.to_s, sha.to_s, event, skip)
57
+ end
58
+
59
+ # owner/name from SSH or HTTPS remote URLs, whatever the host.
60
+ def self.repository_from_url(url)
61
+ path = url.strip.sub(%r{\A[a-z][a-z0-9+.-]*://}i, "").sub(/\A[^@\/]*@/, "")
62
+ path = path.sub(%r{\A[^/:]+[/:]}, "")
63
+ normalise(path)
64
+ end
65
+
66
+ # The last two parts of a path, without a .git ending.
67
+ def self.normalise(path)
68
+ parts = path.to_s.sub(/\.git\z/, "").split("/").reject(&:empty?)
69
+ return nil if parts.size < 2
70
+ parts.last(2).join("/")
71
+ end
72
+
73
+ def self.git(root, *args)
74
+ value = Shell.capture("git", *args, chdir: root.to_s)
75
+ value = value.to_s.strip
76
+ value.empty? ? nil : value
77
+ end
78
+
79
+ def self.present?(value)
80
+ !value.nil? && !value.to_s.empty? && value.to_s != "false"
81
+ end
82
+
83
+ private_class_method :github, :build, :from_git, :git, :present?
84
+ end
85
+ end
@@ -0,0 +1,267 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "shellwords"
4
+ require "tmpdir"
5
+
6
+ module GemfileAudit
7
+ # The gemfile-audit command: start, finish and run. Only start and run can
8
+ # change the environment; finish and run never change the exit status of
9
+ # the customer's test command.
10
+ class CLI
11
+ LOADER_REQUIRE = "gemfile_audit/loader".freeze
12
+
13
+ HELP = <<-TEXT.gsub(/^ {6}/, "").freeze
14
+ gemfile-audit #{GemfileAudit::VERSION}
15
+
16
+ Measures your test run for a gemfile.io automated audit.
17
+
18
+ Usage:
19
+ gemfile-audit start Prepare the environment. In a shell, run: eval "$(gemfile-audit start)"
20
+ gemfile-audit finish [--dry-run] Collect the run and upload it
21
+ gemfile-audit run [--dry-run] -- <command> Run a command, measure it and upload the run
22
+ gemfile-audit --version Print the version
23
+ gemfile-audit help Print this text
24
+
25
+ With --dry-run the upload is printed and nothing is sent.
26
+ The endpoint can be changed with GEMFILE_AUDIT_URL.
27
+ TEXT
28
+
29
+ def initialize(argv, env: ENV, out: $stdout, err: $stderr, dir: Dir.pwd)
30
+ @argv = argv.dup
31
+ @env = env.to_h
32
+ @out = out
33
+ @err = err
34
+ @dir = dir
35
+ end
36
+
37
+ def call
38
+ command = @argv.first
39
+ rest = @argv.drop(1)
40
+ case command
41
+ when "start" then start
42
+ when "finish" then finish(rest)
43
+ when "run" then run(rest)
44
+ when "--version", "-v", "version" then print_line(@out, GemfileAudit::VERSION)
45
+ when "help", "--help", "-h" then print_help
46
+ else
47
+ usage_error(command ? "unknown command #{command}" : "no command given")
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def print_line(stream, text)
54
+ stream.puts(text)
55
+ 0
56
+ end
57
+
58
+ def print_help
59
+ @out.puts(HELP)
60
+ 0
61
+ end
62
+
63
+ def warn_line(message)
64
+ @err.puts("gemfile-audit: #{message}")
65
+ end
66
+
67
+ def usage_error(message)
68
+ @err.puts("gemfile-audit: #{message}.")
69
+ @err.puts(HELP)
70
+ 2
71
+ end
72
+
73
+ # start
74
+
75
+ def start
76
+ variables = audit_environment
77
+ if @env["GITHUB_ACTIONS"] == "true" && !@env["GITHUB_ENV"].to_s.empty?
78
+ File.open(@env["GITHUB_ENV"], "a") do |file|
79
+ variables.each { |name, value| file.puts("#{name}=#{value}") }
80
+ # Later steps inherit RUBYOPT and RUBYLIB, so finish puts them back.
81
+ file.puts("GEMFILE_AUDIT_ORIGINAL_RUBYOPT=#{@env['RUBYOPT']}")
82
+ file.puts("GEMFILE_AUDIT_ORIGINAL_RUBYLIB=#{@env['RUBYLIB']}")
83
+ end
84
+ else
85
+ variables.each { |name, value| @out.puts("export #{name}=#{Shellwords.escape(value)}") }
86
+ end
87
+ 0
88
+ rescue StandardError => error
89
+ warn_line("could not prepare the run: #{sentence(error.message)} Your build is not affected.")
90
+ 0
91
+ end
92
+
93
+ # No path ever goes into RUBYOPT, because Ruby splits it on spaces: the
94
+ # gem's lib folder goes on RUBYLIB and RUBYOPT only names the loader.
95
+ def audit_environment
96
+ variables = {
97
+ "GEMFILE_AUDIT_DIR" => Dir.mktmpdir("gemfile-audit"),
98
+ "GEMFILE_AUDIT_ROOT" => File.expand_path(@dir),
99
+ "GEMFILE_AUDIT_STARTED_AT" => Time.now.to_i.to_s,
100
+ "DISABLE_SPRING" => "1"
101
+ }
102
+ lib = GemfileAudit.lib_path
103
+ if lib.include?(File::PATH_SEPARATOR)
104
+ warn_line("the gem is installed in a folder whose name contains #{File::PATH_SEPARATOR}, so this run is not measured.")
105
+ return variables
106
+ end
107
+ libraries = @env["RUBYLIB"].to_s.split(File::PATH_SEPARATOR)
108
+ libraries.unshift(lib) unless libraries.include?(lib)
109
+ variables["RUBYLIB"] = libraries.join(File::PATH_SEPARATOR)
110
+ option = "-r#{LOADER_REQUIRE}"
111
+ rubyopt = @env["RUBYOPT"].to_s
112
+ rubyopt = "#{rubyopt} #{option}".strip unless rubyopt.split(" ").include?(option)
113
+ variables["RUBYOPT"] = rubyopt
114
+ variables
115
+ end
116
+
117
+ # finish
118
+
119
+ def finish(args)
120
+ dry_run = args.include?("--dry-run")
121
+ ignore_unknown(args - ["--dry-run"])
122
+ finish_run(@env, dry_run)
123
+ ensure
124
+ restore_github_environment
125
+ end
126
+
127
+ # On GitHub, start wrote RUBYOPT and RUBYLIB for every later step. Put
128
+ # the values from before start back, whatever happened while finishing.
129
+ def restore_github_environment
130
+ path = @env["GITHUB_ENV"].to_s
131
+ return unless @env["GITHUB_ACTIONS"] == "true" && !path.empty?
132
+ return unless @env.key?("GEMFILE_AUDIT_ORIGINAL_RUBYOPT") || @env.key?("GEMFILE_AUDIT_ORIGINAL_RUBYLIB")
133
+ File.open(path, "a") do |file|
134
+ file.puts("RUBYOPT=#{@env['GEMFILE_AUDIT_ORIGINAL_RUBYOPT']}")
135
+ file.puts("RUBYLIB=#{@env['GEMFILE_AUDIT_ORIGINAL_RUBYLIB']}")
136
+ end
137
+ rescue StandardError
138
+ nil
139
+ end
140
+
141
+ def finish_run(env, dry_run)
142
+ dir = env["GEMFILE_AUDIT_DIR"].to_s
143
+ root = env["GEMFILE_AUDIT_ROOT"].to_s
144
+ if dir.empty? || root.empty?
145
+ dir = nil
146
+ raise "The start command has not been run, so there is nothing to collect."
147
+ end
148
+ ci = CI.detect(env, root: root)
149
+ if ci.skip_reason
150
+ @out.puts("gemfile-audit: not uploaded: #{ci.skip_reason}.")
151
+ return 0
152
+ end
153
+ if Dir.glob(File.join(dir, "process-*.json")).empty?
154
+ @out.puts("gemfile-audit: not uploaded: no Ruby process was measured. " \
155
+ "Run your tests in this job, after gemfile-audit start.")
156
+ return 0
157
+ end
158
+ upload = Collector.new(dir: dir, root: root, ci: ci, env: env).upload
159
+ if dry_run
160
+ @out.puts(JSON.pretty_generate(upload))
161
+ return 0
162
+ end
163
+ send_upload(upload, env)
164
+ 0
165
+ rescue StandardError, ScriptError => error
166
+ report_error(error.message)
167
+ 0
168
+ ensure
169
+ clear(dir) if dir && !dir.empty?
170
+ end
171
+
172
+ def send_upload(upload, env)
173
+ result = Uploader.post(upload, url: Uploader.url(env))
174
+ if result.error
175
+ report_error(result.error)
176
+ else
177
+ @out.puts("gemfile-audit: your preview is ready: #{result.preview_url}")
178
+ @out.puts("gemfile-audit: it is deleted on #{result.expires_on}.")
179
+ write_summary(result, env)
180
+ end
181
+ end
182
+
183
+ def write_summary(result, env)
184
+ path = env["GITHUB_STEP_SUMMARY"].to_s
185
+ return unless env["GITHUB_ACTIONS"] == "true" && !path.empty?
186
+ File.open(path, "a") do |file|
187
+ file.puts("### gemfile.io automated audit")
188
+ file.puts
189
+ file.puts("[Open your preview](#{result.preview_url}). It is deleted on #{result.expires_on}.")
190
+ end
191
+ rescue StandardError => error
192
+ reason = error.is_a?(SystemCallError) ? "the file could not be written" : error.message
193
+ @err.puts("gemfile-audit: could not write the job summary: #{sentence(reason)}")
194
+ end
195
+
196
+ def report_error(message)
197
+ @err.puts("gemfile-audit: could not upload this run: #{sentence(message)} Your build is not affected.")
198
+ end
199
+
200
+ def sentence(message)
201
+ text = message.to_s.strip
202
+ text.end_with?(".") ? text : "#{text}."
203
+ end
204
+
205
+ # Removes the result files and the folder once they are uploaded.
206
+ def clear(dir)
207
+ Dir.glob(File.join(dir, "process-*.json")).each { |path| File.unlink(path) }
208
+ Dir.rmdir(dir)
209
+ rescue StandardError
210
+ nil
211
+ end
212
+
213
+ # run
214
+
215
+ def run(args)
216
+ options, command = split_run(args)
217
+ return usage_error("run needs a command, for example: gemfile-audit run -- bundle exec rspec") if command.empty?
218
+ dry_run = options.include?("--dry-run")
219
+ ignore_unknown(options - ["--dry-run"])
220
+
221
+ environment = begin
222
+ @env.merge(audit_environment)
223
+ rescue StandardError, ScriptError => error
224
+ warn_line("could not prepare the run: #{sentence(error.message)} Your build is not affected.")
225
+ nil
226
+ end
227
+ status, measured = execute(environment || @env, command)
228
+ finish_run(environment, dry_run) if environment && measured
229
+ clear(environment["GEMFILE_AUDIT_DIR"]) if environment && !measured
230
+ status
231
+ end
232
+
233
+ # Everything after -- is the command. Without --, the command starts at
234
+ # the first argument that is not an option.
235
+ def split_run(args)
236
+ separator = args.index("--")
237
+ if separator
238
+ [args[0...separator], args[(separator + 1)..-1]]
239
+ else
240
+ start = args.index { |argument| !argument.start_with?("--") } || args.size
241
+ [args[0...start], args[start..-1]]
242
+ end
243
+ end
244
+
245
+ def ignore_unknown(options)
246
+ return if options.empty?
247
+ @err.puts("gemfile-audit: ignoring unknown #{options.size == 1 ? 'option' : 'options'} #{options.join(' ')}.")
248
+ end
249
+
250
+ def execute(environment, command)
251
+ @out.flush
252
+ @err.flush
253
+ pid = Process.spawn(environment, *command, chdir: @dir, unsetenv_others: true)
254
+ status = begin
255
+ Process.wait2(pid).last
256
+ rescue Interrupt
257
+ retry
258
+ end
259
+ return [128 + status.termsig, false] if status.signaled?
260
+ [status.exitstatus || 1, true]
261
+ rescue SystemCallError => error
262
+ reason = error.is_a?(Errno::ENOENT) ? "#{command.first} was not found" : "#{command.first} could not be run"
263
+ @err.puts("gemfile-audit: could not start the command: #{reason}.")
264
+ [127, false]
265
+ end
266
+ end
267
+ end