Why caching matters0:01
CACHING Alright, let's talk caching. There are two places in our current action that would really benefit from a cache. First of all, the setup of php with all the extensions, as we discussed at the end of the last episode, and also the installation of our Composer dependencies. Neither of these things change on every run of the action, so by caching those particular parts, it would speed up our workflow no end. Caching is actually an official part of how GitHub actions work. There is a cache action built by GitHub for this exact purpose. It can be a little bit difficult to understand at first,
Caching Composer dependencies0:39
There is a cache action built by GitHub for this exact purpose. It can be a little bit difficult to understand at first, so what we'll do is we'll start nice and simple. We're going to cache our Composer dependencies, and then we'll move on to caching those PHP extensions towards the end of the episode. CACHING So before Composer install, I'm going to create a new step called Cache Dependencies. And little side note here, if you're caching, you always want that step to happen prior to installation. So if you're caching Composer dependencies, it happens before Composer install. If you're caching NPM dependencies, it happens before NPM install.
So if you're caching Composer dependencies, it happens before composer install. If you're caching NPM dependencies, it happens before npm install. And later, if we're caching PHP extensions, that will need to happen prior to configuring PHP. All right, let's get back to it. CACHING Let's go ahead and use the latest version of the official cache action from GitHub, actions/cache, and we'll paste that into our .yaml file, and then we'll provide custom input to this action using with. The first thing we want to look at is the key. The key is a unique reference that essentially tells GitHub actions whether or not there is a valid piece of data cached.
Generating cache keys1:43
The first thing we want to look at is the key. The key is a unique reference that essentially tells GitHub Actions whether or not there is a valid piece of data cached. So we could use a simple string like composer-cache-v1, and then any time we run composer update, we would come into this file and we would manually change to v2. That would work without issues, but you can imagine composer update happens frequently, which means you're going to be having to manually change this value all the time, or else risk having breaks inside GitHub Actions. Instead, it would make much more sense to automate that process. Now, of course, we have a way to do that in Composer. That's exactly why composer.lock exists.
Now, of course, we have a way to do that in Composer. That's exactly why composer.lock exists. composer.lock is literally a hash of all the different packages currently installed, and whenever you run composer update, this file changes. But we need to provide a string to this key. So how do we do that? Well, let's make use of a little bit of magic GitHub syntax again. I'm going to reach for a built-in GitHub Actions function called hashFiles, which will perform an MD5 hash of any files we pass in. So in our case, I'll use ** to search anywhere,
which will perform an MD5 hash of any files we pass in. So in our case, I'll use * to search anywhere, so we don't have to worry about the current directory or anything like that. And then I'll look for the Composer.lock file. And that's it. Now, essentially, when this runs, GitHub Actions is going to go ahead, take the Composer.lock file inside our project, MD5 hash it, and that will give us a perfect version hash of the currently installed dependencies in our project. The second required piece of information for this action is the path we want to cache.
of the currently installed dependencies in our project. The second required piece of information for this action is the path we want to cache. So to start, you might put vendor in there, right? You want to take whatever is inside the vendor directory, and you want to store that information outside of the GitHub action. And then when this runs again, you want to pull that and put it back into the vendor directory. It's a naive approach. It has many pitfalls, which we'll discuss in just a moment, but it will get you somewhere to start.
It has many pitfalls, which we'll discuss in just a moment, but it will get you somewhere to start. So tell you what, let's go ahead. Let's go with this naive bad approach. I'm going to commit and push this, and then let's take a look at the GitHub Actions. If we take a look at the details of our run, there is a new cache dependencies step. It did indeed create an MD5 hash of the Composer.lock file, but, of course, there is no existing cache, so nothing happens.
It did indeed create an MD5 hash of the Composer.lock file, but, of course, there is no existing cache, so nothing happens. We go ahead, install our project dependencies, and then further down, there's a brand new automated step, thanks to that cache action, that is going to take the contents of our vendor directory and save it to the cache using that key that we've just generated. So if we head back to our code base, and let's just go ahead and make a tweak to our code and push this up, we should hopefully see in the second run that cache come into play.
and let's just go ahead and make a tweak to our code and push this up, we should hopefully see in the second run that cache come into play. This time, when cache dependencies runs, it finds a cache hit for this given key, which means it's going to restore the contents of the vendor directory, and when we run composer install, it now takes just two seconds instead of the five, six seconds that it was taking before. So there we go. A basic implementation of caching for our composer dependencies.
Fixing cache pitfalls5:02
So there we go. A basic implementation of caching for our Composer dependencies. But our approach is not without its issues. See, if we update even one Composer dependency, every dependency is going to be invalidated because it will no longer be able to find that cache key. There is a fix for that in GitHub Actions. Along with our primary key, we can also define a list of restore keys. These are key partials that it will fall back to if it can't find this key here.
These are key partials that it will fall back to if it can't find this key here. So we could actually just define Composer cache, and so essentially what will happen is if it doesn't find an exact match here, it will look into the cache and say, well, are there any other older caches that have this prefix, which will essentially be any older Composer cache, right, because that's the prefix we're using. What that means, however, is that old vendor dependencies are going to be installed into the vendor directory,
What that means, however, is that old vendor dependencies are going to be installed into the vendor directory, which is not a great idea. But Composer has a fix for that. If you head to the terminal and you run composer config cache-files-dir, you're going to receive a directory, which is where Composer installs packages before it places them in your vendor folder. In fact, if you use a little bit of bash syntax to wrap this in a list command, then what you'll actually see is all the different vendors you have installed.
In fact, if you use a little bit of bash syntax to wrap this in a list command, then what you'll actually see is all the different vendors you have installed. Here is Laravel. Here is Symfony. If we come a little bit further up, I think we should have pestphp, right? So this is a cache that Composer uses. When you run composer install, it goes and says, okay, have I actually got this package already in the cache? If so, I'm just going to pull it from there. There's no need for me to go off to the internet and grab a brand new version. So we can use that to improve our action.
There's no need for me to go off to the internet and grab a brand new version. So we can use that to improve our action. See, instead of caching the vendor directory, we can cache composer config cache files directory. And so if we fall back to an older cache variant, well, it won't matter. Any packages that haven't changed will be pulled from the cache. Any packages that have changed will be pulled fresh from Composer, and then everything will be stored in a brand new cache using the latest composer.lock. Sadly, however, we can't just pass composer config cache files directory.
using the latest composer.lock. Sadly, however, we can't just pass composer config cache-files-dir to the path key because it expects a string. So we need to generate this outside of this step and then pass it in using variables. So above our cache dependencies step, I'll create a new step. Let's call it getComposerCacheDir. I'm going to say dir a lot in the next couple of minutes. I can feel it. And we'll run that command, composer config cache-files-dir.
I can feel it. And we'll run that command, composer config cache-files-dir. Now, that will appear inside the bash output, but it won't be available to other steps. To make it available, I'll assign it to a variable. Let's say dir equals $ and then brackets, and we'll wrap that command in brackets. I'll echo that out, and then at the end of the command, I'm going to pipe it into a reserved environment variable called GITHUB_OUTPUT.
I'm going to pipe it into a reserved environment variable called github_output. Now, the last thing we need to do is give this step a unique identifier. Let's call it composer_cache_dir. And finally, we can make use of this inside our cache dependency step. So for the path, I'll make use of that special github syntax, $$ and then {$}. I can reach for steps. I can grab that step using the ID I just created. I'm looking for output, and we called the variable dir.
I can grab that step using the ID I just created. I'm looking for output, and we called the variable dir, so I'll grab the dir output from the composer cache dir step. So let's just run through exactly what's happened. We run this composer cache dir step, which essentially takes the output of this command and assigns it to a dir variable inside a global github output config object. Then in the next step, we're going to grab the outputs of that step and find the dir variable that we created, which will be the correct string needed to define the path we're looking for.
and find the dir variable that we created, which will be the correct string needed to define the path we're looking for. Long-winded, but that is all we have to do. We should now be up and running and off to the races. So let's commit this and go and take a look at the workflow file. So of course, once again, on the first run of this, nothing is going to be restored from the cache because there is no cache item to restore from, but do note that we now have two keys. The first key is the exact key with our MD5 hash of composer.lock.
but do note that we now have two keys. The first key is the exact key with our MD5 hash of composer.lock. The second key is this fallback prefix, which it will use in case it can't find this exact match here. So once again, we'll go into our test suite. Let's change this back to four, and then we'll go commit and push this up, and hopefully we should now see restoring from the cache using our new cache files directory. And on our second run, we did indeed restore from the cache. We've gone down from five seconds all the way to three seconds.
And on our second run, we did indeed restore from the cache. We've gone down from five seconds all the way to three seconds. Imagine what you're going to do with all that time. No, but this is a simple project. In your projects, you'll no doubt have many more composer dependencies, and essentially you'll always be restoring within that three, four-second boundary instead of maybe 10, 15, 20 seconds that it might take to install everything from scratch each time. The far bigger gain we're going to see is caching php extensions. So now that we have our heads wrapped around the basics of caching,
Caching PHP extensions10:26
The far bigger gain we're going to see is caching php extensions. So now that we have our heads wrapped around the basics of caching, we can apply that knowledge to do this slightly more complex cache setup. If we head back to the documentation for our setup php action and we scroll down, they actually have documentation on caching extensions, and they provide a separate GitHub action for this very purpose called cacheExtensions. So if you scroll down in the documentation for this, they have an example workflow which will basically work for almost every implementation you use.
they have an example workflow which will basically work for almost every implementation you use. What I'm going to do is copy and paste these two steps here, the setupCache environment and the cacheExtensions step. And then we'll head into our workflow file, and above setupPHP and composer, I'm going to paste these two steps in. All right, let's explain what's actually happening. First of all, note that it creates a special ID called extensionCache so that we can use it in other outputs, in other steps, just as we did with our composer cache files directory a moment ago.
so that we can use it in other outputs, in other steps, just as we did with our composer cache files directory a moment ago. It makes use of this action, and it provides some custom input. Now, in their example, they're using a matrix or an array of PHP versions. We actually just want to use version 8.3. For extensions, why don't we come down here and we'll copy this list of extensions. Don't worry, we're going to clean this up in a moment to reduce duplication, but we can just paste that in for now. And then for the key, this again is a unique cache key.
but we can just paste that in for now. And then for the key, this again is a unique cache key that you will use to bust the cache when things change. And unlike composer, we're going to hard code it. So let's call this extensionCacheV1. If we were to come in and change one of these extensions, either adding an extension or removing an extension, we would change this v1 to v2 and then v3 and v4. You get the idea. So we're manually busting the cache.
You get the idea. So we're manually busting the cache. That's fine for extensions because you will very rarely update your extension list. Okay. Then it makes use of the standard cache action that we've already discussed. It passes this dynamic path that's created in the previous step and the dynamic key. And then it also sets up a restore key as a fallback. That's all we actually need to get this to work. I'm going to refactor in just a moment, but for now,
That's all we actually need to get this to work. I'm going to refactor in just a moment, but for now, let's get this pushed up and take a look at what happens when we run it. So here's our first workflow run. And again, bear in mind that this one will not be cached because the cache hasn't yet been filled. So we set up the cache environment by running that special action that we talked about. And you'll see if I actually maybe drop this down, it's doing a lot of work. All right.
And you'll see if I actually maybe drop this down, it's doing a lot of work. All right. We'd have to do this work manually if not for this action. So super cool that it exists and it allows us to do this. Then we go ahead and cache those extensions. You'll see here is the dynamic key that it created. Here's a fallback key that it created. And it sets up php and Composer, which currently takes 36 seconds. So here's the moment of truth. Let's go ahead, run this workflow again, and see if anything changed.
So here's the moment of truth. Let's go ahead, run this workflow again, and see if anything changed. And change it did. We are now setting up php and Composer in just five seconds. We've shaved at least 30 seconds off of each and every run by using caching. Let's finish off by cleaning up that workflow file before we move on to the next episode. So as it stands, we are duplicating code, right? We are declaring PHP version and extensions in two different places with the exact same set of values.
Refactoring with env vars14:04
We are declaring php version and extensions in two different places with the exact same set of values. So let's refactor. First thing I'm going to do is come to the top of the file just above steps. And in line with steps, I'm going to create environment variables. These will be globally available to all of the steps in the action. And I'm going to state that we want to define some php version, which is 8.3. And I'll also state that I want to define php extensions, which is going to be equal to this list of extensions here.
And I'll also state that I want to define php extensions, which is going to be equal to this list of extensions here. We'll go ahead and grab that list. And then I'll drop it into this environment variable here. Okay, so to make use of this, we're going to reach once more for that special GitHub syntax, $ and then two curly braces. And I can use env in order to grab the PHP version we've defined. We'll do the same for extensions. This time it's env, and then we want to grab the PHP extensions. And then I can copy and paste this code,
This time it's .env, and then we want to grab the PHP extensions. And then I can copy and paste this code, and I can drop it here where we define our setup PHP and composer step so that we only actually have one place to define our version and PHP extensions. And the final thing I'm going to do is extract this extension cache key to an environment variable as well, just so my extensions and cache key are all in one place. So I'll create an environment variable called cacheKey. And then at the top here, we will drop that cacheKey in place,
So I'll create an environment variable called cacheKey. And then at the top here, we will drop that cacheKey in place, and we'll set it as extensionCacheV1. There we go, much cleaner. Now, if ever we change one of these extensions, perhaps we, for some reason, remove the zip extension, I know that I can just bump this to v2, push, and everything will work. So caching is an amazing way to essentially get a free speed boost in your GitHub actions. There's no excuse for not caching values like php extensions.
in your GitHub actions. There's no excuse for not caching values like PHP extensions, composer dependencies, MPM dependencies, and other things that really change across your project. Tiny bit of homework for you. Seeing as we also have our linting action that does very much the same thing, setting up PHP, installing dependencies, I want you to go ahead and apply the exact same caching techniques to that file there, just so you're completely comfortable with what we're creating.
to that file there, just so you're completely comfortable with what we're creating. And then I'll see you in the next one.
