Back to blog

GitHub Actions Cache Is Read-Only on Untrusted Runs: What to Change

GitHub Actions can now block cache writes from untrusted workflow contexts. Learn why the change exists, which jobs are affected, and how to split restore and save safely.

A locked cache vault sends data to a pull request build while a blocked arrow stops before entering it

If a GitHub Actions job suddenly says that its cache could not be saved, the first question is not always whether the key is wrong. GitHub has changed cache permissions for some untrusted workflow contexts. Those jobs can still restore an existing cache, but they may no longer be allowed to write a new entry into the default-branch cache.

That behavior is intentional. A cache is shared state, and a workflow that an outside contributor can influence should not be able to replace data later consumed by a trusted build. GitHub's change is easy to work with once you separate the cache read path from the cache write path.

What changed

GitHub now issues a read-only cache token when both of these conditions are true: the event can be triggered by someone without repository write access, and the workflow execution context plus cache scope come from the shared default-branch SHA. The official announcement covers the exact rule and the affected event patterns.Read-only Actions cache for untrusted triggers is the source to check when a workflow does not match the simple examples below.

The common examples are pull_request_target, issue_comment, and a workflow_run chain started by a pull request. A normal pull_request workflow and a release workflow can keep read-write cache access when their cache scope is not the default-branch scope. The event name by itself is not enough to predict the result. The execution context and scope matter too.

Read-only cache is likely when:

1. An untrusted actor can trigger the event.
2. The workflow runs against the shared default-branch cache scope.

Restore still works. Save may be denied with a warning.

Why cache writes need protection

Dependency caches often contain package-manager directories or build outputs. A workflow that executes attacker-controlled code could try to write a poisoned cache under a key that a later trusted workflow restores. If the trusted workflow then executes a binary or script from that cache, the cache becomes a path from an untrusted run to a privileged run.

The change does not make caches useless for pull requests. Restores are still allowed, so a pull request can reuse a cache that was created by a trusted workflow. What changes is who gets to add or replace shared cache data.

A denied save is usually a warning, not a failed job

The cache action reports the denied write in the log and lets the job continue. The important distinction is between a cache miss and a broken build. A miss means the job needs to install or generate the files again. A denied save means the job finished without creating a new shared entry. Neither should be treated as proof that your cache key is invalid.The actions/cache README documents the read-only behavior and the warning semantics in more detail.

Failed to save: ... cache write denied ...

The job can still complete. Treat this as an expected cache miss or
read-only save, depending on whether the restore step found a match.

The practical fix: restore in one workflow, save in another

The cleanest pattern is to let a trusted push workflow create caches and let pull request workflows restore them. The trusted workflow has a stable source of dependencies and can write to the cache. The pull request workflow gets the speed benefit without getting a write path into shared state.

name: Build on push
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/cache@v6
        with:
          path: ~/.npm
          key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-
      - run: npm ci
      - run: npm test

A push to the default branch can populate the key after dependencies change. A later pull request can use the same key for restore. If the pull request has a cache miss, it still installs dependencies normally and does not need to save them from the untrusted context.

Use the restore and save actions when the split is explicit

The combined actions/cache step is convenient because it restores early and saves automatically at the end of a successful job. For a more deliberate design, use actions/cache/restore and actions/cache/save separately. The pull request job can use only restore, while a trusted job can save using the exact primary key returned by the restore step.

- name: Restore dependencies
  id: cache-restore
  uses: actions/cache/restore@v6
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

- run: npm ci

# Put this step only in a trusted workflow.
- name: Save dependencies
  uses: actions/cache/save@v6
  with:
    path: ~/.npm
    key: ${{ steps.cache-restore.outputs.cache-primary-key }}

Do not copy the save step into every workflow just because it is present in an old example. In a read-only context it will be skipped with a warning, and in a workflow that processes untrusted content it is the part you want to remove.

Do not “fix” this with a broader token

This is a service-level cache rule, not a missing contents permission. Adding permissions to the workflow block will not turn an untrusted cache token into a write token. Avoid changing pull_request_target into a more privileged trigger just to make cache saves work. That trades a visible cache warning for a much harder security review.

If the workflow checks out or executes pull request code, keep secrets and write permissions out of that job unless you have a specific, reviewed reason to do otherwise. A cache optimization should not decide the trust model for the whole workflow.

How to diagnose your workflow

  1. Read the cache step log. Look for a cache hit, a cache miss, or a write-denied warning.
  2. Write down the event, the ref or SHA being built, and whether the workflow is reading the default-branch cache scope.
  3. Check whether the job actually needs to save a new cache, or only needs to restore one to speed up the build.
  4. Move cache creation to a trusted push workflow if the job is processing untrusted input.
  5. Run both paths once: confirm the trusted workflow saves, then confirm the untrusted workflow restores without trying to save.

A safer cache is worth a slightly slower first run

A cache miss on a pull request costs a few install minutes. A poisoned cache restored by a trusted deployment workflow can cost credentials, artifacts, or production access. GitHub's read-only behavior puts that trade-off in the safer direction while keeping existing caches useful.

The design to keep is straightforward: restore wherever the build needs speed, save only from a trusted source, and treat a denied save as a signal to inspect the workflow rather than a reason to grant it more power. The current GitHub Actions dependency caching reference has the cache-key and scope details needed to finish the migration. See the dependency caching reference for the underlying action behavior and examples.