The cache is the part of CI that nobody threat-models until it bites. It is fast, convenient, treated like read-mostly state, and it is one of the few surfaces in a pipeline that an attacker can write to without ever cloning your repository. (You did threat-model the cache, didn’t you?) On 2026-06-26 GitHub published a Changelog entry, “Read-only Actions cache for untrusted triggers”, that reframes cache writes as a permission, and the new default is no.
Cache as a write target
Think about what a cache entry on the default branch actually is. A blob, stored by GitHub, keyed by something your workflow chose, that the next job on the default branch will silently extract before it runs. If an event that anyone on the internet can fire is allowed to write that blob, your protected branch is now executing code shaped by a stranger. Signed-commit policy does not catch this. Required reviews do not catch this. The artefact never appears in your repo. It just shows up in ~/.cache two minutes into the next build.
What flipped on June 26
GitHub frames the change as applying least privilege to the Actions cache. In operational terms: when both conditions hold, the workflow’s cache token is read-only.
- The triggering event is untrusted, defined as an event that someone other than a repository collaborator can fire.
- The workflow’s execution context and cache scope come from the shared default-branch SHA.
GitHub lists the untrusted triggers explicitly: pull_request_target, issue_comment, and fork pull-request workflow_run cascades. These are the same triggers that have been the connective tissue of “pwn request” exploits for years, because they let a fork’s payload run with the base repo’s identity. Read-only is the floor they now sit on.
The list that stays read-write is also explicit: push, schedule, workflow_dispatch, repository_dispatch, delete, registry_package, and page_build. These come from a principal already inside the trust boundary, so they continue to write to the default-branch cache. Non-default-branch scopes such as pull_request and release keep read-write too, because their cache scope is one an attacker cannot use to influence the default branch.
No opt-in. No setting to toggle. The change shows up the next time the trigger fires.
What you have to wire up
The case that breaks for most teams is the comment-triggered or fork-triggered workflow that used to populate the cache as a side effect of running. That side effect is gone. GitHub’s guidance is to move cache saves into a workflow that itself runs on a read-write trigger, typically push, and let the untrusted workflow read what the trusted one wrote.
The minimum split looks roughly like this:
# .github/workflows/cache-warm.yml
on:
push:
branches: [main]
jobs:
warm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<full-40-char-sha>
- uses: actions/cache/save@<full-40-char-sha>
with:
key: deps-$ hashFiles('package-lock.json')
path: node_modules
The PR-target workflow then calls actions/cache/restore@... instead of actions/cache@.... Write on the trusted side of the fence. Read on the untrusted side. Nothing the fork payload does should determine what ends up in next week’s default-branch build.
Where this lands across CI platforms
The cache-as-trust-boundary problem is not unique to GitHub. Other platforms handle it with varying explicitness.
- GitHub Actions is now the only one shipping this split as a platform default. If cache poisoning from fork PRs is on your threat model, this is the closer fit today; the others still expect you to do the work in your config.
-
GitLab CI/CD scopes caches by key and exposes a
policy: pullsetting that turns a job into a read-only consumer. The trust split is yours to wire into protected-branch and merge-request policies. - CircleCI uses content-addressed cache keys. Stopping fork pipelines from writing to default-branch keys is a project configuration step rather than a default.
- Bitbucket Pipelines scopes caches per repository, with no built-in untrusted-trigger isolation. Teams who care usually split untrusted code into a separate pipeline.
- Jenkins treats cache as an operator concern. Whether agents share a volume across fork builds depends on your agent strategy, not a Jenkins primitive.
- Buddy keeps a per-pipeline filesystem cache that does not cross pipelines. One workable shape is to put the cache-warm step in a push-triggered pipeline and have the PR pipeline mount that cache read-only. The concrete reason to reach for this is that the pipeline boundary doubles as the trust boundary; the downside is you are still writing that policy yourself, which is precisely where GitHub Actions has beaten the field.
The residual
Read-only is a floor, not a ceiling. Cache pollution inside trusted triggers is still possible. Keys are still attacker-influenceable through file contents the build itself produces. A workflow that restores a cache it does not validate runs whatever happens to be in the blob. Least privilege on the token is the easy half. Verifying what comes back out is still on you.
Trust the cache only as far as the principal who wrote it.

