Skip to main content
ISSUE-705HighQuickCLIThird-party actions

Release workflow restores a cache with an unscoped key

Control: Release workflows must not restore an untrusted cache· Config key: releaseWorkflowsMustNotRestoreUntrustedCache

📋 What is this?

A release or publish job restores a build cache whose key is not scoped to the release ref. GitHub Actions caches are shared across branches with a permissive prefix fallback, so a run on any feature branch — including an attacker's PR — can populate the same key (or a restore-keys prefix) that the release job later restores.

⚠️ Impact

Whoever can open a PR can prime the cache the trusted release build reuses, injecting compromised artefacts into the published package while nothing changes in the release commit. This is the May 2026 TanStack vector. Pinning the cache action by SHA does not help — the poisoned bytes live in the cache, not the action.

🔧 How to fix

Weave github.ref_name / github.sha into the cache key AND every restore-keys fallback, or disable caching on publish paths (e.g. cache: false on a setup-* action). The action/script inventory and a per-job allowlist are configurable in .plumber.yaml.

✗ BeforeA PR run populates `deps-<hash>`; the release job restores it and publishes the result.
# .github/workflows/release.yml — ❌ Unscoped key on a release job
on: [release]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v4
with:
path: ~/.npm
key: deps-${{ hashFiles('**/package-lock.json') }} # shared with every branch
- uses: JS-DevTools/npm-publish@v3
✓ AfterThe key includes `github.ref_name`, so a PR cache can't win the lookup.
# .github/workflows/release.yml — ✅ Key woven with the release ref
on: [release]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v4
with:
path: ~/.npm
key: release-${{ github.ref_name }}-${{ hashFiles('**/package-lock.json') }}
- uses: JS-DevTools/npm-publish@v3
# .plumber.yaml — the inventories and the whitelist are configurable
github:
controls:
releaseWorkflowsMustNotRestoreUntrustedCache:
enabled: true
publishActions: # actions that mark a job as "release"
- JS-DevTools/npm-publish
- pypa/gh-action-pypi-publish
publishScriptPatterns: # publish commands in run: scripts
- '(?i)cargo\s+publish'
cacheActions: # which actions restore a cache, and how
- {action: actions/cache, mode: always}
- {action: actions/setup-go, mode: default, disableInput: cache, disableValue: false}
- {action: actions/setup-node, mode: opt-in, enableInput: cache}
allowedJobs: # jobs you have reviewed and accept (glob)
- 'docs/*'

💡 Tips

  • A restore-keys prefix fallback must be release-scoped too — a scoped key with an unscoped restore-keys is still poisonable.
  • Release context = a release trigger, a publish action (publishActions), or a publish command in a script (publishScriptPatterns).
  • cacheActions carries per-action semantics: always, default (off via disableInput/disableValue), or opt-in (on via enableInput). Add your org's cache actions there — nothing is hardcoded.
  • allowedJobs is a glob over the <workflow-file>/<job-id> name — the escape hatch for release jobs you have reviewed and accept.
  • setup-* actions only cache when their cache: input is set; actions/cache always restores, so a bogus cache: false on it does not exempt it.

⚙️ Configuration

This control is configured in .plumber.yaml under the github section:

github:
  controls:
    releaseWorkflowsMustNotRestoreUntrustedCache:
      enabled: true

See the CLI documentation for the full configuration reference.