From 4a21476673067248149d581185edf146b163b1d5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 12:50:01 +0200 Subject: [PATCH 1/5] ci: Run the Flagsmith SDK behavioural spec --- .github/workflows/sdk-spec.yml | 16 +++++ flagsmith.gemspec | 2 +- renovate.json | 14 +++++ spec-adapter/Dockerfile | 17 +++++ spec-adapter/adapter.rb | 110 +++++++++++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sdk-spec.yml create mode 100644 spec-adapter/Dockerfile create mode 100644 spec-adapter/adapter.rb diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml new file mode 100644 index 0000000..6cbf98b --- /dev/null +++ b/.github/workflows/sdk-spec.yml @@ -0,0 +1,16 @@ +name: SDK spec + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + sdk-spec: + # Keep the ref in `uses:` and `spec-ref` in sync. + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.1.1 + with: + spec-ref: v0.1.1 diff --git a/flagsmith.gemspec b/flagsmith.gemspec index a1e9459..ee5c2c1 100644 --- a/flagsmith.gemspec +++ b/flagsmith.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(__dir__) do `git ls-files -z`.split("\x0").reject do |f| - (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) + (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|spec-adapter|features)/|\.(?:git|travis|circleci)|appveyor)}) end end diff --git a/renovate.json b/renovate.json index b7a5f2e..b1830e2 100644 --- a/renovate.json +++ b/renovate.json @@ -4,6 +4,15 @@ "ignorePresets": [":semanticPrefixFixDepsChoreOthers"], "semanticCommitType": "deps", "git-submodules": { "enabled": true }, + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/workflows/sdk-spec\\.yml$/"], + "matchStrings": ["spec-ref: (?v[\\d.]+)"], + "depNameTemplate": "Flagsmith/sdk-spec", + "datasourceTemplate": "github-tags" + } + ], "packageRules": [ { "matchUpdateTypes": [ @@ -23,6 +32,11 @@ "matchManagers": ["git-submodules"], "versioning": "semver-coerced", "enabled": true + }, + { + "matchPackageNames": ["Flagsmith/sdk-spec"], + "groupName": "SDK spec", + "enabled": true } ] } diff --git a/spec-adapter/Dockerfile b/spec-adapter/Dockerfile new file mode 100644 index 0000000..a92f676 --- /dev/null +++ b/spec-adapter/Dockerfile @@ -0,0 +1,17 @@ +# Builds the SDK from source with the adapter for the Flagsmith SDK behavioural spec. +# Build from the repository root: docker build -f spec-adapter/Dockerfile . +FROM ruby:3.3-slim + +# The gemspec lists its files with `git ls-files`. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /sdk +COPY . . +RUN gem build flagsmith.gemspec --output flagsmith.gem \ + && gem install --no-document flagsmith.gem webrick + +ENV PORT=8080 +EXPOSE 8080 +CMD ["ruby", "spec-adapter/adapter.rb"] diff --git a/spec-adapter/adapter.rb b/spec-adapter/adapter.rb new file mode 100644 index 0000000..872652a --- /dev/null +++ b/spec-adapter/adapter.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# Adapter that lets the Flagsmith SDK behavioural spec drive this SDK. +# +# Protocol: https://github.com/Flagsmith/sdk-spec/blob/main/ADAPTER.md + +require 'json' +require 'webrick' +require 'flagsmith' + +CLIENT_ROUTE = %r{\A/clients/(?[^/]+)(?/[a-z-]+)?\z} + +$stdout.sync = true + +# Serves the adapter protocol, keeping one SDK client per spec scenario. +class Adapter + def initialize + @clients = {} + @next_client_id = 0 + @mutex = Mutex.new + end + + def call(request, response) + status, body = begin + route(request) + rescue StandardError => e # report SDK errors to the runner + warn e.full_message + [500, { error: "#{e.class}: #{e.message}" }] + end + response.status = status + response['Content-Type'] = 'application/json' + response.body = body.nil? ? '' : JSON.generate(body) + end + + private + + def route(request) + method = request.request_method + path = request.path + return [200, { sdk: 'flagsmith-ruby-client', version: Flagsmith::VERSION }] if method == 'GET' && path == '/health' + return create_client(parse_body(request)) if method == 'POST' && path == '/clients' + + match = CLIENT_ROUTE.match(path) + client = match && @mutex.synchronize { @clients[match[:client_id]] } + return not_found(request) unless client + + route_client(request, match[:client_id], match[:action], client) + end + + def route_client(request, client_id, action, client) + case [request.request_method, action] + when ['DELETE', nil] then delete_client(client_id) + when ['POST', '/environment-flags'] then [200, serialise_flags(client.get_environment_flags)] + when ['POST', '/identity-flags'] then [200, serialise_flags(identity_flags(client, parse_body(request)))] + else not_found(request) + end + end + + def create_client(body) + client = Flagsmith::Client.new(environment_key: body['environmentKey'], api_url: body['apiUrl']) + client_id = @mutex.synchronize { (@next_client_id += 1).to_s.tap { |id| @clients[id] = client } } + [201, { clientId: client_id }] + end + + def delete_client(client_id) + @mutex.synchronize { @clients.delete(client_id) } + [204, nil] + end + + def identity_flags(client, body) + client.get_identity_flags(body['identifier'], **(body['traits'] || {})) + end + + def serialise_flags(flags) + { + flags: flags.all_flags.map do |flag| + { featureName: flag.feature_name, enabled: flag.enabled, value: flag.value } + end + } + end + + def parse_body(request) + request.body.to_s.empty? ? {} : JSON.parse(request.body) + end + + def not_found(request) + [404, { error: "No route for #{request.request_method} #{request.path}" }] + end +end + +# Hands every request to the adapter, whatever its method. +class Servlet < WEBrick::HTTPServlet::AbstractServlet + def initialize(server, adapter) + super + @adapter = adapter + end + + def service(request, response) + @adapter.call(request, response) + end +end + +if $PROGRAM_NAME == __FILE__ + port = Integer(ENV.fetch('PORT', '8080')) + server = WEBrick::HTTPServer.new(BindAddress: '0.0.0.0', Port: port) + server.mount('/', Servlet, Adapter.new) + trap('TERM') { server.shutdown } + trap('INT') { server.shutdown } + server.start +end From d75c5ecde1f03ca67850d3dedd2e2166b44c6591 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 13:08:09 +0200 Subject: [PATCH 2/5] ci: Implement the SDK spec as a steps file --- .github/workflows/sdk-spec.yml | 5 +- flagsmith.gemspec | 2 +- sdk-spec/Dockerfile | 17 +++++ sdk-spec/steps.rb | 28 +++++++++ spec-adapter/Dockerfile | 17 ----- spec-adapter/adapter.rb | 110 --------------------------------- 6 files changed, 49 insertions(+), 130 deletions(-) create mode 100644 sdk-spec/Dockerfile create mode 100644 sdk-spec/steps.rb delete mode 100644 spec-adapter/Dockerfile delete mode 100644 spec-adapter/adapter.rb diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 6cbf98b..74a1fe7 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,6 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.1.1 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@feat/v0.2.0 with: - spec-ref: v0.1.1 + spec-ref: feat/v0.2.0 + language: ruby diff --git a/flagsmith.gemspec b/flagsmith.gemspec index ee5c2c1..adbe62a 100644 --- a/flagsmith.gemspec +++ b/flagsmith.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(__dir__) do `git ls-files -z`.split("\x0").reject do |f| - (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|spec-adapter|features)/|\.(?:git|travis|circleci)|appveyor)}) + (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|sdk-spec|features)/|\.(?:git|travis|circleci)|appveyor)}) end end diff --git a/sdk-spec/Dockerfile b/sdk-spec/Dockerfile new file mode 100644 index 0000000..87527e4 --- /dev/null +++ b/sdk-spec/Dockerfile @@ -0,0 +1,17 @@ +# Runs this SDK, built from source, under the Flagsmith SDK behavioural spec. +# docker build -f sdk-spec/Dockerfile --build-context harness=/harness/ruby . +FROM docker.io/library/ruby:3.3-slim + +# The gemspec lists its files with `git ls-files`. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /sdk +COPY . . +RUN gem build flagsmith.gemspec --output flagsmith.gem \ + && gem install --no-document flagsmith.gem + +COPY --from=harness harness.rb /harness/harness.rb + +ENTRYPOINT ["ruby", "/harness/harness.rb", "sdk-spec/steps.rb"] diff --git a/sdk-spec/steps.rb b/sdk-spec/steps.rb new file mode 100644 index 0000000..01f3867 --- /dev/null +++ b/sdk-spec/steps.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Steps for the Flagsmith SDK behavioural spec: https://github.com/Flagsmith/sdk-spec +# +# Each method implements the Gherkin step in the comment above it. + +require 'flagsmith' + +def as_spec_flags(flags) + flags.all_flags.map do |flag| + { feature: flag.feature_name, enabled: flag.enabled, value: flag.value } + end +end + +# Given an SDK client for the environment +def create_client(environment_key, api_url) + Flagsmith::Client.new(environment_key: environment_key, api_url: api_url) +end + +# When the application gets the environment flags +def get_environment_flags(flagsmith) + as_spec_flags(flagsmith.get_environment_flags) +end + +# When the application gets the flags for identity "{identifier}" with traits: +def get_identity_flags(flagsmith, identifier, traits) + as_spec_flags(flagsmith.get_identity_flags(identifier, **traits)) +end diff --git a/spec-adapter/Dockerfile b/spec-adapter/Dockerfile deleted file mode 100644 index a92f676..0000000 --- a/spec-adapter/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# Builds the SDK from source with the adapter for the Flagsmith SDK behavioural spec. -# Build from the repository root: docker build -f spec-adapter/Dockerfile . -FROM ruby:3.3-slim - -# The gemspec lists its files with `git ls-files`. -RUN apt-get update \ - && apt-get install -y --no-install-recommends git \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /sdk -COPY . . -RUN gem build flagsmith.gemspec --output flagsmith.gem \ - && gem install --no-document flagsmith.gem webrick - -ENV PORT=8080 -EXPOSE 8080 -CMD ["ruby", "spec-adapter/adapter.rb"] diff --git a/spec-adapter/adapter.rb b/spec-adapter/adapter.rb deleted file mode 100644 index 872652a..0000000 --- a/spec-adapter/adapter.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -# Adapter that lets the Flagsmith SDK behavioural spec drive this SDK. -# -# Protocol: https://github.com/Flagsmith/sdk-spec/blob/main/ADAPTER.md - -require 'json' -require 'webrick' -require 'flagsmith' - -CLIENT_ROUTE = %r{\A/clients/(?[^/]+)(?/[a-z-]+)?\z} - -$stdout.sync = true - -# Serves the adapter protocol, keeping one SDK client per spec scenario. -class Adapter - def initialize - @clients = {} - @next_client_id = 0 - @mutex = Mutex.new - end - - def call(request, response) - status, body = begin - route(request) - rescue StandardError => e # report SDK errors to the runner - warn e.full_message - [500, { error: "#{e.class}: #{e.message}" }] - end - response.status = status - response['Content-Type'] = 'application/json' - response.body = body.nil? ? '' : JSON.generate(body) - end - - private - - def route(request) - method = request.request_method - path = request.path - return [200, { sdk: 'flagsmith-ruby-client', version: Flagsmith::VERSION }] if method == 'GET' && path == '/health' - return create_client(parse_body(request)) if method == 'POST' && path == '/clients' - - match = CLIENT_ROUTE.match(path) - client = match && @mutex.synchronize { @clients[match[:client_id]] } - return not_found(request) unless client - - route_client(request, match[:client_id], match[:action], client) - end - - def route_client(request, client_id, action, client) - case [request.request_method, action] - when ['DELETE', nil] then delete_client(client_id) - when ['POST', '/environment-flags'] then [200, serialise_flags(client.get_environment_flags)] - when ['POST', '/identity-flags'] then [200, serialise_flags(identity_flags(client, parse_body(request)))] - else not_found(request) - end - end - - def create_client(body) - client = Flagsmith::Client.new(environment_key: body['environmentKey'], api_url: body['apiUrl']) - client_id = @mutex.synchronize { (@next_client_id += 1).to_s.tap { |id| @clients[id] = client } } - [201, { clientId: client_id }] - end - - def delete_client(client_id) - @mutex.synchronize { @clients.delete(client_id) } - [204, nil] - end - - def identity_flags(client, body) - client.get_identity_flags(body['identifier'], **(body['traits'] || {})) - end - - def serialise_flags(flags) - { - flags: flags.all_flags.map do |flag| - { featureName: flag.feature_name, enabled: flag.enabled, value: flag.value } - end - } - end - - def parse_body(request) - request.body.to_s.empty? ? {} : JSON.parse(request.body) - end - - def not_found(request) - [404, { error: "No route for #{request.request_method} #{request.path}" }] - end -end - -# Hands every request to the adapter, whatever its method. -class Servlet < WEBrick::HTTPServlet::AbstractServlet - def initialize(server, adapter) - super - @adapter = adapter - end - - def service(request, response) - @adapter.call(request, response) - end -end - -if $PROGRAM_NAME == __FILE__ - port = Integer(ENV.fetch('PORT', '8080')) - server = WEBrick::HTTPServer.new(BindAddress: '0.0.0.0', Port: port) - server.mount('/', Servlet, Adapter.new) - trap('TERM') { server.shutdown } - trap('INT') { server.shutdown } - server.start -end From 82d97a70ac482cc0685c3abcfbd01570683e5db2 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 13:34:30 +0200 Subject: [PATCH 3/5] ci: Pin the SDK spec to v0.2.0 --- .github/workflows/sdk-spec.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 74a1fe7..dbf71b8 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@feat/v0.2.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.2.0 with: - spec-ref: feat/v0.2.0 + spec-ref: v0.2.0 language: ruby From 89a5da96de3a8d9feab4f1c9866169d9f40c900d Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 16:18:43 +0200 Subject: [PATCH 4/5] ci: Move to SDK spec v0.3.0 --- .github/workflows/sdk-spec.yml | 4 ++-- sdk-spec/steps.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index dbf71b8..270f0d3 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.2.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0 with: - spec-ref: v0.2.0 + spec-ref: v0.3.0 language: ruby diff --git a/sdk-spec/steps.rb b/sdk-spec/steps.rb index 01f3867..aed63d9 100644 --- a/sdk-spec/steps.rb +++ b/sdk-spec/steps.rb @@ -12,7 +12,7 @@ def as_spec_flags(flags) end end -# Given an SDK client for the environment +# Create the SDK client def create_client(environment_key, api_url) Flagsmith::Client.new(environment_key: environment_key, api_url: api_url) end From ee054c309f5eed1ce63b8397203f2f0f56e6f415 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 16:38:02 +0200 Subject: [PATCH 5/5] ci: Run the SDK spec against the v0.3.0 tag