gitlab-experiment 1.5.0 → 2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 453f39a65f699c77881655a8dbfd40d15133baa47e063b76278e2953c4b1de7c
4
- data.tar.gz: 3cd7e33850a1d0725bd7f4c82bbcd9d8769c01c1e6663226dcc07acdf68d24ec
3
+ metadata.gz: b69f6d4aa4d4bbba5b9ef15f5a1d72c84726df5a6ec648cd09761cdc9c997d5b
4
+ data.tar.gz: ea6f508020bf5941ee7e74647c43052aedf55fa969d8c7dea9d42f86e9b03c8b
5
5
  SHA512:
6
- metadata.gz: 9ba7cc88703891e0ff10a885b55e0f59911459ffa6de2a7102dececf803cf27ea0d0e62912409b543a537c6df1266e403c18d4aa96d6767cfa1f7ae64d638dd3
7
- data.tar.gz: 5f292e0a952811292354e1d9e84705b88eb225b79819bdb00b3fcf55f7a06754ff87a8502ecd8b5016278629a2c6d83609566dad373bdb2f11d44eca4ed7cdd3
6
+ metadata.gz: 62690c224599c2c0cbb78a42bdffee6104d5c811faa43027e92a2fd219006e4d0dda7664ee22bdfbb850ad0aa171fa23a82b944a0e667f3b680f2355119c2ae8
7
+ data.tar.gz: 9b7cfe7e5fcdfce2cfd2826da2974b64c5d61d4989fc87725483e45a9f2547840847ee0f7847d5ebd2e4bcfda13dfe78873404d8c6efd5b60e962b8d6413f56a
data/README.md CHANGED
@@ -685,11 +685,10 @@ end
685
685
  - ✅ Experiment reach stays controlled
686
686
  - ✅ Perfect for multi-page experimental experiences
687
687
 
688
- > **Note:** `only_assigned: true` relies on cached assignments. Control variants are cached
689
- > by default (see [Advanced: Caching Configuration](#advanced-caching-configuration)) so that
690
- > bucketed control users are included in `only_assigned` tracking. If you set
691
- > `cache_control: false`, control users will appear unassigned at secondary sites and be
692
- > excluded from tracking there.
688
+ > **Note:** `only_assigned: true` relies on cached assignments. All rollout
689
+ > strategies cache control variants (see
690
+ > [Advanced: Caching Configuration](#advanced-caching-configuration)) so that bucketed
691
+ > control users are included in `only_assigned` tracking.
693
692
 
694
693
  **Real-world use cases:**
695
694
  - **Post-signup experiences**: Assign at signup, show features throughout the app
@@ -704,7 +703,7 @@ needs.
704
703
 
705
704
  **Built-in strategies:**
706
705
  - [`Percent`](lib/gitlab/experiment/rollout/percent.rb) - Consistent percentage-based assignment (default, recommended)
707
- - [`Random`](lib/gitlab/experiment/rollout/random.rb) - True random assignment (useful for load testing)
706
+ - [`Random`](lib/gitlab/experiment/rollout/random.rb) - Random assignment on first roll (useful for load testing)
708
707
  - [`RoundRobin`](lib/gitlab/experiment/rollout/round_robin.rb) - Cycle through variants (requires caching)
709
708
  - [`Base`](lib/gitlab/experiment/rollout.rb) - Useful for building custom rollout strategies
710
709
 
@@ -885,13 +884,13 @@ graph TD
885
884
  Excluded? -->|No| Segmented?
886
885
  Segmented? -->|Yes / Cached| VariantA
887
886
  Segmented? -->|No| Rollout[Rollout Resolve]
888
- Rollout --> Control
887
+ Rollout -->|Cached| Control
889
888
  Rollout -->|Cached| VariantA
890
889
  Rollout -->|Cached| VariantB
891
890
  Rollout -->|Cached| VariantN
892
891
 
893
- class ForcedVariant,VariantA,VariantB,VariantN included
894
- class Control,Excluded excluded
892
+ class ForcedVariant,VariantA,VariantB,VariantN,Control included
893
+ class Excluded excluded
895
894
  class Cached cached
896
895
  ```
897
896
 
@@ -901,6 +900,7 @@ class Cached cached
901
900
  3. Cache provides consistency across calls
902
901
  4. Segmentation takes priority over rollout
903
902
  5. `only_assigned: true` exits early if no cache hit
903
+ 6. Control is cached like any other variant, so the assignment stays stable across calls for every rollout strategy
904
904
 
905
905
  ### Experiment Context and Stickiness
906
906
 
@@ -1326,31 +1326,17 @@ documented in its implementation.
1326
1326
 
1327
1327
  **Control variant caching:**
1328
1328
 
1329
- By default, the gem caches `:control` assignments alongside variant assignments. This is what
1329
+ The gem always caches `:control` assignments alongside variant assignments. This is what
1330
1330
  makes `only_assigned: true` lookups include bucketed control users -- without it, control
1331
1331
  users would appear unassigned at secondary tracking sites and would be silently excluded.
1332
1332
 
1333
- ```ruby
1334
- # Disable globally (don't cache :control)
1335
- Gitlab::Experiment.configure do |config|
1336
- config.cache_control = false
1337
- end
1338
-
1339
- # Or override per-rollout
1340
- class MyExperiment < ApplicationExperiment
1341
- default_rollout :percent, cache_control: false
1342
- end
1343
- ```
1344
-
1345
- The `Random` rollout strategy intentionally opts out of caching `:control` regardless of this
1346
- setting, because its convergence model depends on re-rolling control assignments on every
1347
- visit until a non-control variant is picked.
1333
+ This applies to every rollout strategy.
1348
1334
 
1349
- **Operational note:** With control caching enabled, `Percent` and `RoundRobin` cache hashes
1350
- grow with every bucketed user, not just users assigned to a candidate variant. For
1351
- long-running experiments the cache will be roughly `1 / candidate_percentage` the size it
1352
- would have been before. Plan periodic cleanup using your store's deletion API -- for the
1353
- `RedisHashStore`, that's `experiment.cache.store.clear(key: experiment.name)`.
1335
+ **Operational note:** Because control is cached, cache hashes grow with every bucketed
1336
+ user, not just users assigned to a candidate variant. For long-running experiments the
1337
+ cache will be roughly `1 / candidate_percentage` the size it would have been before. Plan
1338
+ periodic cleanup using your store's deletion API -- for the `RedisHashStore`, that's
1339
+ `experiment.cache.store.clear(key: experiment.name)`.
1354
1340
 
1355
1341
  ### Advanced: Custom Rollout Strategies
1356
1342
 
@@ -22,20 +22,6 @@ Gitlab::Experiment.configure do |config|
22
22
  # Use `nil` for no caching.
23
23
  config.cache = nil
24
24
 
25
- # Cache `:control` assignments alongside variant assignments.
26
- #
27
- # When set to true (the default), bucketed control users are cached and
28
- # therefore visible to `only_assigned: true` tracking calls. Set to false
29
- # to skip caching control -- control users will be treated as unassigned
30
- # at `only_assigned` sites and excluded from tracking there.
31
- #
32
- # Can be overridden per-rollout:
33
- #
34
- # class ExampleExperiment < ApplicationExperiment
35
- # default_rollout :percent, cache_control: false
36
- # end
37
- config.cache_control = true
38
-
39
25
  # The domain to use on cookies.
40
26
  #
41
27
  # When not set, it uses the current host. If you want to provide specific
@@ -81,7 +81,7 @@ module Gitlab
81
81
  def process_redirect_url(url)
82
82
  return unless Configuration.redirect_url_validator&.call(url)
83
83
 
84
- track('visited', url: url)
84
+ track('visited', url:)
85
85
  url # return the url, which allows for mutation
86
86
  end
87
87
 
@@ -43,18 +43,18 @@ module Gitlab
43
43
  @cache ||= Interface.new(self, Configuration.cache)
44
44
  end
45
45
 
46
- def cache_variant(specified = nil, &block)
46
+ def cache_variant(specified = nil, &)
47
47
  return (specified.presence || yield) unless cache.store
48
48
 
49
- result = migrated_cache_fetch(cache.store) || find_variant(&block)
49
+ result = migrated_cache_fetch(cache.store) || find_variant(&)
50
50
  return result unless specified.present?
51
51
 
52
52
  cache.write(specified) if result.to_s != specified.to_s
53
53
  specified
54
54
  end
55
55
 
56
- def find_variant(&block)
57
- cache.store.fetch(cache_key, &block)
56
+ def find_variant(&)
57
+ cache.store.fetch(cache_key, &)
58
58
  end
59
59
 
60
60
  def cache_key(key = nil, suffix: nil)
@@ -30,21 +30,6 @@ module Gitlab
30
30
  # Use `nil` for no caching.
31
31
  @cache = nil
32
32
 
33
- # Whether `:control` assignments should be written to the cache.
34
- #
35
- # When true (default), control variants are cached alongside other
36
- # variants, which is required for `only_assigned: true` tracking to
37
- # work correctly for the control arm of an experiment.
38
- #
39
- # When false, control variants are not cached. This preserves the
40
- # behavior of versions prior to this option being introduced. Any
41
- # experiment using `only_assigned: true` will silently exclude
42
- # control users from tracking.
43
- #
44
- # Individual rollouts can override this via the `cache_control:`
45
- # option, e.g. `default_rollout :percent, cache_control: false`.
46
- @cache_control = true
47
-
48
33
  # The domain to use on cookies.
49
34
  #
50
35
  # When not set, it uses the current host. If you want to provide specific
@@ -141,7 +126,7 @@ module Gitlab
141
126
  # access experiment methods, like `name`, `context`, and `signature`.
142
127
  @tracking_behavior = lambda do |event, args|
143
128
  # An example of using a generic logger to track events:
144
- Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature: signature)}")
129
+ Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature:)}")
145
130
 
146
131
  # Using something like snowplow to track events (in gitlab):
147
132
  #
@@ -173,7 +158,7 @@ module Gitlab
173
158
  # level1 initiated by file_name.rb:2
174
159
  # level2 initiated by file_name.rb:3
175
160
  @nested_behavior = lambda do |nested_experiment|
176
- raise NestingError.new(experiment: self, nested_experiment: nested_experiment)
161
+ raise NestingError.new(experiment: self, nested_experiment:)
177
162
  end
178
163
 
179
164
  # Called at the end of every experiment run, with the result.
@@ -208,7 +193,6 @@ module Gitlab
208
193
  :base_class,
209
194
  :strict_registration,
210
195
  :cache,
211
- :cache_control,
212
196
  :cookie_domain,
213
197
  :cookie_name,
214
198
  :secure_cookie,
@@ -229,19 +213,6 @@ module Gitlab
229
213
  @default_rollout = Rollout.resolve(*args)
230
214
  end
231
215
 
232
- def cache_control=(value) # rubocop:disable Lint/DuplicateMethods
233
- if value != true
234
- deprecated(
235
- :cache_control,
236
- "setting `cache_control` to a non-true value is deprecated and will be removed in version 2.0. " \
237
- "Control variants will always be cached.",
238
- version: '1.4'
239
- )
240
- end
241
-
242
- @cache_control = value
243
- end
244
-
245
216
  # Internal warning helpers.
246
217
 
247
218
  def deprecated(*args, version:, stack: 0)
@@ -49,7 +49,7 @@ module Gitlab
49
49
  end
50
50
 
51
51
  def signature
52
- @signature ||= { key: key, migration_keys: migration_keys }.compact
52
+ @signature ||= { key:, migration_keys: }.compact
53
53
  end
54
54
 
55
55
  def method_missing(method_name, *)
@@ -22,7 +22,7 @@ module Gitlab
22
22
  return hash.merge(key => cookie) if hash[key].nil?
23
23
 
24
24
  add_unmerged_migration(key => cookie)
25
- cookie_jar.delete(cookie_name, domain: domain)
25
+ cookie_jar.delete(cookie_name, domain:)
26
26
 
27
27
  hash
28
28
  end
@@ -32,7 +32,7 @@ module Gitlab
32
32
 
33
33
  cookie ||= SecureRandom.uuid
34
34
  cookie_jar.permanent.signed[cookie_name] = {
35
- value: cookie, secure: Configuration.secure_cookie, domain: domain, httponly: true
35
+ value: cookie, secure: Configuration.secure_cookie, domain:, httponly: true
36
36
  }
37
37
 
38
38
  hash.merge(key => cookie)
@@ -7,7 +7,6 @@ module Gitlab
7
7
  UnregisteredExperiment = Class.new(Error)
8
8
  ExistingBehaviorError = Class.new(Error)
9
9
  BehaviorMissingError = Class.new(Error)
10
- ControlNotCachedError = Class.new(Error)
11
10
 
12
11
  class NestingError < Error
13
12
  def initialize(experiment:, nested_experiment:)
@@ -1,14 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The random rollout strategy will randomly assign a variant when the context is determined to be within the experiment
4
- # group.
3
+ # The random rollout strategy picks a variant uniformly at random the first time a context is assigned into the
4
+ # experiment group.
5
5
  #
6
- # If caching is enabled this is a predictable and consistent assignment that will eventually assign a non-control
7
- # variant. This relies on the strategy intentionally NOT caching control assignments -- so when a context is sampled
8
- # into control, it gets re-rolled on the next visit until it lands on a non-control variant, at which point the
9
- # assignment is cached and stable. Without caching, assignment is random on every call.
6
+ # If caching is enabled, that first roll is persisted and the context gets the same variant on every subsequent visit,
7
+ # so the assignment is stable/sticky per context -- including :control, which is cached like any other variant. Without
8
+ # caching, assignment is random on every call.
10
9
  #
11
- # This is the only rollout that opts out of caching control: every other rollout caches whatever variant it resolves to.
12
10
  # Example configuration usage:
13
11
  #
14
12
  # config.default_rollout = Gitlab::Experiment::Rollout::Random.new
@@ -31,10 +29,7 @@ module Gitlab
31
29
  protected
32
30
 
33
31
  def execute_assignment
34
- result = behavior_names.sample # pick a random variant
35
- # Drop :control so it's never cached, even if cache_control is true.
36
- # The context will be re-rolled on the next visit and eventually converge on a non-control variant.
37
- result == :control ? nil : result
32
+ behavior_names.sample # pick a random variant; cached on first roll so it stays stable
38
33
  end
39
34
  end
40
35
  end
@@ -31,15 +31,6 @@ module Gitlab
31
31
 
32
32
  @experiment = experiment
33
33
  @options = options
34
-
35
- return if !options.key?(:cache_control) || options[:cache_control] == true
36
-
37
- Configuration.deprecated(
38
- :cache_control,
39
- "setting `cache_control` to a non-true value on a rollout is deprecated and " \
40
- "will be removed in version 2.0. Control variants will always be cached.",
41
- version: '1.4'
42
- )
43
34
  end
44
35
 
45
36
  def enabled?
@@ -49,18 +40,9 @@ module Gitlab
49
40
  def resolve
50
41
  validate! # allow the rollout strategy to validate itself
51
42
 
52
- assignment = execute_assignment
53
- # Cache control assignments unless explicitly opted out. The per-rollout `cache_control:`
54
- # option takes precedence over the global `Configuration.cache_control` default.
55
- return assignment if cache_control_enabled?
56
-
57
- assignment == :control ? nil : assignment
58
- end
59
-
60
- # Returns true when this rollout caches `:control` assignments. Per-rollout
61
- # `cache_control:` option takes precedence over the global default.
62
- def cache_control_enabled?
63
- options.fetch(:cache_control, Configuration.cache_control)
43
+ # Control variants are cached alongside other variants, which is required
44
+ # for `only_assigned: true` tracking to work for the control arm.
45
+ execute_assignment
64
46
  end
65
47
 
66
48
  private
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/core_ext/enumerable'
4
+ require 'active_support/core_ext/object/blank'
5
+
3
6
  module Gitlab
4
7
  class Experiment
5
8
  module TestBehaviors
@@ -24,7 +27,7 @@ module Gitlab
24
27
  end
25
28
 
26
29
  module MethodDouble
27
- def proxy_method_invoked(receiver, *args, &block)
30
+ def proxy_method_invoked(receiver, *args, &)
28
31
  RSpecMocks.track_gitlab_experiment_receiver(original_method, receiver)
29
32
  super
30
33
  end
@@ -121,6 +124,38 @@ module Gitlab
121
124
  end
122
125
  end
123
126
 
127
+ # Records experiment tracking calls captured via Configuration.tracking_behavior, so the
128
+ # `have_tracked_experiment` matcher can assert on the ordered sequence of emitted events. Only
129
+ # GLEX-native data is captured (the action plus the experiment's signature), so it works for any
130
+ # consumer regardless of how the host application wires up tracking.
131
+ class TrackedExperiments
132
+ attr_reader :events
133
+
134
+ def initialize(max_segmentations = 1)
135
+ @events = []
136
+ @max_segmentations = max_segmentations
137
+ end
138
+
139
+ def record(action, signature)
140
+ event = { action: }.merge(signature)
141
+ verify_single_assignment(event)
142
+ @events << event
143
+ end
144
+
145
+ private
146
+
147
+ def verify_single_assignment(event)
148
+ return unless event[:action] == :assignment && event[:key].present?
149
+
150
+ keys = @events.select { |e| e[:action] == :assignment && e[:key].present? }
151
+ .pluck(:key).push(event[:key]).uniq
152
+ return unless keys.size > @max_segmentations
153
+
154
+ raise "#{event[:experiment]} was segmented #{keys.size} " \
155
+ "#{keys.size == 1 ? 'time' : 'times'} (expected #{@max_segmentations}) - #{keys.join(', ')}."
156
+ end
157
+ end
158
+
124
159
  module RSpecMatchers
125
160
  extend RSpec::Matchers::DSL
126
161
 
@@ -135,6 +170,10 @@ module Gitlab
135
170
  experiment
136
171
  end
137
172
 
173
+ def tracked_experiments
174
+ @_tracked_experiments
175
+ end
176
+
138
177
  matcher :register_behavior do |behavior_name|
139
178
  match do |experiment|
140
179
  @experiment = require_experiment(experiment, 'register_behavior')
@@ -262,7 +301,7 @@ module Gitlab
262
301
  chain(:on_next_instance) { @on_next_instance = true }
263
302
 
264
303
  def set_expectations(event, *event_args, negated:)
265
- failure_message = failure_message_with_details(event, negated: negated)
304
+ failure_message = failure_message_with_details(event, negated:)
266
305
  expectations = proc do |e|
267
306
  allow(e).to receive(:track).and_call_original
268
307
 
@@ -306,6 +345,43 @@ module Gitlab
306
345
  details.unshift(base).join("\n")
307
346
  end
308
347
  end
348
+
349
+ matcher :have_tracked_experiment do |experiment_name, expectation = []|
350
+ match do
351
+ @experiment_name = experiment_name
352
+ @expectation = expectation
353
+
354
+ expectation == formatted_events
355
+ end
356
+
357
+ failure_message do
358
+ "Expected to track ordered events:\n#{events_to_s(@expectation)}\n" \
359
+ "but tracked:\n#{events_to_s(formatted_events)}"
360
+ end
361
+
362
+ def formatted_events
363
+ unless tracked_experiments
364
+ raise 'have_tracked_experiment requires the :experiment_tracking metadata tag on the example'
365
+ end
366
+
367
+ tracked_experiments.events
368
+ .select { |event| event[:experiment] == @experiment_name.to_s }
369
+ .uniq { |event| event[:action] }
370
+ .map.with_index { |event, index| format_event(event, @expectation[index] || {}) }
371
+ end
372
+
373
+ # A bare expectation entry (eg. `:assignment`) asserts only the action; a Hash entry (eg.
374
+ # `{ action: :assignment, migration_keys: [...] }`) asserts each named signature field.
375
+ def format_event(event, expected_event)
376
+ return event[:action] unless expected_event.is_a?(Hash)
377
+
378
+ expected_event.keys.index_with { |key| event[key] }
379
+ end
380
+
381
+ def events_to_s(events)
382
+ events.map { |event| " #{event}" }.join(",\n")
383
+ end
384
+ end
309
385
  end
310
386
  end
311
387
  end
@@ -326,6 +402,26 @@ RSpec.configure do |config|
326
402
 
327
403
  config.include Gitlab::Experiment::RSpecMatchers, :experiment
328
404
  config.include Gitlab::Experiment::RSpecMatchers, type: :experiment
405
+ config.include Gitlab::Experiment::RSpecMatchers, :experiment_tracking
406
+
407
+ # Capture experiment tracking calls at the gem's own emission seam (Configuration.tracking_behavior),
408
+ # so `have_tracked_experiment` can assert on them without coupling to any host tracking implementation.
409
+ # The metadata value, when numeric, sets the maximum number of segmentations allowed (defaults to 1).
410
+ config.before(:each, :experiment_tracking) do |example|
411
+ max_segmentations = example.metadata[:experiment_tracking]
412
+ max_segmentations = 1 unless max_segmentations.is_a?(Numeric)
413
+ recorder = @_tracked_experiments = Gitlab::Experiment::TrackedExperiments.new(max_segmentations)
414
+
415
+ @_original_tracking_behavior = Gitlab::Experiment::Configuration.tracking_behavior
416
+ Gitlab::Experiment::Configuration.tracking_behavior = lambda do |action, _args|
417
+ # `instance_exec`'d on the experiment by `Experiment#track`, so `signature` is in scope here.
418
+ recorder.record(action, signature)
419
+ end
420
+ end
421
+
422
+ config.after(:each, :experiment_tracking) do
423
+ Gitlab::Experiment::Configuration.tracking_behavior = @_original_tracking_behavior
424
+ end
329
425
 
330
426
  config.define_derived_metadata(file_path: Regexp.new('spec/experiments/')) do |metadata|
331
427
  metadata[:type] ||= :experiment
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '1.5.0'
5
+ VERSION = '2.0.0'
6
6
  end
7
7
  end
@@ -33,16 +33,16 @@ module Gitlab
33
33
  class << self
34
34
  # Class level behavior registration methods.
35
35
 
36
- def control(*filter_list, **options, &block)
37
- variant(:control, *filter_list, **options, &block)
36
+ def control(*filter_list, **options, &)
37
+ variant(:control, *filter_list, **options, &)
38
38
  end
39
39
 
40
- def candidate(*filter_list, **options, &block)
41
- variant(:candidate, *filter_list, **options, &block)
40
+ def candidate(*filter_list, **options, &)
41
+ variant(:candidate, *filter_list, **options, &)
42
42
  end
43
43
 
44
- def variant(variant, *filter_list, **options, &block)
45
- build_behavior_callback(filter_list, variant, **options, &block)
44
+ def variant(variant, *filter_list, **options, &)
45
+ build_behavior_callback(filter_list, variant, **options, &)
46
46
  end
47
47
 
48
48
  # Class level callback registration methods.
@@ -86,12 +86,12 @@ module Gitlab
86
86
  [Configuration.name_prefix, @_name].compact.join('_')
87
87
  end
88
88
 
89
- def control(&block)
90
- variant(:control, &block)
89
+ def control(&)
90
+ variant(:control, &)
91
91
  end
92
92
 
93
- def candidate(&block)
94
- variant(:candidate, &block)
93
+ def candidate(&)
94
+ variant(:candidate, &)
95
95
  end
96
96
 
97
97
  def variant(name, &block)
@@ -168,13 +168,6 @@ module Gitlab
168
168
  def only_assigned?
169
169
  return false unless context.only_assigned
170
170
 
171
- unless rollout.cache_control_enabled?
172
- raise ControlNotCachedError,
173
- "`only_assigned: true` requires `cache_control: true`, but the rollout for " \
174
- "experiment `#{name}` has it disabled. Control users would be silently excluded " \
175
- "from tracking. Enable cache_control or remove `only_assigned: true`."
176
- end
177
-
178
171
  find_variant.blank?
179
172
  end
180
173
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-experiment
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-09 00:00:00.000000000 Z
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -246,7 +246,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
246
246
  requirements:
247
247
  - - ">="
248
248
  - !ruby/object:Gem::Version
249
- version: '3.0'
249
+ version: '3.2'
250
250
  required_rubygems_version: !ruby/object:Gem::Requirement
251
251
  requirements:
252
252
  - - ">="