Running RuboCop on GitHub Actions With Cache
Here’s how to set up a RuboCop workflow on GitHub Actions with caching for faster workflow runs.
This workflow has been battle-tested on a rather active Rails monolith where running RuboCop would take 8 minutes without cache. Fortunately, caching drops that time to ~40 seconds.
The workflow assumes you have a Gemfile that includes the rubocop
gem.
Finished product first, followed by a detailed breakdown. This can be copy-pasted, but you might need to adjust it to fit your own needs.
# .github/workflows/rubocop.yml
name: RuboCop
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
push:
branches: # Keep one, delete the other
- master
- main
jobs:
rubocop:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
RUBOCOP_CACHE_ROOT: tmp/rubocop
steps:
- name: Git checkout
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Prepare RuboCop cache
uses: actions/cache@v4
env:
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', 'Gemfile.lock') }}
with:
path: ${{ env.RUBOCOP_CACHE_ROOT }}
key: rubocop-cache-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
restore-keys: |
rubocop-cache-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
- name: Run RuboCop
run: bundle exec rubocop --format github
Workflow breakdown
There are a few things going on here; let’s start from the top.
Concurrency
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Mechanism to limit in-progress workflow runs to one per branch. In practice, if you push a commit to a branch while a workflow is already running for a previous commit, that run will be cancelled.
Workflow triggers
on:
pull_request:
push:
branches:
- master
- main
Defines events that trigger the workflow.
pull_request
will run RuboCop when a PR is opened or updated, for example.
push
will run RuboCop on pushes to the default branch (master/main). This is used to both verify the latest commit passes RuboCop and to update the cache (more on that below).
Job setup
jobs:
rubocop:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
RUBOCOP_CACHE_ROOT: tmp/rubocop
Defines the job and its runner and timeout. Adjust the timeout if necessary, but keep it as low as possible in case a workflow run gets stuck so you don’t get billed for wasted minutes (default timeout is 6 hours, which could cost you a couple $$ if something goes awry and you don’t notice it (speaking from experience)).
This part of the workflow also sets up the RUBOCOP_CACHE_ROOT
environment variable to save RuboCop cache in the tmp/rubocop
folder. This is the folder we’ll be saving in GitHub Actions cache later on.
Repo setup
steps:
- name: Git checkout
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
Clones the repo and installs and caches gems.
RuboCop cache preparation
This is the main dish. The following step uses the cache action.
- name: Prepare RuboCop cache
uses: actions/cache@v4
env:
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', 'Gemfile.lock') }}
with:
path: ${{ env.RUBOCOP_CACHE_ROOT }}
key: rubocop-cache-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
restore-keys: |
rubocop-cache-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
The path we’re caching is defined in the environment variable RUBOCOP_CACHE_ROOT
that was set on job-level. Setting this folder explicitly ensures RuboCop cache is located in the same folder that GH Actions caches.
Cache is saved under the key:
rubocop-cache-
${{ runner.os }}-
${{ env.DEPENDENCIES_HASH }}-
${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }
${{ runner.os }}
is added as “standard” practice to not use a cache if you switch to a runner on a different OS.
${{ env.DEPENDENCIES_HASH }}
is an environment variable defined as:
hashFiles('.ruby-version', '**/.rubocop.yml', 'Gemfile.lock')
hashFiles returns a hash of a given set of files and this particular set aims to follow RuboCop’s cache validity rules. An existing cache won’t be used in a later run if:
- Ruby version changes (assuming there's a
.ruby-version
file), or - any
.rubocop.yml
file changes, or - RuboCop version changes (technically, cache will be invalidated any time Gemfile.lock changes, but this is a pragmatic choice to not overcomplicate the setup).
Finally, ${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }
is added to ensure the default branch has up-to-date cache (its cache key ends with workflow run ID, which changes for each commit). Other branches have a single cache (their cache key ends with default
).
When restoring cache, if there’s no exact key match, we try to restore the most recent cache that matches the prefix
rubocop-cache-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
How does this setup function in practice?
On all branches, if dependencies (runner OS or DEPENDENCIES_HASH
) change in a new commit, cache is not restored and RuboCop has to scan the entire codebase from scratch. Consequently, these runs are the slowest ones. When they finish, their RuboCop cache is saved for later use.
On the default branch, each commit creates a new cache entry as means of “updating” the cache. Cache from a previous workflow run is restored if dependencies match, but it will always be saved under a new key. This is necessary because GHA cache is immutable so when there’s a cache hit you won’t be able to update its contents. If we don’t “update” the cache, it will become less and less useful over time as source files change (RuboCop invalidates a file’s cache if its contents change).
On branches other than the default, first workflow run will try to restore the most recent cache from base/default branch. The cache will be saved under a key ending in default
when the workflow finishes. If dependencies don’t change in a later commit, this will be the single cache entry for that branch.
A branch’s default
cache might not be so useful in a later commit if you change a bunch of files, but this setup works okay for most workflows (for example: first commit changes the most files, and later commits update only some files after a PR review, so most of the cache is still utilized).
Blast-off
Finally, we run RuboCop:
- name: Run RuboCop
run: bundle exec rubocop --format github
GitHub Actions formatter will add nice annotations in the UI if there are any offenses.
Enjoy!
PS: A note for large codebases
At the time of writing, RuboCop by default saves max 20k files in cache. If your codebase is larger than that, you’ll want to update your configuration so it caches everything in the codebase to ensure optimal workflow times.
Here’s how to quickly check the number of files RuboCop will scan:
$ bundle exec rubocop --list-target-files | wc -l
Then, update MaxFilesInCache
in your .rubocop.yml
to a value greater than that.
Update on March 28, 2025
This caching step has been added to GHA workflows created by new Rails apps.