eight_ball 3.1.1 → 3.3.2

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: 5c9c10267989e4e3b8d774740ba5310657595a8564a66b5b1d76ec255d4e99be
4
- data.tar.gz: 3b17726499ac88f88ae0762b8efe01fdada35dad21dbb92c3070a53d0dba4738
3
+ metadata.gz: 8b9dcfc459f280769679dbe39a36be388579403e1ea81700ecdb1f78ad42db30
4
+ data.tar.gz: e819abeae5c7d236f7c08b05c10069d0bbeb76210da389077d2621d0cb558227
5
5
  SHA512:
6
- metadata.gz: 254787dbc62d7f79a6601098357a2c01a2a9fccaaa5d3a402f6ebd3e842177a1a84ce529e4793a53ed2e6862e33fee6923f78f933cbdc3f13866399bfad3b844
7
- data.tar.gz: f0faffb243a73515b8bd584b58f5d1fad96eaae99116113ffc3f058d02249851364365b6de8507648993c3885b27dae50c75bddf986d8ed605642870550a8f1c
6
+ metadata.gz: b0d24c2ec843bfa235da786579a36fadff617cfbe3abbb0be7085b5b26b7f445a98c81e3a3b1e1f6747698130a894ad418134d41193f94edfd4fa1dfde1ab200
7
+ data.tar.gz: 1629b60c02126692d6a2c63fe0fd58d66b2d1e5e99e65f117b0e989852d2def11fc996f6e9f16848f8133ffb6691642606a53e86396e584e66b6c1d82381d41c
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.3.10
1
+ 3.4.5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.3.2]
4
+
5
+ - Fix the `tag-and-release` workflow to create the release tag with the `rewind-community-tagger` GitHub App token (`TAGGER_APP_ID`/`TAGGER_PRIVATE_KEY`) instead of the default `GITHUB_TOKEN`, which the organization `rewind-tag` ruleset does not permit to create `v*` tags (the previous release failed with `Reference update failed`).
6
+
7
+ ## [3.3.1]
8
+
9
+ - Fix the `tag-and-release` workflow: grant the release job `contents: write` so tag and GitHub Release creation succeed. The repository's default GitHub Actions token permission had been changed to read-only, which broke the release step with `Resource not accessible by integration`.
10
+
11
+ ## [3.3.0]
12
+
13
+ - Add `percentage` condition for sticky A/B bucketing. Canonical algorithm: `bucket = Integer(SHA256("<flagName>:<value>")[0,8], 16) % 100`, satisfied iff `bucket < percentage`; salted by flag name; buckets on a caller-supplied parameter.
14
+ - Harden JSON unmarshalling: a flag containing an unknown or malformed condition (unknown `type`, or a known type with invalid params) now evaluates to `false` (OFF) and logs a warning, instead of raising and blacking out the entire feature set. Other flags in the blob are unaffected.
15
+ - Add optional top-level `metadata` (`type`, `owner`, `expires_at`) to `Feature`, preserved through marshall/unmarshall and ignored during evaluation.
16
+
17
+ ## [3.2.0]
18
+
19
+ - Upgrade Ruby to 3.4.5
20
+
3
21
  ## [3.1.1]
4
22
 
5
23
  - Loosen `required_ruby_version` to `>= 3.2` for Edge compatibility (EC-4270)
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- eight_ball (3.1.1)
4
+ eight_ball (3.3.2)
5
5
  awrence (~> 1.1)
6
6
  logger (~> 1.7)
7
7
  plissken (~> 1.2)
data/README.md CHANGED
@@ -81,6 +81,26 @@ A Condition must either be `true` or `false`. It describes when a Feature is ena
81
81
  - [List](lib/eight_ball/conditions/list.rb): This condition is satisfied if the given value belongs to its list of accepted values.
82
82
  - [Never](lib/eight_ball/conditions/never.rb): This condition is never satisfied.
83
83
  - [Range](lib/eight_ball/conditions/range.rb): This condition is satisfied if the given value is within the specified range (inclusive).
84
+ - [Percentage](lib/eight_ball/conditions/percentage.rb): This condition is satisfied for a deterministic, sticky subset of subjects sized to `percentage` percent. Bucketing is salted by the flag name so a subject is decorrelated across flags. Wire form: `{"type":"percentage","parameter":"account_id","percentage":<0..100>}`.
85
+
86
+ #### Sticky percentage bucketing
87
+
88
+ The `percentage` condition buckets a subject deterministically:
89
+
90
+ ```
91
+ bucket = Integer(Digest::SHA256.hexdigest("#{flagName}:#{value}")[0, 8], 16) % 100
92
+ satisfied iff bucket < percentage
93
+ ```
94
+
95
+ The salt is the **flag name**, so the same `account_id` lands in independent buckets across different flags. `value` is stringified before hashing. Any reimplementation must reproduce it exactly to produce the same buckets.
96
+
97
+ **Re-randomizing requires a rename.** Because the salt is the flag name, an experiment cannot be re-randomized on the same flag. The salt is stable for the life of the name (this is what decorrelates a subject across flags), so a subject's bucket for a given flag is fixed forever. Changing `percentage` only moves the threshold (it does not reshuffle buckets); to draw fresh buckets, rename the flag.
98
+
99
+ **Requires the bucketing parameter.** Like every parameterized condition, `percentage` requires its bucketing value (the `parameter` it was configured with) in the evaluation bag. If it is absent, evaluation raises `ArgumentError`; supply it or rescue.
100
+
101
+ ### Metadata
102
+
103
+ A Feature may carry an optional, eval-agnostic `metadata` object (`type`, `owner`, `expires_at`, all optional strings). It is preserved through marshall/unmarshall and ignored during evaluation. Its keys go through the same case conversion as the rest of the wire format (e.g. `expires_at` serializes as `expiresAt`) and come back symbol-keyed after unmarshall.
84
104
 
85
105
  ### Provider
86
106
 
data/eight_ball.gemspec CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |spec|
15
15
  spec.homepage = 'https://github.com/rewindio/eight_ball'
16
16
  spec.license = 'MIT'
17
17
 
18
- spec.required_ruby_version = '>= 3.2'
18
+ spec.required_ruby_version = '>= 3.4'
19
19
 
20
20
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec|.github|examples)/}) }
21
21
  spec.bindir = 'exe'
@@ -21,6 +21,23 @@ module EightBall::Conditions
21
21
  state.hash
22
22
  end
23
23
 
24
+ # The wire-format hash for this condition. Subclasses declare their fields via
25
+ # {wire_fields}; the type is the lower-cased class name, which must match a
26
+ # {EightBall::Conditions.by_name} key so the condition round-trips.
27
+ def to_wire
28
+ wire = { type: self.class.name.split('::').last.downcase }
29
+ wire_fields.each do |field|
30
+ value = public_send(field)
31
+ wire[field.to_s] = value unless value.nil?
32
+ end
33
+ wire
34
+ end
35
+
36
+ # The attributes this condition serializes, in output order (default: none).
37
+ def wire_fields
38
+ []
39
+ end
40
+
24
41
  protected
25
42
 
26
43
  def state
@@ -5,10 +5,13 @@ module EightBall::Conditions
5
5
  # @param [String] name The case insensitive name to find the Condition for
6
6
  # @return [EightBall::Conditions] the Condition class represented by the given name
7
7
  def self.by_name(name)
8
+ return nil unless name.is_a?(String)
9
+
8
10
  mappings = {
9
11
  always: EightBall::Conditions::Always,
10
12
  list: EightBall::Conditions::List,
11
13
  never: EightBall::Conditions::Never,
14
+ percentage: EightBall::Conditions::Percentage,
12
15
  range: EightBall::Conditions::Range
13
16
  }
14
17
  mappings[name.downcase.to_sym]
@@ -32,6 +32,10 @@ module EightBall::Conditions
32
32
  values.include? value
33
33
  end
34
34
 
35
+ def wire_fields
36
+ %i[values parameter]
37
+ end
38
+
35
39
  protected
36
40
 
37
41
  def state
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EightBall::Conditions
4
+ # Wraps a condition entry that could not be built (unknown type, invalid params,
5
+ # or a non-object entry). It is never satisfiable and re-serializes to its
6
+ # original raw input, so a Feature holding one is failed closed yet round-trips
7
+ # verbatim. Not registered in {by_name}; produced only as a parse fallback.
8
+ class Opaque < Base
9
+ attr_reader :raw
10
+
11
+ def initialize(raw)
12
+ @raw = raw
13
+ end
14
+
15
+ def satisfied?(*)
16
+ false
17
+ end
18
+
19
+ # Re-emit the original entry unchanged.
20
+ def to_wire
21
+ @raw
22
+ end
23
+
24
+ protected
25
+
26
+ def state
27
+ [@raw]
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module EightBall::Conditions
6
+ # The Percentage Condition is satisfied for a deterministic, sticky subset of
7
+ # values sized to +percentage+ percent. Bucketing is salted by the owning flag's
8
+ # name (injected by {EightBall::Feature}), so a value buckets independently per
9
+ # flag. See the README for the bucket algorithm.
10
+ class Percentage < Base
11
+ attr_reader :percentage, :flag_name
12
+
13
+ # @param [Hash] options
14
+ # @option options [Integer] :percentage Integer 0..100. 0 is never
15
+ # satisfied; 100 is always satisfied.
16
+ # @option options [String] :parameter The name of the parameter this Condition
17
+ # was created for (eg. "account_id").
18
+ def initialize(options = {})
19
+ options ||= {}
20
+
21
+ raise ArgumentError, 'Missing value for percentage' if options[:percentage].nil?
22
+
23
+ percentage = options[:percentage]
24
+ percentage = percentage.to_i if percentage.is_a?(Float) && percentage.to_i == percentage
25
+ unless percentage.is_a?(Integer) && percentage >= 0 && percentage <= 100
26
+ raise ArgumentError, 'percentage must be an integer between 0 and 100'
27
+ end
28
+
29
+ @percentage = percentage
30
+ @flag_name = nil
31
+
32
+ self.parameter = options[:parameter]
33
+ end
34
+
35
+ # Injected by {EightBall::Feature} so the bucket can be salted by flag name.
36
+ def flag_name=(name)
37
+ @flag_name = name
38
+ end
39
+
40
+ # @param value The value of {parameter} for the subject being evaluated.
41
+ # @return [Boolean] whether the subject falls within the bucket.
42
+ # @raise [ArgumentError] if {flag_name} was never injected.
43
+ def satisfied?(value)
44
+ raise ArgumentError, 'flag_name has not been set on Percentage condition' if @flag_name.nil?
45
+
46
+ bucket = Integer(Digest::SHA256.hexdigest("#{@flag_name}:#{value}")[0, 8], 16) % 100
47
+ bucket < @percentage
48
+ end
49
+
50
+ def wire_fields
51
+ %i[percentage parameter]
52
+ end
53
+
54
+ protected
55
+
56
+ # flag_name is a runtime injection, excluded from equality and the wire form.
57
+ def state
58
+ super + [@percentage]
59
+ end
60
+ end
61
+ end
@@ -45,6 +45,10 @@ module EightBall::Conditions
45
45
  value >= min && value <= max
46
46
  end
47
47
 
48
+ def wire_fields
49
+ %i[min max parameter]
50
+ end
51
+
48
52
  protected
49
53
 
50
54
  def state
@@ -4,7 +4,7 @@ module EightBall
4
4
  # A Feature is an element of your application that can be enabled or disabled
5
5
  # based on various {EightBall::Conditions}.
6
6
  class Feature
7
- attr_reader :name, :enabled_for, :disabled_for
7
+ attr_reader :name, :enabled_for, :disabled_for, :metadata
8
8
 
9
9
  # Creates a new instance of an Interval RefreshPolicy.
10
10
  #
@@ -13,13 +13,17 @@ module EightBall
13
13
  # The Condition(s) that need to be satisfied for the Feature to be enabled.
14
14
  # @param disabled_for [Array<EightBall::Conditions>, EightBall::Conditions]
15
15
  # The Condition(s) that need to be satisfied for the Feature to be disabled.
16
+ # @param metadata [Hash, nil] Optional eval-agnostic metadata
17
+ # (e.g. { "type" => ..., "owner" => ..., "expires_at" => ... }).
16
18
  #
17
19
  # @example A Feature which is always enabled
18
20
  # feature = EightBall::Feature.new 'feature1', EightBall::Conditions::Always
19
- def initialize(name, enabled_for = [], disabled_for = [])
21
+ def initialize(name, enabled_for = [], disabled_for = [], metadata: nil)
20
22
  @name = name
21
- @enabled_for = Array enabled_for
22
- @disabled_for = Array disabled_for
23
+ @enabled_for = inject_flag_name(Array(enabled_for))
24
+ @disabled_for = inject_flag_name(Array(disabled_for))
25
+ @metadata = metadata
26
+ @un_evaluable = false
23
27
  end
24
28
 
25
29
  # "EightBall, is this Feature enabled?"
@@ -42,18 +46,35 @@ module EightBall
42
46
  # @example The Feature's {EightBall::Conditions} require an account ID
43
47
  # feature.enabled? account_id: 123
44
48
  def enabled?(parameters = {})
49
+ return false if @un_evaluable
50
+
45
51
  return true if @enabled_for.empty? && @disabled_for.empty?
46
52
  return true if @enabled_for.empty? && !any_satisfied?(@disabled_for, parameters)
47
53
 
48
54
  any_satisfied?(@enabled_for, parameters) && !any_satisfied?(@disabled_for, parameters)
49
55
  end
50
56
 
57
+ # Marks this Feature un-evaluable: {enabled?} always returns +false+ and its
58
+ # Conditions are never inspected. Used by Marshallers to fail a bad flag closed.
59
+ #
60
+ # @return [nil]
61
+ def un_evaluable!
62
+ @un_evaluable = true
63
+ nil
64
+ end
65
+
66
+ # @return [Boolean] whether this Feature has been marked un-evaluable.
67
+ def un_evaluable?
68
+ @un_evaluable
69
+ end
70
+
51
71
  def ==(other)
52
72
  name == other.name &&
53
73
  enabled_for.size == other.enabled_for.size &&
54
74
  enabled_for.all? { |condition| other.enabled_for.any? { |other_condition| condition == other_condition } } &&
55
75
  disabled_for.size == other.disabled_for.size &&
56
- disabled_for.all? { |condition| other.disabled_for.any? { |other_condition| condition == other_condition } }
76
+ disabled_for.all? { |condition| other.disabled_for.any? { |other_condition| condition == other_condition } } &&
77
+ metadata == other.metadata
57
78
  end
58
79
  alias eql? ==
59
80
 
@@ -69,5 +90,16 @@ module EightBall
69
90
  condition.satisfied? value
70
91
  end
71
92
  end
93
+
94
+ # Percentage conditions bucket on a salt of the flag name. Give any condition
95
+ # that accepts it the owning name, duping first so a condition shared across
96
+ # Features is not re-salted in place. Others pass through untouched.
97
+ def inject_flag_name(conditions)
98
+ conditions.map do |condition|
99
+ next condition unless condition.respond_to?(:flag_name=)
100
+
101
+ condition.dup.tap { |copy| copy.flag_name = @name }
102
+ end
103
+ end
72
104
  end
73
105
  end
@@ -62,54 +62,75 @@ module EightBall::Marshallers
62
62
  # marshaller = EightBall::Marshallers::Json.new
63
63
  # marshaller.unmarshall json_string => [Features]
64
64
  def unmarshall(json)
65
- parsed = JSON.parse(json, symbolize_names: true).to_snake_keys
65
+ parsed = JSON.parse(json, symbolize_names: true)
66
66
 
67
67
  raise ArgumentError, 'JSON input was not an array' unless parsed.is_a? Array
68
68
 
69
- parsed.map do |feature|
70
- enabled_for = create_conditions_from_json feature[:enabled_for]
71
- disabled_for = create_conditions_from_json feature[:disabled_for]
72
-
73
- EightBall::Feature.new feature[:name], enabled_for, disabled_for
69
+ parsed.to_snake_keys.filter_map do |feature|
70
+ build_feature feature
74
71
  end
75
72
  rescue JSON::ParserError => e
76
- EightBall.logger.error { "Failed to parse JSON: #{e.message}" }
73
+ EightBall.logger.error { "Failed to parse JSON: #{e.message.inspect}" }
77
74
  []
78
75
  end
79
76
 
80
77
  private
81
78
 
82
- def feature_to_hash(feature)
83
- hash = {
84
- name: feature.name
85
- }
79
+ def build_feature(feature)
80
+ unless feature.is_a?(Hash)
81
+ EightBall.logger.warn { "Skipping non-object feature entry: #{feature.inspect}" }
82
+ return nil
83
+ end
86
84
 
87
- hash[:enabled_for] = feature.enabled_for.map { |condition| condition_to_hash(condition) } unless feature.enabled_for.empty?
88
- hash[:disabled_for] = feature.disabled_for.map { |condition| condition_to_hash(condition) } unless feature.disabled_for.empty?
85
+ enabled_for = create_conditions_from_json feature[:enabled_for]
86
+ disabled_for = create_conditions_from_json feature[:disabled_for]
87
+ built = EightBall::Feature.new feature[:name], enabled_for, disabled_for, metadata: feature[:metadata]
89
88
 
90
- hash
89
+ unparseable = (enabled_for + disabled_for).select { |condition| condition.is_a?(EightBall::Conditions::Opaque) }
90
+
91
+ # A nameless flag can't be looked up, and its flag-name-salted conditions raise at eval; fail it closed.
92
+ if feature[:name].nil?
93
+ EightBall.logger.warn { 'Feature entry has no name; marking it un-evaluable (OFF)' }
94
+ built.un_evaluable!
95
+ elsif !unparseable.empty?
96
+ EightBall.logger.warn { "Feature #{feature[:name].inspect} has unparseable condition(s) #{unparseable.map(&:raw).inspect}; marking it un-evaluable (OFF)" }
97
+ built.un_evaluable!
98
+ end
99
+
100
+ built
91
101
  end
92
102
 
93
- def condition_to_hash(condition)
103
+ def feature_to_hash(feature)
94
104
  hash = {
95
- type: condition.class.name.split('::').last.downcase
105
+ name: feature.name
96
106
  }
97
- condition.instance_variables.each do |var|
98
- next unless condition.instance_variable_get(var)
99
107
 
100
- hash[var.to_s.delete('@')] = condition.instance_variable_get(var)
101
- end
108
+ hash[:enabled_for] = feature.enabled_for.map(&:to_wire) unless feature.enabled_for.empty?
109
+ hash[:disabled_for] = feature.disabled_for.map(&:to_wire) unless feature.disabled_for.empty?
110
+ hash[:metadata] = feature.metadata unless feature.metadata.nil?
102
111
 
103
112
  hash
104
113
  end
105
114
 
106
115
  def create_conditions_from_json(json_conditions)
107
- return [] unless json_conditions&.is_a?(Array)
116
+ return [] if json_conditions.nil?
117
+ return [EightBall::Conditions::Opaque.new(json_conditions)] unless json_conditions.is_a?(Array)
108
118
 
109
- json_conditions.map do |condition|
110
- condition_class = EightBall::Conditions.by_name condition[:type]
111
- condition_class.new condition
112
- end
119
+ json_conditions.map { |condition| build_condition condition }
120
+ end
121
+
122
+ # Build one condition, or wrap anything unbuildable (non-object, unknown type,
123
+ # invalid params) in an Opaque condition so a single bad entry fails only its
124
+ # flag closed instead of aborting the whole parse.
125
+ def build_condition(condition)
126
+ return EightBall::Conditions::Opaque.new(condition) unless condition.is_a?(Hash)
127
+
128
+ condition_class = EightBall::Conditions.by_name condition[:type]
129
+ return EightBall::Conditions::Opaque.new(condition) if condition_class.nil?
130
+
131
+ condition_class.new condition
132
+ rescue StandardError
133
+ EightBall::Conditions::Opaque.new(condition)
113
134
  end
114
135
  end
115
136
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EightBall
4
- VERSION = '3.1.1'
4
+ VERSION = '3.3.2'
5
5
  end
data/lib/eight_ball.rb CHANGED
@@ -11,6 +11,8 @@ require 'eight_ball/conditions/base'
11
11
  require 'eight_ball/conditions/always'
12
12
  require 'eight_ball/conditions/list'
13
13
  require 'eight_ball/conditions/never'
14
+ require 'eight_ball/conditions/opaque'
15
+ require 'eight_ball/conditions/percentage'
14
16
  require 'eight_ball/conditions/range'
15
17
 
16
18
  require 'eight_ball/marshallers/json'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eight_ball
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rewind.io
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-04-17 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: awrence
@@ -175,6 +174,8 @@ files:
175
174
  - lib/eight_ball/conditions/conditions.rb
176
175
  - lib/eight_ball/conditions/list.rb
177
176
  - lib/eight_ball/conditions/never.rb
177
+ - lib/eight_ball/conditions/opaque.rb
178
+ - lib/eight_ball/conditions/percentage.rb
178
179
  - lib/eight_ball/conditions/range.rb
179
180
  - lib/eight_ball/configuration_error.rb
180
181
  - lib/eight_ball/feature.rb
@@ -187,7 +188,6 @@ homepage: https://github.com/rewindio/eight_ball
187
188
  licenses:
188
189
  - MIT
189
190
  metadata: {}
190
- post_install_message:
191
191
  rdoc_options: []
192
192
  require_paths:
193
193
  - lib
@@ -195,15 +195,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
195
195
  requirements:
196
196
  - - ">="
197
197
  - !ruby/object:Gem::Version
198
- version: '3.2'
198
+ version: '3.4'
199
199
  required_rubygems_version: !ruby/object:Gem::Requirement
200
200
  requirements:
201
201
  - - ">="
202
202
  - !ruby/object:Gem::Version
203
203
  version: '0'
204
204
  requirements: []
205
- rubygems_version: 3.5.22
206
- signing_key:
205
+ rubygems_version: 3.6.9
207
206
  specification_version: 4
208
207
  summary: The most cost efficient way to flag features
209
208
  test_files: []