flipper 0.26.0 → 0.26.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5edb0409042f8d4d5905b8d526fd1d240a75dbee2d947b8ae4adae0c534af5f
4
- data.tar.gz: 6bc01d5f7c0ee5f3d64d30faaa599b155d6220e249006d2ead7168bc47938202
3
+ metadata.gz: cbdfdece0141d3724dbd78a9df4062193e6860a95baff3b617b07bb2e81e50a5
4
+ data.tar.gz: bead9a1b7f0842bc3b3d1560ea51132d60a55f1399b80390c3d7ccfcec56abe5
5
5
  SHA512:
6
- metadata.gz: 60fee2e4a0d97a26b7403736b4422c0e69a0e9e452469e3f29e7af684001c38f2c957acdfa3f8f0554315d021a7e7091bb40898247e45d4658b14b31bc1de22f
7
- data.tar.gz: bf45e695be511513be967bffd75c4e48d3af0f05b05264ee3b13a9df8bba8af929ccbf6b7a1f05c6c3a7d5476ceb757e3dda852f8e42b745bf7171f42538fb14
6
+ metadata.gz: 5528f98059ae40042c4f894062652c9438e798d09ae89dbb65a70af1681031c7d2b57facb36791b653899524a2df16d0a58d6be599b766b3baf576f40eff4609
7
+ data.tar.gz: c4c32a2b1d8015645660eef4ce9c4639e0a271ed35144fc3b00a6dfe59f101583e84cf17e6fd7f0f0b23bce224195f2e06ae232e6b4a767c2415b12e7f809a83
@@ -15,7 +15,7 @@ jobs:
15
15
  --health-retries 5
16
16
  strategy:
17
17
  matrix:
18
- ruby: ['2.6', '2.7', '3.0', '3.1']
18
+ ruby: ['2.6', '2.7', '3.0', '3.1', '3.2']
19
19
  rails: ['5.2', '6.0.0', '6.1.0', '7.0.0']
20
20
  exclude:
21
21
  - ruby: "2.6"
@@ -26,15 +26,22 @@ jobs:
26
26
  rails: "5.2"
27
27
  - ruby: "3.1"
28
28
  rails: "6.0.0"
29
+ - ruby: "3.2"
30
+ rails: "5.2"
31
+ - ruby: "3.2"
32
+ rails: "6.0.0"
33
+ - ruby: "3.2"
34
+ rails: "6.1.0"
29
35
  env:
30
36
  SQLITE3_VERSION: 1.4.1
31
37
  REDIS_URL: redis://localhost:6379/0
32
38
  CI: true
39
+ RAILS_VERSION: ${{ matrix.rails }}
33
40
  steps:
34
41
  - name: Setup memcached
35
42
  uses: KeisukeYamashita/memcached-actions@v1
36
43
  - name: Start MongoDB
37
- uses: supercharge/mongodb-github-action@1.8.0
44
+ uses: supercharge/mongodb-github-action@1.9.0
38
45
  with:
39
46
  mongodb-version: 4.0
40
47
  - name: Check out repository code
@@ -46,17 +53,12 @@ jobs:
46
53
  key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ matrix.rails }}-${{ hashFiles('**/Gemfile.lock') }}
47
54
  restore-keys: |
48
55
  ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ matrix.rails }}-
56
+ - name: Install libpq-dev
57
+ run: sudo apt-get -yqq install libpq-dev
49
58
  - name: Set up Ruby ${{ matrix.ruby }}
50
59
  uses: ruby/setup-ruby@v1
51
60
  with:
52
61
  ruby-version: ${{ matrix.ruby }}
53
- - name: Install libpq-dev
54
- run: sudo apt-get -yqq install libpq-dev
55
- - name: Install bundler
56
- run: gem install bundler
62
+ bundler-cache: true # 'bundle install' and cache gems
57
63
  - name: Run Rake with Rails ${{ matrix.rails }}
58
- env:
59
- RAILS_VERSION: ${{ matrix.rails }}
60
- run: |
61
- bundle install --jobs 4 --retry 3
62
- bundle exec rake
64
+ run: bundle exec rake
@@ -32,11 +32,12 @@ jobs:
32
32
  SQLITE3_VERSION: 1.4.1
33
33
  REDIS_URL: redis://localhost:6379/0
34
34
  CI: true
35
+ RAILS_VERSION: ${{ matrix.rails }}
35
36
  steps:
36
37
  - name: Setup memcached
37
38
  uses: KeisukeYamashita/memcached-actions@v1
38
39
  - name: Start MongoDB
39
- uses: supercharge/mongodb-github-action@1.8.0
40
+ uses: supercharge/mongodb-github-action@1.9.0
40
41
  with:
41
42
  mongodb-version: 4.0
42
43
  - name: Check out repository code
@@ -48,20 +49,14 @@ jobs:
48
49
  key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ matrix.rails }}-${{ hashFiles('**/Gemfile.lock') }}
49
50
  restore-keys: |
50
51
  ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ matrix.rails }}-
52
+ - name: Install libpq-dev
53
+ run: sudo apt-get -yqq install libpq-dev
51
54
  - name: Set up Ruby ${{ matrix.ruby }}
52
55
  uses: ruby/setup-ruby@v1
53
56
  with:
54
57
  ruby-version: ${{ matrix.ruby }}
55
- - name: Install libpq-dev
56
- run: sudo apt-get -yqq install libpq-dev
57
- - name: Install bundler
58
- run: gem install bundler
59
- - name: Bundle install with Rails ${{ matrix.rails }}
60
- env:
61
- RAILS_VERSION: ${{ matrix.rails }}
62
- run: bundle install --jobs 4 --retry 3
58
+ bundler-cache: true # 'bundle install' and cache gems
63
59
  - name: Run Examples with Rails ${{ matrix.rails }}
64
60
  env:
65
61
  FLIPPER_CLOUD_TOKEN: ${{ secrets.FLIPPER_CLOUD_TOKEN }}
66
- RAILS_VERSION: ${{ matrix.rails }}
67
62
  run: script/examples
data/Changelog.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.26.1
6
+
7
+ * Improve `Flipper#enabled?` performance by ~37%-55% (https://github.com/jnunemaker/flipper/pull/706)
8
+ * Make Memory adapter threadsafe (https://github.com/jnunemaker/flipper/pull/702 and https://github.com/jnunemaker/flipper/pull/703)
9
+ * ActiveRecord adapter: wrap all reads/writes in `with_connection` (https://github.com/jnunemaker/flipper/pull/705)
10
+ * Improve performance of background polling (https://github.com/jnunemaker/flipper/pull/699)
11
+ * Remove executables directive from gem (https://github.com/jnunemaker/flipper/pull/693)
12
+
5
13
  ## 0.26.0
6
14
 
7
15
  * Cloud Background Polling (https://github.com/jnunemaker/flipper/pull/682)
data/Gemfile CHANGED
@@ -20,6 +20,10 @@ gem 'webmock', '~> 3.0'
20
20
  gem 'ice_age'
21
21
  gem 'redis-namespace'
22
22
  gem 'webrick'
23
+ gem 'stackprof'
24
+ gem 'benchmark-ips'
25
+ gem 'stackprof-webnav'
26
+ gem 'flamegraph'
23
27
 
24
28
  group(:guard) do
25
29
  gem 'guard', '~> 2.15'
@@ -0,0 +1,10 @@
1
+ require 'bundler/setup'
2
+ require 'flipper'
3
+ require 'benchmark/ips'
4
+
5
+ actor = Flipper::Actor.new("User;1")
6
+
7
+ Benchmark.ips do |x|
8
+ x.report("with actor") { Flipper.enabled?(:foo, actor) }
9
+ x.report("without actor") { Flipper.enabled?(:foo) }
10
+ end
@@ -0,0 +1,20 @@
1
+ require 'bundler/setup'
2
+ require 'flipper'
3
+ require 'stackprof'
4
+ require 'benchmark/ips'
5
+
6
+ flipper = Flipper.new(Flipper::Adapters::Memory.new)
7
+ feature = flipper.feature(:foo)
8
+ actor = Flipper::Actor.new("User;1")
9
+
10
+ profile = StackProf.run(mode: :wall, interval: 1_000) do
11
+ 2_000_000.times do
12
+ feature.enabled?(actor)
13
+ end
14
+ end
15
+
16
+ result = StackProf::Report.new(profile)
17
+ puts
18
+ result.print_text
19
+ puts "\n\n\n"
20
+ result.print_method(/Flipper::Feature#enabled?/)
@@ -0,0 +1,21 @@
1
+ require 'bundler/setup'
2
+ require 'flipper'
3
+ require 'active_support/notifications'
4
+ require 'active_support/isolated_execution_state'
5
+ require 'benchmark/ips'
6
+
7
+ class FlipperSubscriber
8
+ def call(name, start, finish, id, payload)
9
+ end
10
+
11
+ ActiveSupport::Notifications.subscribe(/flipper/, new)
12
+ end
13
+
14
+ actor = Flipper::Actor.new("User;1")
15
+ bare = Flipper.new(Flipper::Adapters::Memory.new)
16
+ instrumented = Flipper.new(Flipper::Adapters::Memory.new, instrumenter: ActiveSupport::Notifications)
17
+
18
+ Benchmark.ips do |x|
19
+ x.report("with instrumentation") { instrumented.enabled?(:foo, actor) }
20
+ x.report("without instrumentation") { bare.enabled?(:foo, actor) }
21
+ end
@@ -0,0 +1,19 @@
1
+ require 'bundler/setup'
2
+ require 'flipper'
3
+ require 'benchmark/ips'
4
+
5
+ Benchmark.ips do |x|
6
+ x.report("Typecast.to_boolean true") { Flipper::Typecast.to_boolean(true) }
7
+ x.report("Typecast.to_boolean 1") { Flipper::Typecast.to_boolean(1) }
8
+ x.report("Typecast.to_boolean 'true'") { Flipper::Typecast.to_boolean('true'.freeze) }
9
+ x.report("Typecast.to_boolean '1'") { Flipper::Typecast.to_boolean('1'.freeze) }
10
+ x.report("Typecast.to_boolean false") { Flipper::Typecast.to_boolean(false) }
11
+
12
+ x.report("Typecast.to_integer 1") { Flipper::Typecast.to_integer(1) }
13
+ x.report("Typecast.to_integer '1'") { Flipper::Typecast.to_integer('1') }
14
+
15
+ x.report("Typecast.to_float 1") { Flipper::Typecast.to_float(1) }
16
+ x.report("Typecast.to_float '1'") { Flipper::Typecast.to_float('1'.freeze) }
17
+ x.report("Typecast.to_float 1.01") { Flipper::Typecast.to_float(1) }
18
+ x.report("Typecast.to_float '1.01'") { Flipper::Typecast.to_float('1'.freeze) }
19
+ end
data/flipper.gemspec CHANGED
@@ -13,7 +13,6 @@ end
13
13
 
14
14
  ignored_files = plugin_files
15
15
  ignored_files << Dir['script/*']
16
- ignored_files << '.travis.yml'
17
16
  ignored_files << '.gitignore'
18
17
  ignored_files << 'Guardfile'
19
18
  ignored_files.flatten!.uniq!
@@ -28,7 +27,6 @@ Gem::Specification.new do |gem|
28
27
  gem.homepage = 'https://github.com/jnunemaker/flipper'
29
28
  gem.license = 'MIT'
30
29
 
31
- gem.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
32
30
  gem.files = `git ls-files`.split("\n") - ignored_files + ['lib/flipper/version.rb']
33
31
  gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") - ignored_test_files
34
32
  gem.name = 'flipper'
@@ -1,4 +1,4 @@
1
- require 'set'
1
+ require 'concurrent/atomic/read_write_lock'
2
2
 
3
3
  module Flipper
4
4
  module Adapters
@@ -14,86 +14,93 @@ module Flipper
14
14
 
15
15
  # Public
16
16
  def initialize(source = nil)
17
- @source = source || {}
17
+ @source = Hash.new.update(source || {})
18
18
  @name = :memory
19
+ @lock = Concurrent::ReadWriteLock.new
19
20
  end
20
21
 
21
22
  # Public: The set of known features.
22
23
  def features
23
- @source.keys.to_set
24
+ @lock.with_read_lock { @source.keys }.to_set
24
25
  end
25
26
 
26
27
  # Public: Adds a feature to the set of known features.
27
28
  def add(feature)
28
- @source[feature.key] ||= default_config
29
+ @lock.with_write_lock { @source[feature.key] ||= default_config }
29
30
  true
30
31
  end
31
32
 
32
33
  # Public: Removes a feature from the set of known features and clears
33
34
  # all the values for the feature.
34
35
  def remove(feature)
35
- @source.delete(feature.key)
36
+ @lock.with_write_lock { @source.delete(feature.key) }
36
37
  true
37
38
  end
38
39
 
39
40
  # Public: Clears all the gate values for a feature.
40
41
  def clear(feature)
41
- @source[feature.key] = default_config
42
+ @lock.with_write_lock { @source[feature.key] = default_config }
42
43
  true
43
44
  end
44
45
 
45
46
  # Public
46
47
  def get(feature)
47
- @source[feature.key] || default_config
48
+ @lock.with_read_lock { @source[feature.key] } || default_config
48
49
  end
49
50
 
50
51
  def get_multi(features)
51
- result = {}
52
- features.each do |feature|
53
- result[feature.key] = @source[feature.key] || default_config
52
+ @lock.with_read_lock do
53
+ result = {}
54
+ features.each do |feature|
55
+ result[feature.key] = @source[feature.key] || default_config
56
+ end
57
+ result
54
58
  end
55
- result
56
59
  end
57
60
 
58
61
  def get_all
59
- @source
62
+ @lock.with_read_lock { @source.to_h }
60
63
  end
61
64
 
62
65
  # Public
63
66
  def enable(feature, gate, thing)
64
- @source[feature.key] ||= default_config
65
-
66
- case gate.data_type
67
- when :boolean
68
- clear(feature)
69
- @source[feature.key][gate.key] = thing.value.to_s
70
- when :integer
71
- @source[feature.key][gate.key] = thing.value.to_s
72
- when :set
73
- @source[feature.key][gate.key] << thing.value.to_s
74
- else
75
- raise "#{gate} is not supported by this adapter yet"
67
+ @lock.with_write_lock do
68
+ @source[feature.key] ||= default_config
69
+
70
+ case gate.data_type
71
+ when :boolean
72
+ @source[feature.key] = default_config
73
+ @source[feature.key][gate.key] = thing.value.to_s
74
+ when :integer
75
+ @source[feature.key][gate.key] = thing.value.to_s
76
+ when :set
77
+ @source[feature.key][gate.key] << thing.value.to_s
78
+ else
79
+ raise "#{gate} is not supported by this adapter yet"
80
+ end
81
+
82
+ true
76
83
  end
77
-
78
- true
79
84
  end
80
85
 
81
86
  # Public
82
87
  def disable(feature, gate, thing)
83
- @source[feature.key] ||= default_config
84
-
85
- case gate.data_type
86
- when :boolean
87
- clear(feature)
88
- when :integer
89
- @source[feature.key][gate.key] = thing.value.to_s
90
- when :set
91
- @source[feature.key][gate.key].delete thing.value.to_s
92
- else
93
- raise "#{gate} is not supported by this adapter yet"
88
+ @lock.with_write_lock do
89
+ @source[feature.key] ||= default_config
90
+
91
+ case gate.data_type
92
+ when :boolean
93
+ @source[feature.key] = default_config
94
+ when :integer
95
+ @source[feature.key][gate.key] = thing.value.to_s
96
+ when :set
97
+ @source[feature.key][gate.key].delete thing.value.to_s
98
+ else
99
+ raise "#{gate} is not supported by this adapter yet"
100
+ end
101
+
102
+ true
94
103
  end
95
-
96
- true
97
104
  end
98
105
 
99
106
  # Public
@@ -104,6 +111,12 @@ module Flipper
104
111
  ]
105
112
  "#<#{self.class.name}:#{object_id} #{attributes.join(', ')}>"
106
113
  end
114
+
115
+ # Public: a more efficient implementation of import for this adapter
116
+ def import(source_adapter)
117
+ get_all = source_adapter.get_all
118
+ @lock.with_write_lock { @source.replace(get_all) }
119
+ end
107
120
  end
108
121
  end
109
122
  end
@@ -1,125 +1,2 @@
1
- require 'logger'
2
- require 'concurrent/atomic/read_write_lock'
3
- require 'concurrent/utility/monotonic_time'
4
- require 'concurrent/map'
5
-
6
- module Flipper
7
- module Adapters
8
- class Poll
9
- class Poller
10
- attr_reader :thread, :pid, :mutex, :interval, :last_synced_at
11
-
12
- def self.instances
13
- @instances ||= Concurrent::Map.new
14
- end
15
- private_class_method :instances
16
-
17
- def self.get(key, options = {})
18
- instances.compute_if_absent(key) { new(options) }
19
- end
20
-
21
- def self.reset
22
- instances.each {|_,poller| poller.stop }.clear
23
- end
24
-
25
- def initialize(options = {})
26
- @thread = nil
27
- @pid = Process.pid
28
- @mutex = Mutex.new
29
- @adapter = Memory.new
30
- @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
31
- @remote_adapter = options.fetch(:remote_adapter)
32
- @interval = options.fetch(:interval, 10).to_f
33
- @lock = Concurrent::ReadWriteLock.new
34
- @last_synced_at = Concurrent::AtomicFixnum.new(0)
35
-
36
- if @interval < 1
37
- warn "Flipper::Cloud poll interval must be greater than or equal to 1 but was #{@interval}. Setting @interval to 1."
38
- @interval = 1
39
- end
40
-
41
- @start_automatically = options.fetch(:start_automatically, true)
42
-
43
- if options.fetch(:shutdown_automatically, true)
44
- at_exit { stop }
45
- end
46
- end
47
-
48
- def adapter
49
- @lock.with_read_lock { Memory.new(@adapter.get_all.dup) }
50
- end
51
-
52
- def start
53
- reset if forked?
54
- ensure_worker_running
55
- end
56
-
57
- def stop
58
- @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
59
- operation: :stop,
60
- })
61
- @thread&.kill
62
- end
63
-
64
- def run
65
- loop do
66
- sleep jitter
67
- start = Concurrent.monotonic_time
68
- begin
69
- @instrumenter.instrument("poller.#{InstrumentationNamespace}", operation: :poll) do
70
- adapter = Memory.new
71
- adapter.import(@remote_adapter)
72
-
73
- @lock.with_write_lock { @adapter.import(adapter) }
74
- @last_synced_at.update { |time| Concurrent.monotonic_time }
75
- end
76
- rescue => exception
77
- # you can instrument these using poller.flipper
78
- end
79
-
80
- sleep_interval = interval - (Concurrent.monotonic_time - start)
81
- sleep sleep_interval if sleep_interval.positive?
82
- end
83
- end
84
-
85
- private
86
-
87
- def jitter
88
- rand
89
- end
90
-
91
- def forked?
92
- pid != Process.pid
93
- end
94
-
95
- def ensure_worker_running
96
- # Return early if thread is alive and avoid the mutex lock and unlock.
97
- return if thread_alive?
98
-
99
- # If another thread is starting worker thread, then return early so this
100
- # thread can enqueue and move on with life.
101
- return unless mutex.try_lock
102
-
103
- begin
104
- return if thread_alive?
105
- @thread = Thread.new { run }
106
- @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
107
- operation: :thread_start,
108
- })
109
- ensure
110
- mutex.unlock
111
- end
112
- end
113
-
114
- def thread_alive?
115
- @thread && @thread.alive?
116
- end
117
-
118
- def reset
119
- @pid = Process.pid
120
- mutex.unlock if mutex.locked?
121
- end
122
- end
123
- end
124
- end
125
- end
1
+ warn "DEPRECATION WARNING: Flipper::Adapters::Poll::Poller is deprecated. Use Flipper::Poller instead."
2
+ require 'flipper/adapters/poll'
@@ -1,4 +1,5 @@
1
1
  require 'flipper/adapters/sync/synchronizer'
2
+ require 'flipper/poller'
2
3
 
3
4
  module Flipper
4
5
  module Adapters
@@ -6,6 +7,9 @@ module Flipper
6
7
  extend Forwardable
7
8
  include ::Flipper::Adapter
8
9
 
10
+ # Deprecated
11
+ Poller = ::Flipper::Poller
12
+
9
13
  # Public: The name of the adapter.
10
14
  attr_reader :name, :adapter, :poller
11
15
 
@@ -100,13 +100,12 @@ module Flipper
100
100
  #
101
101
  # Returns true if enabled, false if not.
102
102
  def enabled?(thing = nil)
103
- instrument(:enabled?) do |payload|
104
- values = gate_values
105
- thing = gate(:actor).wrap(thing) unless thing.nil?
106
- payload[:thing] = thing
103
+ thing = Types::Actor.wrap(thing) unless thing.nil?
104
+
105
+ instrument(:enabled?, thing: thing) do |payload|
107
106
  context = FeatureCheckContext.new(
108
107
  feature_name: @name,
109
- values: values,
108
+ values: gate_values,
110
109
  thing: thing
111
110
  )
112
111
 
@@ -207,7 +206,7 @@ module Flipper
207
206
 
208
207
  if values.boolean || values.percentage_of_time == 100
209
208
  :on
210
- elsif non_boolean_gates.detect { |gate| gate.enabled?(values[gate.key]) }
209
+ elsif non_boolean_gates.detect { |gate| gate.enabled?(values.send(gate.key)) }
211
210
  :conditional
212
211
  else
213
212
  :off
@@ -232,7 +231,8 @@ module Flipper
232
231
 
233
232
  # Public: Returns the raw gate values stored by the adapter.
234
233
  def gate_values
235
- GateValues.new(adapter.get(self))
234
+ adapter_values = adapter.get(self)
235
+ GateValues.new(adapter_values)
236
236
  end
237
237
 
238
238
  # Public: Get groups enabled for this feature.
@@ -290,7 +290,7 @@ module Flipper
290
290
  # Returns an Array of Flipper::Gate instances.
291
291
  def enabled_gates
292
292
  values = gate_values
293
- gates.select { |gate| gate.enabled?(values[gate.key]) }
293
+ gates.select { |gate| gate.enabled?(values.send(gate.key)) }
294
294
  end
295
295
 
296
296
  # Public: Get the names of the enabled gates.
@@ -339,20 +339,24 @@ module Flipper
339
339
  #
340
340
  # Returns an array of gates
341
341
  def gates
342
- @gates ||= [
343
- Gates::Boolean.new,
344
- Gates::Actor.new,
345
- Gates::PercentageOfActors.new,
346
- Gates::PercentageOfTime.new,
347
- Gates::Group.new,
348
- ]
342
+ @gates ||= gates_hash.values.freeze
343
+ end
344
+
345
+ def gates_hash
346
+ @gates_hash ||= {
347
+ boolean: Gates::Boolean.new,
348
+ actor: Gates::Actor.new,
349
+ percentage_of_actors: Gates::PercentageOfActors.new,
350
+ percentage_of_time: Gates::PercentageOfTime.new,
351
+ group: Gates::Group.new,
352
+ }.freeze
349
353
  end
350
354
 
351
355
  # Public: Find a gate by name.
352
356
  #
353
357
  # Returns a Flipper::Gate if found, nil if not.
354
358
  def gate(name)
355
- gates.detect { |gate| gate.name == name.to_sym }
359
+ gates_hash[name.to_sym]
356
360
  end
357
361
 
358
362
  # Public: Find the gate that protects a thing.
@@ -368,8 +372,8 @@ module Flipper
368
372
  private
369
373
 
370
374
  # Private: Instrument a feature operation.
371
- def instrument(operation)
372
- @instrumenter.instrument(InstrumentationName) do |payload|
375
+ def instrument(operation, initial_payload = {})
376
+ @instrumenter.instrument(InstrumentationName, initial_payload) do |payload|
373
377
  payload[:feature_name] = name
374
378
  payload[:operation] = operation
375
379
  payload[:result] = yield(payload) if block_given?
@@ -10,10 +10,10 @@ module Flipper
10
10
  # Public: The thing we want to know if a feature is enabled for.
11
11
  attr_reader :thing
12
12
 
13
- def initialize(options = {})
14
- @feature_name = options.fetch(:feature_name)
15
- @values = options.fetch(:values)
16
- @thing = options.fetch(:thing)
13
+ def initialize(feature_name:, values:, thing:)
14
+ @feature_name = feature_name
15
+ @values = values
16
+ @thing = thing
17
17
  end
18
18
 
19
19
  # Public: Convenience method for groups value like Feature has.
@@ -3,16 +3,6 @@ require 'flipper/typecast'
3
3
 
4
4
  module Flipper
5
5
  class GateValues
6
- # Private: Array of instance variables that are readable through the []
7
- # instance method.
8
- LegitIvars = {
9
- 'boolean' => '@boolean',
10
- 'actors' => '@actors',
11
- 'groups' => '@groups',
12
- 'percentage_of_time' => '@percentage_of_time',
13
- 'percentage_of_actors' => '@percentage_of_actors',
14
- }.freeze
15
-
16
6
  attr_reader :boolean
17
7
  attr_reader :actors
18
8
  attr_reader :groups
@@ -27,12 +17,6 @@ module Flipper
27
17
  @percentage_of_time = Typecast.to_percentage(adapter_values[:percentage_of_time])
28
18
  end
29
19
 
30
- def [](key)
31
- if ivar = LegitIvars[key.to_s]
32
- instance_variable_get(ivar)
33
- end
34
- end
35
-
36
20
  def eql?(other)
37
21
  self.class.eql?(other.class) &&
38
22
  boolean == other.boolean &&
@@ -23,18 +23,8 @@ module Flipper
23
23
  #
24
24
  # Returns true if gate open for thing, false if not.
25
25
  def open?(context)
26
- value = context.values[key]
27
- if context.thing.nil?
28
- false
29
- else
30
- if protects?(context.thing)
31
- actor = wrap(context.thing)
32
- enabled_actor_ids = value
33
- enabled_actor_ids.include?(actor.value)
34
- else
35
- false
36
- end
37
- end
26
+ return false if context.thing.nil?
27
+ context.values.actors.include?(context.thing.value)
38
28
  end
39
29
 
40
30
  def wrap(thing)
@@ -24,7 +24,7 @@ module Flipper
24
24
  # Returns true if explicitly set to true, false if explicitly set to false
25
25
  # or nil if not explicitly set.
26
26
  def open?(context)
27
- context.values[key]
27
+ context.values.boolean
28
28
  end
29
29
 
30
30
  def wrap(thing)
@@ -23,14 +23,10 @@ module Flipper
23
23
  #
24
24
  # Returns true if gate open for thing, false if not.
25
25
  def open?(context)
26
- value = context.values[key]
27
- if context.thing.nil?
28
- false
29
- else
30
- value.any? do |name|
31
- group = Flipper.group(name)
32
- group.match?(context.thing, context)
33
- end
26
+ return false if context.thing.nil?
27
+
28
+ context.values.groups.any? do |name|
29
+ Flipper.group(name).match?(context.thing, context)
34
30
  end
35
31
  end
36
32
 
@@ -21,21 +21,19 @@ module Flipper
21
21
  value > 0
22
22
  end
23
23
 
24
+ # Private: this constant is used to support up to 3 decimal places
25
+ # in percentages.
26
+ SCALING_FACTOR = 1_000
27
+ private_constant :SCALING_FACTOR
28
+
24
29
  # Internal: Checks if the gate is open for a thing.
25
30
  #
26
31
  # Returns true if gate open for thing, false if not.
27
32
  def open?(context)
28
- percentage = context.values[key]
29
-
30
- if Types::Actor.wrappable?(context.thing)
31
- actor = Types::Actor.wrap(context.thing)
32
- id = "#{context.feature_name}#{actor.value}"
33
- # this is to support up to 3 decimal places in percentages
34
- scaling_factor = 1_000
35
- Zlib.crc32(id) % (100 * scaling_factor) < percentage * scaling_factor
36
- else
37
- false
38
- end
33
+ return false if context.thing.nil?
34
+
35
+ id = "#{context.feature_name}#{context.thing.value}"
36
+ Zlib.crc32(id) % (100 * SCALING_FACTOR) < context.values.percentage_of_actors * SCALING_FACTOR
39
37
  end
40
38
 
41
39
  def protects?(thing)
@@ -23,8 +23,7 @@ module Flipper
23
23
  #
24
24
  # Returns true if gate open for thing, false if not.
25
25
  def open?(context)
26
- value = context.values[key]
27
- rand < (value / 100.0)
26
+ rand < (context.values.percentage_of_time / 100.0)
28
27
  end
29
28
 
30
29
  def protects?(thing)
@@ -0,0 +1,117 @@
1
+ require 'logger'
2
+ require 'concurrent/utility/monotonic_time'
3
+ require 'concurrent/map'
4
+ require 'concurrent/atomic/atomic_fixnum'
5
+
6
+ module Flipper
7
+ class Poller
8
+ attr_reader :adapter, :thread, :pid, :mutex, :interval, :last_synced_at
9
+
10
+ def self.instances
11
+ @instances ||= Concurrent::Map.new
12
+ end
13
+ private_class_method :instances
14
+
15
+ def self.get(key, options = {})
16
+ instances.compute_if_absent(key) { new(options) }
17
+ end
18
+
19
+ def self.reset
20
+ instances.each {|_,poller| poller.stop }.clear
21
+ end
22
+
23
+ def initialize(options = {})
24
+ @thread = nil
25
+ @pid = Process.pid
26
+ @mutex = Mutex.new
27
+ @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
28
+ @remote_adapter = options.fetch(:remote_adapter)
29
+ @interval = options.fetch(:interval, 10).to_f
30
+ @last_synced_at = Concurrent::AtomicFixnum.new(0)
31
+ @adapter = Adapters::Memory.new
32
+
33
+ if @interval < 1
34
+ warn "Flipper::Cloud poll interval must be greater than or equal to 1 but was #{@interval}. Setting @interval to 1."
35
+ @interval = 1
36
+ end
37
+
38
+ @start_automatically = options.fetch(:start_automatically, true)
39
+
40
+ if options.fetch(:shutdown_automatically, true)
41
+ at_exit { stop }
42
+ end
43
+ end
44
+
45
+ def start
46
+ reset if forked?
47
+ ensure_worker_running
48
+ end
49
+
50
+ def stop
51
+ @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
52
+ operation: :stop,
53
+ })
54
+ @thread&.kill
55
+ end
56
+
57
+ def run
58
+ loop do
59
+ sleep jitter
60
+ start = Concurrent.monotonic_time
61
+ begin
62
+ sync
63
+ rescue => exception
64
+ # you can instrument these using poller.flipper
65
+ end
66
+
67
+ sleep_interval = interval - (Concurrent.monotonic_time - start)
68
+ sleep sleep_interval if sleep_interval.positive?
69
+ end
70
+ end
71
+
72
+ def sync
73
+ @instrumenter.instrument("poller.#{InstrumentationNamespace}", operation: :poll) do
74
+ @adapter.import @remote_adapter
75
+ @last_synced_at.update { |time| Concurrent.monotonic_time }
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def jitter
82
+ rand
83
+ end
84
+
85
+ def forked?
86
+ pid != Process.pid
87
+ end
88
+
89
+ def ensure_worker_running
90
+ # Return early if thread is alive and avoid the mutex lock and unlock.
91
+ return if thread_alive?
92
+
93
+ # If another thread is starting worker thread, then return early so this
94
+ # thread can enqueue and move on with life.
95
+ return unless mutex.try_lock
96
+
97
+ begin
98
+ return if thread_alive?
99
+ @thread = Thread.new { run }
100
+ @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
101
+ operation: :thread_start,
102
+ })
103
+ ensure
104
+ mutex.unlock
105
+ end
106
+ end
107
+
108
+ def thread_alive?
109
+ @thread && @thread.alive?
110
+ end
111
+
112
+ def reset
113
+ @pid = Process.pid
114
+ mutex.unlock if mutex.locked?
115
+ end
116
+ end
117
+ end
@@ -21,11 +21,9 @@ module Flipper
21
21
  # Returns an Integer representation of the value.
22
22
  # Raises ArgumentError if conversion is not possible.
23
23
  def self.to_integer(value)
24
- if value.respond_to?(:to_i)
25
- value.to_i
26
- else
27
- raise ArgumentError, "#{value.inspect} cannot be converted to an integer"
28
- end
24
+ value.to_i
25
+ rescue NoMethodError
26
+ raise ArgumentError, "#{value.inspect} cannot be converted to an integer"
29
27
  end
30
28
 
31
29
  # Internal: Convert value to a float.
@@ -33,11 +31,9 @@ module Flipper
33
31
  # Returns a Float representation of the value.
34
32
  # Raises ArgumentError if conversion is not possible.
35
33
  def self.to_float(value)
36
- if value.respond_to?(:to_f)
37
- value.to_f
38
- else
39
- raise ArgumentError, "#{value.inspect} cannot be converted to a float"
40
- end
34
+ value.to_f
35
+ rescue NoMethodError
36
+ raise ArgumentError, "#{value.inspect} cannot be converted to a float"
41
37
  end
42
38
 
43
39
  # Internal: Convert value to a percentage.
@@ -45,11 +41,11 @@ module Flipper
45
41
  # Returns a Integer or Float representation of the value.
46
42
  # Raises ArgumentError if conversion is not possible.
47
43
  def self.to_percentage(value)
48
- if value.to_s.include?('.'.freeze)
49
- to_float(value)
50
- else
51
- to_integer(value)
52
- end
44
+ result_to_f = value.to_f
45
+ result_to_i = result_to_f.to_i
46
+ result_to_f == result_to_i ? result_to_i : result_to_f
47
+ rescue NoMethodError
48
+ raise ArgumentError, "#{value.inspect} cannot be converted to a percentage"
53
49
  end
54
50
 
55
51
  # Internal: Convert value to a set.
@@ -1,3 +1,3 @@
1
1
  module Flipper
2
- VERSION = '0.26.0'.freeze
2
+ VERSION = '0.26.1'.freeze
3
3
  end
data/lib/flipper.rb CHANGED
@@ -155,6 +155,7 @@ require 'flipper/instrumenters/noop'
155
155
  require 'flipper/identifier'
156
156
  require 'flipper/middleware/memoizer'
157
157
  require 'flipper/middleware/setup_env'
158
+ require 'flipper/poller'
158
159
  require 'flipper/registry'
159
160
  require 'flipper/type'
160
161
  require 'flipper/types/actor'
@@ -14,7 +14,9 @@ RSpec.describe Flipper::Adapters::Memory do
14
14
  flipper.enable_actor :following, Flipper::Actor.new('3')
15
15
  flipper.enable_group :following, Flipper::Types::Group.new(:staff)
16
16
 
17
- expect(source).to eq({
17
+ dup = described_class.new(subject.get_all)
18
+
19
+ expect(dup.get_all).to eq({
18
20
  "subscriptions" => subject.default_config.merge(boolean: "true"),
19
21
  "search" => subject.default_config,
20
22
  "logging" => subject.default_config.merge(:percentage_of_time => "30"),
@@ -11,7 +11,7 @@ RSpec.describe Flipper::FeatureCheckContext do
11
11
  end
12
12
 
13
13
  it 'initializes just fine' do
14
- instance = described_class.new(options)
14
+ instance = described_class.new(**options)
15
15
  expect(instance.feature_name).to eq(feature_name)
16
16
  expect(instance.values).to eq(values)
17
17
  expect(instance.thing).to eq(thing)
@@ -20,46 +20,46 @@ RSpec.describe Flipper::FeatureCheckContext do
20
20
  it 'requires feature_name' do
21
21
  options.delete(:feature_name)
22
22
  expect do
23
- described_class.new(options)
24
- end.to raise_error(KeyError)
23
+ described_class.new(**options)
24
+ end.to raise_error(ArgumentError)
25
25
  end
26
26
 
27
27
  it 'requires values' do
28
28
  options.delete(:values)
29
29
  expect do
30
- described_class.new(options)
31
- end.to raise_error(KeyError)
30
+ described_class.new(**options)
31
+ end.to raise_error(ArgumentError)
32
32
  end
33
33
 
34
34
  it 'requires thing' do
35
35
  options.delete(:thing)
36
36
  expect do
37
- described_class.new(options)
38
- end.to raise_error(KeyError)
37
+ described_class.new(**options)
38
+ end.to raise_error(ArgumentError)
39
39
  end
40
40
 
41
41
  it 'knows actors_value' do
42
42
  args = options.merge(values: Flipper::GateValues.new(actors: Set['User;1']))
43
- expect(described_class.new(args).actors_value).to eq(Set['User;1'])
43
+ expect(described_class.new(**args).actors_value).to eq(Set['User;1'])
44
44
  end
45
45
 
46
46
  it 'knows groups_value' do
47
47
  args = options.merge(values: Flipper::GateValues.new(groups: Set['admins']))
48
- expect(described_class.new(args).groups_value).to eq(Set['admins'])
48
+ expect(described_class.new(**args).groups_value).to eq(Set['admins'])
49
49
  end
50
50
 
51
51
  it 'knows boolean_value' do
52
- instance = described_class.new(options.merge(values: Flipper::GateValues.new(boolean: true)))
52
+ instance = described_class.new(**options.merge(values: Flipper::GateValues.new(boolean: true)))
53
53
  expect(instance.boolean_value).to eq(true)
54
54
  end
55
55
 
56
56
  it 'knows percentage_of_actors_value' do
57
57
  args = options.merge(values: Flipper::GateValues.new(percentage_of_actors: 14))
58
- expect(described_class.new(args).percentage_of_actors_value).to eq(14)
58
+ expect(described_class.new(**args).percentage_of_actors_value).to eq(14)
59
59
  end
60
60
 
61
61
  it 'knows percentage_of_time_value' do
62
62
  args = options.merge(values: Flipper::GateValues.new(percentage_of_time: 41))
63
- expect(described_class.new(args).percentage_of_time_value).to eq(41)
63
+ expect(described_class.new(**args).percentage_of_time_value).to eq(41)
64
64
  end
65
65
  end
@@ -80,13 +80,13 @@ RSpec.describe Flipper::GateValues do
80
80
  it 'raises argument error for percentage of time value that cannot be converted to an integer' do
81
81
  expect do
82
82
  described_class.new(percentage_of_time: ['asdf'])
83
- end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to an integer))
83
+ end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to a percentage))
84
84
  end
85
85
 
86
86
  it 'raises argument error for percentage of actors value that cannot be converted to an int' do
87
87
  expect do
88
88
  described_class.new(percentage_of_actors: ['asdf'])
89
- end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to an integer))
89
+ end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to a percentage))
90
90
  end
91
91
 
92
92
  it 'raises argument error for actors value that cannot be converted to a set' do
@@ -100,35 +100,4 @@ RSpec.describe Flipper::GateValues do
100
100
  described_class.new(groups: 'asdf')
101
101
  end.to raise_error(ArgumentError, %("asdf" cannot be converted to a set))
102
102
  end
103
-
104
- describe '#[]' do
105
- it 'can read the boolean value' do
106
- expect(described_class.new(boolean: true)[:boolean]).to be(true)
107
- expect(described_class.new(boolean: true)['boolean']).to be(true)
108
- end
109
-
110
- it 'can read the actors value' do
111
- expect(described_class.new(actors: Set[1, 2])[:actors]).to eq(Set[1, 2])
112
- expect(described_class.new(actors: Set[1, 2])['actors']).to eq(Set[1, 2])
113
- end
114
-
115
- it 'can read the groups value' do
116
- expect(described_class.new(groups: Set[:admins])[:groups]).to eq(Set[:admins])
117
- expect(described_class.new(groups: Set[:admins])['groups']).to eq(Set[:admins])
118
- end
119
-
120
- it 'can read the percentage of time value' do
121
- expect(described_class.new(percentage_of_time: 15)[:percentage_of_time]).to eq(15)
122
- expect(described_class.new(percentage_of_time: 15)['percentage_of_time']).to eq(15)
123
- end
124
-
125
- it 'can read the percentage of actors value' do
126
- expect(described_class.new(percentage_of_actors: 15)[:percentage_of_actors]).to eq(15)
127
- expect(described_class.new(percentage_of_actors: 15)['percentage_of_actors']).to eq(15)
128
- end
129
-
130
- it 'returns nil for value that is not present' do
131
- expect(described_class.new({})['not legit']).to be(nil)
132
- end
133
- end
134
103
  end
@@ -9,7 +9,7 @@ RSpec.describe Flipper::Gates::PercentageOfActors do
9
9
  Flipper::FeatureCheckContext.new(
10
10
  feature_name: feature,
11
11
  values: Flipper::GateValues.new(percentage_of_actors: percentage_of_actors_value),
12
- thing: thing || Flipper::Types::Actor.new(Flipper::Actor.new(1))
12
+ thing: Flipper::Types::Actor.new(thing || Flipper::Actor.new(1))
13
13
  )
14
14
  end
15
15
 
@@ -0,0 +1,47 @@
1
+ require "flipper/poller"
2
+
3
+ RSpec.describe Flipper::Poller do
4
+ let(:remote_adapter) { Flipper::Adapters::Memory.new }
5
+ let(:remote) { Flipper.new(remote_adapter) }
6
+ let(:local) { Flipper.new(subject.adapter) }
7
+
8
+ subject do
9
+ described_class.new(
10
+ remote_adapter: remote_adapter,
11
+ start_automatically: false,
12
+ interval: Float::INFINITY
13
+ )
14
+ end
15
+
16
+ before do
17
+ allow(subject).to receive(:loop).and_yield # Make loop just call once
18
+ allow(subject).to receive(:sleep) # Disable sleep
19
+ allow(Thread).to receive(:new).and_yield # Disable separate thread
20
+ end
21
+
22
+ describe "#adapter" do
23
+ it "always returns same memory adapter instance" do
24
+ expect(subject.adapter).to be_a(Flipper::Adapters::Memory)
25
+ expect(subject.adapter.object_id).to eq(subject.adapter.object_id)
26
+ end
27
+ end
28
+
29
+ describe "#sync" do
30
+ it "syncs remote adapter to local adapter" do
31
+ remote.enable :polling
32
+
33
+ expect(local.enabled?(:polling)).to be(false)
34
+ subject.sync
35
+ expect(local.enabled?(:polling)).to be(true)
36
+ end
37
+ end
38
+
39
+ describe "#start" do
40
+ it "starts the poller thread" do
41
+ expect(Thread).to receive(:new).and_yield
42
+ expect(subject).to receive(:loop).and_yield
43
+ expect(subject).to receive(:sync)
44
+ subject.start
45
+ end
46
+ end
47
+ end
@@ -56,7 +56,7 @@ RSpec.describe Flipper::Typecast do
56
56
  nil => 0,
57
57
  '' => 0,
58
58
  0 => 0,
59
- 0.0 => 0.0,
59
+ 0.0 => 0,
60
60
  1 => 1,
61
61
  1.1 => 1.1,
62
62
  '0.01' => 0.01,
@@ -100,13 +100,13 @@ RSpec.describe Flipper::Typecast do
100
100
  it 'raises argument error for bad integer percentage' do
101
101
  expect do
102
102
  described_class.to_percentage(['asdf'])
103
- end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to an integer))
103
+ end.to raise_error(ArgumentError, %(["asdf"] cannot be converted to a percentage))
104
104
  end
105
105
 
106
106
  it 'raises argument error for bad float percentage' do
107
107
  expect do
108
108
  described_class.to_percentage(['asdf.0'])
109
- end.to raise_error(ArgumentError, %(["asdf.0"] cannot be converted to a float))
109
+ end.to raise_error(ArgumentError, %(["asdf.0"] cannot be converted to a percentage))
110
110
  end
111
111
 
112
112
  it 'raises argument error for set value that cannot be converted to a set' do
data/spec/spec_helper.rb CHANGED
@@ -22,7 +22,7 @@ Dir[FlipperRoot.join('spec/support/**/*.rb')].sort.each { |f| require f }
22
22
 
23
23
  RSpec.configure do |config|
24
24
  config.before(:example) do
25
- Flipper::Adapters::Poll::Poller.reset if defined?(Flipper::Adapters::Poll::Poller)
25
+ Flipper::Poller.reset if defined?(Flipper::Poller)
26
26
  Flipper.unregister_groups
27
27
  Flipper.configuration = nil
28
28
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flipper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.0
4
+ version: 0.26.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Nunemaker
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-12-05 00:00:00.000000000 Z
11
+ date: 2023-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -35,7 +35,6 @@ files:
35
35
  - ".github/dependabot.yml"
36
36
  - ".github/workflows/ci.yml"
37
37
  - ".github/workflows/examples.yml"
38
- - ".github/workflows/release.yml"
39
38
  - ".rspec"
40
39
  - ".tool-versions"
41
40
  - CODE_OF_CONDUCT.md
@@ -45,6 +44,10 @@ files:
45
44
  - LICENSE
46
45
  - README.md
47
46
  - Rakefile
47
+ - benchmark/enabled_ips.rb
48
+ - benchmark/enabled_profile.rb
49
+ - benchmark/instrumentation_ips.rb
50
+ - benchmark/typecast_ips.rb
48
51
  - docker-compose.yml
49
52
  - docs/DockerCompose.md
50
53
  - docs/README.md
@@ -112,6 +115,7 @@ files:
112
115
  - lib/flipper/metadata.rb
113
116
  - lib/flipper/middleware/memoizer.rb
114
117
  - lib/flipper/middleware/setup_env.rb
118
+ - lib/flipper/poller.rb
115
119
  - lib/flipper/railtie.rb
116
120
  - lib/flipper/registry.rb
117
121
  - lib/flipper/spec/shared_adapter_specs.rb
@@ -160,6 +164,7 @@ files:
160
164
  - spec/flipper/instrumenters/noop_spec.rb
161
165
  - spec/flipper/middleware/memoizer_spec.rb
162
166
  - spec/flipper/middleware/setup_env_spec.rb
167
+ - spec/flipper/poller_spec.rb
163
168
  - spec/flipper/railtie_spec.rb
164
169
  - spec/flipper/registry_spec.rb
165
170
  - spec/flipper/typecast_spec.rb
@@ -239,6 +244,7 @@ test_files:
239
244
  - spec/flipper/instrumenters/noop_spec.rb
240
245
  - spec/flipper/middleware/memoizer_spec.rb
241
246
  - spec/flipper/middleware/setup_env_spec.rb
247
+ - spec/flipper/poller_spec.rb
242
248
  - spec/flipper/railtie_spec.rb
243
249
  - spec/flipper/registry_spec.rb
244
250
  - spec/flipper/typecast_spec.rb
@@ -1,44 +0,0 @@
1
- # https://andrewm.codes/blog/automating-ruby-gem-releases-with-github-actions/
2
- name: release
3
-
4
- on:
5
- push:
6
- branches:
7
- - main
8
-
9
- jobs:
10
- release-please:
11
- runs-on: ubuntu-latest
12
- steps:
13
- - uses: google-github-actions/release-please-action@v3
14
- id: release
15
- with:
16
- release-type: ruby
17
- package-name: flipper
18
- bump-minor-pre-major: true
19
- version-file: "lib/flipper/version.rb"
20
- # Checkout code if release was created
21
- - uses: actions/checkout@v3
22
- if: ${{ steps.release.outputs.release_created }}
23
- # Setup ruby if a release was created
24
- - uses: ruby/setup-ruby@v1
25
- with:
26
- ruby-version: 3.0.0
27
- if: ${{ steps.release.outputs.release_created }}
28
- # Bundle install
29
- - run: bundle install
30
- if: ${{ steps.release.outputs.release_created }}
31
- # Publish
32
- - name: publish gem
33
- run: |
34
- mkdir -p $HOME/.gem
35
- touch $HOME/.gem/credentials
36
- chmod 0600 $HOME/.gem/credentials
37
- printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
38
- gem build *.gemspec
39
- gem push *.gem
40
- env:
41
- # Make sure to update the secret name
42
- # if yours isn't named RUBYGEMS_AUTH_TOKEN
43
- GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}"
44
- if: ${{ steps.release.outputs.release_created }}