Self-hosted GitHub Actions runners download the same large artifacts from GitHub's cache servers on every run — the Flutter SDK, Cargo registries, CocoaPods specs, npm packages. When you have multiple runners on the same machine, each one downloads independently, wasting minutes of CI time on network IO for content that's already sitting on the local disk.
local-cache is a drop-in replacement for
actions/cache that reads and writes a
shared directory on the runner's local filesystem. The first runner to hit a
cache miss downloads and stores the content locally; every subsequent runner
restores from disk in seconds. No network round-trip, no cloud storage, no
per-runner duplication.
Supported platforms: self-hosted runners on Linux or macOS. A Linux runner
inside WSL2 counts as Linux and is the supported way to use local-cache
on a Windows host. Windows runners themselves are not supported — see
Requirements.
actions/cache stores entries on GitHub's servers. Every restore is a download
over the network, and every save is an upload. For large artifacts — the
Flutter SDK is ~1.8 GB, a Cargo registry can be hundreds of MB — this costs
real time on every run, even when nothing has changed.
Self-hosted runners on the same physical machine make this worse: each runner operates independently, so if you have multiple runners and a warm cloud cache, every cold start pays the full download on every runner.
With local-cache, the artifact lives on the machine's local disk. On the
first cold run, all concurrent runners download independently — there is no
mechanism to make later runners wait for the first to finish. The save step
serializes concurrent writers per-key via
curlewlabs-com/local-mutex,
so two runners cannot corrupt the same entry; the second writer hits a
post-acquire re-check, sees the entry already exists, and exits cleanly. After
that initial population, no runner ever downloads again.
- A self-hosted runner on Linux or macOS. A Linux runner inside WSL2
counts as Linux and is fully supported — it is how to run
local-cacheon a Windows host, and it gets a real POSIX filesystem,rsync, andflock. BSDs that shiplockf(1)should work too, but CI covers Linux and macOS only. rsynconPATH. It is the copy engine for every restore and save. Preinstalled on macOS; packaged asrsyncon every Linux distribution.- A SHA-256 command on
PATH:sha256sum(Linuxcoreutils) orshasum(macOS, Perl core). Everything else the scripts call —sh,find,du,stat,mktemp,ls,mv,rmand the rest — is base POSIX userland, present by default on both platforms. - Everything
local-mutexrequires, since every locked step runs throughcurlewlabs-com/local-mutex:lockf(1)orflock(1)onPATH, and a lock directory shared by all the runners you want to serialize —/tmpby default, automatic on a single machine but not on topologies that give each runner a private/tmp. - A persistent
cache-diron local disk, shared across the runners on the machine and outside any per-workspace_workdirectory.
Windows runners are not supported, and two things block it independently.
The composite steps declare shell: sh, which GitHub Actions offers on Linux
and macOS only. And rsync — the copy engine for every restore and save —
ships with neither Windows nor Git for Windows; it has to be hand-installed
from the MSYS2 repository. Supporting Windows properly would take a native lock
primitive in local-mutex (LockFileEx) and a second copy engine here
(robocopy), plus care around path spellings, atomic directory renames, and
virus scanners holding file handles open. Run the runner inside WSL2 instead.
Cache entries are stored as plain directories under
cache-dir/entries/k-<sha256>/. The directory name is a SHA-256 hash of the
caller's raw key (fixed-length, collision-free), and each entry also stores the
original key in a small metadata file. On restore, rsync -a copies the entry
contents to the target path. A marker file (.local-cache-restore) in the
target records which key was last restored:
- Marker matches the matched entry → restore is skipped entirely (constant-time work). For prefix matches, "matched entry" is the resolved cache key for newly saved entries, and the legacy directory name when restoring an older pre-encoding entry.
- Marker missing or different key → target is cleaned and re-synced from cache
- No marker (v1 upgrade) → treated as stale, cleaned and re-synced
Two-phase restore: The restore step runs in two phases. Phase 1 checks the
marker under a per-target lock — keyed by the restore path, not the cache
key, so runners restoring to their own paths never contend and steady-state
restores stay parallel across the fleet. If the target is already current, the
restore finishes there. Otherwise Phase 1 resolves which entry to serve (the
exact key, or the newest restore-keys prefix match) and hands it to Phase 2,
which takes the per-key lock on that entry's own key — the requested key
for an exact hit, the resolved entry's stored key for a prefix hit — shared
with the save step via
curlewlabs-com/local-mutex.
Nested inside it is that same per-target lock; it then rsyncs the resolved
entry. Locking the entry's own key rather than merely the requested one matters
because it is the exact lock the gc takes to evict that
entry: on a prefix hit the two keys differ, and locking the requested key would
leave the rsync source exposed to a concurrent eviction. Phase 2 restores
exactly the entry Phase 1 resolved — never re-resolving to a different,
unlocked one — and misses cleanly if the gc evicted it in the interval. The
per-target lock is what lets the gc reclaim a restore target without racing a
restore to it. No actor holds the target lock while waiting on the key lock
(Phase 1 takes the target lock alone; Phase 2 and the gc nest key ⊃ target), so
a restore and a sweep can never deadlock.
On save, content is synced to a temp directory then renamed atomically into
place. Concurrent writers of the same key are serialized through
curlewlabs-com/local-mutex
(lockf/flock under the hood, kernel-managed cleanup on process death). The
second writer waits for the first to finish, then re-checks and exits cleanly
because the entry now exists. Saves of different keys still run in parallel —
the lock is per-key.
Why clean-before-restore matters: The rm -rf on every re-sync (i.e.
whenever the marker is missing or points at a different key) is deliberate — it
prevents stale content from accumulating across version bumps. Without it,
tools that install new versions alongside old ones (e.g.
subosito/flutter-action in runner.tool_cache) would cause the save step to
capture every version ever installed, growing the cache entry without bound.
The clean re-sync ensures the target only ever contains what the cache entry
has plus what the current install step adds — nothing from previous versions
survives.
Why not hard links? v1 used rsync --link-dest for zero-copy restores.
This is unsafe when multiple runners restore the same entry concurrently: hard
links share the same inode, so if one consumer modifies a file (e.g. flutter
upgrading engine.version during setup), the modification corrupts the cache
entry for every other consumer.
Why not copy-on-write (APFS clones, reflinks)? CoW semantics are not
portable: cp -c is macOS-only (APFS), cp --reflink is Linux-only
(Btrfs/XFS, not ext4), and edge-case behavior (failure modes, metadata
preservation on fallback) varies across OS versions. We optimize for easy to
understand over minimal: one tool (rsync), one behavior, no platform
detection. The marker-based skip also makes CoW redundant for the common case —
steady-state restores are constant-time work, and version bumps (the only case
CoW would help) are rare and take seconds.
Last-use tracking (for eviction): Every restore that serves an entry —
including the constant-time marker-skip path — updates the modification time of
that entry's metadata file (.local-cache-key). This gives each entry an
accurate last-used timestamp, distinct from the entry directory's own mtime
(which records the last write and is what prefix matching sorts on). Nothing
here evicts anything on its own; it makes a least-recently-used sweep possible
and safe — see Eviction.
The restore/save split is intentional: composite actions have no automatic post-step hook, so the save must be called explicitly after your install step. This also gives you control over the condition — you only pay the save cost when the cache actually missed.
- name: Read Flutter version
id: flutter-version
run: echo "version=$(cat .flutter-version)" >> "$GITHUB_OUTPUT"
- name: Restore Flutter SDK
id: flutter-cache
uses: curlewlabs-com/local-cache@v3
with:
path: ${{ runner.tool_cache }}/flutter
key: flutter-${{ steps.flutter-version.outputs.version }}-stable-${{ runner.os }}-${{ runner.arch }}
cache-dir: /path/to/shared/cache # persistent path shared across runners on this machine
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: ${{ steps.flutter-version.outputs.version }}
channel: stable
cache: false
- name: Save Flutter SDK
if: steps.flutter-cache.outputs.cache-hit != 'true'
uses: curlewlabs-com/local-cache/save@v3
with:
path: ${{ runner.tool_cache }}/flutter
key: flutter-${{ steps.flutter-version.outputs.version }}-stable-${{ runner.os }}-${{ runner.arch }}
cache-dir: /path/to/shared/cacheOn first run: cache miss → install runs → save populates the shared cache. On subsequent runs (same key): marker matches → restore skipped → instant. On version bump: marker differs → clean + rsync → a few seconds.
If you use the same tool in multiple workflows, a
local composite action
wrapping restore + install + save collapses it to a single uses: line in
every caller. Because the install step is inside the composite action, the
save can be its last step — no explicit save step needed in the calling
workflow.
# .github/actions/flutter-setup/action.yml
inputs:
flutter-version: { required: true }
cache-dir: { required: true }
runs:
using: composite
steps:
- uses: curlewlabs-com/local-cache@v3
id: cache
with:
path: ${{ runner.tool_cache }}/flutter
key: flutter-${{ inputs.flutter-version }}-stable-${{ runner.os }}-${{ runner.arch }}
cache-dir: ${{ inputs.cache-dir }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ inputs.flutter-version }}
channel: stable
cache: false
- if: steps.cache.outputs.cache-hit != 'true'
uses: curlewlabs-com/local-cache/save@v3
with:
path: ${{ runner.tool_cache }}/flutter
key: flutter-${{ inputs.flutter-version }}-stable-${{ runner.os }}-${{ runner.arch }}
cache-dir: ${{ inputs.cache-dir }}Callers then use uses: ./.github/actions/flutter-setup with just
flutter-version and cache-dir.
- uses: curlewlabs-com/local-cache@v3
with:
path: ${{ env.HOME }}/.cargo/registry
key: cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-
cache-dir: /path/to/shared/cacherestore-keys are tried in order. The first prefix that has any match wins;
within that prefix's matches, the entry with the newest directory mtime — the
most recently saved — is used. (A restore updates an entry's last-used time
on its metadata file, not the directory, so reads never reorder this selection
— see Eviction.) A prefix match sets cache-hit=false so the save
step still runs and writes a new entry under the caller's exact key — the
prefix-matched entry is not updated in place, so repeated runs with a rolling
exact key produce N separate entries over time (see Eviction).
| Input | Required | Description |
|---|---|---|
path |
Yes | Path to restore the cached directory to |
key |
Yes | Exact cache key |
restore-keys |
No | Newline-separated key prefixes for fallback matching |
cache-dir |
Yes | Absolute path to local cache directory (must be persistent and shared across runners; tilde expansion not supported) |
| Output | Description |
|---|---|
cache-hit |
true if an exact key match was found |
cache-matched-key |
Key that was actually restored (empty on miss). For exact hits this is always the original raw key; for prefix hits it is the original raw key for newly saved entries, and the legacy directory name when restoring an older pre-encoding entry. |
The restore step also sets these variables via $GITHUB_ENV for use in
subsequent steps:
| Variable | Description |
|---|---|
LOCAL_CACHE_HIT |
Same as cache-hit — true on exact match, false otherwise |
LOCAL_CACHE_MATCHED_KEY |
Same as cache-matched-key |
These are a convenience for steps that cannot easily reference action outputs (e.g. scripts in a separate composite action).
| Input | Required | Description |
|---|---|---|
path |
Yes | Directory to save |
key |
Yes | Cache key |
cache-dir |
Yes | Must match the restore step |
Change @v2 to @v3 in your workflow files.
v3 features a collision-safe encoded on-disk cache layout. Existing exact-key entries from v2 and v1 remain fully readable, while newly saved entries use the safe encoded format. Your action workflows need no code changes other than bumping the tag; the underlying cache storage migration is transparent.
Change @v1 to @v3 in your workflow files. No other changes needed.
On the first restore after upgrading, the target directory is cleaned and re-synced (since v1 left hard-linked files with no marker). After that, restores with the same key are skipped entirely.
Purge bloated v1 entries after upgrading. v1 did not clean the target before restoring, so tools that install new versions alongside old ones (e.g. Flutter) caused the save step to capture every version ever installed. After upgrading, delete the old entries so the next save creates a clean one:
rm -rf /path/to/cache-dir/entries/* /path/to/cache-dir/entries/.tmp-*The second glob sweeps any orphan staging directories left behind by crashed
saves (see the Limitations section below). The default shell glob * does not
match dotfiles, so both patterns are needed. This forces a one-time cache miss
and re-download. Future entries will be clean because v2's restore starts from
an empty target.
If the tool respects an environment variable that controls where it stores its
cache or installation (e.g. PUB_CACHE, CARGO_HOME, BUN_INSTALL_CACHE_DIR,
GRADLE_USER_HOME), point that variable at a shared persistent directory
instead. Every runner on the machine will use the same live directory with zero
copying overhead — no restore or save step needed.
Use local-cache when you cannot control where a tool installs itself. The
Flutter SDK (subosito/flutter-action installs into runner.tool_cache, which
is per-runner) and the Cargo registry ($HOME/.cargo, which is per-user home)
are typical examples: they do not natively share state across runners on the
same machine.
local-cache never evicts on its own — a save doesn't touch sibling entries —
so a key that encodes a rolling value (a Flutter SDK version, a Cargo.lock
hash) accumulates one entry per value. What the action does provide is an
accurate last-used signal, so an external sweep can reclaim space by
genuine recency of use rather than recency of creation.
Every restore that serves an entry, including the constant-time marker-skip
path, bumps the modification time of that entry's metadata file
(.local-cache-key). So that file's mtime answers "when was this entry last
used", independent of the entry directory's mtime, which answers "when was
this entry last written" (and is what prefix/restore-keys resolution sorts
on). The two never interfere: reads move the former, writes move the latter.
That makes a least-recently-used sweep safe to run on a schedule:
# Remove entries not used in the last 30 days.
# Whole-entry granularity is deliberate: deleting individual files from
# inside a live entry would mutate the rsync source of an in-flight restore
# and corrupt it. Only ever remove an entry directory as a unit.
for meta in /path/to/cache-dir/entries/*/.local-cache-key; do
[ -e "$meta" ] || continue
if [ -n "$(find "$meta" -mtime +30)" ]; then
rm -rf "$(dirname "$meta")"
fi
doneTune the window to your disk budget and bump cadence. Entries written by an
older version that predate the metadata file are skipped by the loop above;
clear those with a one-time rm -rf cache-dir/entries/* after upgrading, which
forces a clean re-download on next use. This manual loop reclaims entries
only — not the per-runner restore-target copies or their records in the
parallel targets/ tree; the gc action below reclaims
both, under the save/restore locks.
curlewlabs-com/local-cache/gc@v3 runs that sweep for you, and additionally
reclaims the restore targets — the per-runner copies an entry was rsynced
to on each cache hit, which accumulate separately from the store. Every
served restore records its target in a parallel targets/<encoded-key>/ tree
(a sibling of entries/, kept out of the entry so it can't disturb
prefix-selection order), one small file per target; when the gc evicts a cold
entry it reclaims those recorded copies first, then the entry itself — each
under the same per-key and per-target locks the save and restore steps hold, so
a sweep never races live traffic.
- uses: curlewlabs-com/local-cache/gc@v3
with:
cache-dir: /path/to/cache-dir # the same value your restore/save steps use
max-age-days: 30 # retention window in days — above your longest job
apply: true # omit or set false for a dry-run reportBecause the last-used mtime is bumped by any runner that shares the store, a cold entry proves every runner's copy is idle — so one scheduled gc reclaims both the store and the per-runner tool caches across all runners on the machine.
Safety is layered. The gc holds the same per-key and per-target locks as
save and restore, so evicting an entry can't race a save or restore of it and
reclaiming a target can't race a restore to it. What no lock can cover is the
window after a restore step — and its lock — has finished, while the job
keeps using the restored copy for minutes: that is what max-age-days covers,
so it must comfortably exceed your longest job. As further independent
backstops the gc spares a target whose owning entry is no longer cold (a
concurrent restore bumped it) or any target with a file modified inside the
window (an in-progress build writes into its restored tree). The locks make
eviction atomic; the time threshold is what makes it safe — so your longest job
is a hard floor. That floor is the minimum, not the target, though:
max-age-days is a retention policy, sized to your disk budget and how fast
your keys roll (days to weeks; the default is 30), and it sits far above a
longest job measured in minutes or hours. Size that window for the job's
total runtime, reads included: rsync -a preserves the cache's original
mtimes and a skip rewrites nothing, so the mtime backstop only ever sees
writes into a target, never a job merely reading one. And since the locks
live in one shared directory (/tmp by default), every runner sharing the
cache-dir must share that lock directory too — automatic on a single machine,
but on a topology that gives each runner a private /tmp the locks don't cross
runners and the gc could delete a target another runner is restoring.
- No automatic eviction. A save never deletes sibling entries, so a key
that encodes a rolling value accumulates one entry per value over time.
Entries do carry an accurate last-used timestamp, so a scheduled
least-recently-used sweep — the bundled
gcaction, which reclaims cold entries and their restore targets under the same locks the save and restore steps hold, or the entry-only shell recipe above — can reclaim space safely. See Eviction. For a full reset,rm -rf cache-dir/entries/* cache-dir/targets/*. - SIGKILL/OOM can orphan staging directories. The save step rsyncs into a
.tmp-<key>-<pid>staging directory underentries/and then renames it into place atomically. A normal exit,INT, orTERMcleans the staging directory up via trap, butSIGKILL/ OOM kill / power loss betweenmkdirandmvleaves the.tmp-*directory behind. These are safe — the restore-side prefix match rejects any.tmp-*name so they never produce ghost cache hits — but they do consume disk space. If you noticeentries/growing unexpectedly, sweep them withrm -rf cache-dir/entries/.tmp-*during a maintenance window. - Each restore is a full copy. When the marker doesn't match (version bump, first v2 restore), the full artifact is copied from cache to target. For a 1.8 GB Flutter SDK this takes a few seconds on SSD — trivial compared to the network download it replaces.
- macOS Spotlight indexing. On macOS runners, restoring large cache entries
(e.g. the Flutter SDK) can trigger
mds/mds_storesto re-index the restored files, causing CPU spikes. Exclude the runner's root directory (or at minimum thecache-dir) from Spotlight indexing via System Settings > Spotlight > Privacy, or programmatically withmdutil -i off /path/to/runner. - Windows Defender on WSL2. If your runners run inside WSL2 and you notice
CPU spikes from
MsMpEng.exeafter cache restores, Windows Defender may be scanning files written to the WSL2 filesystem. Add the WSL2 distribution's directory to the Defender exclusion list in Windows Security settings. - No GitHub cloud fallback. Unlike
actions/cache, there is no network fallback on a local miss. The first run on a new machine always downloads. - Explicit save required. Composite actions have no automatic post-step
hook, so you must call
curlewlabs-com/local-cache/save@v3explicitly after your install step. A JavaScript action with apost:hook would enable a single-step interface, but adds a build step and Node.js dependency that this action avoids by being pure shell.
Every release ships both a fixed patch tag (vMAJOR.MINOR.PATCH, e.g.
v3.0.1) and a floating major tag (vMAJOR, e.g. v3). Pin to @v3.0.1 for
a stable reference; pin to @v3 for automatic minor/patch updates inside the
v3 series. Both tag kinds exist for every release.
We never force-move a patch tag once it is pushed. That is a promise we keep rather than something the platform enforces, so if you need a guarantee instead of a promise, pin the commit SHA:
- uses: curlewlabs-com/local-cache@<40-hex-sha> # v3.0.1See AGENTS.md for the full contract.
After merging to main:
# Fixed patch tag — we never force-move it once pushed.
git tag v3.x.y HEAD
git push origin v3.x.y
# Floating major tag — force-updated to the latest v3.x.y commit on every release.
git tag -f v3 HEAD
git push --force origin v3
# GitHub release for the marketplace.
gh release create v3.x.y --title "v3.x.y" --notes "changelog here"MIT