Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .bundler-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,6 @@ ignore:
# lockfiles on master predate the 2.26.0 release and pick up the fix at the next lock
# refresh. Once no lockfile resolves a mongo < 2.26.0, this entry can be removed.
- CVE-2026-88030
# ruby_llm CVE-2026-67991 (GHSA-42r3-x6vx-x49x, high; ReDoS in RubyLLM::Utils.underscore
# on Ruby 3.1.x, reachable via crafted tool/agent class names):
# fixed only in ruby_llm >= 2.0.0.rc1; no 2.x stable release exists yet, and the
# ai_guard ruby_llm integration does not support the 2.x API (RubyLLM::Content was
# removed, verified 8/8 instrumentation spec failures against 2.0.0.rc3). Remove
# once lockfiles can resolve a fixed ruby_llm (2.0.0+) and the integration supports it.
- CVE-2026-67991

# Gem+version-scoped ignore list, checked in addition to `ignore` above.
# Unlike `ignore`, each entry only suppresses findings for that exact
Expand Down
31 changes: 28 additions & 3 deletions .github/scripts/check_changelog_release_only.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,37 @@ if [[ -z "${HEAD_REF:-}" ]]; then
exit 1
fi

if [[ -z "${BASE_SHA:-}" ]]; then
echo "Error: BASE_SHA environment variable is not set"
if [[ -z "${HEAD_SHA:-}" ]]; then
echo "Error: HEAD_SHA environment variable is not set"
exit 1
fi

changed_files=$(git diff --no-renames --name-only "${BASE_SHA}"...HEAD)
if [[ -z "${BASE_REF:-}" ]]; then
echo "Error: BASE_REF environment variable is not set"
exit 1
fi

if ! git rev-parse --verify --quiet "${HEAD_SHA}^{commit}" >/dev/null; then
echo "Error: pull request commit ${HEAD_SHA} is not present in the local checkout"
exit 1
fi

base_ref="refs/remotes/origin/${BASE_REF}"
if ! git rev-parse --verify --quiet "${base_ref}^{commit}" >/dev/null; then
echo "Error: base branch ${BASE_REF} is not present in the local checkout"
exit 1
fi

if ! merge_base=$(git merge-base "${HEAD_SHA}" "${base_ref}"); then
echo "Error: no common ancestor between ${HEAD_SHA} and ${base_ref}"
exit 1
fi

# HEAD is the merge ref, whose history carries base-branch commits made after the pull
# request's recorded base SHA, so a diff anchored at HEAD reports base-branch CHANGELOG.md
# release bumps as pull request changes. Diff the pull request head from its merge base
# with the current base tip instead, matching the pull request's Files changed view.
changed_files=$(git diff --no-renames --name-only "${merge_base}" "${HEAD_SHA}")

touches_changelog=false
if grep -qx 'CHANGELOG.md' <<< "${changed_files}"; then
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ jobs:
if: github.event_name == 'pull_request'
env:
HEAD_REF: ${{ github.head_ref }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.base_ref }}
run: .github/scripts/check_changelog_release_only.sh

- name: Validate changelog fragment schema
Expand Down
51 changes: 30 additions & 21 deletions appraisal/orphans.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Finds gemfiles with no matching `appraise` definition. `dependency:generate`
# never deletes a gemfile, so one can outlive its definition silently.
# Finds gemfiles with no matching `appraise` definition, and lockfiles with no
# matching gemfile. `dependency:generate` never deletes a gemfile, so one can
# outlive its definition silently; `dependency:lock` only rewrites lockfiles
# whose gemfiles exist, so a lockfile can outlive its gemfile the same way.
#
# Usage: `bundle exec rake dependency:orphans` (or `bundle exec ruby appraisal/orphans.rb` directly)

require_relative '../tasks/appraisal_conversion'
require_relative 'coverage_matrix_helper'
require_relative '../tasks/lockfile'

# Collect only the names `appraisal/#{runtime_identifier}.rb` would generate;
# skip building real `Appraisal::Appraisal`/`Bundler` objects since only the
Expand All @@ -27,26 +30,32 @@
defined_gemfiles = appraised_groups.map { |name| "#{runtime_prefix}#{name}".tr('-', '_') }

orphans = generated_gemfiles - defined_gemfiles
exit if orphans.empty?

matrix = eval(File.read('Matrixfile')).freeze # rubocop:disable Security/Eval
ruby_column = AppraisalConversion.runtime_identifier.delete_prefix('ruby-')

orphans.each do |gemfile|
group = gemfile.delete_prefix(runtime_prefix)

active = matrix.values.any? do |groups|
coverage = groups.find { |matrix_group, _| matrix_group.tr('-', '_') == group }&.last
coverage&.include?("✅ #{ruby_column}")
orphaned_lockfiles = Lockfile.orphaned_lockfile_paths(AppraisalConversion.gemfile_dir)

if orphans.any?
matrix = eval(File.read('Matrixfile')).freeze # rubocop:disable Security/Eval
ruby_column = AppraisalConversion.runtime_identifier.delete_prefix('ruby-')

orphans.each do |gemfile|
group = gemfile.delete_prefix(runtime_prefix)

active = matrix.values.any? do |groups|
coverage = groups.find { |matrix_group, _| matrix_group.tr('-', '_') == group }&.last
coverage&.include?("✅ #{ruby_column}")
end

if active
warn "#{gemfile}.gemfile has no `appraise '#{group}'` block in #{AppraisalConversion.definition}, " \
"but Matrixfile marks it active for Ruby #{ruby_column}. Add the missing `appraise` block."
else
warn "#{gemfile}.gemfile has no `appraise '#{group}'` block in #{AppraisalConversion.definition}, " \
"and Matrixfile does not mark it active for Ruby #{ruby_column}. Delete #{gemfile}.gemfile and its lockfile."
end
end
end

if active
warn "#{gemfile}.gemfile has no `appraise '#{group}'` block in #{AppraisalConversion.definition}, " \
"but Matrixfile marks it active for Ruby #{ruby_column}. Add the missing `appraise` block."
else
warn "#{gemfile}.gemfile has no `appraise '#{group}'` block in #{AppraisalConversion.definition}, " \
"and Matrixfile does not mark it active for Ruby #{ruby_column}. Delete #{gemfile}.gemfile and its lockfile."
end
orphaned_lockfiles.each do |lockfile|
warn "#{lockfile} has no corresponding #{lockfile.chomp('.lock')}; delete the lockfile."
end

exit 1
exit 1 if orphans.any? || orphaned_lockfiles.any?
7 changes: 4 additions & 3 deletions docs/DevelopmentGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,12 +437,13 @@ BUNDLE_GEMFILE=gemfiles/ruby-4.0.gemfile bundle exec rake dependency:audit

If it fails:

1. Preferred fix: `BUNDLE_GEMFILE=<affected gemfile> bundle update GEM_NAME` (or `bundle lock --update GEM_NAME`) to upgrade the flagged gem to a patched version. Plain `bundle exec rake dependency:lock` will not move the version on its own.
2. If no patched version exists for the Ruby/framework constraint in that appraisal, document the exception in `.bundler-audit.yml`:
1. If the error names lockfiles with no corresponding Gemfile, those are orphaned lockfiles: a Gemfile was deleted without its `.lock` companion. Delete each lockfile. `rake dependency:orphans` reports the same orphans in the Lock Dependencies workflow.
2. Preferred fix: `BUNDLE_GEMFILE=<affected gemfile> bundle update GEM_NAME` (or `bundle lock --update GEM_NAME`) to upgrade the flagged gem to a patched version. Plain `bundle exec rake dependency:lock` will not move the version on its own.
3. If no patched version exists for the Ruby/framework constraint in that appraisal, document the exception in `.bundler-audit.yml`:
- Prefer `ignore_gem_versions` (scoped to the exact pinned gem+version, so bumping the gem later makes the finding reappear instead of staying silently hidden).
- Use the top-level `ignore` list (by advisory id) only as a last resort, since it suppresses the advisory for any gem/version.
- Every entry must include a reason explaining what pins the gem and why it can't be upgraded.
3. The job log only prints the finding count; run the command above locally to see the actual advisory id(s), gem(s), and affected lockfile(s), or inspect `tmp/dependency_audit_findings.json` after a local run.
4. The job log only prints the finding count; run the command above locally to see the actual advisory id(s), gem(s), and affected lockfile(s), or inspect `tmp/dependency_audit_findings.json` after a local run.

## Accessing Environment Variables

Expand Down
40 changes: 40 additions & 0 deletions spec/tasks/dependency_audit_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
if Gem.loaded_specs.key?("bundler-audit")
require_relative "../../tasks/dependency_auditing"
require "tmpdir"
require "fileutils"
require "open3"

RSpec.describe DependencyAuditing do
let(:fixtures) { "spec/fixtures/bundler_audit" }
Expand Down Expand Up @@ -108,4 +110,42 @@
end
end
end

# The task aborts the whole process when the gate fires, so it runs in a
# subprocess with its own Rakefile rooted at a throwaway tree.
RSpec.describe "the dependency:audit task" do
let(:tree_dir) { Dir.mktmpdir }

after { FileUtils.remove_entry(tree_dir) }

context "when a lockfile in gemfiles/ has no corresponding Gemfile" do
it "aborts naming the orphaned lockfiles before refreshing the advisory database" do
FileUtils.mkdir_p(File.join(tree_dir, "gemfiles"))
orphaned_lockfile = "gemfiles/ruby-3.1_contrib.gemfile.lock"
File.write(File.join(tree_dir, orphaned_lockfile), "")
File.write(File.join(tree_dir, "Rakefile"), "load #{dependency_audit_rakefile.inspect}\n")

out, status = run_dependency_audit_task

expect(out).to include(
"Dependency audit failed: lockfiles with no corresponding Gemfile: #{orphaned_lockfile}. " \
"Delete each orphaned lockfile; rake dependency:orphans reports the same orphans."
)
expect(out).not_to include("Updating advisory database")
expect(status.exitstatus).to eq(1)
end
end

def run_dependency_audit_task
Open3.capture2e(
{"BUNDLE_GEMFILE" => File.expand_path(Bundler.default_gemfile)},
File.join(File.dirname(Gem.ruby), "bundle"), "exec", "rake", "dependency:audit",
chdir: tree_dir,
)
end

def dependency_audit_rakefile
File.expand_path("../../tasks/dependency_audit.rake", __dir__)
end
end
end
69 changes: 69 additions & 0 deletions spec/tasks/lockfile_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
require "spec_helper"
require "tmpdir"
require_relative "../../tasks/lockfile"

RSpec.describe Lockfile do
describe "#initialize" do
it "rejects paths without the .gemfile.lock suffix" do
expect { described_class.new("gemfiles/ruby-3.1_contrib.gemfile") }
.to raise_error(ArgumentError, /Lockfile path must end with \.gemfile\.lock/)
end
end

describe "#audit_eligible?" do
it "is true for underscore appraisal variants on 3.1+ and false on 3.0 and below" do
expect(described_class.new("ruby_3.1_contrib.gemfile.lock").audit_eligible?).to eq(true)
Expand All @@ -26,6 +34,67 @@
end
end

describe "#gemfile_path" do
it "strips the .lock suffix from the path" do
expect(described_class.new("gemfiles/ruby_3.1_contrib.gemfile.lock").gemfile_path)
.to eq("gemfiles/ruby_3.1_contrib.gemfile")
end
end

describe "#orphaned?" do
it "is true when the companion gemfile is absent" do
Dir.mktmpdir do |dir|
lockfile = described_class.new(File.join(dir, "ruby-3.1_contrib.gemfile.lock"))

expect(lockfile).to be_orphaned
end
end

it "is false when the companion gemfile exists" do
Dir.mktmpdir do |dir|
File.write(File.join(dir, "ruby-3.1_contrib.gemfile"), "")
lockfile = described_class.new(File.join(dir, "ruby-3.1_contrib.gemfile.lock"))

expect(lockfile).not_to be_orphaned
end
end
end

describe ".orphaned_lockfile_paths" do
it "returns the sorted paths of lockfiles with no companion gemfile" do
Dir.mktmpdir do |dir|
File.write(File.join(dir, "ruby-3.1_contrib.gemfile"), "")
File.write(File.join(dir, "ruby-3.1_contrib.gemfile.lock"), "")
File.write(File.join(dir, "ruby-3.2_contrib.gemfile.lock"), "")
File.write(File.join(dir, "ruby-3.3_contrib.gemfile.lock"), "")

expect(described_class.orphaned_lockfile_paths(dir)).to eq([
File.join(dir, "ruby-3.2_contrib.gemfile.lock"),
File.join(dir, "ruby-3.3_contrib.gemfile.lock"),
])
end
end

it "returns [] when every lockfile has a companion gemfile" do
Dir.mktmpdir do |dir|
File.write(File.join(dir, "ruby-3.1_contrib.gemfile"), "")
File.write(File.join(dir, "ruby-3.1_contrib.gemfile.lock"), "")

expect(described_class.orphaned_lockfile_paths(dir)).to eq([])
end
end

it "raises when the directory exists and cannot be read" do
Dir.mktmpdir do |dir|
allow(File).to receive(:readable?).and_call_original
allow(File).to receive(:readable?).with(dir).and_return(false)

expect { described_class.orphaned_lockfile_paths(dir) }
.to raise_error(ArgumentError, "Lockfiles directory not readable: #{dir}")
end
end
end

describe "#has_checksums_section?" do
let(:fixtures) { "spec/fixtures/checksum_coverage" }

Expand Down
Loading
Loading