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
- You’ve already got Fastlane setup for your project, and that you’ve got familiarity with what Fastlane is and how it works.
- Your SPM project is following sem-ver versioning
- You create releases for your SPM package on GitHub
- Your git config is setup correctly, and you can push to the remote without any issues.
Plan
So, how are we going to do this? well, easy enough! what we’re going to do, is create a flow that will:
- Check the current version of our package
- Check the remote version of our package
- If the remote version is higher than the local version, we’ll bump the version of our package
- If the remote version is the same as the local version, we’ll bump it still
- if the local is higher than the remote, we’ll leave it as is (as it was already bumped)
- We’ll push the new version to the remote
- 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:
- First we find the
Package.swift
file. we need to put a../
before it, because things are being called from thefastlane
folder. - Once we found and opened the file, we’re reading it until we find the line starting with
let packageVersion =
- Lastly, we’re removing all the characters we don’t need, so we’re trimming the line and leaving just the version number.
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:
- We’re calling
git describe --tags --abbrev=0
to get the latest tag that was released, suppressing log output for cleaner results. - If the command fails, we’re returning
0.0.0
as a default value - And again we’re using a similar regex to trim the output and leave just the version number.
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:
- We’re setting variables for things that we’re going to use in a few places, because DRY! (remember, just because it’s automation code, doesn’t meant it doesn’t need to be good code!)
- Reading the line we want, and storing it in a variable
- Bumping the version number
- Creating a new line with the bumped version number
- Replacing the old line with the new one
- Writing the new contents back to the file!
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!