diff --git a/.rubocop.yml b/.rubocop.yml index 85e7010a8..e86a721e0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -49,14 +49,14 @@ Style/AsciiComments: # which would be incorrect and not what we want Style/StringConcatenation: Enabled: false - + # This rule was enforced after upgrading Rubocop from `1.22.1` to `1.50.2`. We are disabling this rule for the # time being so that we don't see any unexpected behavior when running Release Toolkit actions that could be # changed by this rule. See https://github.com/wordpress-mobile/release-toolkit/pull/464#pullrequestreview-1396569629 # for more discussion Style/FetchEnvVar: Enabled: false - + ########## Gemspec Rules # This was turned on by default after updating Rubocop to `1.50.2`. We want to disable this for now because diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f0145d2..69951d252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +* Adds automatic version number calculation. [#350] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/alpha_build_version.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/alpha_build_version.rb new file mode 100644 index 000000000..53bffad4e --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/alpha_build_version.rb @@ -0,0 +1,46 @@ +module Fastlane + module Actions + class AlphaBuildVersionAction < Action + def self.run(params) + require 'git' + require_relative '../../helper/version_helper' + + helper = Fastlane::Helper::VersionHelper.new(git: Git.open(params[:project_root])) + + { + build_name: helper.alpha_build_name, + build_number: helper.alpha_build_number + } + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Return a prototype build version based on CI environment variables, and the current state of the repo' + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :project_root, + env_name: 'PROJECT_ROOT_FOLDER', + description: 'The project root folder (that contains the .git directory)', + type: String, + default_value: Dir.pwd, + verify_block: proc { |v| UI.user_error!("Directory does not exist: #{v}") unless File.directory? v } + ), + ] + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/next_rc_version_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/next_rc_version_action.rb new file mode 100644 index 000000000..440771e96 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/next_rc_version_action.rb @@ -0,0 +1,70 @@ +module Fastlane + module Actions + class NextRcVersionAction < Action + def self.run(params) + require 'octokit' + require 'git' + require_relative '../../helper/version_helper' + + client = Octokit::Client.new(access_token: params[:access_token]) + client.auto_paginate = true + + helper = Fastlane::Helper::VersionHelper.new(git: Git.open(params[:project_root])) + + version = Fastlane::Helper::Version.create(params[:version]) + next_version = helper.next_rc_for_version(version, repository: params[:project], github_client: client) + + UI.message "Next RC Version is #{next_version.rc}" + + next_version + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Return the next RC Version for this branch' + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :access_token, + env_name: 'GITHUB_TOKEN', + description: 'The GitHub token to use when querying GitHub', + type: String, + sensitive: true + ), + FastlaneCore::ConfigItem.new( + key: :version, + description: 'The current version', + type: String, + verify_block: proc { |v| UI.user_error!("Invalid version number: #{v}") if Fastlane::Helper::Version.create(v).nil? } + ), + FastlaneCore::ConfigItem.new( + key: :project, + description: 'The project slug (ex: `wordpress-mobile/wordpress-ios`)', + type: String + ), + FastlaneCore::ConfigItem.new( + key: :project_root, + env_name: 'PROJECT_ROOT_FOLDER', + description: 'The project root folder (that contains the .git directory)', + type: String, + default_value: Dir.pwd, + verify_block: proc { |v| UI.user_error!("Directory does not exist: #{v}") unless File.directory? v } + ), + ] + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/prototype_build_version.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/prototype_build_version.rb new file mode 100644 index 000000000..b4649aed2 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/prototype_build_version.rb @@ -0,0 +1,46 @@ +module Fastlane + module Actions + class PrototypeBuildVersionAction < Action + def self.run(params) + require 'git' + require_relative '../../helper/version_helper' + + helper = Fastlane::Helper::VersionHelper.new(git: Git.open(params[:project_root])) + + { + build_name: helper.prototype_build_name, + build_number: helper.prototype_build_number + } + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Return a prototype build version based on CI environment variables, and the current state of the repo' + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :project_root, + env_name: 'PROJECT_ROOT_FOLDER', + description: 'The project root folder (that contains the .git directory)', + type: String, + default_value: Dir.pwd, + verify_block: proc { |v| UI.user_error!("Directory does not exist: #{v}") unless File.directory? v } + ), + ] + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb new file mode 100644 index 000000000..a4e13bd2d --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb @@ -0,0 +1,101 @@ +module Fastlane + module Helper + class VersionHelper + def initialize(git: nil) + @git = git || Git.open(Dir.pwd) + end + + # Generate a prototype build name based on the current pr_number and commit. + # + # Takes optional `pr_number:` and `commit:` arguments to generate a build name + # based on a different pr_number and commit. + def prototype_build_name(pr_number: nil, commit: nil) + pr_number ||= current_pr_number + commit ||= current_commit + + UI.user_error!('Unable to find a PR in the environment – falling back to a branch-based version name. To run this in a development environment, try: `export LOCAL_PR_NUMBER=1234`') if pr_number.nil? + + "pr-#{pr_number}-#{commit.sha[0, 7]}" + end + + # Generate a prototype build number based on the most recent commit. + # + # Takes an optional `commit:` argument to generate a build number based + # on a different commit. + def prototype_build_number(commit: nil) + commit ||= current_commit + commit.date.to_i + end + + # Generate an alpha build name based on the current branch and commit. + # + # Takes optional `branch:` and `commit:` arguments to generate a build name + # based on a different branch and commit. + def alpha_build_name(branch: nil, commit: nil) + branch ||= current_branch + commit ||= current_commit + + "#{branch}-#{commit.sha[0, 7]}" + end + + # Generate an alpha number. + # + # Allows injecting a specific `DateTime` to derive the build number from + def alpha_build_number(now: DateTime.now) + now.to_i + end + + # Find the newest rc of a specific version in a given GitHub repository. + def newest_rc_for_version(version, repository:, github_client:) + tags = github_client.tags(repository) + + # GitHub Enterprise can return raw HTML if the connection isn't + # working, so we need to validate that this is what we expect it is + UI.crash! 'Unable to connect to GitHub. Please try again later.' unless tags.is_a? Array + + tags.map { |t| Version.create(t[:name]) } + .compact + .filter { |v| v.is_rc_of(version) } + .sort + .reverse + .first + end + + # Given the current version of an app and its Git Repository, + # use the existing tags to figure out which RC version should be + # the next one. + def next_rc_for_version(version, repository:, github_client:) + most_recent_rc_version = newest_rc_for_version(version, repository: repository, github_client: github_client) + + # If there is no RC tag, this must be the first one ever + return version.next_rc_version if most_recent_rc_version.nil? + + # If we have a previous RC for this version, we can just bump it + most_recent_rc_version.next_rc_version + end + + private + + # Get the most recent commit on the current branch of the Git repository + def current_commit + @git.log.first + end + + # Get the current branch of the Git repository + def current_branch + @git.current_branch + end + + # Get the current PR number from the CI environment + def current_pr_number + %w[ + BUILDKITE_PULL_REQUEST + CIRCLE_PR_NUMBER + LOCAL_PR_NUMBER + ].map { |k| ENV[k] } + .compact + .first + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/models/version.rb b/lib/fastlane/plugin/wpmreleasetoolkit/models/version.rb new file mode 100644 index 000000000..aed2eb29b --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/models/version.rb @@ -0,0 +1,312 @@ +module Fastlane + module Helper + RC_DELIMITERS = %w[ + rc + beta + b + ].freeze + + class Version + include Comparable + attr :major, :minor, :patch, :rc + + def initialize(major:, minor:, patch: 0, rc_number: nil) + @major = major + @minor = minor + @patch = patch + @rc = rc_number + end + + # Create a new Version object based on a given string. + # + # Can parse a variety of different two, three, and four-segment version numbers, + # including: + # - x.y + # - x.yrc1 + # - x.y.rc1 + # - x.y-rc1 + # - x.y.rc.1 + # - x.y-rc-1 + # - x.y.z + # - x.y.zrc1 + # - x.y.z.rc1 + # - x.y.z-rc1 + # - x.y.z.rc.1 + # - x.y.z-rc-1 + # - Any of the above with `v` prepended + # + def self.create(string) + string = string.downcase + string = string.delete_prefix('v') if string.start_with?('v') + + components = string + .split('.') + .map { |component| component.remove('-') } + .delete_if { |component| component == 'rc' } + + return nil if components.length < 2 + + # Turn RC version codes into simple versions + if components.last.include? 'rc' + rc_segments = VersionHelpers.rc_segments_from_string(components.last) + components.delete_at(components.length - 1) + components = VersionHelpers.combine_components_and_rc_segments(components, rc_segments) + end + + # Validate our work + return nil if components.any? { |component| !VersionHelpers.string_is_valid_int(component) } + + # If this is a simple version string, process it early + major = components.first.to_i + minor = components.second.to_i + patch = components.third.to_i + + # Simple two-segment version numbers can exit here + return Version.new(major: major, minor: minor) if components.length == 2 + + # Simple three-segment version numbers can exit here + return Version.new(major: major, minor: minor, patch: patch) if components.length == 3 + + # Simple four-segment version numbers can exit here + return Version.new(major: major, minor: minor, patch: patch, rc_number: components.fourth.to_i) if components.length == 4 + end + + # Create a new Version object based on a given string. + # + # Raises if the string is invalid + def self.create!(string) + version = create(string) + raise "Invalid Version: #{string}" if version.nil? + + version + end + + # Returns a formatted string suitable for use as an Android Version Name + def android_version_name + return [@major, @minor].join('.') if @patch.zero? && @rc.nil? + return [@major, @minor, @patch].join('.') if !@patch.zero? && rc.nil? + return [@major, "#{@minor}-rc-#{@rc}"].join('.') if @patch.zero? && !rc.nil? + + return [@major, @minor, "#{@patch}-rc-#{@rc}"].join('.') + end + + # Returns a formatted string suitable for use as an Android Version Code + def android_version_code(prefix: 1) + [ + prefix, + @major, + format('%02d', @minor), + format('%02d', @patch), + format('%02d', @rc || 0), + ].join + end + + # Returns a formatted string suitable for use as an iOS Version Number + def ios_version_number + return [@major, @minor, @patch, @rc || 0].join('.') + end + + # Returns a string suitable for comparing two version objects + # + # This method has no version number padding, so its likely to have collisions + def raw_version_code + [@major, @minor, @patch, @rc || 0].join.to_i + end + + # Returns a string suitable for comparing two version objects without the `rc` component. + # + # This method has no version number padding, so its likely to have collisions + def release_version_code + [@major, @minor, @patch].join.to_i + end + + # Is this version number a patch version? + def patch? + !@patch.zero? + end + + # Is this version number a prerelease version? + def prerelease? + !@rc.nil? + end + + # Derive the next major version from this version number + def next_major_version + Version.new( + major: @major + 1, + minor: 0 + ) + end + + # Derive the next minor version from this version number + def next_minor_version + major = @major + minor = @minor + + if minor == 9 + major += 1 + minor = 0 + else + minor += 1 + end + + Version.new( + major: major, + minor: minor + ) + end + + # Derive the next patch version from this version number + def next_patch_version + Version.new( + major: @major, + minor: @minor, + patch: @patch + 1 + ) + end + + # Derive the next rc version from this version number + def next_rc_version + rc = @rc + rc = 0 if rc.nil? + + Version.new( + major: @major, + minor: @minor, + patch: @patch, + rc_number: rc + 1 + ) + end + + # Is this version the same as another version, just with different RC codes? + def is_different_rc_of(other) + return false unless other.is_a?(Version) + return false if @rc.nil? + return false if other.rc.nil? + + return other.major == @major && other.minor == @minor && other.patch == @patch + end + + # Is this version a release candidate for the given version? + def is_rc_of(other) + return false unless other.is_a?(Version) + return false if @rc.nil? + + return other.major == @major && other.minor == @minor && other.patch == @patch + end + + # Is this version the same as another version, just with a different patch version? + def is_different_patch_of(other) + return false unless other.is_a?(Version) + + return other.major == @major && other.minor == @minor + end + + def ==(other) + return false unless other.is_a?(Version) + + raw_version_code == other.raw_version_code + end + + # Treat `Version` comparisons like they're primitives instead of objects + def equal?(other) + self == other + end + + def <=>(other) + # If neither version is prerelease, things are simple + return raw_version_code <=> other.raw_version_code if !prerelease? && !other.prerelease? + + # If these are the same version, but different RCs, the comparison is also pretty simple + return raw_version_code <=> other.raw_version_code if is_different_rc_of(other) + + # If the major/minor versions don't line up, just use those to compare – the RC is irrelevant + return release_version_code <=> other.release_version_code if @major != other.major || @minor != other.minor + + return -1 if prerelease? + return 1 if other.prerelease? + end + end + + # A collection of helpers for the `Version.create` method that extract some of the tricky code + # that's nice to be able to test in isolation – in practice, this is private API and you *probably* + # don't want to use it for other things. + module VersionHelpers + # Determines whether the given string is a valid integer. + # + # Examples: + # - 00 => true + # - 01 => true + # - 1 => true + # - rc => false + # See the `version_helpers_spec` for more test cases. + # + # @param string String The string to check. + # @return bool `true` if the given string is a valid integer. `false` if not. + def self.string_is_valid_int(string) + return true if string.count('0') == string.length + + # Remove any leading zeros + string = string.delete_prefix('0') + + return string.to_i.to_s == string + end + + # Extracts all integers (delimited by anything non-integer value) from a given string + # + # @param string String The string to check. + # @return [int] The integers contained within the string + def self.extract_ints_from_string(string) + string.scan(/\d+/) + end + + # Parses release candidate number (and potentially minor or patch version depending on how the + # version code is formatted) from a given string. This can take a variety of forms because the + # release candidate segment of a version string can be formatted in a lot of different ways. + # + # Examples: + # - 00 => ['0'] + # - rc1 => ['1'] + # - 5rc1 => ['5','1'] + # See the `version_helpers_spec` for more test cases. + # + # @param string String The string to parse. + # @return [string] The leading and trailing digits from the version segment string + def self.rc_segments_from_string(string) + # If the string is all zeros, return zero + return ['0'] if string.scan(/0/).length == string.length + + extract_ints_from_string(string) + end + + # Combines the non-RC version string components with the RC segments extracted by `rc_segments_from_string`. + # + # Because this method needs to be able to assemble the version segments and release candidate segments into a + # coherent version based on a variety of input formats, the implementation looks pretty complex, but it's covered + # by a comprehensive test suite to validate that it does, in fact, work. + # + # Examples: + # - [1.0], [1] => ['1','0', '0', '1'] + # - [1.0], [2,1] => ['1','0', '2', '1'] + # See the `version_helpers_spec` for more test cases. + # + # @param components [string] The version string components (without the RC segments) + # @param rc_segments [string] The return value from `rc_segments_from_string` + # @return [string] An array of stringified integer version components in `major.minor.patch.rc` order + def self.combine_components_and_rc_segments(components, rc_segments) + case true # rubocop:disable Lint/LiteralAsCondition + when components.length == 1 && rc_segments.length == 2 + return [components.first, rc_segments.first, '0', rc_segments.last] + when components.length == 2 && rc_segments.length == 1 + return [components.first, components.second, '0', rc_segments.first] + when components.length == 2 && rc_segments.length == 2 + return [components.first, components.second, rc_segments.first, rc_segments.last] + when components.length == 3 && rc_segments.length == 1 + return [components.first, components.second, components.third, rc_segments.first] + end + + raise "Invalid components: #{components.inspect} or rc_segments: #{rc_segments.inspect}" + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 80cff9bfc..a8ff1a27c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -99,6 +99,24 @@ def with_tmp_file(named: nil, content: '') end end +def version(major:, minor:, patch: 0, rc_number: nil) + Fastlane::Helper::Version.new( + major: major, + minor: minor, + patch: patch, + rc_number: rc_number + ) +end + +Commit = Struct.new(:sha, :date, keyword_init: true) + +def mock_commit(sha: 'abcdef123456', date: DateTime.now) + Commit.new( + sha: sha, + date: date + ) +end + # File Path Helpers EMPTY_FIREBASE_TEST_LOG_PATH = File.join(__dir__, 'test-data', 'empty.json') PASSED_FIREBASE_TEST_LOG_PATH = File.join(__dir__, 'test-data', 'firebase', 'firebase-test-lab-run-passed.log') diff --git a/spec/version_helper_spec.rb b/spec/version_helper_spec.rb new file mode 100644 index 000000000..e97c8eab4 --- /dev/null +++ b/spec/version_helper_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +describe Fastlane::Helper::VersionHelper do + let(:client) do + instance_double(Octokit::Client) + end + + let(:repo) do + instance_double(Git::Base) + end + + let(:now) do + DateTime.now + end + + describe 'version lookup' do + it 'returns the newest RC version' do + allow(client).to receive(:tags).and_return([{ name: '1.1.rc4' }, { name: '1.1.rc3' }]) + manager = described_class.new(git: repo) + + expect(manager.newest_rc_for_version(version(major: 1, minor: 1), repository: 'test', github_client: client)).to eq version(major: 1, minor: 1, rc_number: 4) + end + + it 'ignores invalid version codes' do + allow(client).to receive(:tags).and_return([{ name: 'fleep florp' }, { name: '1.1.rc4' }]) + manager = described_class.new(git: repo) + + expect(manager.newest_rc_for_version(version(major: 1, minor: 1), repository: 'test', github_client: client)).to eq version(major: 1, minor: 1, rc_number: 4) + end + + it 'ignores release version codes' do + allow(client).to receive(:tags).and_return([{ name: '10.0' }, { name: '1.1.rc4' }]) + manager = described_class.new(git: repo) + + expect(manager.newest_rc_for_version(version(major: 1, minor: 1), repository: 'test', github_client: client)).to eq version(major: 1, minor: 1, rc_number: 4) + end + + it 'returns nil if version not found' do + allow(client).to receive(:tags).and_return([{ name: '1.1.rc4' }, { name: '1.1.rc3' }]) + manager = described_class.new(git: repo) + + expect(manager.newest_rc_for_version(version(major: 1, minor: 2), repository: 'test', github_client: client)).to be_nil + end + + it 'raises if GitHub response is invalid' do + allow(client).to receive(:tags).and_return('') + manager = described_class.new(git: repo) + expect { manager.newest_rc_for_version(version(major: 1, minor: 2), repository: 'test', github_client: client) }.to raise_error('Unable to connect to GitHub. Please try again later.') + end + end + + describe 'version calculation' do + before do + allow(ENV).to receive(:[]).with('BUILDKITE_PULL_REQUEST').and_return('1234') + allow(ENV).to receive(:[]).with('CIRCLE_PR_NUMBER').and_return('1234') + allow(ENV).to receive(:[]).with('LOCAL_PR_NUMBER').and_return('1234') # This is just here to allow local integration testing in a project + end + + it 'provides the correct `next_rc_number` for the first RC ever' do + allow(client).to receive(:tags).and_return([]) + manager = described_class.new(git: repo) + + version = Fastlane::Helper::Version.new(major: 1, minor: 1) + expect(manager.next_rc_for_version(version, repository: 'test', github_client: client)).to be Fastlane::Helper::Version.new(major: 1, minor: 1, rc_number: 1) + end + + it 'provides the correct `next_rc_number` for the first RC of a new version' do + allow(client).to receive(:tags).and_return([{ name: '1.1.rc3' }]) + manager = described_class.new(git: repo) + + version = Fastlane::Helper::Version.new(major: 1, minor: 2) + expect(manager.next_rc_for_version(version, repository: 'test', github_client: client)).to be Fastlane::Helper::Version.new(major: 1, minor: 2, rc_number: 1) + end + + it 'provides the correct `next_rc_number` for the second RC of a new version' do + allow(client).to receive(:tags).and_return([{ name: '1.2.rc1' }]) + manager = described_class.new(git: repo) + + version = Fastlane::Helper::Version.new(major: 1, minor: 2) + expect(manager.next_rc_for_version(version, repository: 'test', github_client: client)).to be Fastlane::Helper::Version.new(major: 1, minor: 2, rc_number: 2) + end + + it 'provides the correct `prototype_build_name` for a given branch and commit' do + manager = described_class.new(git: repo) + expect(manager.prototype_build_name(pr_number: 1234, commit: mock_commit)).to eq 'pr-1234-abcdef1' + end + + it 'provides the correct `prototype_build_name` for an autodetected PR and commit' do + allow(repo).to receive(:log).and_return([mock_commit]) + manager = described_class.new(git: repo) + expect(manager.prototype_build_name).to eq 'pr-1234-abcdef1' + end + + it 'provides the correct `prototype_build_number` for a given commit' do + allow(repo).to receive(:log).and_return([mock_commit]) + manager = described_class.new(git: repo) + expect(manager.prototype_build_number).to eq now.to_i + end + + it 'provides the correct `alpha_build_name` for a given branch and commit' do + allow(repo).to receive(:current_branch).and_return('foo') + allow(repo).to receive(:log).and_return([mock_commit]) + manager = described_class.new(git: repo) + expect(manager.alpha_build_name).to eq 'foo-abcdef1' + end + + it 'provides the correct `alpha_build_number` (ie – the current unix timestamp)' do + manager = described_class.new(git: repo) + expect(manager.alpha_build_number(now: now)).to eq now.to_i + end + end +end diff --git a/spec/version_spec.rb b/spec/version_spec.rb new file mode 100644 index 000000000..b60eee1ae --- /dev/null +++ b/spec/version_spec.rb @@ -0,0 +1,239 @@ +require 'spec_helper' + +def mock_version(major: 1, minor: 2, patch: 3, rc_number: 1) + Fastlane::Helper::Version.new( + major: major, + minor: minor, + patch: patch, + rc_number: rc_number + ) +end + +def version(major:, minor:, patch: 0, rc_number: nil) + Fastlane::Helper::Version.new( + major: major, + minor: minor, + patch: patch, + rc_number: rc_number + ) +end + +describe Fastlane::Helper::Version do + describe 'helpers' do + it 'correctly extracts ints' do + expect(Fastlane::Helper::VersionHelpers.extract_ints_from_string('beta1')).to eq ['1'] + expect(Fastlane::Helper::VersionHelpers.extract_ints_from_string('b1')).to eq ['1'] + expect(Fastlane::Helper::VersionHelpers.extract_ints_from_string('rc1')).to eq ['1'] + expect(Fastlane::Helper::VersionHelpers.extract_ints_from_string('rc-1')).to eq ['1'] + expect(Fastlane::Helper::VersionHelpers.extract_ints_from_string('52rc-1')).to eq %w[52 1] + expect(Fastlane::Helper::VersionHelpers.extract_ints_from_string('1a2b3')).to eq %w[1 2 3] + end + + it 'corrently parses rc strings' do + expect(Fastlane::Helper::VersionHelpers.rc_segments_from_string('rc1')).to eq ['1'] + expect(Fastlane::Helper::VersionHelpers.rc_segments_from_string('1rc2')).to eq %w[1 2] + end + + it 'corrently identifies valid integer strings' do + expect(Fastlane::Helper::VersionHelpers.string_is_valid_int('1')).to eq true + expect(Fastlane::Helper::VersionHelpers.string_is_valid_int('01')).to eq true + end + + it 'correctly combines components and rc segments' do + expect(Fastlane::Helper::VersionHelpers.combine_components_and_rc_segments(['1'], %w[2 3])).to eq %w[1 2 0 3] + expect(Fastlane::Helper::VersionHelpers.combine_components_and_rc_segments(%w[1 2], ['3'])).to eq %w[1 2 0 3] + expect(Fastlane::Helper::VersionHelpers.combine_components_and_rc_segments(%w[1 2], %w[3 4])).to eq %w[1 2 3 4] + expect(Fastlane::Helper::VersionHelpers.combine_components_and_rc_segments(%w[1 2 3], %w[4])).to eq %w[1 2 3 4] + end + + it 'raises for invalid component and rc segment combinations' do + expect { Fastlane::Helper::VersionHelpers.combine_components_and_rc_segments(%w[1 2 3], %w[1 32]) }.to raise_error 'Invalid components: ["1", "2", "3"] or rc_segments: ["1", "32"]' + end + end + + describe 'compare' do + it 'correctly recognizes that different versions are equal' do + expect(version(major: 1, minor: 2, patch: 3, rc_number: 4)).to eq version(major: 1, minor: 2, patch: 3, rc_number: 4) + expect(version(major: 1, minor: 2, patch: 3, rc_number: 4)).to be version(major: 1, minor: 2, patch: 3, rc_number: 4) + end + + it 'correctly recognizes that one version is an RC of another version' do + expect(version(major: 1, minor: 2, rc_number: 1).is_rc_of(version(major: 1, minor: 2))).to be true + expect(version(major: 1, minor: 2).is_rc_of(version(major: 1, minor: 2, rc_number: 1))).to be false + expect(version(major: 1, minor: 2).is_rc_of(version(major: 1, minor: 2))).to be false + end + + it 'correctly recognizes that two different versions are the same except for their RC' do + expect(version(major: 1, minor: 2, rc_number: 1).is_different_rc_of(version(major: 1, minor: 2, rc_number: 2))).to be true + expect(version(major: 1, minor: 2).is_different_rc_of(version(major: 1, minor: 2, rc_number: 3))).to be false + expect(version(major: 1, minor: 2, rc_number: 1).is_different_rc_of(version(major: 1, minor: 2))).to be false + end + + it 'correctly recognizes that two different versions are the same except for their PATCH segment' do + expect(version(major: 1, minor: 2).is_different_patch_of(version(major: 1, minor: 2, patch: 1))).to be true + end + + it 'correctly sorts production versions' do + expect(version(major: 1, minor: 2)).to be < version(major: 1, minor: 3) + expect(version(major: 1, minor: 2)).to be < version(major: 1, minor: 2, patch: 1) + expect(version(major: 1, minor: 2, patch: 1)).to be < version(major: 1, minor: 2, patch: 2) + end + + it 'correctly sorts pre-release versions' do + expect(version(major: 1, minor: 2, rc_number: 1)).to be < version(major: 1, minor: 2, rc_number: 2) + expect(version(major: 1, minor: 2, rc_number: 1)).to be == version(major: 1, minor: 2, rc_number: 1) + end + + it 'correctly sorts pre-release versions against release versions' do + expect(version(major: 1, minor: 1)).to be < version(major: 1, minor: 2, rc_number: 1) + end + + it 'correctly identifies release versions as newer than RC versions' do + # Test these both ways to validate the custom sorting logic + expect(version(major: 1, minor: 2, rc_number: 1)).to be < version(major: 1, minor: 2) + expect(version(major: 1, minor: 2)).to be > version(major: 1, minor: 2, rc_number: 1) + end + end + + describe 'create' do + it 'correctly parses two-segment version numbers' do + expect(described_class.create('1.0')).to eq version(major: 1, minor: 0) + expect(described_class.create('1.2')).to eq version(major: 1, minor: 2) + expect(described_class.create('1.00')).to eq version(major: 1, minor: 0) + end + + it 'correctly parses three-segment version numbers' do + expect(described_class.create('01.2.3')).to eq version(major: 1, minor: 2, patch: 3) + end + + it 'correctly parses four-segment version numbers' do + expect(described_class.create('01.2.3.4')).to eq version(major: 1, minor: 2, patch: 3, rc_number: 4) + end + + it 'correctly parses two-segment dotted release candidates' do + expect(described_class.create('1.2.rc1')).to eq version(major: 1, minor: 2, rc_number: 1) + end + + it 'correctly parses two-segment concatenated release candidates' do + expect(described_class.create('1.2rc1')).to eq version(major: 1, minor: 2, patch: 0, rc_number: 1) + end + + it 'correctly parses two-segment dashed release candidates' do + expect(described_class.create('1.2-rc-1')).to eq version(major: 1, minor: 2, patch: 0, rc_number: 1) + expect(described_class.create('1.2-RC-1')).to eq version(major: 1, minor: 2, patch: 0, rc_number: 1) + end + + it 'correctly parses three-segment dotted release candidates' do + expect(described_class.create('1.2.3.rc.1')).to eq version(major: 1, minor: 2, patch: 3, rc_number: 1) + expect(described_class.create('1.2.3.RC.1')).to eq version(major: 1, minor: 2, patch: 3, rc_number: 1) + end + + it 'correctly parses three-segment dashed release candidates' do + expect(described_class.create('1.2.3-rc-1')).to eq version(major: 1, minor: 2, patch: 3, rc_number: 1) + expect(described_class.create('1.2.3-RC-1')).to eq version(major: 1, minor: 2, patch: 3, rc_number: 1) + end + + it 'correctly parses three-segment concatenated release candidates' do + expect(described_class.create('1.2.3rc1')).to eq version(major: 1, minor: 2, patch: 3, rc_number: 1) + expect(described_class.create('1.2.3RC1')).to eq version(major: 1, minor: 2, patch: 3, rc_number: 1) + end + + # Github encourages version numbers prefixed with `v` + it 'correctly parses v-prefixed version numbers' do + expect(described_class.create('v23.8.0.00')).to eq version(major: 23, minor: 8, patch: 0, rc_number: 0) + expect(described_class.create('V23.8.0.00')).to eq version(major: 23, minor: 8, patch: 0, rc_number: 0) + end + + it 'rejects invalid version formats' do + expect(described_class.create('alpha/2022-03-16/1647467717')).to be_nil + expect(described_class.create('builds/beta/239008')).to be_nil + expect(described_class.create('alpha-abcdef')).to be_nil + expect(described_class.create('alpha-123456')).to be_nil + expect(described_class.create('1.2.3.4.5')).to be_nil + end + + it 'raises for invalid version codes if requested' do + expect { described_class.create!('1.2.3.4.5') }.to raise_error 'Invalid Version: 1.2.3.4.5' + end + + it 'does not raise for valid version codes' do + expect(described_class.create!('1.2.3.4')).to eq version(major: 1, minor: 2, patch: 3, rc_number: 4) + end + end + + describe 'properties' do + it 'patch? is valid' do + expect(described_class.create('1.2').patch?).to be false + expect(described_class.create('1.2.1').patch?).to be true + end + + it 'prerelease? is valid' do + expect(described_class.create('1.2').prerelease?).to be false + expect(described_class.create('1.2rc1').prerelease?).to be true + end + end + + describe 'formatters' do + it 'prints the Android version name correctly' do + expect(described_class.new(major: 1, minor: 2).android_version_name).to eq '1.2' + expect(described_class.new(major: 1, minor: 2, patch: 0).android_version_name).to eq '1.2' + expect(described_class.new(major: 1, minor: 2, patch: 3).android_version_name).to eq '1.2.3' + expect(described_class.new(major: 1, minor: 2, patch: 3, rc_number: 4).android_version_name).to eq '1.2.3-rc-4' + expect(described_class.new(major: 1, minor: 2, patch: 0, rc_number: 4).android_version_name).to eq '1.2-rc-4' + end + + it 'prints the Android version code correctly' do + expect(described_class.new(major: 1, minor: 2).android_version_code).to eq '11020000' + expect(described_class.new(major: 1, minor: 2, patch: 3).android_version_code).to eq '11020300' + expect(described_class.new(major: 1, minor: 2, patch: 3, rc_number: 4).android_version_code).to eq '11020304' + expect(described_class.new(major: 1, minor: 2, patch: 0, rc_number: 4).android_version_code).to eq '11020004' + end + + it 'prints the Android version code correctly if a prefix is provided' do + expect(described_class.new(major: 1, minor: 2).android_version_code(prefix: 2)).to eq '21020000' + expect(described_class.new(major: 1, minor: 2, patch: 3).android_version_code(prefix: 2)).to eq '21020300' + expect(described_class.new(major: 1, minor: 2, patch: 3, rc_number: 4).android_version_code(prefix: 2)).to eq '21020304' + expect(described_class.new(major: 1, minor: 2, patch: 0, rc_number: 4).android_version_code(prefix: 2)).to eq '21020004' + end + + it 'prints the iOS version code correctly' do + expect(described_class.new(major: 1, minor: 2).ios_version_number).to eq '1.2.0.0' + expect(described_class.new(major: 1, minor: 2, patch: 0).ios_version_number).to eq '1.2.0.0' + expect(described_class.new(major: 1, minor: 2, patch: 3).ios_version_number).to eq '1.2.3.0' + expect(described_class.new(major: 1, minor: 2, patch: 3, rc_number: 4).ios_version_number).to eq '1.2.3.4' + expect(described_class.new(major: 1, minor: 2, patch: 0, rc_number: 4).ios_version_number).to eq '1.2.0.4' + end + end + + describe 'bumpers' do + it 'bumps the major version correctly' do + new_version = described_class.new(major: 1, minor: 5).next_major_version + expect(new_version).to eq described_class.new(major: 2, minor: 0) + end + + it 'bumps the minor version correctly' do + new_version = described_class.new(major: 1, minor: 0).next_minor_version + expect(new_version).to eq described_class.new(major: 1, minor: 1) + end + + it 'rolls the major version as needed when bumping the minor version' do + new_version = described_class.new(major: 1, minor: 9).next_minor_version + expect(new_version).to eq described_class.new(major: 2, minor: 0) + end + + it 'bumps the patch version correctly' do + new_version = described_class.new(major: 1, minor: 0).next_patch_version + expect(new_version).to eq described_class.new(major: 1, minor: 0, patch: 1) + end + + it 'bumps the rc version correctly when none is present' do + new_version = described_class.new(major: 1, minor: 0).next_rc_version + expect(new_version).to eq described_class.new(major: 1, minor: 0, rc_number: 1) + end + + it 'bumps the rc version correctly for an existing RC' do + new_version = described_class.new(major: 1, minor: 0, rc_number: 1).next_rc_version + expect(new_version).to eq described_class.new(major: 1, minor: 0, rc_number: 2) + end + end +end