From ff59c8c7794220a36767c0137049bf723475dd43 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 23 Mar 2022 17:33:03 -0600 Subject: [PATCH 01/10] Add automatic version numbering --- CHANGELOG.md | 2 +- lib/fastlane/plugin/wpmreleasetoolkit.rb | 2 +- .../helper/version_helper.rb | 86 ++++++ .../wpmreleasetoolkit/models/version.rb | 284 ++++++++++++++++++ spec/github_helper_spec.rb | 2 + spec/spec_helper.rb | 9 + spec/version_helper_spec.rb | 89 ++++++ spec/version_spec.rb | 190 ++++++++++++ 8 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/models/version.rb create mode 100644 spec/version_helper_spec.rb create mode 100644 spec/version_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b0cad7750..2466ab450 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.rb b/lib/fastlane/plugin/wpmreleasetoolkit.rb index dad7c7064..a1128823c 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit.rb @@ -4,7 +4,7 @@ module Fastlane module Wpmreleasetoolkit # Return all .rb files inside the "actions" and "helper" directory def self.all_classes - Dir[File.expand_path('**/{actions,helper}/**/*.rb', File.dirname(__FILE__))] + Dir[File.expand_path('**/{actions,helper,models}/**/*.rb', File.dirname(__FILE__))] 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..822702aec --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb @@ -0,0 +1,86 @@ +module Fastlane + module Helper + class VersionHelper + def initialize(github_client:, git: nil) + @git = git || Git.open(Dir.pwd) + @github_client = github_client + end + + # Generate a prototype 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 prototype_build_name(branch: nil, commit: nil) + branch ||= current_branch + commit ||= current_commit + + "#{branch}-#{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(repository) + .map { |t| Version.create(t[:name]) } + .compact + .filter { |v| v.is_different_rc_of(version) } + .filter(&:prerelease?) + .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:) + most_recent_rc_version = newest_rc_for_version(version, repository: repository) + + # 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 most current branch of the Git repository + def current_branch + @git.current_branch + 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..b8b8e236f --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/models/version.rb @@ -0,0 +1,284 @@ +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) + [ + '1', + @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 + + # 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 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 + + def equal?(other) + self == other + end + + def <=>(other) + raw_version_code <=> other.raw_version_code + 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/github_helper_spec.rb b/spec/github_helper_spec.rb index 089bb5de2..6e5bccb7e 100644 --- a/spec/github_helper_spec.rb +++ b/spec/github_helper_spec.rb @@ -147,4 +147,6 @@ def mock_comment(body: ' Test', user_id: 1234) instance_double('Comment', id: 1234, body: body, user: instance_double('User', id: user_id)) end end + + describe '' end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index bc0b19630..56f15a6c3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -94,3 +94,12 @@ def with_tmp_file(named: nil, content: '') File.delete(file_path) 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 diff --git a/spec/version_helper_spec.rb b/spec/version_helper_spec.rb new file mode 100644 index 000000000..64a5623f7 --- /dev/null +++ b/spec/version_helper_spec.rb @@ -0,0 +1,89 @@ +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(github_client: client, git: repo) + + expect(manager.newest_rc_for_version(version(major: 1, minor: 1), repository: 'test')).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(github_client: client, git: repo) + + expect(manager.newest_rc_for_version(version(major: 1, minor: 1), repository: 'test')).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(github_client: client, git: repo) + + expect(manager.newest_rc_for_version(version(major: 1, minor: 2), repository: 'test')).to be_nil + end + end + + describe 'version calculation' do + it 'provides the correct `next_rc_number` for the first RC ever' do + allow(client).to receive(:tags).and_return([]) + manager = described_class.new(github_client: client, git: repo) + + version = Fastlane::Helper::Version.new(major: 1, minor: 1) + expect(manager.next_rc_for_version(version, repository: 'test')).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(github_client: client, git: repo) + + version = Fastlane::Helper::Version.new(major: 1, minor: 2) + expect(manager.next_rc_for_version(version, repository: 'test')).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(github_client: client, git: repo) + + version = Fastlane::Helper::Version.new(major: 1, minor: 2) + expect(manager.next_rc_for_version(version, repository: 'test')).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 + allow(repo).to receive(:current_branch).and_return('foo') + allow(repo).to receive(:log).and_return([{ sha: 'abcdef123456' }]) + manager = described_class.new(github_client: client, git: repo) + expect(manager.prototype_build_name).to eq 'foo-abcdef1' + end + + it 'provides the correct `prototype_build_number` for a given commit' do + allow(repo).to receive(:log).and_return([{ date: now }]) + manager = described_class.new(github_client: client, 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([{ sha: 'abcdef123456' }]) + manager = described_class.new(github_client: client, 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(github_client: client, 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..6faeccf86 --- /dev/null +++ b/spec/version_spec.rb @@ -0,0 +1,190 @@ +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 + 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) + 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 + 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 + 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 + 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 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 From 434382663cabc36512bb7d948d9dbdd5603fd3f7 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 24 Mar 2022 23:05:41 -0600 Subject: [PATCH 02/10] Add next_rc_version action --- .../versions/next_rc_version_action.rb | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/next_rc_version_action.rb 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..25352fced --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/next_rc_version_action.rb @@ -0,0 +1,73 @@ +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( + github_client: client, + git: Git.open(Dir.pwd) + ) + + version = Fastlane::Helper::Version.create(params[:version]) + next_version = helper.next_rc_for_version(version, repository: params[:project]) + + 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 From e7f347258e707630fc124d07d26e5b19c05e7af3 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 24 Mar 2022 23:18:58 -0600 Subject: [PATCH 03/10] Better error handling --- .../plugin/wpmreleasetoolkit/helper/version_helper.rb | 10 +++++++--- spec/version_helper_spec.rb | 6 ++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb index 822702aec..cfbd09ccb 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb @@ -46,9 +46,13 @@ def alpha_build_number(now: DateTime.now) # Find the newest rc of a specific version in a given GitHub repository. def newest_rc_for_version(version, repository:) - @github_client - .tags(repository) - .map { |t| Version.create(t[:name]) } + 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.' if !tags.is_a? Array + + tags.map { |t| Version.create(t[:name]) } .compact .filter { |v| v.is_different_rc_of(version) } .filter(&:prerelease?) diff --git a/spec/version_helper_spec.rb b/spec/version_helper_spec.rb index 64a5623f7..473071dec 100644 --- a/spec/version_helper_spec.rb +++ b/spec/version_helper_spec.rb @@ -34,6 +34,12 @@ expect(manager.newest_rc_for_version(version(major: 1, minor: 2), repository: 'test')).to be_nil end + + it 'raises if GitHub response is invalid' do + allow(client).to receive(:tags).and_return('foo') + manager = described_class.new(github_client: client, git: repo) + expect{ manager.newest_rc_for_version(version(major: 1, minor: 2), repository: 'test') }.to raise_error('Unable to connect to GitHub. Please try again later.') + end end describe 'version calculation' do From c0f5dbbc659387d2499f5e7604ef8cf7b6e61cc0 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Mar 2022 11:09:21 -0600 Subject: [PATCH 04/10] Add prototype_build_version --- .../versions/next_rc_version_action.rb | 7 +-- .../versions/prototype_build_version.rb | 46 ++++++++++++++++ .../helper/version_helper.rb | 51 ++++++++++-------- spec/version_helper_spec.rb | 52 +++++++++++-------- 4 files changed, 109 insertions(+), 47 deletions(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/prototype_build_version.rb 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 index 25352fced..440771e96 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/next_rc_version_action.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/next_rc_version_action.rb @@ -9,13 +9,10 @@ def self.run(params) client = Octokit::Client.new(access_token: params[:access_token]) client.auto_paginate = true - helper = Fastlane::Helper::VersionHelper.new( - github_client: client, - git: Git.open(Dir.pwd) - ) + 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]) + next_version = helper.next_rc_for_version(version, repository: params[:project], github_client: client) UI.message "Next RC Version is #{next_version.rc}" 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 index cfbd09ccb..fbbded9c4 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb @@ -1,20 +1,19 @@ module Fastlane module Helper class VersionHelper - def initialize(github_client:, git: nil) + def initialize(git: nil) @git = git || Git.open(Dir.pwd) - @github_client = github_client end - # Generate a prototype build name based on the current branch and commit. + # Generate a prototype build name based on the current pr_number and commit. # - # Takes optional `branch:` and `commit:` arguments to generate a build name - # based on a different branch and commit. - def prototype_build_name(branch: nil, commit: nil) - branch ||= current_branch + # 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 - "#{branch}-#{commit[:sha][0, 7]}" + "pr-#{pr_number}-#{commit[:sha][0, 7]}" end # Generate a prototype build number based on the most recent commit. @@ -45,27 +44,27 @@ def alpha_build_number(now: DateTime.now) end # Find the newest rc of a specific version in a given GitHub repository. - def newest_rc_for_version(version, repository:) - tags = @github_client.tags(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.' if !tags.is_a? Array + # 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_different_rc_of(version) } - .filter(&:prerelease?) - .sort - .reverse - .first + .compact + .filter { |v| v.is_different_rc_of(version) } + .filter(&:prerelease?) + .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:) - most_recent_rc_version = newest_rc_for_version(version, repository: repository) + 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? @@ -81,10 +80,20 @@ def current_commit @git.log.first end - # Get the most current branch of the Git repository + # 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 + ].map { |k| ENV[k] } + .compact + .first + end end end end diff --git a/spec/version_helper_spec.rb b/spec/version_helper_spec.rb index 473071dec..d96df5ebd 100644 --- a/spec/version_helper_spec.rb +++ b/spec/version_helper_spec.rb @@ -16,79 +16,89 @@ 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(github_client: client, git: repo) + manager = described_class.new(git: repo) - expect(manager.newest_rc_for_version(version(major: 1, minor: 1), repository: 'test')).to eq version(major: 1, minor: 1, rc_number: 4) + 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(github_client: client, git: repo) + manager = described_class.new(git: repo) - expect(manager.newest_rc_for_version(version(major: 1, minor: 1), repository: 'test')).to eq version(major: 1, minor: 1, rc_number: 4) + 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(github_client: client, git: repo) + manager = described_class.new(git: repo) - expect(manager.newest_rc_for_version(version(major: 1, minor: 2), repository: 'test')).to be_nil + 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('foo') - manager = described_class.new(github_client: client, git: repo) - expect{ manager.newest_rc_for_version(version(major: 1, minor: 2), repository: 'test') }.to raise_error('Unable to connect to GitHub. Please try again later.') + 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') + 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(github_client: client, git: repo) + manager = described_class.new(git: repo) version = Fastlane::Helper::Version.new(major: 1, minor: 1) - expect(manager.next_rc_for_version(version, repository: 'test')).to be Fastlane::Helper::Version.new(major: 1, minor: 1, rc_number: 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(github_client: client, git: repo) + manager = described_class.new(git: repo) version = Fastlane::Helper::Version.new(major: 1, minor: 2) - expect(manager.next_rc_for_version(version, repository: 'test')).to be Fastlane::Helper::Version.new(major: 1, minor: 2, rc_number: 1) + 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(github_client: client, git: repo) + manager = described_class.new(git: repo) version = Fastlane::Helper::Version.new(major: 1, minor: 2) - expect(manager.next_rc_for_version(version, repository: 'test')).to be Fastlane::Helper::Version.new(major: 1, minor: 2, rc_number: 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 - allow(repo).to receive(:current_branch).and_return('foo') + manager = described_class.new(git: repo) + expect(manager.prototype_build_name(pr_number: 1234, commit: { sha: 'abcdef123456' })).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([{ sha: 'abcdef123456' }]) - manager = described_class.new(github_client: client, git: repo) - expect(manager.prototype_build_name).to eq 'foo-abcdef1' + 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([{ date: now }]) - manager = described_class.new(github_client: client, git: repo) + 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([{ sha: 'abcdef123456' }]) - manager = described_class.new(github_client: client, git: repo) + 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(github_client: client, git: repo) + manager = described_class.new(git: repo) expect(manager.alpha_build_number(now: now)).to eq now.to_i end end From 851168a55313a0b84736d558707d5b53d15105bc Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Mar 2022 12:07:39 -0600 Subject: [PATCH 05/10] More stabilization --- .../helper/version_helper.rb | 5 +- .../wpmreleasetoolkit/models/version.rb | 32 +++++++++++- spec/version_helper_spec.rb | 8 ++- spec/version_spec.rb | 49 +++++++++++++++++++ 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb index fbbded9c4..562d3ecbf 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb @@ -13,6 +13,8 @@ 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 @@ -53,8 +55,7 @@ def newest_rc_for_version(version, repository:, github_client:) tags.map { |t| Version.create(t[:name]) } .compact - .filter { |v| v.is_different_rc_of(version) } - .filter(&:prerelease?) + .filter { |v| v.is_rc_of(version) } .sort .reverse .first diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/models/version.rb b/lib/fastlane/plugin/wpmreleasetoolkit/models/version.rb index b8b8e236f..aed2eb29b 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/models/version.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/models/version.rb @@ -93,7 +93,7 @@ def android_version_name # Returns a formatted string suitable for use as an Android Version Code def android_version_code(prefix: 1) [ - '1', + prefix, @major, format('%02d', @minor), format('%02d', @patch), @@ -113,6 +113,13 @@ 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? @@ -174,6 +181,16 @@ def next_rc_version # 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 @@ -191,12 +208,23 @@ def ==(other) 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) - raw_version_code <=> other.raw_version_code + # 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 diff --git a/spec/version_helper_spec.rb b/spec/version_helper_spec.rb index d96df5ebd..7054f24cd 100644 --- a/spec/version_helper_spec.rb +++ b/spec/version_helper_spec.rb @@ -28,6 +28,13 @@ 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) @@ -43,7 +50,6 @@ 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') diff --git a/spec/version_spec.rb b/spec/version_spec.rb index 6faeccf86..b60eee1ae 100644 --- a/spec/version_spec.rb +++ b/spec/version_spec.rb @@ -45,20 +45,54 @@ def version(major:, minor:, patch: 0, rc_number: nil) 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 @@ -117,6 +151,14 @@ def version(major:, minor:, patch: 0, rc_number: 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 @@ -147,6 +189,13 @@ def version(major:, minor:, patch: 0, rc_number: nil) 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' From 932541d44b319178caef6d95a2cf9098831e09bd Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Mar 2022 12:10:50 -0600 Subject: [PATCH 06/10] Enable local integration testing --- lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb | 1 + spec/version_helper_spec.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb index 562d3ecbf..a2a26caf3 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb @@ -91,6 +91,7 @@ def current_pr_number %w[ BUILDKITE_PULL_REQUEST CIRCLE_PR_NUMBER + LOCAL_PR_NUMBER ].map { |k| ENV[k] } .compact .first diff --git a/spec/version_helper_spec.rb b/spec/version_helper_spec.rb index 7054f24cd..49fd6034f 100644 --- a/spec/version_helper_spec.rb +++ b/spec/version_helper_spec.rb @@ -53,6 +53,7 @@ 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 From bd618edef8c8229f521a546ae9b517f4a34fc670 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Mar 2022 12:47:17 -0600 Subject: [PATCH 07/10] Fix git integration --- .../plugin/wpmreleasetoolkit/helper/version_helper.rb | 6 +++--- spec/spec_helper.rb | 8 ++++++++ spec/version_helper_spec.rb | 8 ++++---- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb index a2a26caf3..a4e13bd2d 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/version_helper.rb @@ -15,7 +15,7 @@ def prototype_build_name(pr_number: nil, commit: nil) 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]}" + "pr-#{pr_number}-#{commit.sha[0, 7]}" end # Generate a prototype build number based on the most recent commit. @@ -24,7 +24,7 @@ def prototype_build_name(pr_number: nil, commit: nil) # on a different commit. def prototype_build_number(commit: nil) commit ||= current_commit - commit[:date].to_i + commit.date.to_i end # Generate an alpha build name based on the current branch and commit. @@ -35,7 +35,7 @@ def alpha_build_name(branch: nil, commit: nil) branch ||= current_branch commit ||= current_commit - "#{branch}-#{commit[:sha][0, 7]}" + "#{branch}-#{commit.sha[0, 7]}" end # Generate an alpha number. diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 56f15a6c3..059993f51 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,7 @@ require 'simplecov' require 'codecov' require 'webmock/rspec' +require 'ostruct' # SimpleCov.minimum_coverage 95 SimpleCov.start @@ -103,3 +104,10 @@ def version(major:, minor:, patch: 0, rc_number: nil) rc_number: rc_number ) end + +def mock_commit(sha: 'abcdef123456', date: DateTime.now) + OpenStruct.new( + sha: sha, + date: date, + ) +end diff --git a/spec/version_helper_spec.rb b/spec/version_helper_spec.rb index 49fd6034f..e97c8eab4 100644 --- a/spec/version_helper_spec.rb +++ b/spec/version_helper_spec.rb @@ -82,24 +82,24 @@ 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: { sha: 'abcdef123456' })).to eq 'pr-1234-abcdef1' + 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([{ sha: 'abcdef123456' }]) + 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([{ date: now }]) + 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([{ sha: 'abcdef123456' }]) + 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 From b417cece3ec34d03264cb4a7dd396ab31624f43a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 25 Mar 2022 13:04:00 -0600 Subject: [PATCH 08/10] Add alpha_build_version action --- .../actions/versions/alpha_build_version.rb | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/versions/alpha_build_version.rb 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 From 1afd7ca3342bc3d0bfff508eefe2d07d0c352a1c Mon Sep 17 00:00:00 2001 From: Spencer Transier Date: Wed, 30 Aug 2023 16:26:45 -0700 Subject: [PATCH 09/10] Apply rubocop fixes --- .rubocop.yml | 3 +++ spec/spec_helper.rb | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 85e7010a8..04ce063a5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -56,6 +56,9 @@ Style/StringConcatenation: # for more discussion Style/FetchEnvVar: Enabled: false + +Style/OpenStructUse: + Enabled: false ########## Gemspec Rules diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index cd6968af0..29b040e3c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -112,11 +112,11 @@ def version(major:, minor:, patch: 0, rc_number: nil) def mock_commit(sha: 'abcdef123456', date: DateTime.now) OpenStruct.new( sha: sha, - date: date, + 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') -FAILED_FIREBASE_TEST_LOG_PATH = File.join(__dir__, 'test-data', 'firebase', 'firebase-test-lab-run-failure.log') \ No newline at end of file +FAILED_FIREBASE_TEST_LOG_PATH = File.join(__dir__, 'test-data', 'firebase', 'firebase-test-lab-run-failure.log') From b9674ec134cee94c59cca4d3647a6ada9bfb6a28 Mon Sep 17 00:00:00 2001 From: Spencer Transier Date: Thu, 31 Aug 2023 12:48:31 -0700 Subject: [PATCH 10/10] Fix rubocop OpenStruct error --- .rubocop.yml | 5 +---- spec/spec_helper.rb | 5 +++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 04ce063a5..e86a721e0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -49,7 +49,7 @@ 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 @@ -57,9 +57,6 @@ Style/StringConcatenation: Style/FetchEnvVar: Enabled: false -Style/OpenStructUse: - 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/spec/spec_helper.rb b/spec/spec_helper.rb index 29b040e3c..a8ff1a27c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,7 +3,6 @@ require 'simplecov' require 'codecov' require 'webmock/rspec' -require 'ostruct' require 'buildkite/test_collector' # SimpleCov.minimum_coverage 95 @@ -109,8 +108,10 @@ def version(major:, minor:, patch: 0, rc_number: nil) ) end +Commit = Struct.new(:sha, :date, keyword_init: true) + def mock_commit(sha: 'abcdef123456', date: DateTime.now) - OpenStruct.new( + Commit.new( sha: sha, date: date )