What Changed on September 10
GitHub's changelog of September 10, 2026 introduces cache-mode, a workflow key for least-privilege access to the Actions cache. GitHub says it is generally available on github.com for all GitHub plans. You can set it at the top of a workflow, on a single job, or both; the job-level value wins for that job.
| Value | Restore caches | Save caches |
|---|---|---|
read | Yes | No |
write | Yes | Yes |
write-only | No | Yes |
none | No | No |
The workflow syntax reference says access is enforced with scoped cache tokens, so a job cannot restore or save beyond its mode. Workflows that omit the key keep GitHub's existing defaults. The new exposure is a declaration: an explicit write or write-only on a low-trust trigger overrides the read-only default, and GitHub adds a warning annotation when that happens. An agent that edits workflow files can add that one line.
Why Agent Traffic Hits Low-Trust Triggers
The dependency caching reference is blunt about the payload: cache contents are not signed or verified, and an extracted cache may modify files that a later step executes. A run can restore caches from its own branch or the default branch, and a pull request run also from its base branch. A single default-branch entry is therefore shared by many later runs, including privileged ones.
Seven triggers can create or overwrite caches in the default branch's scope: push, workflow_dispatch, repository_dispatch, delete, registry_package, page_build and schedule. Runs from any other event that resolves to the default branch get read-only access there. GitHub names pull_request_target, issue_comment and workflow_run as examples. Comment commands, bot integrations and chained workflows around AI pull requests often run on exactly these events.
Plain pull_request is not affected by that restriction. Its caches are created for the merge ref, refs/pull/.../merge, and can be restored by re-runs of that pull request. The docs do not put it in the trusted or low-trust column, so do not guess its default; record it from a run.
Build the Trigger-by-Trigger Inventory
The effective mode for one job on one event is the job's cache-mode, else the workflow's, else the trigger default: write for the seven triggers above and read for low-trust events on the default branch. Inventory per trigger and job, not per file. A workflow that listens to both push and issue_comment has two different defaults for the same job.
Reusable workflows need their own column. According to the caching reference, when the calling job neither sets nor inherits an explicit cache-mode, the called workflow can explicitly request write even though the caller's low-trust trigger defaults to read. Setting cache-mode: read on the calling job caps it. That makes an unset caller the gap to look for first.
# cache_mode_inventory.py - run from the repository root; needs PyYAML
import pathlib, sys, yaml
TRUSTED = {"push", "workflow_dispatch", "repository_dispatch", "delete",
"registry_package", "page_build", "schedule"}
def events(doc):
on = doc.get("on", doc.get(True)) # PyYAML loads a bare on: key as True
return [on] if isinstance(on, str) else list(on or [])
def default_for(event):
if event in TRUSTED:
return "write (trusted default)"
if event == "pull_request":
return "not classified; record ACTIONS_CACHE_MODE"
if event == "workflow_call":
return "capped by caller"
return "read (low-trust default on default branch)"
rows = []
for path in sorted(pathlib.Path(".github/workflows").glob("*.y*ml")):
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
for event in events(doc):
for job_id, job in (doc.get("jobs") or {}).items():
explicit = job.get("cache-mode", doc.get("cache-mode"))
flag = ""
if explicit in ("write", "write-only") and event not in TRUSTED | {"pull_request", "workflow_call"}:
flag = "EXPLICIT WRITE ON LOW-TRUST TRIGGER"
elif "uses" in job and explicit is None and event not in TRUSTED:
flag = "UNCAPPED REUSABLE CALL"
rows.append((path.name, event, job_id, explicit or default_for(event), flag))
for row in rows:
print(" | ".join(row))
sys.exit(1 if any(row[4] for row in rows) else 0)
The comment on events() is not decoration. PyYAML's documentation shows on and off loading as booleans, so a naive doc["on"] lookup silently finds no triggers and prints an empty, reassuring inventory. The script is a static reading of your files. The runtime truth for a job is the ACTIONS_CACHE_MODE environment variable, which the runner sets to the effective mode and which actions/cache honors. The fixtures below print it.
Fixture 1: A Low-Trust Run Cannot Save
Use a disposable repository with no secrets and commit these files to its default branch. The probe and the control are identical except for the workflow name and trigger; the event name goes into both the marker and the key, so neither file hard-codes its own result.
# .github/workflows/lowtrust-save-probe.yml
name: Low-trust save probe
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
probe:
runs-on: ubuntu-latest
steps:
- run: echo "ACTIONS_CACHE_MODE=$ACTIONS_CACHE_MODE"
- run: mkdir -p probe && echo "${{ github.event_name }}-${{ github.run_id }}" > probe/marker.txt
- uses: actions/cache@v4
with:
path: probe
key: cache-mode-probe-${{ github.event_name }}-v1
# .github/workflows/trusted-save-control.yml
name: Trusted save control
on:
workflow_dispatch:
permissions:
contents: read
jobs:
probe:
runs-on: ubuntu-latest
steps:
- run: echo "ACTIONS_CACHE_MODE=$ACTIONS_CACHE_MODE"
- run: mkdir -p probe && echo "${{ github.event_name }}-${{ github.run_id }}" > probe/marker.txt
- uses: actions/cache@v4
with:
path: probe
key: cache-mode-probe-${{ github.event_name }}-v1
Do not read the probe's status as evidence. GitHub documents that a blocked or skipped save leaves the step and job running, with a log message instead of a failure, so the low-trust run should finish green. Verify from a third, trusted run that itself cannot write:
# .github/workflows/verify-cache-probe.yml
name: Verify cache probe
on:
workflow_dispatch:
cache-mode: read
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- run: echo "ACTIONS_CACHE_MODE=$ACTIONS_CACHE_MODE"
- id: lowtrust
uses: actions/cache/restore@v4
with:
path: probe
key: cache-mode-probe-issue_comment-v1
- id: trusted
uses: actions/cache/restore@v4
with:
path: probe
key: cache-mode-probe-workflow_dispatch-v1
- run: |
echo "low-trust hit=${{ steps.lowtrust.outputs.cache-hit }} trusted hit=${{ steps.trusted.outputs.cache-hit }}"
test "${{ steps.trusted.outputs.cache-hit }}" = "true"
test "${{ steps.lowtrust.outputs.cache-hit }}" != "true"
Dispatch the control, post a comment on an issue in the test repository, wait for both runs to finish, then dispatch the verifier. Keep path: probe identical in every file: GitHub stamps a cache version with the path, so a restore with a different path can miss and look exactly like a blocked save. The trusted hit is your positive control; without it, a broken key or a missing save would pass the test.
Fixture 2: An Over-Requesting Callee Fails
Add a called workflow that asks for more than a low-trust caller should grant, and a caller that caps it:
# .github/workflows/callee-write.yml
name: Callee requesting write
on: workflow_call
cache-mode: write
jobs:
report:
runs-on: ubuntu-latest
steps:
- run: echo "ACTIONS_CACHE_MODE=$ACTIONS_CACHE_MODE"
# .github/workflows/capped-caller.yml
name: Capped caller
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
call:
cache-mode: read
uses: ./.github/workflows/callee-write.yml
The caching reference says a called workflow that declares access beyond the caller's explicit limit stops the run before it starts, with a validation error. For the control, delete the single cache-mode: read line from the caller and comment again. The docs say the unset caller lets the callee request write. The docs do not say whether the callee must declare its mode at workflow or job level, so record which form you tested.
| Case | Trigger | Declared | Expected evidence |
|---|---|---|---|
| Trusted control | workflow_dispatch | Nothing | Log shows write; verifier trusted hit is true |
| Low-trust save | issue_comment | Nothing | Log shows read; job green; verifier low-trust hit is not true |
| Capped call | issue_comment | Caller read, callee write | Run does not start; validation error retained |
| Uncapped control | issue_comment | Callee write | Run starts; callee log shows write |
These are expected results from GitHub's documentation, not results observed for this article. If a row differs, keep the run URL and log and stop before changing production workflows.
Close the Gaps Before Agents Scale Up
For every low-trust row, GitHub's mitigations are specific. Put an explicit cache-mode: read on the job, or none if it does not need the cache. Switch the job to actions/cache/restore, and let a push-triggered workflow maintain the entry. For every UNCAPPED REUSABLE CALL, add cache-mode: read to the calling job. If a low-trust job truly must write, the docs advise limiting that to jobs that process no untrusted input, including code checked out from forks and pull requests, before saving. Treat the resulting cache as untrusted everywhere, and do not restore it into runs with secrets or elevated permissions.
Keep secrets out of cached paths as well: GitHub notes that anyone who can open a pull request can read caches in the base branch. The caching step in the AI-assisted CI/CD guide makes builds faster; this inventory decides which triggers may save that cache.
Make the warning annotation a review blocker when an AI-authored pull request touches .github/workflows/. Rerun the inventory script on that change. Pair it with the reusable workflow identity fixture so a reviewer knows which callee ran and how much cache access it had. The deliverable is the four-row table with run URLs, plus an inventory that exits 0.
Sources checked 2026-09-11
- GitHub Changelog — Control GitHub Actions cache access with cache-mode
- GitHub Docs — Dependency caching reference: low-trust triggers, defaults, reusable workflows, and secure use
- GitHub Docs — Workflow syntax: cache-mode and jobs.<job_id>.cache-mode
- PyYAML Documentation — implicit boolean resolution used by the inventory script