Validate GitHub Actions cache-mode on low-trust triggers

John Burns

A workflow that restores dependencies from a cache is making a trust decision, not only a performance decision. GitHub Actions caches are shared by scope, and a later job restores their contents as files on its runner. A cache that a low-trust workflow can write can become an input to a trusted workflow, which is the boundary that makes cache poisoning relevant.

GitHub added cache-mode on 10 September 2026. The setting can apply at the workflow level or to one job. It has four values: read, write, write-only, and none. The useful part is that the cache service enforces the selected capability rather than expecting every cache step to make the right choice. The dangerous part is just as explicit: write and write-only can override the read-only default GitHub applies to low-trust events.

This guide adds a small repository-side check for that one mistake. It accepts a pull_request_target workflow with cache-mode: read, accepts a separate trusted push workflow that uses write-only, and rejects a low-trust workflow that explicitly asks for write. The check is intentionally narrow. It is not a general YAML parser, does not determine whether a workflow checks out untrusted code, and does not replace GitHub’s service-side enforcement. It makes an exception visible in review before the workflow reaches GitHub.

The validation run used Node.js v22.23.2 and actionlint v1.7.12 on Linux. The actionlint archive SHA-256 matched its release checksum manifest before extraction. Actionlint rejected all three fixtures because this release does not yet recognize the new cache-mode key. The small Node check accepted the two safe fixtures with exit status zero and rejected the unsafe pull_request_target fixture with exit status one. That distinction matters during the adoption window: a workflow linter can correctly report an unknown new key while still leaving a repository without a policy check for the risky value.

Start with the cache boundary

cache-mode describes access to the Actions cache service, not general GitHub token permissions. Keep the two controls separate. A job with contents: read can still need a cache decision, and a job with no useful cache access can still have permissions that deserve review.

GitHub documents read as restore-only, write as restore-and-save, write-only as save-only, and none as no cache access. A workflow-level setting is the default for its jobs. A job-level setting overrides it for that job, so review both levels before deciding what a workflow can do.

For a pull-request workflow that only needs restored dependencies, make the read-only choice visible:

name: PR checks
on:
  pull_request_target:
cache-mode: read

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

The trigger in this example is deliberately narrow. pull_request_target runs in a trusted repository context and needs special care, particularly when a job checks out or executes pull-request-controlled content. Prefer pull_request for ordinary tests of contributor code. If a repository has a legitimate metadata-only pull_request_target job, cache-mode: read prevents that job from saving a cache while retaining restores that the workflow actually needs.

Do not infer safety from a missing cache-mode key. GitHub retains trigger-dependent defaults for workflows that omit it. An explicit read is useful when the repository wants the review intent to survive later trigger changes. Conversely, do not add write merely to make a cache miss faster. A job that cannot work without saving a cache is not using the cache as an optimization.

Separate cache producers from pull-request checks

A cache producer usually belongs on a trusted event, such as a push to the default branch. If it only prepares an entry for later jobs, it does not need to restore one first. write-only makes that one-way purpose clear:

name: Main cache producer
on:
  push:
    branches: [main]
cache-mode: write-only

jobs:
  warm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: npm ci

Whether a dedicated warm-up job is worth maintaining depends on the package manager and workflow layout. Most repositories can use a normal trusted build with write. The important boundary is not the name of the workflow; it is whether the event and its checked-out content are trusted to create files that another job may restore.

A job-level exception deserves more scrutiny than a workflow default because it is easy to miss between runs-on, permissions, and steps. This is the unsafe shape the check will reject:

name: PR checks
on:
  pull_request_target:

jobs:
  test:
    runs-on: ubuntu-latest
    cache-mode: write
    steps:
      - uses: actions/checkout@v6
      - run: npm test

The explicit write overrides the low-trust read-only cache default. GitHub adds a warning annotation for this case, but a review gate can stop it earlier and give the repository a policy it can test without waiting for a hosted run. Do not convert the rule into a blanket ban on pull_request_target; instead, keep privileged metadata work separate from untrusted code and require an intentional, documented exception when one is unavoidable.

Add a narrow pre-merge check

Save the following as scripts/check-cache-mode.mjs. It reads workflow files passed on the command line. It flags only a direct pull_request_target: trigger combined with a direct cache-mode: write or cache-mode: write-only declaration. It handles the common block style shown here; repositories using YAML anchors, generated workflows, or unusual quoting should implement the equivalent rule in their existing policy engine instead of extending this text matcher beyond its stated boundary.

import { readFileSync } from 'node:fs';

let failures = 0;
for (const path of process.argv.slice(2)) {
  const text = readFileSync(path, 'utf8');
  const lowTrust = /^\s*pull_request_target\s*:/m.test(text);
  const writes = [...text.matchAll(
    /^\s*cache-mode\s*:\s*(write|write-only)\s*(?:#.*)?$/gm,
  )];
  if (lowTrust && writes.length) {
    console.error(
      `${path}: pull_request_target must not declare write-capable cache-mode`,
    );
    failures++;
  }
}
if (failures) process.exit(1);
console.log('PASS: no write-capable cache mode on pull_request_target');

Run the policy after generating workflows and before a deployment or merge step. Passing only hand-written source files is insufficient when a template, chart, or build script writes the YAML that GitHub will execute.

node scripts/check-cache-mode.mjs .github/workflows/*.yml

The script does not parse cache keys, paths, or actions/cache steps. Those questions remain worth reviewing: caches can contain attacker-controlled files, should not contain credentials, and must not be treated as trustworthy executable input. This gate has one measurable contract: a low-trust trigger cannot gain an explicitly write-capable cache mode unnoticed.

Test both decisions and the linter gap

Keep accepted and rejected fixtures beside the check. In the validation run, the read-only pull-request fixture and the trusted write-only producer each printed the pass message and returned zero. The fixture with pull_request_target and job-level cache-mode: write printed this diagnostic and returned one:

unsafe-pr.yml: pull_request_target must not declare write-capable cache-mode

Run the same fixtures through the workflow linter already used by the repository. The tested actionlint v1.7.12 returned status one for each fixture because it reported cache-mode as an unexpected workflow or job key. That is a compatibility result, not evidence that the safe fixtures are unsafe or that the unsafe fixture is protected. Keep the linter in the workflow, watch for a release that understands the syntax, and remove any temporary exclusion only after retesting both positive and negative fixtures.

Finally, test the hosted behavior in an authorized non-production repository. Confirm a read-mode job can restore but not save, confirm a write-only producer does not restore, and inspect the warning GitHub adds if a low-trust trigger explicitly requests write access. Do not use a production cache or a workflow with deployment credentials as the test target.

cache-mode gives workflow authors a service-enforced cache boundary. A small local gate complements it by making the one high-risk override fail during review, including while local linters are catching up with the new workflow syntax.

Sources