rollout 3.2.0 → 3.3.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: cadea20a7d731c5538e5d6b6b19b34075843230fd5c6d75ffc4c269ce667a63f
4
- data.tar.gz: '06483d373ff87c4e0537ab73d57be5bba089d43a4abacd87168bdf338cb4ca5e'
3
+ metadata.gz: ecf20a456dab296cc321049b845972bff0c72075e4bb7e30e2e5314e33cff1b2
4
+ data.tar.gz: d66bce69c2ec03dc790f922635501b970b790886281651708b98fd133d6c3c3e
5
5
  SHA512:
6
- metadata.gz: 515d4d646e3c4c2c2fee20d77088a9dd22fe5730dd605764abfbc0e189591f7eaaf076830940358500e05eed30f456b9389f8843bad1df283d01b795eae6e935
7
- data.tar.gz: c41503b0e1acff3c3c99de9b08d7c06167f2d4a2f7c7fc493230c11c70f633dcae0bc4c5a7028d04e11b01dfd7edfb6eaaf6d33255c559cd4b93fda206e10427
6
+ metadata.gz: dddafc84d403c006b8aafe0ef961b9a883b192a7ba1e4d7816a28bf72c38b60c86b7c86e5e6d10a9a8fa5921b5d8493361023553aecb455f6e2c954eed4bacdd
7
+ data.tar.gz: 1b2de6537a44270239c74d403051eac56cad4580fb995beaaedef5597e354cb7f839cca9b581f814d25451454f0ebfeaa16e0f2ca1e21469106c3413fa4cdee8
@@ -10,14 +10,16 @@ on:
10
10
  permissions:
11
11
  contents: write
12
12
  id-token: write
13
+ pull-requests: read
13
14
 
14
15
  jobs:
15
16
  publish:
16
- runs-on: ubuntu-latest
17
+ runs-on: ubuntu-26.04
17
18
  outputs:
18
19
  name: ${{ steps.package.outputs.name }}
19
20
  version: ${{ steps.package.outputs.version }}
20
21
  previous_tag: ${{ steps.package.outputs.previous_tag }}
22
+ paths: ${{ steps.package.outputs.paths }}
21
23
  steps:
22
24
  - name: Checkout code
23
25
  uses: actions/checkout@v5
@@ -31,9 +33,9 @@ jobs:
31
33
  id: package
32
34
  run: |
33
35
  case "$GITHUB_REF_NAME" in
34
- rollout/v*) name=rollout; directory=. ;;
35
- rollout-redis-adapter/v*) name=rollout-redis-adapter; directory=rollout-redis-adapter ;;
36
- rollout-active_record-adapter/v*) name=rollout-active_record-adapter; directory=rollout-active_record-adapter ;;
36
+ rollout/v*) name=rollout; directory=.; paths="lib rollout.gemspec" ;;
37
+ rollout-redis-adapter/v*) name=rollout-redis-adapter; directory=rollout-redis-adapter; paths=rollout-redis-adapter ;;
38
+ rollout-active_record-adapter/v*) name=rollout-active_record-adapter; directory=rollout-active_record-adapter; paths=rollout-active_record-adapter ;;
37
39
  *) echo "Unexpected tag $GITHUB_REF_NAME" >&2; exit 1 ;;
38
40
  esac
39
41
  version="${GITHUB_REF_NAME##*/v}"
@@ -48,6 +50,7 @@ jobs:
48
50
  echo "version=$version" >> "$GITHUB_OUTPUT"
49
51
  echo "directory=$directory" >> "$GITHUB_OUTPUT"
50
52
  echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT"
53
+ echo "paths=$paths" >> "$GITHUB_OUTPUT"
51
54
  - name: Build selected gem
52
55
  working-directory: ${{ steps.package.outputs.directory }}
53
56
  run: gem build "${{ steps.package.outputs.name }}.gemspec"
@@ -59,14 +62,37 @@ jobs:
59
62
 
60
63
  github-release:
61
64
  needs: publish
62
- runs-on: ubuntu-latest
65
+ runs-on: ubuntu-26.04
63
66
  steps:
67
+ - name: Checkout code
68
+ uses: actions/checkout@v5
69
+ with:
70
+ fetch-depth: 0
71
+ # GitHub's generated notes list every PR in the repo between tags; keep
72
+ # only PRs that touched the released gem's files.
73
+ - name: Build release notes
74
+ env:
75
+ GH_TOKEN: ${{ github.token }}
76
+ PREVIOUS_TAG: ${{ needs.publish.outputs.previous_tag }}
77
+ PATHS: ${{ needs.publish.outputs.paths }}
78
+ run: |
79
+ {
80
+ echo "## What's Changed"
81
+ git log --reverse --format=%H "$PREVIOUS_TAG..$GITHUB_REF_NAME" -- $PATHS |
82
+ while read -r sha; do
83
+ gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" \
84
+ --jq '.[] | "* \(.title) by @\(.user.login) in \(.html_url)"'
85
+ done |
86
+ awk '!seen[$0]++'
87
+ echo
88
+ echo "**Full Changelog**: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/$PREVIOUS_TAG...$GITHUB_REF_NAME"
89
+ } > release-notes.md
90
+ cat release-notes.md
64
91
  - name: Create GitHub Release
65
92
  uses: softprops/action-gh-release@v3
66
93
  with:
67
94
  tag_name: ${{ github.ref }}
68
95
  name: ${{ needs.publish.outputs.name }} ${{ needs.publish.outputs.version }}
69
- generate_release_notes: true
70
- previous_tag: ${{ needs.publish.outputs.previous_tag }}
96
+ body_path: release-notes.md
71
97
  draft: false
72
98
  prerelease: false
@@ -10,18 +10,24 @@ on:
10
10
 
11
11
  jobs:
12
12
  core:
13
- runs-on: ubuntu-latest
13
+ runs-on: ubuntu-26.04
14
+ # setup-ruby has no Ruby 3.1 or older builds for ubuntu-26.04, so use the official Ruby images.
15
+ container: ruby:${{ matrix.ruby-version }}
14
16
  strategy:
15
17
  matrix:
16
18
  ruby-version: ['3.3', '3.2', '3.1', '3.0', '2.7', '2.6', '2.5', '2.4']
17
19
  steps:
18
20
  - name: Checkout code
19
21
  uses: actions/checkout@v5
20
- - name: Setup Ruby
21
- uses: ruby/setup-ruby@v1
22
- with:
23
- ruby-version: ${{ matrix.ruby-version }}
24
- bundler-cache: true
22
+ - name: Install Bundler
23
+ # The Ruby 2.7 and older images ship Bundler 1.17 or 2.1; install the versions setup-ruby used.
24
+ run: |
25
+ case "$RUBY_VERSION" in
26
+ 2.4.*|2.5.*) gem install bundler -v '~> 2.3.0' ;;
27
+ 2.6.*|2.7.*) gem install bundler -v '~> 2.4.0' ;;
28
+ esac
29
+ - name: Install gems
30
+ run: bundle install --jobs 4
25
31
  - name: Run core tests
26
32
  run: |
27
33
  mkdir -p test_results
@@ -35,15 +41,17 @@ jobs:
35
41
  retention-days: 30
36
42
 
37
43
  redis:
38
- runs-on: ubuntu-latest
44
+ runs-on: ubuntu-26.04
45
+ # setup-ruby has no Ruby 3.1 or older builds for ubuntu-26.04, so use the official Ruby images.
46
+ container: ruby:${{ matrix.ruby-version }}
39
47
  strategy:
40
48
  matrix:
41
49
  ruby-version: ['3.3', '3.2', '3.1', '3.0', '2.7', '2.6', '2.5', '2.4']
50
+ env:
51
+ REDIS_HOST: redis
42
52
  services:
43
53
  redis:
44
54
  image: redis:7-alpine
45
- ports:
46
- - 6379:6379
47
55
  options: >-
48
56
  --health-cmd "redis-cli ping"
49
57
  --health-interval 10s
@@ -52,12 +60,16 @@ jobs:
52
60
  steps:
53
61
  - name: Checkout code
54
62
  uses: actions/checkout@v5
55
- - name: Setup Ruby
56
- uses: ruby/setup-ruby@v1
57
- with:
58
- ruby-version: ${{ matrix.ruby-version }}
59
- working-directory: rollout-redis-adapter
60
- bundler-cache: true
63
+ - name: Install Bundler
64
+ # The Ruby 2.7 and older images ship Bundler 1.17 or 2.1; install the versions setup-ruby used.
65
+ run: |
66
+ case "$RUBY_VERSION" in
67
+ 2.4.*|2.5.*) gem install bundler -v '~> 2.3.0' ;;
68
+ 2.6.*|2.7.*) gem install bundler -v '~> 2.4.0' ;;
69
+ esac
70
+ - name: Install gems
71
+ working-directory: rollout-redis-adapter
72
+ run: bundle install --jobs 4
61
73
  - name: Run Redis adapter tests
62
74
  working-directory: rollout-redis-adapter
63
75
  run: |
@@ -72,7 +84,9 @@ jobs:
72
84
  retention-days: 30
73
85
 
74
86
  active_record:
75
- runs-on: ubuntu-latest
87
+ runs-on: ubuntu-26.04
88
+ # setup-ruby has no Ruby 3.1 or older builds for ubuntu-26.04, so use the official Ruby images.
89
+ container: ruby:${{ matrix.ruby-version }}
76
90
  strategy:
77
91
  fail-fast: false
78
92
  matrix:
@@ -104,20 +118,18 @@ jobs:
104
118
  env:
105
119
  ACTIVERECORD_VERSION: ${{ matrix.activerecord }}
106
120
  ROLLOUT_AR_ADAPTER: ${{ matrix.db }}
107
- POSTGRES_HOST: localhost
121
+ POSTGRES_HOST: postgres
108
122
  POSTGRES_USER: postgres
109
123
  POSTGRES_PASSWORD: postgres
110
124
  POSTGRES_DB: rollout_test
111
- MYSQL_HOST: 127.0.0.1
125
+ MYSQL_HOST: mysql
112
126
  MYSQL_USER: root
113
127
  MYSQL_PASSWORD: root
114
128
  MYSQL_DATABASE: rollout_test
115
- REDIS_HOST: 127.0.0.1
129
+ REDIS_HOST: redis
116
130
  services:
117
131
  redis:
118
132
  image: redis:7-alpine
119
- ports:
120
- - 6379:6379
121
133
  options: >-
122
134
  --health-cmd "redis-cli ping"
123
135
  --health-interval 10s
@@ -129,8 +141,6 @@ jobs:
129
141
  POSTGRES_USER: postgres
130
142
  POSTGRES_PASSWORD: postgres
131
143
  POSTGRES_DB: rollout_test
132
- ports:
133
- - 5432:5432
134
144
  options: >-
135
145
  --health-cmd "pg_isready -U postgres"
136
146
  --health-interval 10s
@@ -141,8 +151,6 @@ jobs:
141
151
  env:
142
152
  MYSQL_ROOT_PASSWORD: root
143
153
  MYSQL_DATABASE: rollout_test
144
- ports:
145
- - 3306:3306
146
154
  options: >-
147
155
  --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -proot"
148
156
  --health-interval 10s
@@ -151,15 +159,16 @@ jobs:
151
159
  steps:
152
160
  - name: Checkout code
153
161
  uses: actions/checkout@v5
154
- - name: Install database client libraries
155
- run: sudo apt-get update && sudo apt-get install -y libpq-dev default-libmysqlclient-dev
156
- - name: Setup Ruby
157
- uses: ruby/setup-ruby@v1
158
- with:
159
- ruby-version: ${{ matrix.ruby-version }}
160
- working-directory: rollout-active_record-adapter
161
- bundler-cache: true
162
- cache-version: ar-${{ matrix.ruby-version }}-${{ matrix.activerecord }}-${{ matrix.db }}
162
+ - name: Install Bundler
163
+ # The Ruby 2.7 and older images ship Bundler 1.17 or 2.1; install the versions setup-ruby used.
164
+ run: |
165
+ case "$RUBY_VERSION" in
166
+ 2.4.*|2.5.*) gem install bundler -v '~> 2.3.0' ;;
167
+ 2.6.*|2.7.*) gem install bundler -v '~> 2.4.0' ;;
168
+ esac
169
+ - name: Install gems
170
+ working-directory: rollout-active_record-adapter
171
+ run: bundle install --jobs 4
163
172
  - name: Run Active Record adapter tests
164
173
  working-directory: rollout-active_record-adapter
165
174
  run: |
data/README.md CHANGED
@@ -184,6 +184,63 @@ feature.to_hash
184
184
  # => { percentage: 5.0, groups: [:caretakers], users: ["1"], data: {} }
185
185
  ```
186
186
 
187
+ ## Defining features
188
+
189
+ Describe each feature in its own YAML file, named after the feature. Rollout
190
+ looks in `config/rollouts` under the current directory, which is your app's
191
+ root when you start it from there.
192
+
193
+ ```text
194
+ config/rollouts/
195
+ ├── chat.yml
196
+ └── new_inbox.yml
197
+ ```
198
+
199
+ Only `.yml` files directly in that folder are read. Subfolders, `.yaml` files
200
+ and files starting with a dot are ignored. The file name without `.yml` is the
201
+ feature name, the same one you pass to `$rollout.active?`.
202
+
203
+ ```yaml
204
+ # config/rollouts/chat.yml
205
+ description: Let members chat in groups.
206
+ ```
207
+
208
+ ```yaml
209
+ # config/rollouts/new_inbox.yml
210
+ description: Show conversations as threads.
211
+ owner: messaging
212
+ release_date: "2026-11-15"
213
+ ```
214
+
215
+ ```ruby
216
+ Rollout.definitions.keys # => [:chat, :new_inbox]
217
+ Rollout.definitions[:new_inbox].description # => "Show conversations as threads."
218
+ Rollout.definitions[:new_inbox].path # => "/app/config/rollouts/new_inbox.yml"
219
+ Rollout.definitions[:new_inbox].attributes
220
+ # => { "description" => "Show conversations as threads.", "owner" => "messaging", "release_date" => "2026-11-15" }
221
+ ```
222
+
223
+ Definitions only describe features: they don't create or change stored
224
+ features, and features without a file work as before. Rollout reads
225
+ `description`. Every key, including your own like `owner`, is kept in
226
+ `attributes` with string keys for your application.
227
+
228
+ `Rollout.definitions` reads the folder on every call. A missing folder means no
229
+ definitions. `description` is optional.
230
+
231
+ Files are read with `YAML.safe_load`, so values can be strings, numbers,
232
+ booleans, `null`, lists and mappings. Quote dates and times
233
+ (`release_date: "2026-10-28"`). A file raises `Rollout::InvalidDefinition` if
234
+ it isn't valid YAML, isn't a mapping, uses anything else (an unquoted date, an
235
+ alias, a `:symbol`), or sets `description` to something other than a string.
236
+
237
+ Use another folder, or turn definitions off:
238
+
239
+ ```ruby
240
+ Rollout.definitions_path = Rails.root.join("config", "features")
241
+ Rollout.definitions_path = nil
242
+ ```
243
+
187
244
  ## Namespacing
188
245
 
189
246
  Rollout separates its keys from other keys in the data store using the
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ class Rollout
6
+ class InvalidDefinition < ArgumentError
7
+ end
8
+
9
+ class Definition
10
+ attr_reader :name, :path, :description, :attributes
11
+
12
+ def self.load(path)
13
+ attributes = YAML.safe_load(File.read(path))
14
+ raise InvalidDefinition, "#{path}: definition must be a mapping" unless attributes.is_a?(Hash)
15
+
16
+ new(name: File.basename(path, '.yml'), attributes: attributes, path: path)
17
+ rescue Psych::Exception => e
18
+ raise InvalidDefinition, "#{path}: #{e.message}"
19
+ end
20
+
21
+ def initialize(name:, attributes: {}, path: nil)
22
+ @name = name.to_sym
23
+ @path = path
24
+ @attributes = attributes.freeze
25
+ @description = attributes['description']
26
+ return if @description.nil? || @description.is_a?(String)
27
+
28
+ raise InvalidDefinition, "#{path || name}: description must be a string"
29
+ end
30
+ end
31
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rollout
4
- VERSION = '3.2.0'
4
+ VERSION = '3.3.0'
5
5
  end
data/lib/rollout.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'rollout/definition'
3
4
  require 'rollout/feature'
4
5
  require 'rollout/logging'
5
6
  require 'rollout/version'
@@ -13,6 +14,28 @@ class Rollout
13
14
 
14
15
  RAND_BASE = (2**32 - 1) / 100.0
15
16
 
17
+ class << self
18
+ attr_writer :definitions_path
19
+
20
+ def definitions_path
21
+ return @definitions_path if defined?(@definitions_path)
22
+
23
+ File.expand_path(File.join('config', 'rollouts'))
24
+ end
25
+
26
+ # Reads the folder on every call, so new files show up without a restart.
27
+ def definitions
28
+ folder = definitions_path&.to_s
29
+ return {} unless folder && File.directory?(folder)
30
+
31
+ # Dir.entries rather than a glob, so brackets or asterisks in the folder's path are taken literally.
32
+ Dir.entries(folder).grep(/\A[^.].*\.yml\z/).sort.each_with_object({}) do |file, definitions|
33
+ definition = Definition.load(File.join(folder, file))
34
+ definitions[definition.name] = definition
35
+ end
36
+ end
37
+ end
38
+
16
39
  attr_reader :options, :adapter
17
40
 
18
41
  def initialize(adapter:, **options)
@@ -0,0 +1,83 @@
1
+ require "spec_helper"
2
+ require "tmpdir"
3
+
4
+ RSpec.describe "Rollout.definitions" do
5
+ around do |example|
6
+ Dir.mktmpdir do |dir|
7
+ @dir = dir
8
+ example.run
9
+ end
10
+ end
11
+
12
+ after do
13
+ Rollout.send(:remove_instance_variable, :@definitions_path) if Rollout.instance_variable_defined?(:@definitions_path)
14
+ end
15
+
16
+ def write(name, content)
17
+ File.write(File.join(@dir, name), content)
18
+ end
19
+
20
+ it "loads each .yml file directly in the folder, named after the file" do
21
+ write("chat.yml", "description: Group chat.\ncache: user\n")
22
+ write("notes.yaml", "description: Not loaded.\n")
23
+ FileUtils.mkdir_p(File.join(@dir, "drafts"))
24
+ write("drafts/inbox.yml", "description: Not loaded.\n")
25
+ Rollout.definitions_path = @dir
26
+
27
+ chat = Rollout.definitions.fetch(:chat)
28
+
29
+ expect(Rollout.definitions.keys).to eq [:chat]
30
+ expect(chat.description).to eq "Group chat."
31
+ expect(chat.attributes).to eq("description" => "Group chat.", "cache" => "user")
32
+ expect(chat.path).to eq File.join(@dir, "chat.yml")
33
+ end
34
+
35
+ it "defaults to config/rollouts under the current directory" do
36
+ folder = File.join(@dir, "config", "rollouts")
37
+ FileUtils.mkdir_p(folder)
38
+ File.write(File.join(folder, "chat.yml"), "description: Group chat.\n")
39
+
40
+ Dir.chdir(@dir) do
41
+ expect(Rollout.definitions.keys).to eq [:chat]
42
+ end
43
+ end
44
+
45
+ it "picks up a file added after an earlier call" do
46
+ Rollout.definitions_path = @dir
47
+ expect(Rollout.definitions).to eq({})
48
+
49
+ write("chat.yml", "description: Group chat.\n")
50
+ expect(Rollout.definitions.keys).to eq [:chat]
51
+ end
52
+
53
+ it "takes brackets in the folder's path literally" do
54
+ folder = File.join(@dir, "rollouts[1]")
55
+ FileUtils.mkdir_p(folder)
56
+ File.write(File.join(folder, "chat.yml"), "description: Group chat.\n")
57
+ Rollout.definitions_path = folder
58
+
59
+ expect(Rollout.definitions.keys).to eq [:chat]
60
+ end
61
+
62
+ it "has no definitions when the folder is missing or the path is nil" do
63
+ Rollout.definitions_path = File.join(@dir, "missing")
64
+ expect(Rollout.definitions).to eq({})
65
+
66
+ Rollout.definitions_path = nil
67
+ expect(Rollout.definitions).to eq({})
68
+ end
69
+
70
+ it "names the file that is not valid YAML" do
71
+ write("chat.yml", "description: [unclosed\n")
72
+ Rollout.definitions_path = @dir
73
+
74
+ expect { Rollout.definitions }.to raise_error(Rollout::InvalidDefinition, /chat\.yml/)
75
+ end
76
+
77
+ it "rejects a description that is not a string" do
78
+ write("chat.yml", "description: 42\n")
79
+ Rollout.definitions_path = @dir
80
+
81
+ expect { Rollout.definitions }.to raise_error(Rollout::InvalidDefinition, /description must be a string/)
82
+ end
83
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rollout
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.0
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Golick
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-01 00:00:00.000000000 Z
11
+ date: 2026-10-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: observer
@@ -142,11 +142,13 @@ files:
142
142
  - Rakefile
143
143
  - docs/upgrading-to-v3.md
144
144
  - lib/rollout.rb
145
+ - lib/rollout/definition.rb
145
146
  - lib/rollout/feature.rb
146
147
  - lib/rollout/feature_state.rb
147
148
  - lib/rollout/logging.rb
148
149
  - lib/rollout/version.rb
149
150
  - rollout.gemspec
151
+ - spec/rollout/definition_spec.rb
150
152
  - spec/rollout/feature_spec.rb
151
153
  - spec/rollout/feature_state_spec.rb
152
154
  - spec/rollout/logging_spec.rb