GitHub is rolling out cache-mode, a setting that applies least privilege to the cache used by Actions workflows. The point is practical: close the door on cross-branch cache poisoning without slowing builds down.
A shared cache is an attack surface
The Actions cache speeds up continuous integration by keeping dependencies and build outputs between runs. Its lookup logic crosses branches: a run first restores an entry from its own branch, then falls back to the default branch, and to the base branch when the trigger is a pull request, including from a forked repository. That wide reach is convenient, but it opens a gap: an untrusted contribution can write a cache entry that a trusted workflow later restores. This is cache poisoning, long handled with carefully prefixed keys and homegrown checks on the most tightly hardened self-hosted servers.
Four modes, two defaults
The setting boils down to one keyword describing what a run may do with the cache. Two defaults apply automatically depending on how trusted the trigger is.
| Mode | Restore | Save | Default for |
|---|---|---|---|
read | yes | no | low-trust events (pull_request_target) |
write | yes | yes | trusted events (push) |
write-only | no | yes | cache pre-warming |
none | no | no | full isolation |
Setting it per workflow or per job
The key can sit on the whole workflow or on a single job, with the job value overriding the workflow value. The recommended pattern is to put the workflow into restore-only mode, then grant write access only to jobs triggered by a trusted branch.
name: CI
on: [push, pull_request]
# Default for every job: restore from cache, never write to it
cache-mode: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
- run: npm ci && npm test
warm-cache:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
# Only trusted pushes to main are allowed to repopulate the cache
cache-mode: write
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
- run: npm ciA job that never needs to write to the cache should never be allowed to.
Inheritance in reusable workflows
The mode is enforced by the cache service, not only by the runner. It carries through reusable workflows: a called workflow can never receive more access than its caller granted. Teams that centralise their integration chain in shared workflows therefore inherit a consistent privilege ceiling, without relying on every consuming repository to stay vigilant.
Declaring write or write-only on a low-trust event reopens exactly the risk the setting is meant to close. GitHub adds a warning annotation in that case; a workflow that specifies nothing keeps the safe defaults.
What to remember
Cache access control is now available on every plan, with no upfront configuration. Well-tuned projects will see nothing change, since the defaults follow the trust logic already in place. The benefit goes to anyone handling sensitive triggers or external contributions: they finally get an explicit dial instead of a hand-rolled workaround. Reviewing your CI workflows to spot jobs that write without needing to takes a few minutes and removes a whole class of attacks.
I have seen more than one pipeline where a pull-request job could write to the same cache as the main branch, purely by configuration inheritance. We patched it by hand, with prefixed keys and a fair amount of hope. Having a declarative mode enforced on the service side is the kind of detail that changes how calm a security review feels. I will set mine to read by default across my repositories this week. — Simon Janvier
Further reading: the official announcement on the GitHub Changelog and the dependency caching documentation.
