A GitHub action that saves and restores the caches of Scala builds: the coursier cache, and, depending on the build tool your repository uses, the sbt or mill caches.
It looks at your build files to decide which caches to handle, and derives the cache keys from their content, so that the caches are updated when your build (and so, likely, its dependencies) changes, and only then.
Add a coursier/cache-action@v9 step to your workflow, after checking out your repository and
before running your build:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: coursier/cache-action@v9
- uses: coursier/setup-action@v3 # installs a JVM and sbt, if your runner doesn't have them
with:
apps: sbt
- run: sbt testThe caches are restored when the step runs, and saved at the end of the job, if the job succeeds.
Placing the step before the ones that install tools with coursier (like
coursier/setup-action) lets them benefit from the
cache too.
This action only handles caches: it doesn't install a JVM, sbt or mill itself.
No configuration is needed in most cases. The inputs let you
- point the action to a build in a sub-directory (
root), - take more files or values into account in the cache keys (
extraFiles,extraKey, …), - restrict when caches are restored or saved (
cacheMode,disableFallback).
| Cache | Directories | Cached when | Output |
|---|---|---|---|
| Coursier | ~/.cache/coursier/v1 (Linux), ~/Library/Caches/Coursier/v1 (macOS), ~\AppData\Local\Coursier\Cache\v1 (Windows), or COURSIER_CACHE / path if set |
Always | cache-hit-coursier |
| sbt | ~/.sbt, ~/.ivy2/cache |
sbt build files are found | cache-hit-sbt-ivy2-cache |
| mill | ~/.cache/mill |
mill files are found | cache-hit-mill |
Build files are looked for in the root directory, which is the root of the repository
by default.
Always cached. Only the cache itself is (v1, in the
default OS-dependent location, or
wherever the COURSIER_CACHE environment variable or the path input point), not the
directories next to it, like the archive cache (arc, whose archives get extracted again from the
ones in v1) or the privileged archive cache (priv, that has files only root can read).
Its key takes into account the build files of all the caches below (sbt and mill ones), as well
as extraFiles.
Cached when any of these sbt files are found:
*.sbtproject/**.sbtproject/**.scalaproject/build.properties
or when files matching extraSbtFiles are found.
Files that sbt and Ivy re-generate on each run (~/.ivy2/cache/**/ivydata-*.properties, and
~/.sbt/**/*.lock) are removed before saving the cache.
Cached when any of these mill files are found:
build.mill,build.mill.yaml,build.mill.scalamill-build/build.mill,mill-build/build.mill.yaml.mill-*(like.mill-version).config/mill-*(like.config/mill-version)mill,mill.bat,millw(mill launcher scripts)
or when files matching extraMillFiles are found.
Its key also takes into account the mill build definition files: *.mill, *.mill.yaml,
*.mill.scala, **/package.mill, **/package.mill.yaml, **/package.mill.scala, and *.sc
(for older mill versions).
Each cache has its own key, made of the following parts, separated by -:
- the cache name (
coursier,sbt-ivy2-cacheormill), followed, for all caches but the coursier one, by the OS and CPU architecture of the runner (likelinux-x64ordarwin-arm64), - the job id (see
jobandignoreJob), matrix-followed by a hash of the matrix values, if the job uses a matrix (seematrixandignoreMatrix),extraKey, if set,- the cache-specific extra key (
extraSbtKey, …), if set, - a hash of the build files listed above, of
extraFiles, and ofextraHashedContent.
Parts that don't apply are left out. For example, the sbt cache of a test job with a matrix
gets a key like
sbt-ivy2-cache-linux-x64-test-matrix-<matrix hash>-<build files hash>
When the job starts:
- if a cache with that exact key exists, it is restored, the
cache-hit-…output istrue, and the cache isn't saved again at the end of the job, as it is up-to-date already, - else, the action falls back on the most recent cache whose key shares the longest prefix with
that key, dropping the parts above from the end one by one (
sbt-ivy2-cache-linux-x64-test-matrix-<matrix hash>-, thensbt-ivy2-cache-linux-x64-test-, thensbt-ivy2-cache-linux-x64-). Thecache-hit-…output isfalse, and, as new dependencies may get downloaded during the job, the cache is saved under the new key at the end of the job. SetdisableFallbacktotrueto start from an empty cache instead.
Build files are hashed with LF line endings, so that checking them out with CRLF ones
(core.autocrlf, on Windows) doesn't change the keys.
Caches are only saved if the job succeeds, and only if the cache mode allows it.
The action logs the key and the fallback keys of each cache, and which one was restored, if any, in the output of its step. If a cache isn't restored the way you expect, comparing these between runs usually shows which part of the key changed.
All inputs are optional.
| Input | Default | Description |
|---|---|---|
root |
. |
Directory containing the build definition |
path |
Path of the coursier cache, if not the default one | |
extraFiles |
Extra files to hash in all cache keys | |
extraSbtFiles |
Extra sbt build files | |
extraMillFiles |
Extra mill build files | |
extraKey |
Extra value added to all cache keys | |
extraCoursierKey, extraSbtKey, extraMillKey |
Extra value added to a single cache key | |
extraHashedContent |
Extra content to hash in all cache keys | |
extraCoursierHashedContent, extraSbtHashedContent, extraMillHashedContent |
Extra content to hash in a single cache key | |
read-cs-cache-files |
true |
Read extra files from .github/cs-cache-files |
job |
${{ github.job }} |
Job name, added to the cache keys |
matrix |
${{ toJson(matrix) }} |
Matrix values, hashed in the cache keys |
ignoreJob |
false |
Leave the job name out of the cache keys |
ignoreMatrix |
false |
Leave the matrix values out of the cache keys |
disableFallback |
false |
Only restore caches whose key matches exactly |
cacheMode |
Restrict cache access for this step (read, write, write-only, none) |
Directory containing the build definition sources (build.sbt, build.mill, etc.), relative to
the repository root. Defaults to the repository root.
If the sbt or mill build definition files are in a sub-directory, pass the path to this sub-directory here:
- uses: coursier/cache-action@v9
with:
root: backendPath of the coursier cache.
By default, the coursier cache is assumed to be at the location the COURSIER_CACHE environment
variable points to, if it is set when the action runs, else in the
default OS-dependent location.
Set this input to override that. The action then also sets the COURSIER_CACHE environment
variable for the subsequent steps, so that you don't have to set it yourself.
Extra files to take into account in the keys of all caches.
Pass either
- a single path or glob as a string,
- multiple paths or globs in a JSON array, encoded in a string.
Globs are processed by @actions/glob. Paths are
relative to the repository root, not to root.
- uses: coursier/cache-action@v9
with:
extraFiles: '["dependencies.json", "scripts/*.sh"]'Files can also be listed, one path or glob per line, in a .github/cs-cache-files file in your
repository. If that file exists, its entries are added to extraFiles, unless
read-cs-cache-files is false.
Extra sbt build files, same format as extraFiles. They are taken into account in
the sbt and coursier cache keys, and finding any of them enables the sbt cache.
Extra mill build files, same format as extraFiles. They are taken into account in
the mill and coursier cache keys, and finding any of them enables the mill cache.
Extra value added as is to the keys of all caches. See How cache keys work.
Use it to have separate caches for jobs that would get the same keys otherwise. For values that
don't fit in a cache key, or that you'd rather not show in the logs, use
extraHashedContent.
Same as extraKey, but only added to the key of the coursier, sbt or mill cache.
Extra content to take into account in the keys of all caches. It is hashed along with the build files, so changing it gives new caches, initialized from the previous ones (see How cache keys work).
Same as extraHashedContent, but only taken into account in the key of the
coursier cache (extraCoursierHashedContent), or in the key of the sbt or mill cache and in the
key of the coursier cache (extraSbtHashedContent, extraMillHashedContent).
Default: true
Set to false to ignore the .github/cs-cache-files file (see extraFiles).
Job name, added to the cache keys, so that each job has its own caches. Defaults to the id of the
current job (${{ github.job }}). See also ignoreJob.
Matrix values, hashed and added to the cache keys, so that each instance of a matrix job
(one per Scala version, JDK, OS, …) has its own caches, and those caches don't collide.
Defaults to the matrix values of the current job (${{ toJson(matrix) }}), so that you don't
need to set it in most cases. See also ignoreMatrix.
Default: false
Set to true to leave the job name out of the cache keys, so that different jobs share their
caches.
Default: false
Set to true to leave the matrix values out of the cache keys, so that the instances of a matrix
job share their caches.
Default: false
Set to true to only restore caches whose key matches exactly. By default, when there's no exact
match, the action restores the closest cache, which can come from another job or matrix instance
(see How cache keys work). Disabling the fallback prevents sharing caches
across jobs or matrix instances with different dependencies, at the cost of starting from an empty
cache whenever the build files change.
Default: empty (use the workflow / job cache-mode as is)
Cache access for this step: one of read, write, write-only, or none. This can only narrow
the access granted by the workflow or job cache-mode, never widen it.
For example, pass read to restore caches without saving them.
| Output | Description |
|---|---|
cache-hit-coursier |
true if the coursier cache was restored from an exact key match |
cache-hit-sbt-ivy2-cache |
true if the sbt cache was restored from an exact key match |
cache-hit-mill |
true if the mill cache was restored from an exact key match |
Each output is false when no cache or only a fallback one was restored,
and empty when the corresponding cache isn't handled (no sbt files for cache-hit-sbt-ivy2-cache,
for example).
Using the cache-hit-… outputs, subsequent steps can be skipped when a cache was restored from an
exact key match. Give the action step an id, and refer to it in the if condition of the steps
to skip:
steps:
- uses: actions/checkout@v7
- uses: coursier/cache-action@v9
id: coursier-cache
- name: Fetch dependencies
if: steps.coursier-cache.outputs.cache-hit-coursier != 'true'
run: sbt +updateGitHub Actions lets workflows restrict their access to the cache with the
cache-mode key,
set at the workflow level, at the job level, or both (a job-level value overrides the workflow-level one).
This action honors it, like actions/cache does:
cache-mode |
Caches restored | Caches saved |
|---|---|---|
write |
Yes | Yes |
read |
Yes | No |
write-only |
No | Yes |
none |
No | No |
When cache-mode isn't set, GitHub picks write for trusted triggers (push, schedule, workflow_dispatch, …)
and read for low-trust ones (pull_request_target, issue_comment, workflow_run, …).
When the mode doesn't permit an operation, the action logs a message and moves on, and the job doesn't fail.
A skipped restore counts as a cache miss, so the cache-hit-… outputs are false. A skipped save just doesn't
happen.
For example, to have a job restore caches without ever saving them:
jobs:
test:
runs-on: ubuntu-latest
cache-mode: read
steps:
- uses: actions/checkout@v7
- uses: coursier/cache-action@v9
- run: sbt testTo restrict only this action, and leave other caching steps of the job unaffected, use the
cacheMode input instead:
steps:
- uses: actions/checkout@v7
- uses: coursier/cache-action@v9
with:
cacheMode: read # restore the coursier / sbt / mill caches, but never save themcacheMode can only narrow what the workflow or job grants. Combining cacheMode: read with a job
cache-mode: write-only, for example, gives none. The input also works on runners that don't support
the cache-mode key, such as older GitHub Enterprise Server releases.
Caches saved by pull_request runs are only visible to that pull request, but they still count towards the
repository cache quota, and can get caches from main evicted. To have pull requests reuse the caches of main
without saving their own, while main keeps updating them, compute cacheMode from the branch:
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: coursier/cache-action@v9
with:
cacheMode: ${{ github.ref == 'refs/heads/main' && 'write' || 'read' }}
- run: sbt testOn main, the caches are restored, then saved if their key changed. In pull requests (github.ref is
refs/pull/<number>/merge there), the caches of main are restored, but nothing is saved. Pull requests whose build
files differ from main fall back on the closest cache from main, as described in
How cache keys work.
GitHub doesn't document ${{ … }} expressions as supported in the cache-mode key, so getting the same behavior with
it means separate workflows, such as one triggered on push to main (where the default is write) and another one
triggered on pull_request that sets cache-mode: read at the top level.
As a cache restored from a fallback key is saved again under the new key with everything it already had, caches only grow over time, and keep dependencies your build doesn't use anymore. Invalidating them from time to time gets rid of those.
To force the caches to be saved again without changing your build files, set the
COURSIER_CACHE_ACTION_CACHE_VERSION environment variable in your workflow. Changing its value
changes the keys of all caches, so that they're saved under new keys at the end of the job.
Like for any other key change, the previous caches are still restored as a
fallback when the job starts. To start from empty caches, also set
disableFallback to true for one run.
The variable can be set at the workflow level so that it applies to every step:
env:
COURSIER_CACHE_ACTION_CACHE_VERSION: 1 # increment this value to invalidate the caches
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: coursier/cache-action@v9Or it can be scoped to only the coursier/cache-action step:
steps:
- uses: actions/checkout@v7
- uses: coursier/cache-action@v9
env:
COURSIER_CACHE_ACTION_CACHE_VERSION: 1 # increment this value to invalidate the cachesThis is equivalent to passing the version as extraHashedContent, but more
convenient, as it can be set for the whole workflow (or even as a repository variable or secret)
without touching the individual steps.