I never thought I'd get into software infrastructure and maintenance. After all, most software developers start off by building. Building new projects, buidling features, building whatever interests them. Rarely do we want to work with legacy applications just performing upgrades and maintenance.
But as a freelance software engineer, I found myself working on Heroku Stack updates. When I was approached by the nice folks at Book A Street Artist in March 2023, I had just lost my main client and was eager to take on projects to keep my freelance business going. I wasn't yet prepared to go back into full-time employment, so I was open to pretty much any type of software project. I'm not sure how Mario found me, but he messaged me on LinkedIn and asked if I was available to work on a project that involved updating to a more recent stack on Heroku. At that point in time, I didn't even know that Heroku Stacks were a thing, so I first had to understand the task at hand.
After a bit of discussion about the exact requirements, travelling back from Austria, where I'd been on a week-long skiing holiday, and meeting up with the team in a café in Berlin, I got the gig and started work on the upgrades. Moving from Heroku Stack 18 to 20 was seamless as the Ruby versioin (2.7.2) and Rails version (6.0.0) were already supported, so no Ruby dependency updates were necessary at that point. I had to do some updates of Webpacker and fix the build, but that was pretty much it.
Aside from these updates, they had me fix some small bugs that had crept into the system since 2021, when they had let go the entire tech team. Picking up a 2-year dormant project was a challenge, and I'm grateful to the previous team for having left detailed instructions for how to set up the development environment as well as the sandbox environment (also on Heroku) and testing.
Fast-forward to late last year, when the Heroku warning emails started to come through: "Your recently built app is using the Heroku-20 stack, which is deprecated." Shit. It's happening again, and this time the challenge will be much greater.
Not only was Stack 20 going to be deprecated, but along with it Ruby 2 and Rails 6. Heroku Stack 22 (and those that follow) no longer support these 'legacy' versions of Ruby and Rails meaning that dependency upgrades were now unavoidable.
Getting Stuck In
I started by getting the project's test suite in top shape. Since starting to work on the project, I'd not touched any tests in it. There was already a huge test suite running everything from unit tests to system tests but a large chunk of them were failing due to many reasons, mostly the changes that I'd introduced in the previous 2 years. This includes some changes to forms, database schema changes that altered the validations of entities, and some smaller changes.
I wouldn't have confidence that the upgrades from Ruby 2 to Ruby 3 and from Rails 6 to Rails 7 had gone smoothly unless I first fixed all the relevant tests. I didn't keep count how many failing tests had to be fixed, it was well over 200. Some had to be rigorously followed and updated. The system tests that go through an extensive form and submit it, showing errors when there are issues and confirmation messages when successful were a challenge. Not only was the form long, it had different variations according to the type of artist.
Many of the test fixes were simple, adding or removing some fixture data, adding some missing associations, or general setup configuration was enough to get a large proportion passing.
After several hours of debugging and troubleshooting, I had a fully green test suite. Now the real work could begin.
Dependency Hell
When it came to the dependency updates, I didn't know where to start. I knew I would have to update both the Ruby and Rails versions but I was unsure what the correct procedure looked like. Fortunately, this isn't a new problem and Rails provides a pretty good guide for performing version upgrades.
Unfortunately, I didn't discover this guide until I had already dived into the updates. I started with the target Ruby version and decided to see if I could get a Gemfile
and Gemfile.lock
that would satisfy the new Ruby version, which is 3.1.6
. After longer than I care to admit getting the new Ruby version installed with rvm
, I managed to bump dependency versions just enough to satisfy my new requirements and the project was built successfully in the CI/CD pipeline in GitHub Actions.
The next step was the to get on the correct Rails version. I didn't do incremental updates here, despite that being the conventional guidance from the Rails team. Instead of going to Raild 6.1, the minor version in between 6.0 and 7.0, I opted to go straight to 7.0. In hindsight, this was a gamble and could have backfired but I got lucky and the dependencies all played ball quite nicely with this update as well. Many direct dependenices of Rails, such as actioncable
, actionview
, activemodel
, activerecord
and activestorage
have strict rules around being on the exact same version as Rails itself, so there wasn't much to do for those.
Some others were much more nuanced and often it comes down to a game of cat and mouse trying to pinpoint a version of one package that is the dependency of many others, to ensure that it satisfies all the nested requirements that exist. This chain where dependencies have their own dependencies, and those dependencies have their own dependencies can cause a lot of headaches, and on one occasion I decided to stop the process and start from scratch, as I was caught in a loop of never-satisfied conditions.
In the end, I managed to find a set of versions for every dependency that satisfied the bundler and the project was building again in the CI/CD pipeline. The problem now was getting the tests working again.
Fixing Tests...Again
Now that the dependencies were on their new, shiny versions, it introduced a whole raft of test failures, mostly for the system tests. With experience, I've come to rely much more heavily on system (some call them functional or end-to-end) tests over unit tests for application behaviour. Unit tests are great for testing niche edge cases for functions and utilities, but system tests are the ones that give you the highest level of confidence that everything is working as it should. Using Capybara, the system test runner that comes with Rails, it's possible to have the pages rendered in a browser (usually Chrome) so that you can follow along the process of using the application from the user's perspective.
Some of the errors here were quite simple to fix, as they were directly related to the differences between the old and new Ruby versions, as well as the old and new Rails versions. The main culprits I found at this stage were:
Fixtures
When I took over the project, it was using regular Rails fixtures for all types of tests. Between the version upgrades, I introduced an issue with the associations between some fixtures. From the very beginning, I had wanted to replace the fixtures with FactoryBot, but I knew that doing so would be a huge undertaking and I would need to dedicate serious time and effort into making the switch. Once this issue reared its ugly head, I decided to just get started with the switch rather than wasting time investigating the cause of the issue. It wasn't immediately obvious, so I thought the trade-off was easy to justify.
I began by introducing just the factories that I required for fixing the failing tests and committed to never again introducing more fixtures or even using the existing ones in new tests - from now on only factories.
With that, I began the slow migration from fixtures to factories. It won't be done any time soon. It's possible that I'll never complete it, but every step taken in the direction from fixtures to factories is a step in the right direction, as far as I'm concerned.
Keyword arguments in functions
The old version of Ruby was not strict when it came to keyword arguments. These are function arguments that are named so as to give more context when calling the function. They generally make the code where the function is called more readable. Let's quickly take a look at the difference between them and positional arguments.
def get_tax(total, tax_percentage)
total * tax_percentage
end
tax = get_tax(200, 0.16)
It's a simple enough function and it makes sense just by reading it. But the calling code is not very explicit, especially if it's not clear where the 200
or the 0.16
are coming from. Let's see the same example with keyword arguments.
def get_tax(total:, tax_percentage:)
total * tax_percentage
end
tax = get_tax(total: 200, tax_percentage: 0.16)
other_tax = get_tax(tax_percentage: 0.16, total: 400)
There are a couple of immediately obvious benefits:
- You can rearrange the order of the arguments
- The arguments have an explicit name making the code more readable.
However, between Ruby 2 and 3, the flexibility of this approach was reduced. In Ruby 2, you are able to pass in a hash literal with keys corresponding to the keyword params. In Ruby 3, this is no longer possible and gives an argument error at run time. Here's a quick example for the purposes of demonstration.
def get_tax(total:, tax_percentage:)
total * tax_percentage
end
tax = get_tax({ total: 200, tax_percentage: 0.16 }) # this works in Ruby 2 but not in Ruby 3
other_tax = get_tax(tax_percentage: 0.16, total: 400) # this works in Ruby 3 and Ruby 3
There were several instances of this incorrect function call scattered throughout the codebase. Mercifully (for me), they had tests that invoked those functions and caused the errors to be raised in the tests themselves. This allowed me to fix the syntax error without too much trouble.
Strong params
I'm not exactly sure of the precise version number, but somewhere between Rails 6 and Rails 7, strong params were introduced by default in controller actions.
Somewhat irritatingly, this didn't present itself as any sort of error that was happening at run time, but manifested itself in my case in a bunch of failed tests. Trying to access the params results in nil
being returned when they are not explicitly permitted in the controller, and I have a rather complex search endpoint that relies on several input params to return the correct search results. The effect of this issue was that all the search results were always being returned in the system tests. At first, I thought this might be related to the switch from fixtures to factories, as detailed above. However, with some debugging, it became clear that the search was not being performed correctly and that all the records were returned in all cases.
It was only a small step from that realisation to discovering the source of the issue, which were the strong parameters. Naturally, the fix was also a very simple one and suddenly I had a fully functional search endpoint again - result!
Redirecting to an external URL
The final one was the simplest but probably most pervasive. Rails 7 requires explicit instruction to redirect to an external URL - that is one with a different domain. The fix is simple when you know how:
redirect_to some_external_url # from this
redirect_to some_external_url, allow_other_host: true # to this
Takeaways
Now that the switch has been made - the application is running smoothly on Heroku's latest stack, Stack 24; Rails is on version 7.0.8.7
; and Ruby is on version 3.1.6
- I can look back on a pretty successful project. If I had to do it again, here are the things that I would do differently:
- not rush to upgrade the Ruby version to the target version
Knowing the target version from the Heroku stack documentation, I decided to aim directly for it. This resulted in me taking a big leap in many versions of the dependencies which proved very hard to manage and resulted in the next takeaway
- not rush to upgrade the Rails version to the target version
As per the Rails docs, it's recommended to upgrade Rails one minor version at a time. That means, in my case, from 6.0.x
to 6.1.x
and then from 6.1.x
to 7.0.x
. However, I dediced to skip right ahead and this led to even more dependency issues.
- take more care with dependencies
Upgrading dependencies was often a case of trial and error. Upgrading to the minimum version required and then running bundle update <dependency>
very often led me down a difficult or even impossible path. Taking a slower approach as detailed above would have been much simpler and would have allowed me to follow the dependency upgrades more closely.