Multiple self-hosted GitHub Actions runners on the same machine share
~/.cocoapods, ~/.local/bin, /tmp, and every other singleton OS resource.
When two runners hit pod install --repo-update, claude update,
brew upgrade, or any shared-state operation at the same time, they corrupt
each other silently.
local-mutex wraps any shell command in a kernel-level file lock
(lockf(1) on macOS/BSD,
flock(1) on Linux,
whichever is on PATH). The lock is acquired before your command runs, held
for the duration, and released automatically when it exits - including on
SIGKILL, OOM kill, or machine reboot. No external services, no stale-lock
recovery, no PID tracking.
Self-hosted runners on the same machine sharing the same OS user share /tmp,
~/.local/, and every other singleton OS resource. The moment two of them try
to update the same tool at once, or rebuild the same cache entry, they race
each other.
GitHub Actions' built-in concurrency: mechanism doesn't help - it serializes
whole jobs (or whole workflows) at the GitHub API level, which is far too
coarse. If you have multiple runners and want them all running independent jobs
in parallel except for the one moment they need to update a shared binary,
concurrency: would force you down to one runner total.
Distributed mutex actions (actions-mutex, gh-action-locks, k8s-lock, etc.)
all use external coordination - git push contention, GitHub API artifacts, HTTP
services, k8s secrets - because they assume runners are on different ephemeral
machines. For the same-physical-machine case, that's massive overkill: you're
emulating over the network something the kernel will do for you in
microseconds.
local-mutex is the kernel solution: a thin wrapper over the OS-native
command-locking primitive, exposed as a GitHub composite action.
- name: Update Claude Code
uses: curlewlabs-com/local-mutex@v2
with:
name: claude-update
run: |
claude update
claude --versionThat's the entire interface. The lock is acquired before run: starts, held
for the duration, and released when run: exits - normally or otherwise. If
another runner is holding the same name, this caller waits indefinitely
(bounded only by the job's timeout-minutes).
jobs:
review:
runs-on: self-hosted
steps:
- name: Update Claude Code
uses: curlewlabs-com/local-mutex@v2
with:
name: claude-update
run: |
claude update
claude --version
- name: Run review
run: claude -p "review the diff" < pr.diffIf multiple runners hit this job at the same time, the update step on each one waits for the previous one's update to finish, then runs (likely a no-op since the binary is already current). The subsequent step then runs in parallel across all of them with no contention.
- name: Install CocoaPods dependencies
uses: curlewlabs-com/local-mutex@v2
with:
name: cocoapods-spec
run: |
cd app/ios
pod install || pod install --repo-updateMultiple macOS runners sharing ~/.cocoapods will race on spec-cache updates.
Without serialization, --repo-update on one runner can partially overwrite
the cache another runner is reading, producing cryptic pod resolution failures.
- name: Rebuild shared dependency cache
uses: curlewlabs-com/local-mutex@v2
with:
name: dep-cache-rebuild
run: |
./scripts/build-deps.sh
mv build-output /shared/dep-cache/$(cat .deps-hash)Two runners running the same build in parallel would otherwise race on writing
to /shared/dep-cache/<hash>/. Wrapping the build+publish step in
local-mutex makes the second runner wait for the first to finish; if your
build script checks whether the entry already exists and exits early when it
does, the second runner becomes a fast no-op.
curlewlabs-com/local-cache -
a sister composite action that provides a local-disk cache for self-hosted
runners - uses local-mutex for exactly this pattern. Its save/action.yml
wraps the per-key write step in local-mutex so concurrent saves of the same
cache key serialize via lockf/flock instead of needing per-script PID
tracking and stale-lock recovery. See its
save/action.yml for a working production usage.
By default the lock file lives under /tmp. On bare-metal self-hosted runners
that's already shared across every runner on the host, so callers don't need to
think about it. In containerized deployments where each runner sees its own
/tmp, point lock-dir at a bind-mounted path on the host:
- name: Update shared binary
uses: curlewlabs-com/local-mutex@v2
with:
name: tool-update
lock-dir: /opt/runner-shared/locks
run: |
/opt/runner-shared/tools/update-toolchain.shAll runners must mount the same host directory at the same in-container path,
and all of them must run under the host's kernel. The lock-file basename
inside lock-dir is the SHA-256 of name, so two runners sharing lock-dir
and name always land on the same inode and serialize via the host kernel's
lock.
The kernel condition is the one a bind mount does not give you for free. The lock this tool takes belongs to the kernel that took it, so a shared directory is a shared lock domain only for processes inside one kernel. A container that runs on its host's kernel meets that condition; one that runs inside a VM has the VM's kernel, and a directory it shares with processes outside the VM crosses a kernel boundary, across which this tool promises nothing. When in doubt, run the manual check with one terminal on each side. The second terminal must block.
name(required): Lock identifier. Echoed verbatim into the diagnostic::notice::annotations so callers see a human-readable identifier in the log, and hashed with SHA-256 to form the lock file basename (local-mutex-<64-hex-digest>.lock) insidelock-dir. Pick a name that describes the resource being protected. Any length is accepted (SHA-256 produces a fixed 64-character basename regardless of input length). Arbitrary bytes are accepted, including non-ASCII. Empty, whitespace-only, or control-character-containing (newline, tab, etc.) values are rejected.run(required): Shell command to execute while holding the lock. Runs under/bin/sh. Multi-line scripts work. Empty or whitespace-onlyrunis rejected.lock-dir(optional): Absolute path to the directory where the lock file is created. Defaults to/tmp. Override only when/tmpisn't shared across the runners on the same machine - for example, on containerized self-hosted runners where/tmpis container-local. The directory must exist and be writable by the runner user. Callers setting the samenamefrom two runners continue to serialize as long as they share the samelock-dirand one kernel. A directory shared across a kernel boundary does not meet the second condition; see the containerized example above.
output-file: Path to a file containing all$GITHUB_OUTPUTwrites made by the inner command. Because composite actions don't propagate outputs from nested steps automatically, callers that need the inner command's outputs must read this file in a subsequent step.
If your inner command writes to $GITHUB_OUTPUT and you need those values in
later steps, add a propagation step:
- name: Build under lock
id: locked-build
uses: curlewlabs-com/local-mutex@v2
with:
name: shared-build
run: |
./build.sh
printf 'build-hash=%s\n' "$(cat build-hash.txt)" >> "$GITHUB_OUTPUT"
- name: Propagate build outputs
id: build
shell: sh
run: cat "${{ steps.locked-build.outputs.output-file }}" >> "$GITHUB_OUTPUT"
- name: Use build hash
run: echo "Built ${{ steps.build.outputs.build-hash }}"The composite action wraps one command in one lock - the right shape for a workflow step, but not for a job that has to lock many resources it only discovers at runtime: a garbage collector sweeping N cache keys, a reconcile loop over N shared directories. A composite action's steps can't iterate a runtime-computed set, so those callers take each per-item lock from inside a shell loop by invoking the lock script directly.
Every command run through local-mutex gets two variables in its environment:
LOCAL_MUTEX_CLI (the absolute path to this lock script) and
LOCAL_MUTEX_LOCK_DIR (the resolved lock directory). The script takes the same
arguments as the action and gives the same guarantee - the command runs under
the named lock, released when it exits, including on SIGKILL:
sh "$LOCAL_MUTEX_CLI" <name> <command> [lock-dir]Omit [lock-dir] in a nested call and it inherits LOCAL_MUTEX_LOCK_DIR - the
outer lock's directory - so the whole loop stays in one lock domain without
threading the path through every call.
So a sweep wraps itself in one outer lock via the action, then takes a per-item lock for each unit of work inside the loop:
- name: Garbage-collect the shared cache
uses: curlewlabs-com/local-mutex@v2
with:
name: cache-gc # one outer lock: one sweep at a time
run: |
list_cold_keys | while IFS= read -r key; do
# Each reclaim runs under the SAME per-key lock the writers hold, so
# it can never race a concurrent write of that key. Pass the item
# through the environment rather than string-interpolating it into
# the command, which runs in a fresh `sh -c`.
CACHE_KEY="$key" sh "$LOCAL_MUTEX_CLI" "cache-save-$key" 'reclaim "$CACHE_KEY"'
doneThe per-item lock shares the action's lock domain: a lock taken via the script
with name: foo serializes against a uses: curlewlabs-com/local-mutex step
using name: foo and the same lock-dir, because both hash foo to the
same lockfile. Nested calls inherit the outer lock-dir automatically through
LOCAL_MUTEX_LOCK_DIR, so a loop stays in one domain even under a custom
lock-dir - you never thread it through each call. That is what lets a cleanup
loop serialize against the very writers it is cleaning up after - the reason to
reuse this lock instead of rolling a second one.
Keep the wrap-a-command shape; there is deliberately no lock / unlock
pair. Binding the lock to the wrapped command's process is what makes it
un-leakable - a caller cannot acquire and then forget to release, or die still
holding it. The loop pays one short-lived sh per item, which is nothing
beside the work being serialized.
Nesting is fine as long as the names differ (an outer cache-gc lock around
inner cache-save-<key> locks). Nesting the same name deadlocks - the lock
is not reentrant.
The section above assumes the caller is already inside a wrap - that is where
LOCAL_MUTEX_CLI comes from. Plenty of callers are not. The shared write a job
needs to serialize is often several levels down inside its own build script:
bun install in a check.sh prelude, bundle install behind a make target, a
cargo fetch in a test harness. Wrapping the whole step in the action would
hold the lock for the length of the build rather than the length of the write,
and the script has no supported way to find this one on its own.
setup is that way. Run it once per job and every later step - and every
script those steps call - can reach the CLI:
- uses: curlewlabs-com/local-mutex/setup@v2
- name: Run the checks
run: ./scripts/check.sh # takes the lock around its own install# inside check.sh
sh "$LOCAL_MUTEX_CLI" bun-cache 'bun install --frozen-lockfile'It writes LOCAL_MUTEX_CLI to $GITHUB_ENV, so the value is the same script
the action runs, at the version the workflow already pinned. Nothing in the
consuming repo has to know where the runner checked this action out. A lock
taken this way is in the same domain as one taken through uses: with the same
name and lock-dir, for the same reason a per-item lock in a loop is: both
hash the name to the same lockfile.
Pass lock-dir if the job needs one, and it is exported as
LOCAL_MUTEX_LOCK_DIR for every later CLI call that omits its 3rd argument:
- uses: curlewlabs-com/local-mutex/setup@v2
with:
lock-dir: /mnt/shared/locksA script that also runs outside CI - the common case for a build script - sees
an unset LOCAL_MUTEX_CLI there and has to decide what that means. Decide it
loudly. Falling back to running the command unlocked is the worst available
answer in CI, where the caller believes it is serialized and is not; a
developer machine running one build at a time is the case where unlocked is
genuinely correct. Branch on something that distinguishes the two, and fail
rather than guess:
if [ -n "${LOCAL_MUTEX_CLI:-}" ]; then
sh "$LOCAL_MUTEX_CLI" bun-cache 'bun install --frozen-lockfile'
elif [ -n "${CI:-}" ]; then
echo "LOCAL_MUTEX_CLI unset in CI - add local-mutex/setup" >&2
exit 1
else
bun install --frozen-lockfile
fiThe action emits three GitHub Actions ::notice:: annotations to
stderr around each lock acquire:
::notice::local-mutex: waiting for lock <name> at <UTC timestamp>
::notice::local-mutex: acquired <name> at <UTC timestamp> after waiting <N>s
::notice::local-mutex: released <name> at <UTC timestamp> after holding <N>s
When another caller holds the lock, the wait notice names it:
::notice::local-mutex: waiting for lock <name> at <t> - last recorded holder: run 4242 job build attempt 1 (pid 91011)
Read acquired first when a step hangs. A waiting line followed by
silence has two completely different causes - still blocked on the lock, or the
lock was taken instantly and the wrapped command is what hung - and they want
opposite fixes. The acquired line separates them, and the two elapsed figures
say which side was pathological:
| What you see | What it means |
|---|---|
waiting, then nothing |
Still blocked. The holder named on the wait line is what to chase. |
acquired ... after waiting 0s, then nothing |
Lock was free. The wrapped command is the hang; the lock is a red herring. |
acquired ... after waiting 900s |
Real contention. Something held this lock for 15 minutes. |
released ... after holding 900s |
This caller was that something. |
The holder identity comes from GITHUB_RUN_ID / GITHUB_JOB /
GITHUB_RUN_ATTEMPT when they are set, and the pid otherwise, so it is useful
off Actions too. It is written to a <lockfile>.holder breadcrumb while the
lock is held and removed on release.
That breadcrumb is diagnostic only and never load-bearing: nothing reads it
to decide whether the lock is free, and no staleness is inferred from it. A
SIGKILLed holder leaves the file behind, which is why the wait line says last
recorded holder - the kernel, not that file, is what releases the lock, so the
next acquirer takes it immediately and the after waiting 0s on its own
acquired line says so.
The wait notice is emitted before handing off to the lock primitive; acquired
is the first thing that runs with the lock actually held; released is emitted
after the wrapped command exits - on success, on failure, and on signal-driven
exits the inner shell can trap. All three appear in the step log and surface in
the job summary annotations.
If the wrapped run command installs its own trap '...' EXIT, POSIX shell
replaces our trap with the caller's. The caller's trap still runs correctly;
only our release notice and the breadcrumb cleanup are suppressed. The wait and
acquire notices are unaffected.
The script validates name (non-empty, no control characters) and lock-dir
(absolute, exists, writable; default /tmp), hashes name with SHA-256 to
form the lockfile basename, emits the wait notice, then probes for and execs
the chosen lock primitive. The locking core (after validation, hashing, and the
diagnostic trap setup described above) is:
lockfile="${lock_dir}/local-mutex-${name_hash}.lock"
if command -v lockf >/dev/null 2>&1; then
exec lockf -k "$lockfile" sh -c "$cmd"
elif command -v flock >/dev/null 2>&1; then
exec flock -o -x "$lockfile" sh -c "$cmd"
else
printf '::error::local-mutex: neither lockf(1) nor flock(1) found on PATH. Install util-linux (Linux) or use a system that ships lockf (macOS, *BSD).\n' >&2
exit 127
fiNo timeout flag (the job-level timeout-minutes bounds it). No PID tracking.
No stale-lock recovery. When the process running this script exits, the kernel
releases the lock - including on SIGKILL, OOM kill, or machine reboot.
Orphaned descendants of the wrapped command continue to exist as reparented
processes but no longer hold the lock; that matches normal Unix process
semantics.
Why hand the primitive a command instead of a descriptor? Both lockf and
flock can instead take a bare file descriptor and leave the caller holding
the lock. Avoid that form - it is also the expensive one. Given a file and a
command, macOS lockf(1) takes the lock inside open(..., O_EXLOCK), so a
blocked waiter sleeps in the kernel at 0% CPU. Given only a descriptor it has
nothing left to open and falls back to a flock(fd, LOCK_EX|LOCK_NB) retry
loop with no sleep in it, so one waiter burns 100% of a core for the whole
wait - on a contended self-hosted runner, precisely the resource the mutex
exists to protect. Neither man page mentions this, and every other property
survives the switch, so CI asserts it directly: a blocked waiter must burn
under a second of CPU while waiting six.
Why probe instead of branching on uname? Probing for the actual binary
handles edge cases without an OS allowlist: a Linux user with lockf from a
non-default package works; a macOS user with flock from Homebrew works;
FreeBSD/OpenBSD/NetBSD work because they all ship lockf(1). The probe is
simpler than maintaining an OS table and more robust than guessing. When both
binaries are installed, lockf is preferred because its default fork/exec
pattern (the child closes the lock FD before exec) cleanly releases the lock
when the holding process is killed, matching the documented guarantee without
an extra flag.
Why lockf -k? Without -k, lockf unlink(2)s the lock file on release.
That lets a fresh acquirer open(O_CREAT) a brand-new inode under the same
name while a previous waiter is still blocked on the now-anonymous original
inode - both end up holding locks on different inodes and the mutex silently
breaks. -k skips the unlink so all callers always lock the same inode.
Why flock -o -x? -x is exclusive (the default, but explicit for
clarity). -o (or --close) closes the lock file descriptor in the flock
child before exec, so the wrapped command's descendants don't inherit it.
Without -o, killing the flock parent on Linux leaves orphan processes still
holding the lock - the SIGKILL release guarantee silently breaks. macOS lockf
doesn't need an equivalent flag because BSD lockf already closes the lock FD
in the forked child before exec.
Why no timeout input? Locks are bounded by the job's timeout-minutes.
Adding a per-step timeout would just give callers two ways to specify the same
thing. If you need a timeout shorter than the job, set timeout-minutes on the
calling step.
- You have multiple self-hosted GitHub Actions runners on the same physical machine under the same OS user
- They share a resource that cannot tolerate concurrent access (a binary being self-updated, a cache directory being written, a configuration file being rewritten, a database being migrated)
- You want to serialize that one operation without serializing the whole job
- Runners are on different machines. Local file locks can't coordinate
across machines. Use GitHub Actions' built-in
concurrency:instead - it serializes whole jobs across all runners, which is the right granularity when you need cross-machine coordination. (Most published "distributed mutex" composite actions are now archived and explicitly point users toconcurrency:.) - Runners do not share a kernel, even on one machine. A runner inside a VM has the VM's kernel, and this tool promises nothing across a kernel boundary, however the directory is shared. The containerized example above has the reasoning, and the manual check at the end of this document is the test.
- You need fairness or FIFO ordering across operating systems. On Linux
(
flock), acquisition order is not guaranteed - whichever caller the kernel happens to wake up first wins. On macOS/BSD, thelockf(1)man page documents that-k"will guarantee lock ordering," which this action passes. If your fleet mixes both OSes, don't design around FIFO; if it's all BSD-family, you can rely on it. - You need reentrant locks. A process acquiring the same lock twice will deadlock.
- You need a lock with a timeout shorter than the job. Use
timeout-minuteson the calling step instead. - You're trying to serialize work outside the runner machine (a Cloudflare API call, a database operation, a remote service). The lock is local - it can't see beyond the runner host's filesystem.
| local-mutex | concurrency: (built-in) |
|
|---|---|---|
| Coordination scope | Same kernel (one machine, no VM boundary) | GitHub API (cross-machine) |
| Granularity | Per-step / per-resource | Per-job or per-workflow |
| Latency to acquire | Microseconds (kernel) | Queues whole jobs (cancels with cancel-in-progress: true) |
| Setup required | None - composite action only | None - built into Actions |
| Stale lock recovery | Automatic (kernel-managed) | n/a |
| Cross-runner-machine | No | Yes |
Pick local-mutex when runners share a machine and the bottleneck is a
local resource - you want all your runners to keep running in parallel except
when they touch the one shared thing. Pick concurrency: when you want to
ensure only one job (or one workflow) runs at a time across all runners,
regardless of machine.
- Self-hosted GitHub Actions runner on Linux, macOS, or any BSD that ships
lockf(1). A Linux runner inside WSL2 counts as Linux and is fully supported - it is how to runlocal-mutexon a Windows host. - One of
lockf(1)orflock(1)onPATH. Both are standard:- macOS:
lockfis at/usr/bin/lockfon every install (BSD heritage). - Linux:
flockis inutil-linux, installed by default on every modern distribution.
- macOS:
- A SHA-256 command on
PATH. Both are standard:- macOS:
shasumis at/usr/bin/shasumon every install (Perl core). - Linux:
sha256sumis incoreutils, installed by default on every modern distribution.
- macOS:
- A writable directory shared between concurrent runners through one kernel.
Defaults to
/tmp, which already fits bare-metal self-hosted runners under the same OS user. Containerized runners that don't share/tmpshould passlock-dir:pointing at a bind-mounted host path, and must run under the host's kernel for that path to be a shared lock domain; a runner inside a VM does not. If no directory is shared between the runners you want to coordinate, or it is shared only across a kernel boundary, a local mutex can't help - use a distributed lock instead.
Windows runners are not supported. GitHub Actions offers shell: sh on
Linux and macOS only, and neither Windows nor Git for Windows ships a lockf
or flock command for the probe to find. Run the runner inside WSL2 instead.
GitHub-hosted runners (ubuntu-latest, macos-latest) also work - they have
the binaries - but the use case doesn't apply because GitHub-hosted runners are
ephemeral and don't share state across jobs.
This repository's CI still runs on GitHub-hosted Linux and macOS runners
because public-repo self-hosted testing is operationally awkward and
security-sensitive. That CI meaningfully verifies the action's contract at the
lock-primitive level (lockf on macOS, flock on Linux), but it does not
fully reproduce the production topology of multiple self-hosted runners sharing
one physical machine and one /tmp. If you need to validate that exact
deployment shape, run the manual same-machine check below on the host where
your runners live.
To validate the real deployment model end-to-end, open two terminals on the same machine under the same OS user and run the script directly from this checkout in both terminals with the same lock name.
Terminal 1
sh lib/local-mutex.sh manual-check 'date; echo "terminal 1 acquired"; sleep 10; echo "terminal 1 releasing"; date'Terminal 2 (start this while Terminal 1 is still sleeping)
sh lib/local-mutex.sh manual-check 'date; echo "terminal 2 acquired"; echo "terminal 2 releasing"; date'Expected result: Terminal 2 blocks until Terminal 1 exits, then acquires the
same lock immediately after. If you want to mimic the action more closely,
repeat the same experiment from two separate self-hosted runner jobs on the
same host using uses: curlewlabs-com/local-mutex@v2 with the same name:.
For a containerized deployment, run the same two commands with one terminal
inside the container and one outside it, both passing the shared lock-dir
as the third argument. That is also the test for whether the two sides share
a kernel. If Terminal 2 acquires the lock while Terminal 1 is still sleeping,
they do not, and no choice of lock-dir will make them serialize.
Every release ships both a fixed patch tag (vMAJOR.MINOR.PATCH, e.g.
v2.0.1) and a floating major tag (vMAJOR, e.g. v2). The repository blocks
updates and deletions of fixed patch tags, and publishing the GitHub Release
makes its tag and release assets immutable. Users who want exact reproducibility
pin to @v2.0.1; users who want automatic minor/patch updates inside the v2
series pin to @v2. The floating tag never gets a GitHub Release, because it
must remain movable. See AGENTS.md for the full contract.
After merging to main:
# Fixed patch tag - protected from updates and deletion.
git tag v2.x.y HEAD
git push origin v2.x.y
# Floating major tag - force-updated to the latest v2.x.y commit on every release.
git tag -f v2 HEAD
git push --force origin v2
# Publishing the GitHub release locks the patch tag and release assets.
gh release create v2.x.y --title "v2.x.y" --notes "changelog here"MIT - see LICENSE.