From 2a5cc7f086223f0f915ef66dbc4f248ef0e3016a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:18:58 +0000 Subject: [PATCH 1/2] feat: Add environment ID support for hooks Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- contract-tests/hook.rb | 2 + contract-tests/service.rb | 1 + lib/ldclient-rb/impl/data_source.rb | 37 ++++++++ lib/ldclient-rb/impl/data_source/polling.rb | 15 +++- lib/ldclient-rb/impl/data_source/requestor.rb | 16 +++- lib/ldclient-rb/impl/data_source/stream.rb | 2 + lib/ldclient-rb/impl/data_system.rb | 9 ++ lib/ldclient-rb/impl/data_system/fdv1.rb | 5 ++ lib/ldclient-rb/impl/data_system/fdv2.rb | 26 +++++- lib/ldclient-rb/interfaces/hooks.rb | 12 ++- lib/ldclient-rb/ldclient.rb | 2 +- spec/impl/data_source/polling_spec.rb | 37 ++++++++ spec/impl/data_source/requestor_spec.rb | 12 +++ spec/impl/data_source/stream_spec.rb | 33 +++++++ spec/impl/data_source_spec.rb | 23 +++++ spec/impl/data_system/fdv2_datasystem_spec.rb | 89 +++++++++++++++++++ spec/ldclient_hooks_spec.rb | 32 +++++++ 17 files changed, 346 insertions(+), 7 deletions(-) diff --git a/contract-tests/hook.rb b/contract-tests/hook.rb index 0c9eb221..0cec68e4 100644 --- a/contract-tests/hook.rb +++ b/contract-tests/hook.rb @@ -34,6 +34,7 @@ def before_evaluation(evaluation_series_context, data) context: @context_filter.filter(evaluation_series_context.context), defaultValue: evaluation_series_context.default_value, method: evaluation_series_context.method, + environmentId: evaluation_series_context.environment_id, }, evaluationSeriesData: data, stage: 'beforeEvaluation', @@ -58,6 +59,7 @@ def after_evaluation(evaluation_series_context, data, detail) context: @context_filter.filter(evaluation_series_context.context), defaultValue: evaluation_series_context.default_value, method: evaluation_series_context.method, + environmentId: evaluation_series_context.environment_id, }, evaluationSeriesData: data, evaluationDetail: { diff --git a/contract-tests/service.rb b/contract-tests/service.rb index 31dd8d71..779ed37d 100644 --- a/contract-tests/service.rb +++ b/contract-tests/service.rb @@ -46,6 +46,7 @@ 'instance-id', 'anonymous-redaction', 'evaluation-hooks', + 'hook-environment-id', 'omit-anonymous-contexts', 'client-prereq-events', 'persistent-data-store-consul', diff --git a/lib/ldclient-rb/impl/data_source.rb b/lib/ldclient-rb/impl/data_source.rb index 2fdd983c..e64f860c 100644 --- a/lib/ldclient-rb/impl/data_source.rb +++ b/lib/ldclient-rb/impl/data_source.rb @@ -7,6 +7,27 @@ module LaunchDarkly module Impl module DataSource + LD_ENVID_HEADER = "X-LD-EnvID" + + # + # Records the environment ID reported by LaunchDarkly, when the response headers provide one and the sink is + # able to hold it. Externally implemented sinks are not required to support this. + # + # @param sink [LaunchDarkly::Interfaces::DataSource::UpdateSink, nil] + # @param headers [#[], nil] + # @return [void] + # + def self.record_environment_id(sink, headers) + return if headers.nil? || !sink.respond_to?(:set_environment_id) + + environment_id = headers[LD_ENVID_HEADER] + # The http gem returns arrays for repeated headers; normalize to a string. + environment_id = environment_id.first if environment_id.is_a?(Array) + return unless environment_id.is_a?(String) && !environment_id.empty? + + sink.set_environment_id(environment_id) + end + class StatusProvider include LaunchDarkly::Interfaces::DataSource::StatusProvider @@ -41,12 +62,28 @@ def initialize(data_store, status_broadcaster, flag_change_broadcaster) @dependency_tracker = LaunchDarkly::Impl::DependencyTracker.new @mutex = Mutex.new + @environment_id = nil @current_status = LaunchDarkly::Interfaces::DataSource::Status.new( LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING, Time.now, nil) end + # + # @return [String, nil] The environment ID reported by LaunchDarkly, if known + # + def environment_id + @mutex.synchronize { @environment_id } + end + + # + # @param environment_id [String] + # @return [void] + # + def set_environment_id(environment_id) + @mutex.synchronize { @environment_id = environment_id } + end + def init(all_data) old_data = nil monitor_store_update do diff --git a/lib/ldclient-rb/impl/data_source/polling.rb b/lib/ldclient-rb/impl/data_source/polling.rb index e5b772ce..13e22448 100644 --- a/lib/ldclient-rb/impl/data_source/polling.rb +++ b/lib/ldclient-rb/impl/data_source/polling.rb @@ -1,3 +1,4 @@ +require "ldclient-rb/impl/data_source" require "ldclient-rb/impl/repeating_task" require "ldclient-rb/impl/util" @@ -35,7 +36,8 @@ def stop def poll begin - all_data = @requestor.request_all_data + all_data, headers = request_all_data + DataSource.record_environment_id(@config.data_source_update_sink, headers) if all_data update_sink_or_data_store.init(all_data) if @initialized.make_true @@ -92,6 +94,17 @@ def poll @config.data_source_update_sink || @config.feature_store end + # + # Requestors provided by application code may not be able to report the response headers. + # + # @return [Array(Hash, HTTP::Headers, nil)] + # + private def request_all_data + return @requestor.request_all_data_with_headers if @requestor.respond_to?(:request_all_data_with_headers) + + [@requestor.request_all_data, nil] + end + # # @param [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] error_info # diff --git a/lib/ldclient-rb/impl/data_source/requestor.rb b/lib/ldclient-rb/impl/data_source/requestor.rb index f1e714f5..d1110582 100644 --- a/lib/ldclient-rb/impl/data_source/requestor.rb +++ b/lib/ldclient-rb/impl/data_source/requestor.rb @@ -40,8 +40,18 @@ def initialize(sdk_key, config) end def request_all_data() - all_data = JSON.parse(make_request("/sdk/latest-all"), symbolize_names: true) - Impl::Model.make_all_store_data(all_data, @config.logger) + request_all_data_with_headers.first + end + + # + # Requests the full data set, also returning the headers of the response it was retrieved from. + # + # @return [Array(Hash, HTTP::Headers)] + # + def request_all_data_with_headers + body, headers = make_request("/sdk/latest-all") + all_data = JSON.parse(body, symbolize_names: true) + [Impl::Model.make_all_store_data(all_data, @config.logger), headers] end def stop @@ -80,7 +90,7 @@ def make_request(path) etag = response.headers["etag"] @cache.write(uri, CacheEntry.new(etag, body)) unless etag.nil? end - body + [body, response.headers] end def fix_encoding(body, content_type) diff --git a/lib/ldclient-rb/impl/data_source/stream.rb b/lib/ldclient-rb/impl/data_source/stream.rb index bded4070..c7b4cb2d 100644 --- a/lib/ldclient-rb/impl/data_source/stream.rb +++ b/lib/ldclient-rb/impl/data_source/stream.rb @@ -1,3 +1,4 @@ +require "ldclient-rb/impl/data_source" require "ldclient-rb/impl/model/serialization" require "ldclient-rb/impl/util" require "ldclient-rb/in_memory_store" @@ -54,6 +55,7 @@ def start uri = Impl::Util.add_payload_filter_key(@config.stream_uri + "/all", @config) @es = SSE::Client.new(uri, **opts) do |conn| + conn.on_connect { |response_headers| DataSource.record_environment_id(@data_source_update_sink, response_headers) } conn.on_event { |event| process_message(event) } conn.on_error { |err| log_connection_result(false) diff --git a/lib/ldclient-rb/impl/data_system.rb b/lib/ldclient-rb/impl/data_system.rb index 62ee272c..d137d460 100644 --- a/lib/ldclient-rb/impl/data_system.rb +++ b/lib/ldclient-rb/impl/data_system.rb @@ -119,6 +119,15 @@ def set_diagnostic_accumulator(diagnostic_accumulator) raise NotImplementedError, "#{self.class} must implement #set_diagnostic_accumulator" end + # + # Returns the ID of the environment the SDK is connected to, if LaunchDarkly has reported one. + # + # @return [String, nil] + # + def environment_id + raise NotImplementedError, "#{self.class} must implement #environment_id" + end + # # Represents the availability of data in the SDK. # diff --git a/lib/ldclient-rb/impl/data_system/fdv1.rb b/lib/ldclient-rb/impl/data_system/fdv1.rb index 1eb3fa25..b6ba6cbc 100644 --- a/lib/ldclient-rb/impl/data_system/fdv1.rb +++ b/lib/ldclient-rb/impl/data_system/fdv1.rb @@ -121,6 +121,11 @@ def flag_change_broadcaster @flag_change_broadcaster end + # (see DataSystem#environment_id) + def environment_id + @data_source_update_sink.environment_id + end + # # (see DataSystem#data_availability) # diff --git a/lib/ldclient-rb/impl/data_system/fdv2.rb b/lib/ldclient-rb/impl/data_system/fdv2.rb index edd016f0..860b0238 100644 --- a/lib/ldclient-rb/impl/data_system/fdv2.rb +++ b/lib/ldclient-rb/impl/data_system/fdv2.rb @@ -100,6 +100,9 @@ def initialize(sdk_key, config, data_system_config) @store.with_persistence(wrapper, writable, @data_store_status_provider) end + @environment_id = nil + @environment_id_lock = Mutex.new + # Threading @stop_event = Concurrent::Event.new @ready_event = Concurrent::Event.new @@ -185,6 +188,11 @@ def flag_change_broadcaster @flag_change_broadcaster end + # (see DataSystem#environment_id) + def environment_id + @environment_id_lock.synchronize { @environment_id } + end + # (see DataSystem#data_availability) def data_availability return DataAvailability::REFRESHED if @store.selector.defined? @@ -270,6 +278,7 @@ def run_initializers if basis_result.success? basis = basis_result.value @logger.info { "[LDClient] Initialized via #{initializer.name}" } + record_environment_id(basis.environment_id) # Apply the basis to the store regardless of whether fallback was signalled. # If the server returned a valid payload alongside the directive we still want @@ -460,7 +469,10 @@ def consume_synchronizer_results(synchronizer, check_recovery: false) @store.apply(update.change_set, true) if update.change_set # Set ready event on valid update - @ready_event.set if update.state == LaunchDarkly::Interfaces::DataSource::Status::VALID + if update.state == LaunchDarkly::Interfaces::DataSource::Status::VALID + @ready_event.set + record_environment_id(update.environment_id) + end # Update status @data_source_status_provider.update_status(update.state, update.error) @@ -481,6 +493,18 @@ def consume_synchronizer_results(synchronizer, check_recovery: false) SyncResult::REMOVE end + # + # Retains the environment ID reported by a successful connection to LaunchDarkly. + # + # @param environment_id [String, nil] + # @return [void] + # + def record_environment_id(environment_id) + return unless environment_id.is_a?(String) && !environment_id.empty? + + @environment_id_lock.synchronize { @environment_id = environment_id } + end + # # Determine if we should fallback to the next synchronizer. # diff --git a/lib/ldclient-rb/interfaces/hooks.rb b/lib/ldclient-rb/interfaces/hooks.rb index 4c27bb21..9f4238a9 100644 --- a/lib/ldclient-rb/interfaces/hooks.rb +++ b/lib/ldclient-rb/interfaces/hooks.rb @@ -70,17 +70,27 @@ class EvaluationSeriesContext attr_reader :default_value attr_reader :method + # + # The ID of the LaunchDarkly environment the SDK is connected to, if known. This is only available once the + # SDK has received data from LaunchDarkly. + # + # @return [String, nil] + # + attr_reader :environment_id + # # @param key [String] # @param context [LaunchDarkly::LDContext] # @param default_value [any] # @param method [Symbol] + # @param environment_id [String, nil] # - def initialize(key, context, default_value, method) + def initialize(key, context, default_value, method, environment_id = nil) @key = key @context = context @default_value = default_value @method = method + @environment_id = environment_id end end end diff --git a/lib/ldclient-rb/ldclient.rb b/lib/ldclient-rb/ldclient.rb index d503d387..fa750cc2 100644 --- a/lib/ldclient-rb/ldclient.rb +++ b/lib/ldclient-rb/ldclient.rb @@ -421,7 +421,7 @@ def variation_detail(key, context, default) # Hooks can be added and we want to ensure all correct stages for a given hook execute. For example, we do not # want to trigger the after_evaluation method without also triggering the before_evaluation method. hooks = @hooks.dup - evaluation_series_context = Interfaces::Hooks::EvaluationSeriesContext.new(key, context, default, method) + evaluation_series_context = Interfaces::Hooks::EvaluationSeriesContext.new(key, context, default, method, @data_system.environment_id) [hooks, evaluation_series_context] end diff --git a/spec/impl/data_source/polling_spec.rb b/spec/impl/data_source/polling_spec.rb index 120162c3..3dcfdc30 100644 --- a/spec/impl/data_source/polling_spec.rb +++ b/spec/impl/data_source/polling_spec.rb @@ -84,6 +84,43 @@ def with_processor(store, initialize_to_valid = false) end end + describe 'environment ID' do + flag = Impl::Model::FeatureFlag.new({ key: 'flagkey', version: 1 }) + all_data = { + Impl::DataStore::FEATURES => { flagkey: flag }, + Impl::DataStore::SEGMENTS => {}, + } + + it 'is recorded from the response headers' do + allow(requestor).to receive(:request_all_data_with_headers).and_return([all_data, { "X-LD-EnvID" => "env-abc" }]) + store = InMemoryFeatureStore.new + with_processor(store) do |processor| + config = processor.instance_variable_get(:@config) + processor.start.wait + expect(config.data_source_update_sink.environment_id).to eq("env-abc") + end + end + + it 'is not recorded when the header is absent' do + allow(requestor).to receive(:request_all_data_with_headers).and_return([all_data, {}]) + store = InMemoryFeatureStore.new + with_processor(store) do |processor| + config = processor.instance_variable_get(:@config) + processor.start.wait + expect(config.data_source_update_sink.environment_id).to be_nil + end + end + + it 'is not recorded for an error response' do + allow(requestor).to receive(:request_all_data_with_headers).and_raise(Impl::DataSource::UnexpectedResponseError.new(503)) + with_processor(InMemoryFeatureStore.new, true) do |processor| + config = processor.instance_variable_get(:@config) + processor.start.wait(1) + expect(config.data_source_update_sink.environment_id).to be_nil + end + end + end + describe 'connection error' do it 'does not cause immediate failure, does not set initialized' do allow(requestor).to receive(:request_all_data).and_raise(StandardError.new("test error")) diff --git a/spec/impl/data_source/requestor_spec.rb b/spec/impl/data_source/requestor_spec.rb index 22230876..0ed7676a 100644 --- a/spec/impl/data_source/requestor_spec.rb +++ b/spec/impl/data_source/requestor_spec.rb @@ -46,6 +46,18 @@ def with_requestor(base_uri, opts = {}) end end + it "returns the response headers" do + expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) + with_server do |server| + with_requestor(server.base_uri.to_s) do |requestor| + server.setup_ok_response("/", expected_data.to_json, "application/json", { "X-LD-EnvID" => "env-abc" }) + data, headers = requestor.request_all_data_with_headers + expect(data).to eq expected_data.to_store_data + expect(headers["X-LD-EnvID"]).to eq "env-abc" + end + end + end + it "logs debug output" do logger = ::Logger.new($stdout) logger.level = ::Logger::DEBUG diff --git a/spec/impl/data_source/stream_spec.rb b/spec/impl/data_source/stream_spec.rb index 57b0d5dd..12080697 100644 --- a/spec/impl/data_source/stream_spec.rb +++ b/spec/impl/data_source/stream_spec.rb @@ -68,6 +68,39 @@ module LaunchDarkly end end + describe 'environment ID' do + def with_connect_handler(processor) + connection = double("connection", on_event: nil, on_error: nil) + handler = nil + allow(connection).to receive(:on_connect) { |&block| handler = block } + allow(SSE::Client).to receive(:new) do |_uri, **_opts, &block| + block.call(connection) + double("SSE::Client", close: nil) + end + + processor.start + begin + yield handler + ensure + processor.stop + end + end + + it 'is recorded from the connection response headers' do + with_connect_handler(processor) do |handler| + handler.call({ "X-LD-EnvID" => "env-abc" }) + expect(config.data_source_update_sink.environment_id).to eq("env-abc") + end + end + + it 'is not recorded when the header is absent' do + with_connect_handler(processor) do |handler| + handler.call({}) + expect(config.data_source_update_sink.environment_id).to be_nil + end + end + end + describe '#log_connection_result' do it "logs successful connection when diagnostic_accumulator is provided" do diagnostic_accumulator = double("DiagnosticAccumulator") diff --git a/spec/impl/data_source_spec.rb b/spec/impl/data_source_spec.rb index 5d2d2847..cef347b5 100644 --- a/spec/impl/data_source_spec.rb +++ b/spec/impl/data_source_spec.rb @@ -15,6 +15,29 @@ module Impl expect(sink.current_status.last_error).to be_nil end + it "has no environment ID until one is reported" do + expect(sink.environment_id).to be_nil + end + + it "records the environment ID from response headers" do + DataSource.record_environment_id(sink, { "X-LD-EnvID" => "env-abc" }) + expect(sink.environment_id).to eq("env-abc") + end + + it "ignores responses without a usable environment ID" do + DataSource.record_environment_id(sink, nil) + DataSource.record_environment_id(sink, {}) + DataSource.record_environment_id(sink, { "X-LD-EnvID" => "" }) + expect(sink.environment_id).to be_nil + end + + it "does not overwrite a known environment ID with an unusable one" do + DataSource.record_environment_id(sink, { "X-LD-EnvID" => "env-abc" }) + DataSource.record_environment_id(sink, { "X-LD-EnvID" => "" }) + DataSource.record_environment_id(sink, {}) + expect(sink.environment_id).to eq("env-abc") + end + it "setting status to interrupted while initializing maintains initializing state" do sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, nil) expect(sink.current_status.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING) diff --git a/spec/impl/data_system/fdv2_datasystem_spec.rb b/spec/impl/data_system/fdv2_datasystem_spec.rb index 3cff8ec1..eb724467 100644 --- a/spec/impl/data_system/fdv2_datasystem_spec.rb +++ b/spec/impl/data_system/fdv2_datasystem_spec.rb @@ -125,6 +125,95 @@ def build(_sdk_key, _config) end end + describe "environment ID" do + def basis_with_environment_id(environment_id) + LaunchDarkly::Interfaces::DataSystem::FetchResult.new( + result: LaunchDarkly::Result.success( + LaunchDarkly::Interfaces::DataSystem::Basis.new( + change_set: LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.empty( + LaunchDarkly::Interfaces::DataSystem::Selector.new(state: "state", version: 1) + ), + persist: true, + environment_id: environment_id + ) + ) + ) + end + + def mock_initializer(fetch_result) + initializer = double("initializer") + allow(initializer).to receive(:name).and_return("mock-initializer") + allow(initializer).to receive(:fetch).and_return(fetch_result) + initializer + end + + def mock_synchronizer(updates) + synchronizer = double("synchronizer") + allow(synchronizer).to receive(:name).and_return("mock-synchronizer") + allow(synchronizer).to receive(:stop) + allow(synchronizer).to receive(:sync) { |_store, &block| updates.each { |update| block.call(update) } } + synchronizer + end + + def with_data_system(initializers, synchronizers) + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers(initializers) + .synchronizers(synchronizers) + .build + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + begin + fdv2.start.wait(2) + yield fdv2 + ensure + fdv2.stop + end + end + + it "is not available before initialization" do + td = LaunchDarkly::Integrations::TestDataV2.data_source + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers(nil) + .synchronizers([td.test_data_ds_builder]) + .build + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + expect(fdv2.environment_id).to be_nil + end + + it "is retained from the initializer basis" do + with_data_system([MockBuilder.new(mock_initializer(basis_with_environment_id("env-abc")))], []) do |fdv2| + expect(fdv2.environment_id).to eq("env-abc") + end + end + + it "is retained from a valid synchronizer update" do + update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::VALID, + environment_id: "env-abc" + ) + + with_data_system(nil, [MockBuilder.new(mock_synchronizer([update]))]) do |fdv2| + expect(fdv2.environment_id).to eq("env-abc") + end + end + + it "ignores updates without a usable environment ID" do + interrupted = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, + environment_id: "env-from-error" + ) + valid = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::VALID, + environment_id: "" + ) + + with_data_system(nil, [MockBuilder.new(mock_synchronizer([interrupted, valid]))]) do |fdv2| + expect(fdv2.environment_id).to be_nil + end + end + end + describe "secondary synchronizer fallback" do it "falls back to secondary synchronizer when primary fails" do mock_primary = double("primary_synchronizer") diff --git a/spec/ldclient_hooks_spec.rb b/spec/ldclient_hooks_spec.rb index 4c403981..16feb0bd 100644 --- a/spec/ldclient_hooks_spec.rb +++ b/spec/ldclient_hooks_spec.rb @@ -1,5 +1,6 @@ require "ldclient-rb" +require "http_util" require "mock_components" require "model_builders" require "spec_helper" @@ -115,6 +116,37 @@ module LaunchDarkly end end + it "hook receives no environment ID when LaunchDarkly has not reported one" do + environment_id = :unset + config_hook = MockHook.new(->(_, _) { }, ->(series_context, _, _) { environment_id = series_context.environment_id }) + with_client(test_config(hooks: [config_hook])) do |client| + client.variation("doesntmatter", basic_context, "default") + + expect(environment_id).to be_nil + end + end + + it "hook receives the environment ID reported by LaunchDarkly" do + environment_id = nil + config_hook = MockHook.new(->(_, _) { }, ->(series_context, _, _) { environment_id = series_context.environment_id }) + + with_server do |poll_server| + poll_server.setup_ok_response("/sdk/latest-all", { flags: {}, segments: {} }.to_json, "application/json", { "X-LD-EnvID" => "env-abc" }) + + config = test_config( + stream: false, + data_source: nil, + base_uri: poll_server.base_uri.to_s, + hooks: [config_hook] + ) + with_client(config) do |client| + client.variation("doesntmatter", basic_context, "default") + + expect(environment_id).to eq "env-abc" + end + end + end + it "from before evaluation to after evaluation" do actual = nil config_hook = MockHook.new(->(_, _) { "example string returned" }, ->(_, hook_data, _) { actual = hook_data }) From 4ce7349c160ca4cb748d68a9930a103e95f3b35f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:29:17 +0000 Subject: [PATCH 2/2] ci: Use the latest v3 contract test harness Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- .github/actions/check/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/check/action.yml b/.github/actions/check/action.yml index 48dc37e8..e7455238 100644 --- a/.github/actions/check/action.yml +++ b/.github/actions/check/action.yml @@ -51,4 +51,4 @@ runs: test_service_port: 9000 enable_persistence_tests: true token: ${{ inputs.token }} - version: v3.0.0-alpha.6 + version: v3