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@v2
    - 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@v2

      # 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@v2
    - 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@v2
    - 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 little more verbose because ruby/setup-ruby takes care of caching for us whereas with actions/setup-node we have to do it ourselves using actions/cache.

  cache_node_modules:
    name: Cache node_modules
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-node@v1

      # actions/setup-node isn't quite as friendly as ruby/setup-ruby and we
      # have to configure our own caching. The following will read from the
      # cache and later write to it if the job runs successfully.
    - uses: actions/cache@v2
      with:
        path: node_modules
        key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }}
        restore-keys: |
          ${{ runner.os }}-yarn-v1-

      # Now actually install the dependencies.
    - run: yarn install --frozen-lockfile

  jest:
    name: Jest
    runs-on: ubuntu-latest
    needs: cache_node_modules
    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-node@v1

      # Once again we need to download the cached dependencies.
    - uses: actions/cache@v2
      with:
        path: node_modules
        key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }}
        restore-keys: |
          ${{ runner.os }}-yarn-v1-

      # But we should also try to install the dependencies in case the cache
      # hasn't been properly warmed for some reason.
    - 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@v2
    - uses: actions/setup-node@v1
    - uses: actions/cache@v2
      with:
        path: node_modules
        key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }}
        restore-keys: |
          ${{ runner.os }}-yarn-v1-
    - 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@v2
     - uses: ruby/setup-ruby@v1
       with:
         bundler-cache: true
+    - uses: actions/setup-node@v1
+    - uses: actions/cache@v2
+      with:
+        path: node_modules
+        key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-v1-
+    - run: yarn install --frozen-lockfile
     - run: bundle exec rspec