GitHub Actions Cache Mode Makes CI Caches a Permission

GitHub Actions cache-mode turns dependency cache restore and save rights into explicit workflow permissions, reducing cache poisoning risk in CI pipelines.

GitHub Actions added cache-mode on September 10, 2026. It is a small YAML key with a useful idea behind it: dependency caches should have permissions, not just keys.

That matters because CI caches are not passive performance hints. They are mutable artifacts restored into build environments. If a workflow can restore a cache, it can consume files that influence compilation, testing, packaging, linting, and sometimes deployment. If a workflow can save a cache, it can create content that later workflows may trust. The new control gives engineers a direct way to separate those rights.

What Happened

GitHub announced that cache-mode is generally available on all GitHub plans. It can be set at the workflow level or on individual jobs. It controls whether a workflow run can restore caches, save caches, both, or neither.

There are four modes. read allows cache restores but blocks cache saves. write allows both restore and save. write-only allows saving new cache entries but blocks restores. none disables cache access entirely.

The defaults are intentionally asymmetric. Trusted triggers such as push, workflow_dispatch, repository_dispatch, delete, registry_package, page_build, and schedule default to write-capable cache access. Low-trust triggers that can be influenced by people without repository write access, such as pull_request_target, issue_comment, and workflow_run, default to read-only cache access when they resolve to the default branch. The normal pull_request event is handled differently because caches created by pull request runs are scoped to the pull request merge ref, not to the default branch's cache scope.

The override behavior is explicit. A job-level cache-mode overrides a workflow-level value. If a workflow manually declares cache-mode: write or cache-mode: write-only on a low-trust trigger, it bypasses GitHub's secure read-only default and GitHub adds a warning annotation. Existing workflows that do not set cache-mode continue to use the previous secure defaults.

The change also applies to reusable workflows. A called workflow cannot receive more cache access than the caller grants. That is important for organizations that centralize build logic in shared workflows, because cache authority now follows the same mental model as other job-level controls: callers can cap what shared automation is allowed to do.

Why It Matters

Caching is usually sold as a speed feature. In practice, it is also a supply-chain boundary.

Most serious CI pipelines cache package manager directories, build tool state, compiler artifacts, framework caches, test runners, browser binaries, and language-specific indexes. Those files are restored into future jobs before commands run. Some are only data. Some affect resolution, code generation, test execution, native compilation, or postinstall behavior. A poisoned cache does not need to look like a secret leak. It can look like a slightly faster install step that quietly changes what the next job executes.

GitHub's dependency caching docs are blunt about the risk: cache contents are not signed or verified, and extracted caches can modify files that are later executed in a workflow run. The docs also warn against storing sensitive data in cache paths because anyone who can open a pull request against a repository may be able to read base-branch caches through a workflow.

Before cache-mode, teams had partial tools for this problem. They could avoid caching in risky workflows. They could use actions/cache/restore instead of the combined restore-and-save behavior. They could write careful cache keys. They could split workflows by trigger. All of that still matters, but it was easy for intent to drift. A shared action, a setup action, or a reusable workflow could start saving caches as an implementation detail.

cache-mode changes the control point from "which steps happen to run" to "what cache authority does this job token have." That is a cleaner abstraction. A job can contain actions that know how to cache, but if the job is read, save operations are skipped. If it is none, both restore and save are unavailable. The policy sits at the workflow boundary where reviewers can see it.

This is especially relevant now that teams are increasing automation around pull requests. Coding agents, dependency update bots, static analysis systems, and issue-driven workflows all generate changes that land in CI. The more autonomous the contributor, the more disciplined the build boundary needs to be. You do not want a workflow triggered by untrusted input writing state that a later privileged workflow restores.

The point is not that caches are uniquely dangerous. The point is that caches are a form of cross-run state, and cross-run state needs access control.

How It Works

cache-mode is a workflow syntax key. You can set it near other top-level workflow controls, or set it per job:

cache-mode: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

At the job level, the accepted values are the same: read, write, write-only, and none. A job-level setting wins over the workflow-level setting.

Under the hood, GitHub describes the feature as scoped cache-token enforcement. The effective mode is exposed to the runner as ACTIONS_CACHE_MODE. The official actions/cache action and the @actions/cache toolkit honor that mode. If the mode does not allow reads, restore is skipped and treated as a cache miss. If the mode does not allow writes, save is skipped. Those skips log informational messages and do not fail the step or the run.

That last detail is useful for adoption. You can make jobs read-only without breaking every action that tries to save a cache at the end of a run. The job may get slower if it cannot write a warmed cache, but the security posture improves without requiring every action author to change code.

The mode also composes with existing cache scoping rules. Cache entries are scoped by branch, tag, or pull request merge ref. A workflow run can restore caches from its current branch and usually from the default branch. Pull request runs can also restore caches from the base branch. A cache created by a pull_request run is stored under the merge ref and is not restored by the base branch or other pull requests. That is why the new docs call out pull_request_target, issue_comment, and workflow_run more sharply than ordinary pull_request runs: those events can execute in a more privileged repository context.

Reusable workflows deserve special attention. If a caller explicitly grants cache-mode: read, a called workflow cannot declare write and escalate. GitHub will reject a called workflow that asks for more cache access than the caller granted. Because read and write-only grant non-overlapping capabilities, GitHub treats a mismatch as an over-request as well. That is the right behavior. A reusable workflow should not be able to turn a caller's restore-only build into a cache writer by hiding the save operation in shared YAML.

Example

The most practical pattern is to split cache warming from cache consumption. Let trusted events maintain caches. Let routine verification jobs consume caches. Avoid letting low-trust or ambiguous workflows write cache entries that future jobs may restore.

Here is a simplified Node.js workflow:

name: ci

on:
  pull_request:
  push:
    branches:
      - main

permissions:
  contents: read

cache-mode: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: "24"
      - 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 test

  warm-cache:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    cache-mode: write
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: "24"
      - uses: actions/cache/restore@v4
        id: npm-cache
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
      - run: npm ci
      - uses: actions/cache/save@v4
        if: steps.npm-cache.outputs.cache-hit != 'true'
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

The workflow-level default is read, so the test job can restore caches but cannot write them. The warm-cache job opts into write, but only runs on trusted pushes to main. That keeps the write path narrow and reviewable.

In a larger organization, the same idea applies to reusable workflows:

jobs:
  shared-tests:
    uses: octo-org/.github/.github/workflows/node-tests.yml@main
    cache-mode: read
    permissions:
      contents: read

The caller is saying, "Run the shared test workflow, but do not let it save caches." If the shared workflow later adds a cache save step, the caller's cap still holds.

There are cases where write-only is useful. A cache-primer job may compute expensive artifacts from trusted source and publish them for later jobs without ever consuming older cache state. That reduces the chance that stale or attacker-influenced cache content affects the cache-building job itself. none is useful for release, signing, publishing, and deployment jobs where determinism matters more than speed.

My Take

This is a good release because it names a boundary engineers already cared about but could not express cleanly.

CI systems are full of implied trust. We review permissions, environment protections, branch rules, secret availability, package publish credentials, artifact uploads, and deployment gates. Caches often slip through because they feel operational rather than security-sensitive. But a cache is an artifact. It crosses time. It crosses jobs. It often sits close to executable code. That makes it part of the build's trust model.

The most important design choice is that GitHub did not make cache-mode an option on actions/cache only. It is a workflow and job permission enforced by the cache service. That is the right layer. Teams can now audit workflows for cache authority the same way they audit token permissions.

I would not treat this as a reason to loosen other controls. Good cache keys still matter. Broad restore-keys still deserve scrutiny because they intentionally accept partial matches. Cache paths still need to exclude secrets, tokens, generated credentials, and mutable tool configuration you would not want restored from another run. Low-trust workflows should still avoid checking out and executing untrusted code in privileged contexts. cache-mode is a guardrail, not a waiver.

The biggest win is for platform teams that maintain shared GitHub Actions infrastructure. They can set organizational templates to cache-mode: read, create separate trusted cache-warming workflows, and require explicit review when a job needs write access. For reusable workflows, callers can cap cache rights instead of trusting every future change to the shared workflow.

There is also an agent angle here. As coding agents move from local edits to repository-level automation, CI becomes the containment boundary for machine-generated code. It is perfectly reasonable for an agent-authored pull request to get fast feedback from restored dependency caches. It is much less reasonable for that same path to create cache state that a privileged release or deployment workflow later consumes. cache-mode gives teams a precise knob for that distinction.

Conclusion

cache-mode is not flashy, but it is the kind of CI feature that changes how careful teams design pipelines. It turns cache access into an explicit permission with readable values, sensible defaults, service-side enforcement, reusable workflow propagation, and warnings when teams override low-trust protections.

The immediate action is simple: review workflows that use actions/cache, setup-action caching, or shared reusable workflows. Mark normal verification jobs as read, isolate cache writers behind trusted triggers, use write-only for cache-primer jobs that should not consume prior cache state, and set none on release jobs that should not depend on cross-run filesystem state. Your builds may not get faster, but they will become easier to reason about.

Sources

Enjoying this article?

Subscribe to get new NestJS, Node.js, and backend engineering posts directly in your inbox.

Subscribe for free