GitHub Actions caching now has a permission boundary of its own.
With cache-mode, a workflow can explicitly decide whether a job may restore caches, save caches, do both, or do neither.
The four modes are:
| Mode | Restore cache | Save cache |
|---|---|---|
read |
Yes | No |
write |
Yes | Yes |
write-only |
No | Yes |
none |
No | No |
That gives CI pipelines a cleaner way to apply least privilege.
A test job that only needs cached dependencies no longer needs permission to update the cache. A trusted cache-maintenance job can write. A deployment job can stay outside the cache boundary completely.
Why cache access deserves its own permission
Caches are more than a speed optimization.
A restored cache can contain dependencies, package-manager data, build outputs, or other files that later influence execution.
That creates a trust question:
Who is allowed to put data into a cache that another workflow may later restore?
GitHub specifically calls out cache poisoning as a risk when a lower-trust workflow can write cache data that a more privileged workflow later consumes.
cache-mode lets the workflow state that boundary directly.
read: restore without modifying the cache
Use read when a job should benefit from an existing cache but should not create or update one.
A validation job is a common example.
name: Pull request checks
on:
pull_request:
cache-mode: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Restore npm cache
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- run: npm ci
- run: npm test
The job may restore matching cache entries.
It cannot save new ones.
That is useful when the job exists to verify code rather than maintain shared build state.
write: restore and save
write allows both operations.
This fits trusted jobs that are intentionally responsible for maintaining the cache.
name: Main build
on:
push:
branches:
- main
cache-mode: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Cache npm data
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npm ci
- run: npm test
- run: npm run build
The trust boundary matters here.
A job with write can influence cache contents that later jobs may consume, so the permission should be deliberate rather than accidental.
write-only: produce a cache without consuming one
write-only can be useful for dedicated cache-building jobs.
The job may save cache data, but it cannot restore an existing cache first.
name: Warm dependency cache
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * *"
cache-mode: write-only
jobs:
warm-cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Prepare npm cache
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npm ci
That can be useful when a trusted workflow should build fresh cache state without first consuming previous cache contents.
none: keep the job outside caching
Some jobs have no reason to interact with a cache.
Make that explicit.
jobs:
deploy:
cache-mode: none
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Deploy
run: ./scripts/deploy.sh
none blocks both restore and save operations.
This is a useful default for jobs where caching adds no value.
Set a workflow default, then override specific jobs
cache-mode works at both workflow and job level.
For example:
cache-mode: read
jobs:
test:
runs-on: ubuntu-latest
trusted-cache-builder:
cache-mode: write
runs-on: ubuntu-latest
The workflow defaults to restore-only access.
The cache-maintenance job explicitly receives write permission.
A job-level value overrides the workflow-level value.
That makes the exception visible beside the job that needs it.
Low-trust triggers need extra care
GitHub already restricts cache writing for workflow triggers that can be influenced by people without repository write access.
Examples include:
pull_request_targetissue_commentworkflow_run
When these runs resolve to the default branch, GitHub normally gives them read-only cache access.
An explicit:
cache-mode: write
or:
cache-mode: write-only
can override that protection.
GitHub warns that doing so can reintroduce cache-poisoning risk.
A safer pattern is often:
on:
pull_request_target:
cache-mode: read
while a trusted push workflow maintains the cache.
The normal pull_request event is handled differently because its cache is scoped to the pull request merge ref instead of the default branch's cache scope.
Reusable workflows inherit cache boundaries
Reusable workflows are another place where access can become difficult to see.
A caller can cap the cache access available to a called workflow:
jobs:
shared-tests:
uses: ./.github/workflows/reusable-tests.yml
cache-mode: read
The called workflow cannot request broader cache access than the explicit limit granted by the caller.
If it does, GitHub rejects the workflow before the run starts.
One subtle case matters here:
If the caller does not explicitly set or inherit a cache-mode, a reusable workflow may explicitly request write even when the trigger would otherwise default the caller to read-only access.
If the caller must remain read-only, state that boundary explicitly.
Check the effective mode
GitHub exposes the effective value through:
ACTIONS_CACHE_MODE
You can inspect it during a run:
- name: Show effective cache mode
run: echo "cache mode=$ACTIONS_CACHE_MODE"
This is useful when rolling out new permissions or investigating why a cache operation was skipped.
When the effective mode blocks an operation:
- a blocked restore is treated as a cache miss
- a blocked save is skipped
- the workflow continues instead of failing
A practical permission review
For each job that uses caching, ask:
- Does this job need to restore an existing cache?
- Does it need to create a new cache?
- Can untrusted input influence the job before a cache is saved?
- Will a more privileged workflow later consume that cache?
A reasonable starting point is:
| Job type | Starting mode |
|---|---|
| Validation that only consumes cache | read |
| Trusted cache-maintenance job | write |
| Dedicated trusted cache producer | write-only |
| Job with no caching need | none |
These are starting points rather than universal rules.
Trigger type, cache contents, repository trust, reusable workflows, and downstream consumers still matter.
Cache permissions do not replace cache hygiene
The permission boundary helps, but the cached data still needs careful treatment.
Do not store secrets, credentials, or access tokens in cache paths.
GitHub recommends treating restored cache contents as untrusted input.
Review who can trigger workflows that write caches.
Keep cache scopes and keys narrow enough that unrelated workflows do not accidentally share state.
A cache is useful because another run will trust it enough to reuse it.
That is exactly why write access deserves scrutiny.
The useful change
cache-mode makes cache privileges visible in workflow configuration.
A validation job can restore without writing.
A trusted builder can maintain cache state.
A dedicated producer can save without restoring.
A job that has no caching need can opt out completely.
That makes CI caching easier to review when the same repository contains workflows with very different trust levels.
Top comments (0)