Bumping SPM Version Using Fastlane

Introduction

If you’re using SPM (Swift Package Manager) for your SDKs/ packages / to have a modularized project, you might have had an issue with forgetting to bump the version of the package before merging, and having to do a silly PR after the fact, usually called “bump_version”, and have your teammates mock you and your forgetfulness. Well, fear no more ! I am here to show you how to automate this process using Fastlane!

Assumptions

Plan

So, how are we going to do this? well, easy enough! what we’re going to do, is create a flow that will:

  1. Check the current version of our package
  2. Check the remote version of our package
  3. If the remote version is higher than the local version, we’ll bump the version of our package
  4. If the remote version is the same as the local version, we’ll bump it still
  5. if the local is higher than the remote, we’ll leave it as is (as it was already bumped)
  6. We’ll push the new version to the remote
  7. We’ll sit back and have a coffee, knowing that we’ve automated this process and our imposter syndrome is at bay

Implementation

We’re going to follow the steps above, by writing lanes and ruby methods to assist us. (ICYMI, Fastlane is Ruby based, so we can use Ruby!)

Step 1: Find the current package version

Following the steps we’ve outlined above, let’s start by finding out what’s the current version of our SPM package. To do that, let’s write a quick ruby method:

desc 'The version of the SPM pacakge'
def package_version
  version_line =
    File.open('../Package.swift') do |file|
      file.find do |line|
        line =~ /^let packageVersion =/
      end
    end
  version_line.tr!("'a-zA-Z\n=\"\s", '')
end

Let’s unpack it a bit, because it’s a bit daunting to look at:

Step 2: Find the remote package version

Great! so we have a way to get the local package number, now we need a way to get the remote one as well! To do that we’re going to, you’ve guessed it, write another ruby method:

desc 'Get the latest version that was released on git'
def released_package_version
  sh('git', 'describe', '--tags', '--abbrev=0', log: false) do |status, output, _command|
    return '0.0.0' unless status.success?
    return output.to_s.tr("a-z'\n\s", '')
  end
end

Again, a bit of a mouthful, but let’s break it down:

Step 3: Comparing

Now that we’ve got methods to get both local and remote version of our package, let’s compare them and see if we need to bump. Can you guess what we’re going to do? yes, write another method!

desc 'Bumps the version of an SPM package if needed.'
def bump_version_if_required
  local_version_number = package_version
  remote_version_number = released_package_version
  # If the local version is smaller or equal to the remove version, bump it
  if Gem::Version.new(local_version_number) <= Gem::Version.new(remote_version_number)
    # Here we're going to bump the version
  else
    # If it's not, it was already bumped and we can ignore it
    puts 'Local version number is up to date'
  end
end

This one is a bit more straightforward, we’re comparing the two versions, and if the local version is smaller or equal to the remote version, we’re going to bump it. We’re using the built in Gem::Version class to compare the two versions, so we don’t have to deal with the sem-ver comparing ourselves.

Step 4: Bumping the version

Now that we’ve got the comparison, we need a way to actually bump the version when it’s needed.

Firstly, let’s write a to do the actual bumping:

def bump_version_number(version_number)
  version_number_array = version_number.split('.').map(&:to_i)
  version_number_array[-1] += 1
  version_number_array.join('.')
end

This method takes in a version, bumps it’s patch number and returns the new version. simple!

Next, we need to write a method that will actually write the new version to the Package.swift file:

def bump_version(remote_version)
  package_version_key = 'let packageVersion = '
  file_path = '../Package.swift'
  file_contents = File.read(file_path)
  matching_line = file_contents.lines.find { |line| line.include?(package_version_key) }
  new_version_number = bump_version_number(remote_version)
  puts "New local version number: #{new_version_number}"
  new_line = "#{package_version_key}\"#{new_version_number}\"\n"
  updated_contents = file_contents.gsub(matching_line, new_line)
  File.write(file_path, updated_contents)
end

This is very similar to what we’ve done above when we wanted to read the version number, but this time we’re writing it back to the file. So let’s break it down:

And that’s it, voila and you’ve bump the version of your SPM package! let’s have a quick update at our bump_version_if_required method, to include the new bumping logic:

desc 'Bumps the version of an SPM package if needed.'
def bump_version_if_required
  local_version_number = spm_package_version
  remote_version_number = released_package_version
  # If the local version is smaller or equal to the remove version, bump it
  if Gem::Version.new(local_version_number) <= Gem::Version.new(remote_version_number)
    # Here we're going to bump the version
    puts 'Bumping version number'
    bump_version(remote_version_number)
    puts 'Version number bumped'
    puts spm_package_version
  else
    # If it's not, it was already bumped and we can ignore it
    puts 'Local version number is up to date'
  end
end

Step 5: Pushing the new version

Now that we’ve bumped the version, we need to push it to the remote, so that it’s up to date. let’s write a lane that will do that for us. We’re going to use the git action that Fastlane provides, to do this, so we will need to have require 'git' and fastlane_require 'git' at the top of our file:

lane :commit_to_git do |options|
  repo = options[:repo] || Git.open('.')
  current_branch = repo.current_branch
  git_commit(path: '.', message: options[:commit_message])

  begin
    repo.push('origin', current_branch)
  rescue Git::GitExecuteError => e
    raise e unless e.message.match?(/no upstream branch/)

    repo.push('origin', current_branch, set_upstream: true)
  end
end

This just takes in a parameter, commit_message, and a repo, and commits and pushes the changes to the remote. If for some reason the remote isn’t there, or we get an error, we print it out so we’ll know what happened. let’s add that to our bump_version_if_required method:

desc 'Bumps the version of an SPM package if needed.'
def bump_version_if_required
  local_version_number = spm_package_version
  remote_version_number = released_package_version
  # If the local version is smaller or equal to the remove version, bump it
  if Gem::Version.new(local_version_number) <= Gem::Version.new(remote_version_number)
    # Here we're going to bump the version
    puts 'Bumping version number'
    bump_version(remote_version_number)
    puts 'Version number bumped'
    repo = Git.open('.')
    commit_to_git(commit_message: "Bump version number from #{local_version_number} to #{spm_package_version}", repo: repo)

  else
    # If it's not, it was already bumped and we can ignore it
    puts 'Local version number is up to date'
  end
end

Step 6: Putting it all together

Well done on making it so far! we’ve done it, we’ve created a flow for us to automate the version bumping of our SPM package, and we’ve done it using Fastlane! let’s put it all together in a Fastfile, so we can call it easily:

#!/usr/bin/ruby
# frozen_string_literal: true

fastlane_version '2.225.0'
fastlane_require 'git'

# rubocop:disable Metrics/BlockLength
default_platform :ios
platform :ios do
  desc 'The version of the SPM pacakge'
def spm_package_version
  version_line =
    File.open('../Package.swift') do |file|
      file.find do |line|
        line =~ /^let packageVersion =/
      end
    end
  version_line.tr!("'a-zA-Z\n=\"\s", '')
end

desc 'Get the latest version that was released on git'
def released_package_version
  # Get the most recent Git tag, suppressing log output for cleaner results
  sh('git', 'describe', '--tags', '--abbrev=0', log: false) do |status, output, _command|
    # Returns a placeholder if no Git tags have been defined, indicating no release has been made
    return '<never released>' unless status.success?

    # Removes any lowercase letters, apostrophes, newline, and whitespace characters from the Git tag,
    # so we get just a number,and  returns the cleaned version number as the result of the method
    return output.to_s.tr("a-z'\n\s", '')
  end
end

desc 'Bumps the version of an SPM package if needed.'
def bump_version_if_required
  local_version_number = spm_package_version
  remote_version_number = released_package_version
  # If the local version is smaller or equal to the remove version, bump it
  if Gem::Version.new(local_version_number) <= Gem::Version.new(remote_version_number)
    # Here we're going to bump the version
    puts 'Bumping version number'
    bump_version(remote_version_number)
    puts 'Version number bumped'
    repo = Git.open('.')
    commit_to_git(commit_message: "Bump version number from #{local_version_number} to #{spm_package_version}", repo: repo)

  else
    # If it's not, it was already bumped and we can ignore it
    puts 'Local version number is up to date'
  end
end

def bump_version_number(version_number)
  version_number_array = version_number.split('.').map(&:to_i)
  version_number_array[-1] += 1
  version_number_array.join('.')
end

def bump_version(remote_version)
  package_version_key = 'let packageVersion = '
  file_path = '../Package.swift'
  file_contents = File.read(file_path)
  matching_line = file_contents.lines.find { |line| line.include?(package_version_key) }
  new_version_number = bump_version_number(remote_version)
  puts "New local version number: #{new_version_number}"
  new_line = "#{package_version_key}\"#{new_version_number}\"\n"
  updated_contents = file_contents.gsub(matching_line, new_line)
  File.write(file_path, updated_contents)
end

lane :commit_to_git do |options|
  repo = options[:repo] || Git.open('.')
  current_branch = repo.current_branch
  git_commit(path: '.', message: options[:commit_message])

  begin
    repo.push('origin', current_branch)
  rescue Git::GitExecuteError => e
    raise e unless e.message.match?(/no upstream branch/)

    repo.push('origin', current_branch, set_upstream: true)
  end
end

lane :bump_spm_package do
  puts bump_version_if_required
end

end

And that’s it! now you can call fastlane bump_spm_package and it will bump the version of your SPM package for you! this can be called locally or from a CI, whatever your setup is.

This was a lot of fun to do, and shows how small things can really increase our quality of life, and remove a concern from us - so we can focus on what’s really important, arguing on Twitter Thanks for reading!