Saying stuff about stuff.

Caching dependencies on GitHub Actions

I previously wrote about caching gems on CircleCI and although this is even easier to achieve with GitHub Actions there’s still a useful approach worth applying that will minimise the overall time spent installing dependencies when a workflow contains many jobs.

The thing I want to prevent is each job having to download and install its dependencies, when this occurs one of the jobs will finish first and write to the cache and then we’ll see something like Cache hit occurred on the primary key BIG-LONG-KEY, not saving cache from the Post Run actions/cache@v2 output in other jobs. Nothing will blow up, but all of those separate jobs installing dependencies are wasting billable time.

The simple fix is to declare an initial job whose sole purpose is to do the work of installing dependencies and to make them available for the other jobs. Rather than store the installed dependencies as a workflow artifact I prefer to treat this step as warming a cache — which may be a little more verbose but I think is simpler overall (also, with this approach, the cache is treated as a performance optimisation and each dependant job is still able to run if it isn’t present for for some reason).

Caching gem dependencies

Here’s an example of installing gems, note the job cache_gems and how the other jobs declare it as a dependency by specifying needs: cache_gems:

on: [push]

jobs:
  cache_gems:
    name: Cache gems
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: ruby/setup-ruby@v1
      with:
        # We always need to tell ruby/setup-ruby to cache the gems for us.
        bundler-cache: true

  brakeman:
    name: Brakeman
    runs-on: ubuntu-latest

    # Tell this job to wait for the `cache_gems` job to successfully complete
    # before it runs.
    needs: cache_gems

    steps:
    - uses: actions/checkout@v3

    # Each dependant job still need to install Ruby and ensure that it reads
    # from the cache before installing the gems (which is as simple as
    # specifying `bundler-cache: true` when using ruby/setup-ruby).
    - uses: ruby/setup-ruby@v1
      with:
        bundler-cache: true

    # Now run your actual CI command.
    - run: bundle exec brakeman --quiet --run-all-checks

  rspec:
    name: RSpec
    runs-on: ubuntu-latest
    needs: cache_gems
    steps:
    - uses: actions/checkout@v3
    - uses: ruby/setup-ruby@v1
      with:
        bundler-cache: true
    - run: bundle exec rspec

  rubocop:
    name: Rubocop
    runs-on: ubuntu-latest
    needs: cache_gems
    steps:
    - uses: actions/checkout@v3
    - uses: ruby/setup-ruby@v1
      with:
        bundler-cache: true
    - run: bundle exec rubocop --parallel

Caching JavaScript dependencies

I use the same technique for installing JavaScript dependencies but it’s a tiny bit more involved because while ruby/setup-ruby takes care of caching and installing for us actions/setup-node only does the caching so we have to do the install ourselves.

  cache_node_modules:
    name: Cache node_modules
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3

    # I'm using Yarn so I select the yarn caching strategy so setup-node will
    # read/write from Yarn's global install location.
    - uses: actions/setup-node@v3
      with:
        cache: 'yarn'

    # Now actually install the dependencies (which actually reads from Yarn's
    # global install location).
    - run: yarn install --frozen-lockfile

  jest:
    name: Jest
    runs-on: ubuntu-latest
    needs: cache_node_modules
    steps:
    - uses: actions/checkout@v3

    # Mirror the cache job's setup-node steps.
    - uses: actions/setup-node@v3
      with:
        cache: 'yarn'
    - run: yarn install --frozen-lockfile

    # And here's your CI command.
    - run: yarn test

  prettier:
    name: Prettier
    runs-on: ubuntu-latest
    needs: cache_node_modules
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v1
      with:
        cache: 'yarn'
    - run: yarn install --frozen-lockfile
    - run: yarn prettier --check app/javascript

Running system tests

Running system tests may require both Ruby and JavaScript dependencies which can be achieved with the following changes:

   rspec:
     name: RSpec
     runs-on: ubuntu-latest
-    needs: cache_gems
+    needs: [cache_gems, cache_node_modules]
     steps:
     - uses: actions/checkout@v3
     - uses: ruby/setup-ruby@v1
       with:
         bundler-cache: true
+    - uses: actions/setup-node@v3
+      with:
+        cache: 'yarn'
+    - run: yarn install --frozen-lockfile
     - run: bundle exec rspec

Updated 2022-09-05: Updated Node examples to use actions/setup-node v3 and take advantage of its inbuilt caching.