Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Latest commit

 

History

63 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

local-mutex

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.

Why

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.

Usage

- name: Update Claude Code
  uses: curlewlabs-com/local-mutex@v2
  with:
    name: claude-update
    run: |
      claude update
      claude --version

That'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).

Example: serializing a self-updating CLI

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.diff

If 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.

Example: serializing CocoaPods across runners

- name: Install CocoaPods dependencies
  uses: curlewlabs-com/local-mutex@v2
  with:
    name: cocoapods-spec
    run: |
      cd app/ios
      pod install || pod install --repo-update

Multiple 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.

Example: serializing a shared cache directory write

- 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.

Example: containerized runners with a non-shared /tmp

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.sh

All 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.

Inputs

  • 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) inside lock-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-only run is rejected.
  • lock-dir (optional): Absolute path to the directory where the lock file is created. Defaults to /tmp. Override only when /tmp isn't shared across the runners on the same machine - for example, on containerized self-hosted runners where /tmp is container-local. The directory must exist and be writable by the runner user. Callers setting the same name from two runners continue to serialize as long as they share the same lock-dir and one kernel. A directory shared across a kernel boundary does not meet the second condition; see the containerized example above.

Outputs

  • output-file: Path to a file containing all $GITHUB_OUTPUT writes 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.

Propagating inner outputs

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 }}"

Taking locks from a shell loop

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"'
      done

The 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.

Taking locks from a step's own script

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/locks

A 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
fi

Diagnostic notices

The 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.

How it works

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
fi

No 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.

When to use this - and when not to

Use it when

  • 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

Don't use it when

  • 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 to concurrency:.)
  • 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, the lockf(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-minutes on 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.

Comparison with the alternatives

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.

Requirements

  • 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 run local-mutex on a Windows host.
  • One of lockf(1) or flock(1) on PATH. Both are standard:
    • macOS: lockf is at /usr/bin/lockf on every install (BSD heritage).
    • Linux: flock is in util-linux, installed by default on every modern distribution.
  • A SHA-256 command on PATH. Both are standard:
    • macOS: shasum is at /usr/bin/shasum on every install (Perl core).
    • Linux: sha256sum is in coreutils, installed by default on every modern distribution.
  • 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 /tmp should pass lock-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.

Manual validation on a shared runner host

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.

Releasing

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"

License

MIT - see LICENSE.

About

Wrap a command in a local-filesystem mutex (lockf/flock) on self-hosted GitHub Actions runners. For when N runners on the same machine need to serialize access to a shared resource.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages