GitHub Actions Cache Not Saving: Fix Read-Only Trigger Warnings
When GitHub Actions says a cache could not be saved on a pull request, first check the event trust boundary. A cache warning can be expected: GitHub introduced read-only cache tokens for some untrusted triggers so a workflow cannot overwrite a cache used by the default branch. The job can still restore an existing cache, while the save step emits a warning and continues.
The reliable fix is to separate the two responsibilities. Let an untrusted validation workflow restore dependencies, and let a trusted workflow on a controlled branch create or refresh the cache. Do not “fix” the warning by giving untrusted code broader secrets or write permissions.
GitHub made the cache-mode workflow key generally available on GitHub.com on September 10, 2026. Check it before changing an event or adding a save step: an explicit write or write-only can override the low-trust read-only default and restore the cache-poisoning risk. A job-level value overrides the workflow-level value. GitHub’s announcement and workflow syntax document the setting.
Read the warning as a trust-boundary signal
GitHub’s June 26, 2026 changelog says cache tokens can be read-only for untrusted events when the cache scope involves the default branch. The changelog calls out scenarios such as pull_request_target, issue_comment, and fork pull-request workflow_run cascades. It also notes that push, schedule, workflow_dispatch, and several repository-controlled events retain read-write behavior under the documented conditions.
That means “cache not saving” does not always indicate a broken key, a bad path, or an invalid action version. Use the observed event, cache scope, and workflow setting to choose the next step:
| What you find | What it means | Safer next step |
|---|---|---|
A low-trust event writes to the default-branch scope and no cache-mode is set | The default access is read-only: restores work, but saves are denied. | Keep the restriction and warm the shared cache from a trusted push. |
A normal pull_request run uses its merge-ref scope | The default low-trust restriction does not apply to this cache scope. | Set cache-mode: read if this job must never save a cache. |
A low-trust job sets cache-mode: write or write-only | The explicit mode overrides the read-only default; write-only also prevents restores. | Remove the override for untrusted code and use a trusted writer instead of suppressing the warning. |
| A workflow and job set different modes, or a job calls a reusable workflow | The job value overrides the workflow value; a caller’s explicit mode can cap the called workflow. | Inspect both levels and set cache-mode: read on the caller when that is the intended limit. |
| The log reports a denied save, but the job succeeds | A read-only save warning is not a cache miss or a failed job; restores remain available. | Check cache scope and key separately before changing the workflow. |
The dependency caching documentation also warns that cache storage can enter a read-only state when usage limits are reached. If the log does not match the event and mode cases above, check repository cache usage and the key before assuming the untrusted-trigger policy is responsible.
Use restore-only behavior for pull requests
The validation workflow should consume a cache without trying to publish one. Set cache-mode: read when the workflow or job must be limited to restores, then use the dedicated restore action to make that intent visible. A normal pull_request run is not affected by the default low-trust restriction because its cache is scoped to the pull request merge ref; an explicit read mode still prevents that job from saving. If a job calls a reusable workflow, set the mode on the calling job to cap the cache access the called workflow can request.
name: Validate
on: pull_request:
cache-mode: read
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5
- name: Restore dependencies uses: actions/cache/restore@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} restore-keys: | npm-${{ runner.os }}-
- run: npm ci - run: npm testThis workflow still works when the cache is cold; npm ci downloads dependencies normally. A restore miss is a performance issue, not a reason to grant the pull request permission to write a shared cache.
If your project uses pnpm, replace the path and lockfile expression with the values from your project. The cache boundary is independent of the package manager. The pnpm Git dependency CI guide covers a different pnpm failure path—private Git dependencies—but the same principle applies: keep credentials and write access out of untrusted validation jobs.
Save caches from a trusted workflow
Create or refresh the cache from a push to a protected branch, or from another event your repository controls. A separate workflow can use the matching key:
name: Warm dependency cache
on: push: branches: [main]
jobs: cache: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5
- name: Install dependencies run: npm ci
- name: Save dependencies uses: actions/cache/save@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}The key must describe the same dependency state that the validation workflow wants to restore. Include the operating system and lockfile hash when they affect the installed files. Do not assume that every branch can see every cache: GitHub applies branch and event scope rules. Inspect the restore log on a representative pull request and a default-branch push.
A useful refinement is to keep the pull request workflow read-only even when it runs against a branch in the same repository. This prevents a later workflow change, permission expansion, or unusual event path from turning a test job into a cache publisher.
Do not use pull_request_target as a cache shortcut
pull_request_target runs in the context of the base repository, which makes it tempting to use for write access. That choice is dangerous when the job checks out or executes code from the pull request. GitHub’s secure use guidance for pull_request_target warns against running untrusted code with the elevated context; its example also describes cache-save failure as a warning that does not fail the job.
Keep the concerns separate:
- Use
pull_requestfor ordinary untrusted validation. - Restore only in that validation path.
- Use a trusted
pushor controlled maintenance workflow to save caches. - Never add secrets or broad write permissions just to suppress a cache warning.
If the warning appeared after a GitHub platform change, record the event name and cache action log in the issue. That evidence distinguishes an intentional read-only token from a malformed path, an exhausted cache budget, or a key that can never match.
Quick diagnosis checklist
- Identify the exact event that started the run.
- Check whether the pull request comes from a fork or other untrusted source.
- Read the cache step’s warning instead of treating every miss as a save failure.
- Confirm the cache path exists after dependency installation.
- Confirm the key includes the expected lockfile and runner dimensions.
- Keep restore in the pull request workflow and move save to a trusted push.
- Re-run one pull request and one default-branch push, then compare both logs.
FAQ
Q: Does a read-only cache warning fail the workflow?
A: GitHub documents the restricted save as a warning while cache restore remains available. A separate failing step, such as dependency installation, can still fail the job.
Q: Can a fork pull request save a shared Actions cache?
A: Do not design the workflow around that. Untrusted trigger paths can receive read-only cache tokens. Restore in the pull request job and save from a trusted branch instead.
Q: Should I add actions/cache/save after every test job?
A: No. Add saving only where the event and cache scope are trusted. A dedicated default-branch workflow is easier to audit and keeps validation jobs read-only.
Q: What if the warning is actually a cache quota problem?
A: Check the cache usage and the action log. GitHub also documents read-only behavior when cache limits are reached, so not every warning is caused by an untrusted trigger.
References: