Problem
A GitHub Actions workflow restored an existing cache but did not save a new one. The cache step did not fail the job; it logged that the save was skipped or unavailable under the effective cache access mode.
The key and path looked correct, and the same workflow had previously populated caches.
Diagnosis
GitHub Actions now exposes an effective cache access mode through ACTIONS_CACHE_MODE. I added a temporary diagnostic step:
- name: Show effective cache access
run: echo "Cache mode is $ACTIONS_CACHE_MODE"The affected run reported:
Cache mode is readThe workflow used a low-trust trigger. GitHub's secure default gives those runs read-only cache access, so they may restore an existing cache but may not save or replace one.
Root Cause
The cache operation was behaving as designed. GitHub Actions supports four cache-mode values:
| Mode | Restore | Save |
|---|---|---|
read | Yes | No |
write | Yes | Yes |
write-only | No | Yes |
none | No | No |
Trusted triggers such as a normal push default to write. Low-trust triggers such as pull_request_target, issue_comment, and workflow_run default to read when cache-mode is omitted.
When a mode disallows a cache operation, actions/cache logs the result and continues. A blocked restore becomes a cache miss; a blocked save is skipped. That is why the job stayed green.
Safe Fix
I kept the low-trust workflow read-only and moved cache population to a trusted workflow triggered by a push to the default branch:
name: Build trusted cache
on:
push:
branches: [main]
cache-mode: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- run: npm ciThe low-trust workflow explicitly documents its intended access:
name: Validate external event
on:
pull_request_target:
cache-mode: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/cache/restore@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}This removes the misleading save attempt and preserves the protection against cache poisoning.
Why I Did Not Set write on the Untrusted Trigger
An explicit cache-mode: write or write-only overrides the secure default. That may make the warning disappear, but it can allow untrusted code to place files in a cache later restored by a privileged workflow.
GitHub warns that cache contents are not signed or verified. A poisoned cache can modify files that a later job executes. A low-trust workflow should only receive write access after a security review proves that it does not process attacker-controlled code or data before saving.
For ordinary fork validation, keep cache access read-only and let a trusted push workflow maintain the cache.
Reusable Workflow Check
cache-mode propagates from a caller to a reusable workflow. A called workflow cannot request access beyond an explicit limit from its caller.
For example, this caller caps access at read:
jobs:
test:
cache-mode: read
uses: acme/shared-workflows/.github/workflows/test.yml@v3If the called workflow declares write, the run fails validation before jobs start. The fix is to reduce the called workflow's requested access or move cache writing into a separately trusted workflow. Do not widen the caller simply to satisfy validation.
Verification
After the change, I verified both paths:
- A
pushrun reportedACTIONS_CACHE_MODE=writeand saved a new cache. - The low-trust run reported
ACTIONS_CACHE_MODE=readand restored the trusted cache. - The low-trust workflow no longer attempted a save.
- A changed lock file produced a new key during the next trusted run.
If the mode is correct but the workflow still misses the cache, check the key, path, branch scope, and retention behavior in the broader GitHub Actions cache troubleshooting guide.
Lesson
A green cache step does not mean both restore and save were allowed. Check ACTIONS_CACHE_MODE first. For low-trust triggers, a skipped save is normally a security control—not a cache-key bug.