From ad2ef4a692cccdb483b0ffce74ff127f50b3d102 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 12 Jun 2026 19:31:48 +0200 Subject: [PATCH 1/2] chore: add public API snapshot check --- .github/workflows/release.yml | 3 - .github/workflows/unit-tests.yml | 3 + CONTRIBUTING.md | 7 + Rakefile | 15 +++ public_api_snapshot.txt | 119 +++++++++++++++++ scripts/public_api_snapshot.rb | 216 +++++++++++++++++++++++++++++++ 6 files changed, 360 insertions(+), 3 deletions(-) create mode 100644 Rakefile create mode 100644 public_api_snapshot.txt create mode 100644 scripts/public_api_snapshot.rb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 556b7c0..464d01e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,6 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main - fetch-depth: 0 - name: Check for changesets id: check @@ -86,7 +85,6 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main - fetch-depth: 0 token: ${{ steps.releaser.outputs.token }} - name: Configure Git @@ -202,7 +200,6 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main - fetch-depth: 0 - name: Configure Git run: | diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 7f61393..e47017c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -55,6 +55,9 @@ jobs: - name: Run RuboCop run: bundle exec rubocop + - name: Check public API snapshot + run: bundle exec rake public_api:check + gem-build: name: Gem build runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea0afae..5bdab2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,13 @@ Run the same checks CI uses before opening a PR: ```bash bundle exec rspec bundle exec rubocop +bundle exec rake public_api:check +``` + +If you intentionally change the public Ruby API, update the snapshot and review the diff: + +```bash +bundle exec rake public_api:generate ``` ## Rails package diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..693ca35 --- /dev/null +++ b/Rakefile @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'scripts/public_api_snapshot' + +namespace :public_api do + desc 'Generate the public API snapshot' + task :generate do + PublicApiSnapshot.write + end + + desc 'Check that the public API snapshot matches the current code' + task :check do + exit 1 unless PublicApiSnapshot.check + end +end diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt new file mode 100644 index 0000000..482f122 --- /dev/null +++ b/public_api_snapshot.txt @@ -0,0 +1,119 @@ +# This file is generated by `bundle exec rake public_api:generate`. +# Run `bundle exec rake public_api:check` to detect local public API changes. + +module PostHog +class PostHog::Client +instance_method PostHog::Client#alias(attrs) +instance_method PostHog::Client#capture(attrs) +instance_method PostHog::Client#capture_exception(exception, distinct_id = ..., additional_properties = ..., flags: ...) +instance_method PostHog::Client#clear() +instance_method PostHog::Client#dequeue_last_message() +instance_method PostHog::Client#evaluate_flags(distinct_id, groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ..., disable_geoip: ..., flag_keys: ...) +instance_method PostHog::Client#flush() +instance_method PostHog::Client#get_all_flags(distinct_id, groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ...) +instance_method PostHog::Client#get_all_flags_and_payloads(distinct_id, groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ...) +instance_method PostHog::Client#get_feature_flag(key, distinct_id, groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ..., send_feature_flag_events: ...) +instance_method PostHog::Client#get_feature_flag_payload(key, distinct_id, match_value: ..., groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ...) +instance_method PostHog::Client#get_feature_flag_result(key, distinct_id, groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ..., send_feature_flag_events: ...) +instance_method PostHog::Client#get_remote_config_payload(flag_key) +instance_method PostHog::Client#group_identify(attrs) +instance_method PostHog::Client#identify(attrs) +instance_method PostHog::Client#is_feature_enabled(flag_key, distinct_id, groups: ..., person_properties: ..., group_properties: ..., only_evaluate_locally: ..., send_feature_flag_events: ...) +instance_method PostHog::Client#queued_messages() +instance_method PostHog::Client#reload_feature_flags() +instance_method PostHog::Client#shutdown() +class_method PostHog::Client.logger() +class_method PostHog::Client.reset_instance_tracking!() +module PostHog::Defaults +module PostHog::Defaults::BackoffPolicy +constant PostHog::Defaults::BackoffPolicy::MAX_TIMEOUT_MS: Integer +constant PostHog::Defaults::BackoffPolicy::MIN_TIMEOUT_MS: Integer +constant PostHog::Defaults::BackoffPolicy::MULTIPLIER: Float +constant PostHog::Defaults::BackoffPolicy::RANDOMIZATION_FACTOR: Float +module PostHog::Defaults::FeatureFlags +constant PostHog::Defaults::FeatureFlags::FLAG_REQUEST_TIMEOUT_SECONDS: Integer +constant PostHog::Defaults::MAX_HASH_SIZE: Integer +module PostHog::Defaults::Message +constant PostHog::Defaults::Message::MAX_BYTES: Integer +module PostHog::Defaults::MessageBatch +constant PostHog::Defaults::MessageBatch::MAX_BYTES: Integer +constant PostHog::Defaults::MessageBatch::MAX_SIZE: Integer +module PostHog::Defaults::Queue +constant PostHog::Defaults::Queue::MAX_SIZE: Integer +module PostHog::Defaults::Request +constant PostHog::Defaults::Request::HEADERS: Hash +constant PostHog::Defaults::Request::HOST: String +constant PostHog::Defaults::Request::PATH: String +constant PostHog::Defaults::Request::PORT: Integer +constant PostHog::Defaults::Request::RETRIES: Integer +constant PostHog::Defaults::Request::SSL: Boolean +class PostHog::FeatureFlagEvaluations +instance_method PostHog::FeatureFlagEvaluations#distinct_id() +instance_method PostHog::FeatureFlagEvaluations#enabled?(key) +instance_method PostHog::FeatureFlagEvaluations#evaluated_at() +instance_method PostHog::FeatureFlagEvaluations#flag_definitions_loaded_at() +instance_method PostHog::FeatureFlagEvaluations#get_flag(key) +instance_method PostHog::FeatureFlagEvaluations#get_flag_payload(key) +instance_method PostHog::FeatureFlagEvaluations#groups() +instance_method PostHog::FeatureFlagEvaluations#keys() +instance_method PostHog::FeatureFlagEvaluations#only(keys) +instance_method PostHog::FeatureFlagEvaluations#only_accessed() +instance_method PostHog::FeatureFlagEvaluations#request_id() +constant PostHog::FeatureFlagEvaluations::EVALUATED_LOCALLY_REASON: String +class PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord < Struct +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#enabled() +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#enabled=(_) +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#id() +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#id=(_) +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#key() +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#key=(_) +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#locally_evaluated() +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#locally_evaluated=(_) +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#payload() +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#payload=(_) +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#reason() +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#reason=(_) +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#variant() +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#variant=(_) +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#version() +instance_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord#version=(_) +class_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord.[](*arg0) +class_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord.inspect() +class_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord.keyword_init?() +class_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord.members() +class_method PostHog::FeatureFlagEvaluations::EvaluatedFlagRecord.new(*arg0) +class PostHog::FeatureFlagEvaluations::Host < Struct +instance_method PostHog::FeatureFlagEvaluations::Host#capture_flag_called_event_if_needed() +instance_method PostHog::FeatureFlagEvaluations::Host#capture_flag_called_event_if_needed=(_) +instance_method PostHog::FeatureFlagEvaluations::Host#log_warning() +instance_method PostHog::FeatureFlagEvaluations::Host#log_warning=(_) +class_method PostHog::FeatureFlagEvaluations::Host.[](*arg0) +class_method PostHog::FeatureFlagEvaluations::Host.inspect() +class_method PostHog::FeatureFlagEvaluations::Host.keyword_init?() +class_method PostHog::FeatureFlagEvaluations::Host.members() +class_method PostHog::FeatureFlagEvaluations::Host.new(*arg0) +class PostHog::FeatureFlagResult +instance_method PostHog::FeatureFlagResult#enabled?() +instance_method PostHog::FeatureFlagResult#key() +instance_method PostHog::FeatureFlagResult#payload() +instance_method PostHog::FeatureFlagResult#value() +instance_method PostHog::FeatureFlagResult#variant() +class_method PostHog::FeatureFlagResult.from_value_and_payload(key, value, payload) +class_method PostHog::FeatureFlagResult.parse_payload(payload) +module PostHog::FlagDefinitionCacheProvider +class_method PostHog::FlagDefinitionCacheProvider.validate!(provider) +constant PostHog::FlagDefinitionCacheProvider::REQUIRED_METHODS: Array +class PostHog::InconclusiveMatchError < StandardError +module PostHog::Logging +instance_method PostHog::Logging#logger() +class_method PostHog::Logging.included(base) +class_method PostHog::Logging.logger() +class_method PostHog::Logging.logger=(arg0) +class PostHog::RequiresServerEvaluation < StandardError +class PostHog::SendFeatureFlagsOptions +instance_method PostHog::SendFeatureFlagsOptions#group_properties() +instance_method PostHog::SendFeatureFlagsOptions#only_evaluate_locally() +instance_method PostHog::SendFeatureFlagsOptions#person_properties() +instance_method PostHog::SendFeatureFlagsOptions#to_h() +class_method PostHog::SendFeatureFlagsOptions.from_hash(hash) +constant PostHog::VERSION: String diff --git a/scripts/public_api_snapshot.rb b/scripts/public_api_snapshot.rb new file mode 100644 index 0000000..9833dd8 --- /dev/null +++ b/scripts/public_api_snapshot.rb @@ -0,0 +1,216 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'open3' +require 'tempfile' + +module PublicApiSnapshot + ROOT = File.expand_path('..', __dir__).freeze + LIB_DIR = File.join(ROOT, 'lib').freeze + SNAPSHOT_PATH = File.join(ROOT, 'public_api_snapshot.txt').freeze + HEADER = <<~HEADER + # This file is generated by `bundle exec rake public_api:generate`. + # Run `bundle exec rake public_api:check` to detect local public API changes. + HEADER + + module_function + + def generate + load_sdk + + entries = [] + append_constant(entries, Object, :PostHog, 'PostHog') + + "#{HEADER}\n#{entries.join("\n")}\n" + end + + def write + File.write(SNAPSHOT_PATH, generate) + puts "Updated #{relative_path(SNAPSHOT_PATH)}" + end + + def check + expected = File.exist?(SNAPSHOT_PATH) ? File.read(SNAPSHOT_PATH) : nil + actual = generate + + return true if expected == actual + + warn "Public API snapshot is out of date. Run `bundle exec rake public_api:generate` and review the diff." + print_diff(expected, actual) + false + end + + def load_sdk + $LOAD_PATH.unshift(LIB_DIR) unless $LOAD_PATH.include?(LIB_DIR) + require 'posthog' + end + + def append_constant(entries, parent, constant_name, qualified_name) + return if private_constant?(parent, constant_name, qualified_name) + + value = parent.const_get(constant_name, false) + return unless value.is_a?(Module) + + entries << constant_entry(value, qualified_name) + append_methods(entries, value, qualified_name) + append_nested_constants(entries, value, qualified_name) + end + + def append_nested_constants(entries, mod, qualified_name) + mod.constants(false).sort_by(&:to_s).each do |constant_name| + append_literal_constant(entries, mod, constant_name, qualified_name) + append_constant(entries, mod, constant_name, "#{qualified_name}::#{constant_name}") + end + end + + def append_literal_constant(entries, mod, constant_name, qualified_name) + return if private_constant?(mod, constant_name, "#{qualified_name}::#{constant_name}") + + value = mod.const_get(constant_name, false) + return if value.is_a?(Module) + + entries << "constant #{qualified_name}::#{constant_name}: #{constant_type(value)}" + end + + def append_methods(entries, mod, qualified_name) + public_instance_methods(mod).each do |method_name| + next if private_api_method?(mod.instance_method(method_name), method_name) + + entries << "instance_method #{qualified_name}##{method_name}#{method_signature(mod.instance_method(method_name))}" + end + + public_singleton_methods(mod).each do |method_name| + method = mod.method(method_name) + next if private_api_method?(method, method_name) + + entries << "class_method #{qualified_name}.#{method_name}#{method_signature(method)}" + end + end + + def constant_entry(value, qualified_name) + case value + when Class + superclass = value.superclass + suffix = superclass && superclass != Object ? " < #{superclass.name}" : '' + "class #{qualified_name}#{suffix}" + else + "module #{qualified_name}" + end + end + + def public_instance_methods(mod) + mod.public_instance_methods(false).sort_by(&:to_s) + end + + def public_singleton_methods(mod) + mod.singleton_class.public_instance_methods(false).sort_by(&:to_s) + end + + def private_constant?(parent, constant_name, qualified_name) + qualified_name.start_with?('PostHog::Internal') || + source_marked_private?(parent.const_source_location(constant_name, false)) + end + + def private_api_method?(method, method_name) + method_name.to_s.start_with?('_') || source_marked_private?(method.source_location) + end + + def source_marked_private?(location) + return false unless location + + file, line = location + return false unless file&.start_with?(LIB_DIR) && File.file?(file) + + preceding_comment(file, line).include?('@api private') + end + + def preceding_comment(file, line) + lines = File.readlines(file) + index = line - 2 + comment = [] + + while index >= 0 + text = lines[index] + break unless text.match?(/^\s*#/) || text.match?(/^\s*$/) + + comment.unshift(text) + index -= 1 + end + + comment.join + end + + def constant_type(value) + return 'Boolean' if value == true || value == false + return 'nil' if value.nil? + + value.class.name + end + + def method_signature(method) + params = method.parameters.map.with_index do |(type, name), index| + format_parameter(type, name, index) + end + "(#{params.join(', ')})" + end + + def format_parameter(type, name, index) + name ||= "arg#{index}" + + case type + when :req + name.to_s + when :opt + "#{name} = ..." + when :rest + "*#{name}" + when :keyreq + "#{name}:" + when :key + "#{name}: ..." + when :keyrest + "**#{name}" + when :block + "&#{name}" + else + "#{type}:#{name}" + end + end + + def print_diff(expected, actual) + Tempfile.create('public-api-expected') do |expected_file| + Tempfile.create('public-api-actual') do |actual_file| + expected_file.write(expected || '') + actual_file.write(actual) + expected_file.flush + actual_file.flush + + diff_output, stderr, status = Open3.capture3( + 'diff', '-u', '--label', relative_path(SNAPSHOT_PATH), expected_file.path, + '--label', 'generated public API', actual_file.path + ) + warn stderr unless stderr.empty? + puts diff_output unless diff_output.empty? + warn 'No diff output available.' unless status.exitstatus == 1 || !diff_output.empty? + end + end + rescue Errno::ENOENT + warn 'Install diff or run `bundle exec rake public_api:generate` to update the snapshot.' + end + + def relative_path(path) + path.delete_prefix("#{ROOT}/") + end +end + +if $PROGRAM_NAME == __FILE__ + case ARGV.first + when '--check' + exit(PublicApiSnapshot.check ? 0 : 1) + when '--generate', nil + PublicApiSnapshot.write + else + warn 'Usage: ruby scripts/public_api_snapshot.rb [--check|--generate]' + exit 2 + end +end From 6726325418c3ea412d893554c5f4bb1773c2d57f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 12 Jun 2026 19:33:25 +0200 Subject: [PATCH 2/2] chore: fix public API snapshot lint --- scripts/public_api_snapshot.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/public_api_snapshot.rb b/scripts/public_api_snapshot.rb index 9833dd8..346517b 100644 --- a/scripts/public_api_snapshot.rb +++ b/scripts/public_api_snapshot.rb @@ -1,4 +1,3 @@ -#!/usr/bin/env ruby # frozen_string_literal: true require 'open3' @@ -35,7 +34,7 @@ def check return true if expected == actual - warn "Public API snapshot is out of date. Run `bundle exec rake public_api:generate` and review the diff." + warn 'Public API snapshot is out of date. Run `bundle exec rake public_api:generate` and review the diff.' print_diff(expected, actual) false end @@ -141,7 +140,7 @@ def preceding_comment(file, line) end def constant_type(value) - return 'Boolean' if value == true || value == false + return 'Boolean' if [true, false].include?(value) return 'nil' if value.nil? value.class.name