Skip to content

Test the compatibility range instead of asserting it - #14

Open
VSN2015 wants to merge 1 commit into
masterfrom
ci/compatibility-matrix
Open

Test the compatibility range instead of asserting it#14
VSN2015 wants to merge 1 commit into
masterfrom
ci/compatibility-matrix

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

CI ran exactly one combination — the newest of everything — while the gemspec advertised activesupport >= 5.0, < 9. Testing the range found two real problems.

Finding 1: no contract can be declared on activesupport 5.0 or 5.1

The registry is class_attribute :permittable_contracts, default: [], and class_attribute's default: arrived in Rails 5.2. On 5.0/5.1 it's silently ignored, so the attribute is nil:

lib/permittable.rb:659:in `permit_params': undefined method `+' for nil:NilClass (NoMethodError)
      self.permittable_contracts = permittable_contracts + [rule]

Nobody reported it because nobody could have — Rails 5 doesn't run on Ruby 3.2, which is this gem's own floor. The advertised range was simply wrong.

Finding 2: require "permittable" raised on activesupport ≤ 7.0.8.4

NameError: uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger
  activesupport-6.1.7.10/lib/active_support/logger_thread_safe_level.rb:16

concurrent-ruby 1.3.5 stopped requiring logger for those versions, and this gem's first line is require "active_support". Fixed with one stdlib require "logger" ahead of it, so the gem loads whatever the host's own boot order happens to be. spec/spec_helper.rb needs the same, since it requires activerecord first.

The floor is now >= 6.1

That's the oldest line the full suite is actually run against. 5.2 and 6.0 do load and validate (verified), but they predate Ruby 3.x support — and claiming an untested version is exactly the habit this PR breaks. The gemspec now says why, in a comment, so nobody has to rediscover it.

The matrix

One pinned gemfile per activesupport line under gemfiles/ — plain BUNDLE_GEMFILE, no new dev dependency — crossed with the supported Rubies as an explicit include list rather than a cross product, so it doubles as the answer to "which combinations are supported?":

Ruby 3.2 Ruby 3.3 Ruby 3.4
newest (root Gemfile)
activesupport 6.1
activesupport 7.0
activesupport 7.1
activesupport 7.2
activesupport 8.0
activesupport 8.1

Variant lockfiles stay uncommitted on purpose: each run resolves the newest patch of its line, so a regression in a supported version fails CI instead of being frozen out by a stale lock.

Verified locally on Ruby 3.2 — 199 examples, 0 failures on every one of 6.1, 7.0, 7.1, 7.2, 8.0, 8.1 and the root Gemfile.

A job that proves the gem's central claim

"activesupport is the only runtime dependency" is the gemspec's main promise, and the spec suite cannot test it — it bundles actionpack and activerecord to exercise the integration and schema-drift paths.

The new runtime-deps job installs the built gem with nothing but its declared dependencies, asserts actionpack/activerecord/rails are genuinely absent, and then exercises every controller-free surface:

declared runtime dependencies
  ok   activesupport is the only one
environment
  ok   action_controller is genuinely absent
  ...
standalone contracts
  ok   casts, defaults, normalizes and finalizes
  ok   nested plain hashes convert without the Rails core extensions
  ok   reports violations as data, with qualified paths
  ok   a missing root carries 400 semantics
the concern on a plain params duck
  ok   validates without before_action, rescue_from, logger or render
  ok   unknown: :error works with no logger to warn through
  ok   sensitive: registers for redaction with no Railtie to install the filter
  ok   monitor mode passes raw params through with no logger
  ok   instrumentation fires through activesupport alone
exporters
  ok   JSON Schema exports from the frozen rule
  ok   OpenAPI assembles a document without Rails routes
generator
  ok   drafts a contract from a scanned permit call, with no model to read

PASS — activesupport is enough.

Every respond_to? / defined? guard in the gem is a promise. A missing one now fails CI instead of a user's boot. The nested-plain-hash check is the 0.5.1 regression, permanently pinned outside Rails.

Verification

  • 199 examples, 0 failures on all six activesupport lines plus the root Gemfile (Ruby 3.2, local)
  • runtime_deps_smoke.rb passes against the built gem in an isolated GEM_HOME
  • RuboCop clean, 33 files
  • Ruby 3.3 / 3.4 rows are covered by CI on this PR — I only have 3.2 locally

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #14: Excellent compatibility matrix expansion. Explicitly testing the ActiveSupport matrix across real Ruby versions and testing with minimal runtime dependencies validates the gem's core claims.

Comment thread lib/permittable.rb
# already loaded, because concurrent-ruby 1.3.5 stopped requiring it for them.
# One stdlib require makes `require "permittable"` work on every activesupport
# version the gemspec claims, whatever the host's own boot order.
require "logger"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crucial fix: explicitly requiring logger ahead of active_support prevents NameError on ActiveSupport <= 7.0.8.4 under concurrent-ruby >= 1.3.5.

Comment thread permittable.gemspec
# ... default:` — how the contract registry is declared — arrived in 5.2, so
# 5.0 and 5.1 cannot declare a contract at all, and 5.2/6.0 predate Ruby 3.x
# support, which this gem's own Ruby floor requires.
spec.add_runtime_dependency "activesupport", ">= 6.1", "< 9"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate adjustment of the runtime dependency floor to >= 6.1. Given class_attribute ..., default: requirements and Ruby 3.x compatibility, this reflects the true supported range.


puts "declared runtime dependencies"
check "activesupport is the only one" do
deps = Gem::Specification.find_by_name("permittable").runtime_dependencies.map(&:name).sort

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice smoke test. Validating runtime_dependencies programmatically against the installed gem guarantees no transitive or dev gems leak into runtime requirements.

@VSN2015
VSN2015 force-pushed the ci/compatibility-matrix branch from 4bc8900 to 2109249 Compare September 11, 2026 22:01
CI ran exactly one combination — the newest of everything — while the
gemspec advertised activesupport >= 5.0, < 9. Testing the range found
two real problems.

On activesupport 5.0 and 5.1 a contract cannot be declared at all. The
registry is a `class_attribute :permittable_contracts, default: []`, and
class_attribute's default: arrived in Rails 5.2, so permit_params died
on `undefined method '+' for nil:NilClass`. Nobody reported it because
nobody could have: Rails 5 doesn't run on Ruby 3.2, which is this
gem's own floor.

On activesupport <= 7.0.8.4, `require "permittable"` itself raised
NameError: uninitialized constant
ActiveSupport::LoggerThreadSafeLevel::Logger — concurrent-ruby 1.3.5
stopped requiring `logger` for them, and the gem's first line is
`require "active_support"`. Fixed with one stdlib require ahead of it,
so the gem loads whatever the host's own boot order; the spec_helper
needs the same, since it requires activerecord first.

The floor is now >= 6.1, which is the oldest line the full suite is
actually run against. 5.2 and 6.0 do load and validate, but they
predate Ruby 3.x support and claiming an untested version is the habit
this commit is breaking.

The matrix is one pinned gemfile per activesupport line (plain
BUNDLE_GEMFILE, no new dev dependency) crossed with the supported
Rubies as an explicit include list, so it doubles as the answer to
"which combinations are supported?". Variant lockfiles stay
uncommitted on purpose: each run resolves the newest patch of its line,
so a regression fails CI rather than being frozen out by a stale lock.

A second job proves the gemspec's central claim, which the spec suite
cannot: it bundles actionpack and activerecord to exercise the
integration and drift paths. runtime-deps installs the built gem with
nothing but its declared dependencies, asserts actionpack, activerecord
and rails are genuinely absent, then exercises every controller-free
surface — standalone contracts, the concern on a params duck,
unknown: :error with no logger to warn through, sensitive: with no
Railtie, monitor mode, instrumentation, JSON Schema, OpenAPI, and the
generator. Every respond_to?/defined? guard in the gem is a promise;
a missing one now fails CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VSN2015
VSN2015 force-pushed the ci/compatibility-matrix branch from 2109249 to 9c6236f Compare September 11, 2026 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant