diff --git a/README.md b/README.md index 1f28974..4de4609 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A plugin including commonly used automation logic for RevenueCat SDKs. - `commit_current_changes`: This action will commit all currently modified files into the current branch in the local repository. (This will not include untracked files) - `get_latest_github_release_within_same_major`: This action will return the latest release found in github for the same major version as the one given as parameter. - `update_hybrids_versions_file`: This action is meant for hybrid sdks only. It will update the `VERSIONS.md` file given with a new entry including the new version if the SDK and the iOS, Android and hybrid common sdk versions. +- `validate_version_not_in_maven_central`: This action checks if a specific version of Maven artifacts already exists in Maven Central before deployment. It prevents accidental re-releases by failing if any of the specified artifacts are already published. ## Example diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 733c2c4..bc70972 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -81,6 +81,15 @@ lane :sample_determine_next_version_using_labels_action do |options| ) end +lane :sample_validate_version_not_in_maven_central_action do |options| + validate_version_not_in_maven_central( + group_id: 'com.revenuecat.purchases', + artifact_ids: ['purchases', 'purchases-ui'], + version: '7.0.0', + auth_token: 'maven-central-auth-token' # This can also be obtained from ENV FETCH_PUBLICATIONS_USER_TOKEN_MAVEN_CENTRAL + ) +end + lane :sample_flaky_test do test_artifact_path = File.absolute_path('./test_output/xctest/ios') diff --git a/lib/fastlane/plugin/revenuecat_internal/actions/validate_version_not_in_maven_central_action.rb b/lib/fastlane/plugin/revenuecat_internal/actions/validate_version_not_in_maven_central_action.rb new file mode 100644 index 0000000..7106199 --- /dev/null +++ b/lib/fastlane/plugin/revenuecat_internal/actions/validate_version_not_in_maven_central_action.rb @@ -0,0 +1,60 @@ +require 'fastlane/action' +require 'fastlane_core/configuration/config_item' +require 'fastlane_core/ui/ui' +require_relative '../helper/maven_central_helper' + +module Fastlane + module Actions + class ValidateVersionNotInMavenCentralAction < Action + def self.run(params) + group_id = params[:group_id] + artifact_ids = params[:artifact_ids] + version = params[:version] + auth_token = params[:auth_token] || ENV.fetch("FETCH_PUBLICATIONS_USER_TOKEN_MAVEN_CENTRAL", nil) + + UI.message("Checking if version #{version} already exists in Maven Central...") + + if artifact_ids.empty? + UI.user_error!("No artifacts provided. Please provide at least one artifact ID to check") + else + UI.message("Found #{artifact_ids.length} artifacts to check: #{artifact_ids.join(', ')}") + Helper::MavenCentralHelper.check_version_not_published(version, group_id, artifact_ids, auth_token) + UI.success("Version #{version} does not exist in Maven Central. Proceeding with deployment.") + end + end + + def self.description + "Checks if a specific version of Maven artifacts already exists in Maven Central before deployment" + end + + def self.authors + ["RevenueCat"] + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new(key: :group_id, + description: "Maven group ID (e.g., 'com.revenuecat.purchases')", + optional: false, + type: String), + FastlaneCore::ConfigItem.new(key: :artifact_ids, + description: "Array of artifact IDs to check (e.g., ['purchases', 'purchases-ui'])", + optional: false, + type: Array), + FastlaneCore::ConfigItem.new(key: :version, + description: "Version to check (e.g., '7.0.0')", + optional: false, + type: String), + FastlaneCore::ConfigItem.new(key: :auth_token, + description: "Authentication token for Maven Central API (defaults to FETCH_PUBLICATIONS_USER_TOKEN_MAVEN_CENTRAL env var)", + optional: true, + type: String) + ] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/revenuecat_internal/helper/maven_central_helper.rb b/lib/fastlane/plugin/revenuecat_internal/helper/maven_central_helper.rb new file mode 100644 index 0000000..df88c94 --- /dev/null +++ b/lib/fastlane/plugin/revenuecat_internal/helper/maven_central_helper.rb @@ -0,0 +1,62 @@ +require 'fastlane_core/ui/ui' +require 'fastlane/action' +require 'rest-client' +require 'json' +require 'uri' + +module Fastlane + UI = FastlaneCore::UI unless Fastlane.const_defined?(:UI) + + module Helper + class MavenCentralHelper + def self.check_version_not_published(version, group_id, artifact_ids, auth_token) + if auth_token.nil? || auth_token.empty? + UI.user_error!("FETCH_PUBLICATIONS_USER_TOKEN_MAVEN_CENTRAL environment variable is not set. Please provide a valid token to check Maven Central publications.") + end + + base_url = "https://central.sonatype.com/api/v1/publisher/published" + existing_artifacts = [] + + artifact_ids.each do |artifact_id| + # Build query parameters for the API endpoint with proper URI encoding + # The API uses 'name' parameter which corresponds to the artifact_id + api_url = "#{base_url}?namespace=#{URI.encode_www_form_component(group_id)}&name=#{URI.encode_www_form_component(artifact_id)}&version=#{URI.encode_www_form_component(version)}" + + UI.verbose("Checking Sonatype API for publication status: #{group_id}:#{artifact_id}:#{version}") + + begin + response = RestClient.get( + api_url, + { + 'Authorization' => "Bearer #{auth_token}", + 'accept' => 'application/json' + } + ) + + # Parse JSON response + response_data = JSON.parse(response.body) + + # Check if published field is true + if response_data["published"] == true + existing_artifacts << artifact_id + UI.important("Artifact #{group_id}:#{artifact_id}:#{version} already exists in Maven Central") + end + rescue StandardError => e + UI.user_error!("Failed to check #{group_id}:#{artifact_id}:#{version}: #{e.message}") + end + end + + unless existing_artifacts.empty? + error_message = "Version #{version} already exists in Maven Central for the following artifacts:\n" + existing_artifacts.each do |artifact_id| + error_message += " - #{group_id}:#{artifact_id}:#{version}\n" + end + error_message += "\nDeployment cancelled to prevent duplicate releases." + UI.user_error!(error_message) + end + + true + end + end + end +end diff --git a/spec/actions/validate_version_not_in_maven_central_action_spec.rb b/spec/actions/validate_version_not_in_maven_central_action_spec.rb new file mode 100644 index 0000000..d6481fc --- /dev/null +++ b/spec/actions/validate_version_not_in_maven_central_action_spec.rb @@ -0,0 +1,269 @@ +describe Fastlane::Actions::ValidateVersionNotInMavenCentralAction do + describe '#run' do + let(:group_id) { 'com.revenuecat.purchases' } + let(:artifact_ids) { ['purchases', 'purchases-ui'] } + let(:version) { '7.0.0' } + let(:auth_token) { 'test-token' } + let(:base_url) { 'https://central.sonatype.com/api/v1/publisher/published' } + + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("FETCH_PUBLICATIONS_USER_TOKEN_MAVEN_CENTRAL").and_return(auth_token) + end + + context 'when version does not exist in Maven Central' do + before do + artifact_ids.each do |artifact_id| + # Use a more flexible URL pattern that matches regardless of parameter order + stub_request(:get, /#{Regexp.escape(base_url)}/) + .with( + query: hash_including({ + 'namespace' => group_id, + 'name' => artifact_id, + 'version' => version + }) + ) + .to_return(status: 200, body: { "published" => false }.to_json) + end + end + + it 'succeeds and shows success message' do + expect(FastlaneCore::UI).to receive(:message).with("Checking if version #{version} already exists in Maven Central...") + expect(FastlaneCore::UI).to receive(:message).with("Found #{artifact_ids.length} artifacts to check: #{artifact_ids.join(', ')}") + expect(FastlaneCore::UI).to receive(:success).with("Version #{version} does not exist in Maven Central. Proceeding with deployment.") + + described_class.run({ + group_id: group_id, + artifact_ids: artifact_ids, + version: version, + auth_token: auth_token + }) + end + end + + context 'when version exists for some artifacts in Maven Central' do + before do + # First artifact exists + stub_request(:get, /#{Regexp.escape(base_url)}/) + .with( + query: hash_including({ + 'namespace' => group_id, + 'name' => artifact_ids[0], + 'version' => version + }) + ) + .to_return(status: 200, body: { "published" => true }.to_json) + + # Second artifact doesn't exist + stub_request(:get, /#{Regexp.escape(base_url)}/) + .with( + query: hash_including({ + 'namespace' => group_id, + 'name' => artifact_ids[1], + 'version' => version + }) + ) + .to_return(status: 200, body: { "published" => false }.to_json) + end + + it 'fails with detailed error message' do + expect(FastlaneCore::UI).to receive(:message).with("Checking if version #{version} already exists in Maven Central...") + expect(FastlaneCore::UI).to receive(:message).with("Found #{artifact_ids.length} artifacts to check: #{artifact_ids.join(', ')}") + expect(FastlaneCore::UI).to receive(:important).with("Artifact #{group_id}:#{artifact_ids[0]}:#{version} already exists in Maven Central") + + expected_error = "Version #{version} already exists in Maven Central for the following artifacts:\n " \ + "- #{group_id}:#{artifact_ids[0]}:#{version}\n" \ + "\nDeployment cancelled to prevent duplicate releases." + + expect(FastlaneCore::UI).to receive(:user_error!).with(expected_error).and_raise(StandardError.new("Version exists")) + + expect do + described_class.run({ + group_id: group_id, + artifact_ids: artifact_ids, + version: version, + auth_token: auth_token + }) + end.to raise_error(StandardError, "Version exists") + end + end + + context 'when all versions exist in Maven Central' do + before do + artifact_ids.each do |artifact_id| + stub_request(:get, /#{Regexp.escape(base_url)}/) + .with( + query: hash_including({ + 'namespace' => group_id, + 'name' => artifact_id, + 'version' => version + }) + ) + .to_return(status: 200, body: { "published" => true }.to_json) + end + end + + it 'fails with detailed error message listing all artifacts' do + expect(FastlaneCore::UI).to receive(:message).with("Checking if version #{version} already exists in Maven Central...") + expect(FastlaneCore::UI).to receive(:message).with("Found #{artifact_ids.length} artifacts to check: #{artifact_ids.join(', ')}") + + artifact_ids.each do |artifact_id| + expect(FastlaneCore::UI).to receive(:important).with("Artifact #{group_id}:#{artifact_id}:#{version} already exists in Maven Central") + end + + expected_error = "Version #{version} already exists in Maven Central for the following artifacts:\n " \ + "- #{group_id}:#{artifact_ids[0]}:#{version}\n " \ + "- #{group_id}:#{artifact_ids[1]}:#{version}\n" \ + "\nDeployment cancelled to prevent duplicate releases." + + expect(FastlaneCore::UI).to receive(:user_error!).with(expected_error).and_raise(StandardError.new("All versions exist")) + + expect do + described_class.run({ + group_id: group_id, + artifact_ids: artifact_ids, + version: version, + auth_token: auth_token + }) + end.to raise_error(StandardError, "All versions exist") + end + end + + context 'when auth token is missing' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("FETCH_PUBLICATIONS_USER_TOKEN_MAVEN_CENTRAL").and_return(nil) + end + + it 'fails with authentication error' do + expect(FastlaneCore::UI).to receive(:message).with("Checking if version #{version} already exists in Maven Central...") + expect(FastlaneCore::UI).to receive(:message).with("Found #{artifact_ids.length} artifacts to check: #{artifact_ids.join(', ')}") + expect(FastlaneCore::UI).to receive(:user_error!).with("FETCH_PUBLICATIONS_USER_TOKEN_MAVEN_CENTRAL environment variable is not set. Please provide a valid token to check Maven Central publications.").and_raise(StandardError.new("Authentication failed")) + + expect do + described_class.run({ + group_id: group_id, + artifact_ids: artifact_ids, + version: version + }) + end.to raise_error(StandardError, "Authentication failed") + end + end + + context 'when empty auth token is provided' do + it 'fails with authentication error' do + expect(FastlaneCore::UI).to receive(:message).with("Checking if version #{version} already exists in Maven Central...") + expect(FastlaneCore::UI).to receive(:message).with("Found #{artifact_ids.length} artifacts to check: #{artifact_ids.join(', ')}") + expect(FastlaneCore::UI).to receive(:user_error!).with("FETCH_PUBLICATIONS_USER_TOKEN_MAVEN_CENTRAL environment variable is not set. Please provide a valid token to check Maven Central publications.").and_raise(StandardError.new("Authentication failed")) + + expect do + described_class.run({ + group_id: group_id, + artifact_ids: artifact_ids, + version: version, + auth_token: '' + }) + end.to raise_error(StandardError, "Authentication failed") + end + end + + context 'when no artifact IDs are provided' do + it 'fails with validation error' do + expect(FastlaneCore::UI).to receive(:message).with("Checking if version #{version} already exists in Maven Central...") + expect(FastlaneCore::UI).to receive(:user_error!).with("No artifacts provided. Please provide at least one artifact ID to check").and_raise(StandardError.new("Validation failed")) + + expect do + described_class.run({ + group_id: group_id, + artifact_ids: [], + version: version, + auth_token: auth_token + }) + end.to raise_error(StandardError, "Validation failed") + end + end + + context 'when API request fails' do + before do + # First artifact fails + stub_request(:get, /#{Regexp.escape(base_url)}/) + .with( + query: hash_including({ + 'namespace' => group_id, + 'name' => artifact_ids[0], + 'version' => version + }) + ) + .to_raise(StandardError.new("Network error")) + + # Second artifact should not be reached due to first failure, but add stub just in case + stub_request(:get, /#{Regexp.escape(base_url)}/) + .with( + query: hash_including({ + 'namespace' => group_id, + 'name' => artifact_ids[1], + 'version' => version + }) + ) + .to_return(status: 200, body: { "published" => false }.to_json) + end + + it 'fails with API error message' do + expect(FastlaneCore::UI).to receive(:message).with("Checking if version #{version} already exists in Maven Central...") + expect(FastlaneCore::UI).to receive(:message).with("Found #{artifact_ids.length} artifacts to check: #{artifact_ids.join(', ')}") + expect(FastlaneCore::UI).to receive(:user_error!).with("Failed to check #{group_id}:#{artifact_ids[0]}:#{version}: Network error").and_raise(StandardError.new("API error")) + + expect do + described_class.run({ + group_id: group_id, + artifact_ids: artifact_ids, + version: version, + auth_token: auth_token + }) + end.to raise_error(StandardError, "API error") + end + end + end + + describe '.description' do + it 'returns the correct description' do + expect(described_class.description).to eq("Checks if a specific version of Maven artifacts already exists in Maven Central before deployment") + end + end + + describe '.available_options' do + it 'returns the correct options' do + options = described_class.available_options + expect(options).to be_an(Array) + expect(options.length).to eq(4) + + group_id_option = options.find { |opt| opt.key == :group_id } + expect(group_id_option).not_to be_nil + expect(group_id_option.optional).to be false + expect(group_id_option.data_type).to eq(String) + + artifact_ids_option = options.find { |opt| opt.key == :artifact_ids } + expect(artifact_ids_option).not_to be_nil + expect(artifact_ids_option.optional).to be false + expect(artifact_ids_option.data_type).to eq(Array) + + version_option = options.find { |opt| opt.key == :version } + expect(version_option).not_to be_nil + expect(version_option.optional).to be false + expect(version_option.data_type).to eq(String) + + auth_token_option = options.find { |opt| opt.key == :auth_token } + expect(auth_token_option).not_to be_nil + expect(auth_token_option.optional).to be true + expect(auth_token_option.data_type).to eq(String) + end + end + + describe '.is_supported?' do + it 'returns true for all platforms' do + expect(described_class.is_supported?(:ios)).to be true + expect(described_class.is_supported?(:android)).to be true + expect(described_class.is_supported?(:mac)).to be true + end + end +end