Only one CI job at a time gets to touch a shared resource. mutex keeps advisory locks in a PostgreSQL table, so a workflow that wants the staging environment waits for whoever has it instead of racing them.
Two front ends sit over one table: a GitHub Action for locking inside a workflow, and a mutex CLI for everywhere else. A lock taken by either excludes the other. The Action comments on the pull request when a lock is taken and given back, and can post the same to Slack.
A third way in drives the CLI rather than the table: an agent plugin that lets a coding agent hold a lock around work you ask it to guard.
You need a PostgreSQL database. Neon has a free tier if you do not already have one. mutex creates its own table on first use.
permissions:
contents: read
pull-requests: write
steps:
- uses: releasetools/mutex@v1
env:
MUTEX_DATABASE_URL: ${{ secrets.MUTEX_DATABASE_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
command: "lock"
id: "staging"Any other job using id: staging now waits. The lock goes back when the job ends, so there is no unlock step to forget.
Install the public package from npm. Node.js 24 or newer is required:
npm install --global @releasetools/mutex@1
mutex version@1 picks the newest v1 release at installation time; name an exact version
instead when the installation has to stay pinned. npm never updates a global
installation on its own, so re-run the command to move it:
# Move an exact installation to a specific newer release.
npm install --global @releasetools/mutex@1.4.0
# Refresh an installation that follows the latest v1 release.
npm install --global @releasetools/mutex@1npm writes the command to its configured global prefix; a Node version manager or user-owned npm prefix keeps the whole installation rootless.
mise's npm backend can install mutex without npm, but it does not add the Node runtime that mutex needs. If your global mise configuration already provides Node.js 24 or newer, install only mutex. Because the package is still below mise's default download-count threshold, explicitly approve it; the exception does not apply to dependencies:
mise use --global \
'npm:@releasetools/mutex[allow_low_downloads=true]@1'
mutex versionFor a new mise setup, install the runtime and mutex together:
mise use --global node@24 \
'npm:@releasetools/mutex[allow_low_downloads=true]@1'
mutex versionThe second command makes Node.js 24 the global mise default. A project-local configuration can override that version; force the supported runtime when a project selects an older one:
mise exec node@24 -- mutex versionWith mise activated, mutex is available directly. Without shell activation,
run it through mise exec -- mutex. allow_low_downloads requires mise 2026.8.8
or newer.
mutex version above can report the release before the newest one. mise ignores
releases younger than minimum_release_age, which defaults to 24 hours, so
on the day of a release it installs the one before it and says so:
mise WARN 1 newer npm:@releasetools/mutex release hidden by minimum_release_age
Nothing has gone wrong, and the new release arrives on its own once it has aged in. To have it today, name it exactly - an exact version installs immediately, which is how this repository's own release verifies a publication seconds after making it:
mise use --global \
'npm:@releasetools/mutex[allow_low_downloads=true]@1.4.0'The delay is a supply-chain protection covering every tool mise installs, so
minimum_release_age = "0d" to switch it off, or
minimum_release_age_excludes = ["npm:*"] to exempt this backend, are worth
setting knowingly rather than to save a day.
Update only a moving mutex installation with:
mise upgrade 'npm:@releasetools/mutex'@1 follows the newest v1 release and stops there rather than crossing into a
future major. Name an exact version instead when the installation must stay
pinned:
mise use --global \
'npm:@releasetools/mutex[allow_low_downloads=true]@1.4.0'MUTEX_DATABASE_URL="postgres://..." mutex lock staging -- ./deploy.shThe lock is held for exactly as long as deploy.sh runs, and released however it exits.
| Input | Default | |
|---|---|---|
command |
required | lock or unlock |
id |
required | Name of the lock |
reason |
"" |
Why it is being taken. Shows up in PR comments and mutex status |
owner |
"" |
Who owns it. Only the same owner may unlock or renew a named lock |
expiration |
60 |
Seconds the lock lasts |
max-wait |
-1 |
Seconds to wait for it. -1 waits for as long as expiration |
poll-interval |
10 |
Seconds between attempts |
auto-release |
true |
Give the lock back when the job ends |
disable-pr-updates |
false |
Stop commenting on the pull request |
slack-channel |
Channel ID to post to, such as C12345678. Setting it turns Slack on |
MUTEX_DATABASE_URL, GITHUB_TOKEN and SLACK_BOT_TOKEN are accepted as inputs too, if you would rather pass them under with: than as environment variables. See Slack's chat.postMessage docs for the channel ID formats it accepts.
Warning
release is deprecated. It still works as a synonym for unlock, and logs a warning when used, so workflows written against earlier versions keep running. It goes away in a future major version.
| Output | |
|---|---|
status |
locked, released, failed or skipped |
version |
Which build of the action ran. The release workflow asserts it against the tag |
| Variable | |
|---|---|
MUTEX_DATABASE_URL |
The connection string. Required by the Action, a direct CLI command, or the server process |
GITHUB_TOKEN |
Needed by the Action for PR comments |
SLACK_BOT_TOKEN |
Read only when slack-channel is set. Requires chat:write, and the bot has to be a member of the channel or posting fails |
SKIP_MUTEX |
Present in the environment at all, whatever the value, and the Action skips locking. Also works as a PR label, or a word in a PR description or comment |
MUTEX_OWNER |
CLI only. Supplies --owner when the flag is left off |
| Command | |
|---|---|
mutex lock <id> |
Acquire a lock, waiting up to --max-wait |
mutex lock <id> -- <program> |
Acquire it, run the program, release it |
mutex try-lock <id> |
Acquire it in a single attempt |
mutex unlock <id> |
Release it |
mutex renew <id> |
Extend a lock you already hold |
mutex status <id> |
Show who holds it |
mutex list |
List locks, expired ones included |
mutex list --owner <name> |
List only that owner's locks |
mutex prune |
Delete locks that have already expired |
mutex profile [name] |
List profiles, or make one the default |
mutex server start |
Start the selected server in the background |
mutex server run |
Run it in the foreground for service managers |
mutex server status |
Show version, protocol, log and pool status |
mutex server stop |
Gracefully stop it |
mutex help [command] |
Show help |
mutex version |
Print the version |
| Option | Default | |
|---|---|---|
-r, --reason <text> |
Why the lock is being taken | |
-e, --expiration <seconds> |
60, or 3600 on renew |
How long the lock lasts |
-w, --max-wait <seconds> |
-1 |
How long to wait for it. -1 means --expiration |
-i, --poll-interval <secs> |
10 |
Delay between attempts |
-o, --owner <name> |
$MUTEX_OWNER, else none |
Who is taking the lock. On list, whose to show |
--no-renew |
Do not renew while a wrapped program runs | |
--dry-run |
prune only. List what would go, delete nothing |
|
-p, --profile <name> |
Default profile | Use a profile for this command without changing the default |
--json |
Machine-readable output | |
-q, --quiet |
Errors only | |
--verbose |
Include debug output | |
-h, --help |
Show help |
| Code | |
|---|---|
0 |
Success. For status, the lock is held |
1 |
Error |
2 |
Usage error |
3 |
No usable connection string |
4 |
Not acquired, or not held |
5 |
Another owner holds the lock, and was not named |
126 |
The wrapped program exists but could not be run |
127 |
The wrapped program was not found |
While wrapping a program, its exit status is returned instead.
Taking a lock inserts a row, or takes over an expired one, inside a transaction guarded by a Postgres advisory lock. Two callers cannot both win. If the lock is held, the caller waits or fails depending on how you configure it.
mutex creates the releasetools_mutex table on first use and keeps its schema current. If the role in the connection string cannot create or alter tables, create it yourself first from the definition in database.ts.
The command names map onto mutex.ts: lock and try-lock both call tryLock, unlock calls tryUnlock.
Direct mode opens PostgreSQL from each CLI process. It remains the simplest option for scripts and occasional use: if no profiles file exists and MUTEX_DATABASE_URL is set, mutex uses it directly without prompting, writing configuration, or probing a TCP port.
The server is useful when the database is remote. It keeps a PostgreSQL connection pool warm, so each short CLI invocation talks over local TCP instead of establishing another database and TLS connection. The existing CLI commands, polling, wrapped programs, renewals, output and exit codes are the same in both modes.
Run this once to configure it:
mutex profileOn a terminal, mutex asks for a working directory and suggests ${XDG_CONFIG_HOME:-$HOME/.config}/releasetools-mutex. It creates that directory and ${XDG_CONFIG_HOME:-$HOME/.config}/releasetools-mutex/profiles.toml, prints the generated file and its path to stderr, then lets you choose between profiles with the arrow keys. It never asks for a port; the generated server listens on localhost:5625.
[server]
mode = "server"
default = true
bind_address = "localhost:5625"
working_dir = "/home/alice/.config/releasetools-mutex"
[direct]
mode = "direct"
default = falseExactly one profile is the default. Custom names are allowed. default = false only means that a profile is not selected implicitly; it remains available through --profile. mutex profile direct makes an existing profile the default and clears the default marker from the others atomically; an unknown name fails and lists the defined names. mutex profile shows the list instead of opening the arrow-key selector when stdin is not a terminal.
Use -p to override the default profile for one command without waiting for a failed connection or changing the file:
mutex status deploy -p direct
mutex server status -p serverSelection is explicit. A direct profile never probes the server, and a server profile never falls back to PostgreSQL. Once a profiles file exists, a direct command must select its direct profile and still needs MUTEX_DATABASE_URL in that command's environment.
The CLI and the server each carry a protocol version, and lock commands refuse each other by name when the two differ, rather than letting one answer a question the other did not ask. mutex server stop and mutex server status are exempt on purpose: they are how a mismatch is seen and how it is fixed, so they work whatever version the other end speaks, and mutex server status reports the version and protocol the running server was built with, next to this one when they differ. Upgrading mutex while a server is running is what makes them differ: restart it with mutex server stop && mutex server start, or through whichever service manager owns it.
Either kind of profile may also set ssl_negotiation, which is how the TLS handshake starts:
[direct]
mode = "direct"
default = true
ssl_negotiation = "direct"direct opens TLS immediately instead of asking first and waiting for the server's one-byte reply, saving a round trip on every connection mutex opens - about 25 ms against a hosted database, and worth measuring with benchmarks/ssl-negotiation before assuming it matters. It requires PostgreSQL 17 or newer; older servers read the TLS handshake as a malformed startup packet and close the connection, and mutex says so when a handshake fails that way. The default, postgres, works everywhere.
It belongs here rather than only in the connection string because it describes the server rather than the credential, and the connection string is often a secret issued by somebody else. sslnegotiation=direct in the connection string does the same thing; when both say something, the profile wins.
A server profile does not need the setting: it tries direct negotiation on its own whenever the connection uses TLS, and gives up on it permanently the first time a server refuses. That costs one failed connection against PostgreSQL 16 or older, once, at startup - a fair price for a process that will open many, and not one a CLI command could recover. Setting ssl_negotiation = "postgres" turns the attempt off. Neither front end asks for direct negotiation on a connection without TLS, which node-postgres rejects outright.
The server also keeps one connection open. node-postgres closes an idle connection after ten seconds and its floor is zero connections, so a lock server asked for something every few minutes was paying a fresh handshake nearly every time - about 180 ms against a hosted database, against the 25 ms direct negotiation saves. It now holds one, and that is where most of the saving is.
Start the server after making MUTEX_DATABASE_URL visible to it:
mutex server start
mutex server statusstart detaches and waits for both PostgreSQL and local TCP to be ready. run stays in the foreground, which is the right form under systemd or launchd. The process changes to the profile's working_dir before opening the database. mutex does not read secret files or invoke a secret manager: exported variables, service-manager environments, and environment tools all work as long as the mutex process can read MUTEX_DATABASE_URL. The value never belongs in the profiles file or on the command line.
Every server-side lock operation appends one line to <working_dir>/mutex-<profile>.log:
|2026-08-16T14:32:09.417Z|lock|deploy|alice|127.0.0.1|workstation.local|
Fields are UTC timestamp, operation, lock ID, owner, client IP, and client hostname. Missing values are -; separators, newlines and control characters are escaped. Poll attempts each get a line, while health checks and direct operations do not. The server never truncates the file or logs the database URL.
The TCP protocol is versioned and newline-delimited JSON. It has no application authentication or TLS; the default is localhost, and deployments that widen the bind address are responsible for IP ACLs.
contrib/systemd/releasetools-mutex@.service is an instance unit: the instance name is the profile. Review its User, Group, WorkingDirectory, executable path, and hardening paths, then install it:
sudo install -m 0644 contrib/systemd/releasetools-mutex@.service /etc/systemd/system/
sudo install -d -m 0750 -o mutex -g mutex /var/lib/releasetools-mutex
sudo install -d -m 0750 /etc/releasetools-mutex
sudo install -m 0644 profiles.toml /etc/releasetools-mutex/profiles.toml
sudo install -m 0600 server.env /etc/releasetools-mutex/server.env
sudo systemctl daemon-reload
sudo systemctl enable --now releasetools-mutex@server.serviceserver.env contains MUTEX_DATABASE_URL=... and is read by systemd, not passed in argv. The unit sets XDG_CONFIG_HOME=/etc, so mutex reads /etc/releasetools-mutex/profiles.toml. The configured working_dir must match the unit's writable working directory.
contrib/launchd/com.releasetools.mutex.plist is a per-user LaunchAgent. It runs as the logged-in user, so it has no UserName or GroupName and needs no root installation. Run mutex profile as that user first; accepting the suggested working directory creates ~/.config/releasetools-mutex with user ownership.
The plist contains no database URL. Its user-owned wrapper changes to the working directory, retrieves MUTEX_DATABASE_URL from dotsecenv at startup, exports it only to the mutex process, and replaces itself with mutex server run. Neither the value nor a command containing it reaches the plist, a file, or process arguments.
Copy both templates, then replace YOUR_USERNAME, the executable paths if mutex or dotsecenv is installed elsewhere, YOUR_NAMESPACE::MUTEX_DATABASE_URL, and the profile if it is not named server. launchd needs literal absolute paths in the plist: it does not run a shell to expand ~, $HOME, or command substitutions.
install -d -m 0700 "$HOME/Library/LaunchAgents"
install -m 0700 contrib/launchd/run-mutex-server.zsh "$HOME/.config/releasetools-mutex/run-mutex-server.zsh"
install -m 0600 contrib/launchd/com.releasetools.mutex.plist "$HOME/Library/LaunchAgents/com.releasetools.mutex.plist"
${EDITOR:-vi} "$HOME/.config/releasetools-mutex/run-mutex-server.zsh"
${EDITOR:-vi} "$HOME/Library/LaunchAgents/com.releasetools.mutex.plist"
plutil -lint "$HOME/Library/LaunchAgents/com.releasetools.mutex.plist"
launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.releasetools.mutex.plist"Keep the plist mode 0600 and the wrapper mode 0700; both stay owned by the current user. The wrapper uses dotsecenv's normal vault and identity access from the configured working directory, and launchd owns the resulting mutex process and restarts only failures. Stop or replace it without root:
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.releasetools.mutex.plist"mutex lock deploy-staging --reason "deploying $GIT_SHA" -- ./deploy.shThe lock is held for exactly as long as the program runs, and released on every exit path including a crash or Ctrl-C.
- The program's exit status becomes mutex's, like
flock. Killed by a signal gives128 + signal. - The program owns stdout. mutex reports on stderr, so pipelines stay clean.
SIGINT,SIGTERMandSIGHUPreach the program, and the lock goes back once it exits. Signals stay handled during that release, so a secondCtrl-Cwaits instead of killing mutex with the lock still held. Three of them and it gives up, saying so.- The lock renews in the background every
--expiration / 3seconds, so a long program does not carry on holding a lock somebody else has taken. Disable with--no-renew.
It also checks which lock it gives back. It remembers the created_at of the acquisition it made and declines the id if a later one replaced it, so a lock that lapses mid-run and is taken by somebody else stays theirs.
Acquiring depends on expiry alone: while a lock is held nobody gets it, including its owner. Ownership decides who may unlock and renew.
| Lock | Caller | unlock |
renew |
|---|---|---|---|
| unowned | anyone | yes | yes |
| same name | same name | yes | yes |
| named | anyone else | refused | refused |
Only a named lock is protected. An unowned one is open to anyone. Both the Action's owner input and the CLI's --owner option are unset by default, so existing workflows keep creating unowned locks unless they opt in.
The Action reuses owner for explicit unlock steps and post-job auto-release. A workflow can therefore identify its lock without preventing its own cleanup:
- uses: releasetools/mutex@v1
with:
command: "lock"
id: "deploy"
owner: "${{ github.repository }}@${{ github.run_id }}"There is no --force. To break somebody else's lock you name them, and the refusal says how:
$ mutex unlock deploy
'deploy' is held by 'alice'; this call is unowned. Pass --owner 'alice' to unlock it.
$ mutex unlock deploy --owner alice
Unlocked 'deploy'.That is confirmation rather than authorisation, since anyone can read the owner from mutex status. It makes breaking a lock deliberate.
list reads the same owner, so "what do I hold?" is a question for the database rather than a filter applied to the whole table afterwards:
mutex list --owner "$CI_RUN" # only that owner's locks
mutex list # every lock, or $MUTEX_OWNER's when that is set
mutex list --owner '' # names nobody, so every lock againWhat crosses the wire is the answer rather than the table, which is what makes it worth asking for. An empty list still exits 0: holding nothing is an answer, not a failure.
renew pushes out the expiry of a job still running:
mutex renew deploy --owner "$CI_RUN" --expiration 300Omitted, it defaults to an hour rather than the 60 seconds lock uses, since anything asking for a renewal has been running a while.
Renewing only ever adds time. The new expiry is whichever is later, now + --expiration or the expiry the lock already had:
$ mutex lock deploy -e 7200 --owner ci # two hours
$ mutex renew deploy --owner ci # asks for one
Kept lock 'deploy'
expires: 2026-08-16T01:44:20.165Z (in 2h 0m)Otherwise it is strict. The id and the owner must both match, and it never takes a lock rather than renewing one. An expired lock is refused too, since somebody else may already have taken it over. Exit code 4 means gone or expired, 5 means held by another owner.
Locks taken by the GitHub Action without an owner are unowned, so a CLI caller that names no owner can renew them. When the Action names one, the CLI must pass that same value with --owner.
$MUTEX_DATABASE_URL, and from the environment only. A direct CLI command reads it itself; in server mode only the server process needs it.
There is no flag for it. An argument lands in shell history and in ps, where every user on the machine can read it for as long as mutex runs:
MUTEX_DATABASE_URL="postgres://..." mutex lock deploymutex does not read secret stores. Whatever holds the secret can put it in the environment for one command, with dotsecenv for example:
MUTEX_DATABASE_URL="$(dotsecenv secret get myapp::DATABASE_URL)" mutex lock deployInteractively there is nothing to pass, because dotsecenv's shell plugin exports it when you cd into the project.
There is no option for reading some other variable, because a value already living under another name needs an assignment rather than an option:
MUTEX_DATABASE_URL="$LOCKS_URL" mutex lock deployDATABASE_URL is not read at all, in either front end. mutex read it up to 1.2.2 and warned; the prefix is the point, because frameworks, ORMs, PaaS providers and CI systems all set that name, and they set it to the application's own database. A repository that had one and then added mutex was keeping its locks in the app's database without ever being told, and locks in the wrong database exclude nobody.
mutex decides what the sslmode in a connection string means, rather than inheriting whichever meaning the installed node-postgres holds:
sslmode |
What mutex does |
|---|---|
verify-full |
Encrypts, and checks the certificate chain and the hostname |
require, prefer, verify-ca, allow |
The same as verify-full |
no-verify |
Encrypts without checking the certificate |
disable |
No TLS. Warns when the host is not local |
| unset | No TLS, as node-postgres has always done. Warns when the host is not local |
The four promoted modes mean something weaker in libpq - encrypt, but do not check who answered - which is no protection against something that can answer in the server's place. node-postgres has always read them as verify-full and warns that pg v9 will adopt libpq's meaning instead, which would quietly weaken every connection string that says require. Deciding here is what makes that upgrade a no-op, and it is why the warning no longer appears.
Certificates named by sslrootcert, sslcert and sslkey are loaded as usual, so a private CA keeps working. uselibpqcompat=true still hands the decision back to node-postgres, and mutex warns once about what that costs. PGSSLMODE is read when the connection string says nothing.
Promotion is stricter than the name suggests, which shows up as a certificate error against a server whose CA is private. mutex adds what to do to that failure rather than leaving the certificate to explain itself. Run any command with --verbose to see what a connection settled on: Database connection: sslmode=require applied as verify-full.
Read-only commands answer through the exit code:
if mutex status deploy-staging --quiet; then
echo "someone is deploying"
fi--quiet silences the ordinary report and leaves the exit code to answer. It does not silence a lock not acquired, a release refused, or a lock left held: those go to stderr whatever the verbosity. --json is unaffected by both.
The mutex agent plugin is an agent skill: what a coding agent needs to know to guard an operation with a lock, and a helper it runs to take one. It lives in releasetools/agent-plugins, which is where to change it. It is deliberately narrow. It takes a lock when the user asks for one, hands it back when the work is done, and speaks up before the lease runs out. It never volunteers a lock, never breaks somebody else's, never runs mutex profile or mutex server on its own, and never reads the connection string.
One directory serves every agent. Claude Code and Codex install it as a plugin through their own manifests. Hermes, Gemini and Antigravity discover skills by walking a directory under their own home, so they get a copy of the same files - which travels in the npm package, since a global install is the only checkout most people have.
Installing the plugin installs no mutex command and supplies no connection string. It runs the CLI, so install that first - the short path is below - and set MUTEX_DATABASE_URL yourself; the plugin never reads its value. /mutex:preflight reports whether the lock table is reachable, and what is missing when it is not.
mise use --global node@24 \
'npm:@releasetools/mutex[allow_low_downloads=true]@1'
mutex versionclaude plugin marketplace add releasetools/agent-plugins
claude plugin install mutex@ReleaseToolsThe same two steps work as /plugin marketplace add and /plugin install inside a session.
codex plugin marketplace add releasetools/agent-plugins
codex plugin add mutex@ReleaseToolsBoth install from releasetools/agent-plugins, which carries a copy of plugins/mutex/ written by this repository's release rather than a pointer back at it. So a marketplace install is a published plugin version, independent of what main happens to hold, and one marketplace serves every releasetools plugin instead of one per repository.
These read a skills directory rather than a plugin manifest, so the skill is copied into each. It ships with the CLI package, so there is nothing else to fetch:
node "$(npm root -g)/@releasetools/mutex/scripts/install-agent-skills.mjs"From a checkout of the marketplace, node scripts/install-agent-skills.mjs does the same thing.
--check reports what is missing or out of date and writes nothing, which is what to run after upgrading the CLI. --target <agent> names one, including claude or codex for a plain copy instead of a plugin. An agent whose home directory does not exist is skipped rather than created.
The plugin puts six commands in the slash menu, so the common operations are discoverable rather than something you have to describe:
| Command | |
|---|---|
/mutex:preflight |
Can mutex reach its lock table here, and if not, why |
/mutex:lock <id> [reason] |
Take a lock, an hour by default |
/mutex:status [id] |
A table of what you hold, and on request the rest |
/mutex:renew <id> [seconds] |
Extend a lock before it lapses |
/mutex:unlock <id> |
Hand it back |
/mutex:help |
What the plugin does, and what it will not |
/mutex:status names this session as the owner and lets the database do the
narrowing, so what comes back is what you hold rather than the whole table; the
helper's --all asks the wider question, and pays for it in rows.
Each one is a single deterministic invocation rather than a description of what
to do, because the difference is measured in tens of seconds. /mutex:preflight
and /mutex:status run their command before the model is asked anything, so
they cost one turn and no tool call; the rest name the exact command and say
what to report. They consult the skill only when an answer comes back that a
plain report does not cover, and nothing runs a preflight before every
operation - the operation itself reports a missing connection string perfectly
well.
There is deliberately no command for starting the pooled server, choosing a profile or pruning expired locks: those are yours to run, and the plugin says so instead of doing them.
Claude Code and Codex read commands/ as it stands. Gemini reads TOML, so the
installer renders the same files into ~/.gemini/commands/mutex/ on the way in:
one source, translated, rather than two that drift. Hermes has no command
surface, and gets the skill.
| Step | |
|---|---|
preflight |
Can mutex reach the table here, through a profile or $MUTEX_DATABASE_URL. Run when something fails, not before every lock |
| permissions | /mutex:preflight appends Bash(mutex:*) and the helper's own invocation to permissions.allow, so the skill never stops to ask |
lock |
An hour by default rather than the CLI's minute, waiting 30 seconds rather than the whole lease, under an owner naming this session |
renew |
Only after asking. Reminders arrive on their own; the decision to extend does not |
unlock |
With the owner it recorded, so it releases what it took and nothing else |
An hour because a conversation is not a CI step: it does not know how long it will take, and a lease that lapses mid-conversation hands the resource to somebody else while the work is still going on.
Locks are taken under a name that says who holds them: the agent, the host and the session, as in claude@workstation:22ca1fea-a521-4d5c-ad62-b6d05809f8ef. It is derived rather than generated, so it is the same name every time that session asks for it - which is what lets a lock be released after the note of it is lost, and what stops one session from releasing another's. $MUTEX_OWNER overrides it. Where nothing in the environment names a session the owner is the agent and host alone, and /mutex:preflight says so, because then every session on that machine can take the others' locks back.
A lock nobody is watching expires quietly, so the helper writes down what it took - the id, the owner, the session and the expiry - in ${XDG_STATE_HOME:-$HOME/.local/state}/releasetools-mutex/agent-locks.json. Nothing in it is secret, and it is a reminder rather than a source of truth: PostgreSQL still holds the locks, and mutex status <id> still names the owner needed to release one.
What reads it is a prompt hook: it asks the agent to check with you at ten minutes and again at two, and says so once when a lock has expired. The file is every session on the machine, deliberately - seeing that something else already holds staging is worth knowing - so a lock another session took is mentioned as context rather than as something this one can extend or release. It ships in hooks/hooks.json, needs no wiring in Claude Code, and reads nothing but that file - no database round trip, and nothing to remember to run. Anywhere else that can run a command between turns, the same warning comes from node .../agent-lock.mjs nudge.
That is the point of writing it down at all: a deadline that has to be asked about is a deadline nobody sees.
Optional: a status line segment
agent-lock.mjs statusline prints one line - 🔒 staging 42m, amber under ten minutes and red under two - and nothing at all when nothing is held. Nothing installs it, and it deliberately replaces nobody's status line: it is a segment to append to whichever one you already have.
# find the copy your agent installed, or use the one in a global CLI install
find ~/.claude/plugins ~/.hermes/skills ~/.gemini/skills -name agent-lock.mjs 2>/dev/null | head -1# at the end of your own status line script
held=$(node /path/to/skills/mutex/agent-lock.mjs statusline)
[ -n "$held" ] && printf " | %s" "$held"Worth it if you keep long locks and like seeing them; the hook covers the case that actually matters without it.
The plugin is in releasetools/agent-plugins - its source, its version, its tests and its validation. Change it there, bump the version in both manifests, and the merge is the release.
It carries a version of its own because it is installed from that marketplace rather than from npm or a version tag, so it moves when the plugin changes and not when this CLI does. What this repository still does is carry the skill in the npm package, so that the agents with no plugin manifest can be seeded from a global install:
npm run package:release -- --marketplace ../agent-pluginsThat copies skills/, commands/ and the installer out of a checkout of the marketplace, and refuses to build without one.
Contributions are welcome.
Node.js 24 or newer is required. Clone the source and create a global npm link once:
git clone https://github.com/releasetools/mutex.git
cd mutex
npm ci
npm run cli:link # build, then put this checkout's `mutex` on PATHThe link points at the checkout, so it does not need to be recreated after each
edit. The command reads compiled files from lib/; either rebuild explicitly
or leave the compiler running while developing:
npm run build
# Or keep lib/ current as source files change.
npm run build:watchRun the linked command normally from another terminal:
mutex
mutex status deployWithout the global link, run the same compiled CLI through npm:
npm run mutex -- status deployRun the tests alone with npm test. Before committing, use the full shorthand:
npm run checkThe PostgreSQL integration suite runs when MUTEX_TEST_DATABASE_URL is
available and is skipped otherwise. Point it at a disposable database; the
suite creates and truncates releasetools_mutex:
MUTEX_TEST_DATABASE_URL="postgresql://mutex@localhost/mutex_test" npm testcheck formats the source, builds the CLI and Action, and runs every test. The
pre-commit hook runs the relevant formatting, tests, and build again before a
commit is accepted. Remove the development link with:
npm run cli:unlink| Path | What lives there |
|---|---|
src/mutex.ts |
tryLock / tryUnlock, the polling logic, with no GitHub dependencies |
src/database.ts |
The PostgreSQL lock store |
src/main.ts |
The Action's entry point. src/post.ts auto-releases at the end of a job |
src/cli/ |
The mutex CLI |
src/mutex.ts and src/database.ts take a Logger and emit events instead of calling @actions/core, which is what lets both front ends share them.
GitHub has a tutorial on writing JavaScript actions.
main holds source only. What releasetools/mutex@v1 resolves to is built during the release and published to the release/v1 branch, while the same compiled CLI is published as @releasetools/mutex on npm. A release is therefore a workflow run rather than a git tag or a manual npm publish.
The releasetools organization must exist on npmjs.com, and the releasing
account must be allowed to publish public packages in that scope. npm cannot
configure a trusted publisher until the package exists, so bootstrap the first
release with a short-lived granular token that may publish with 2FA bypass:
-
Add the token as the
NPM_TOKENActions secret in this GitHub repository. -
Run the first release normally. The workflow publishes the package with provenance and creates
@releasetools/mutex. -
With npm 11.15 or newer and 2FA enabled, authorize this workflow:
npm trust github @releasetools/mutex \ --repo releasetools/mutex \ --file release.yaml \ --allow-publish
-
Delete the
NPM_TOKENsecret. In the package's npm settings, require 2FA and disallow token publishing; future releases authenticate only through short-lived GitHub OIDC credentials.
The trusted publisher settings are case-sensitive. Configure the repository as
releasetools/mutex, the workflow filename as release.yaml, no environment,
and allow npm publish.
Add the notes for the new version to RELEASE.md under a ## 1.4.0 heading, merge that to main, then:
gh workflow run release.yaml -f version=v1.4.0The release bumps package.json itself and pushes that to main as a signed commit, so there is no version to remember to edit and no way for package.json and the tag to disagree.
Two options, both off by default:
| Option | |
|---|---|
allow-lower-version |
Publish below the highest released version, for back-porting to an older line. Without it, v1.2.22 after v1.3.0 is refused. |
overwrite-existing |
Replace an Action version and GitHub release. An npm version is immutable and remains unchanged if it already exists. |
They are separate on purpose. Replacing a release and releasing out of order are different decisions, so neither flag grants the other.
| Step | |
|---|---|
check-e2e-pin |
Refuses to publish a major the verify step cannot test |
| Check | Rejects a malformed version, one already released, or one below the highest released |
| Bump | Sets the version in package.json and package-lock.json, and pushes that to main |
| Build | npm ci, lint, test |
| Package | npm run package:release assembles publish/: the Action bundle, compiled CLI, runtime manifest, README, and license |
| Publish | signed-push commits that tree to release/v1, signed server-side by GitHub, and points v1.4.0 and the floating v1 at it |
| Release | Creates or updates the GitHub release, with the notes from RELEASE.md |
| npm | Publishes @releasetools/mutex with provenance; an older backport gets the backport dist-tag instead of moving latest backwards |
| Verify npm | Installs the exact version from the public registry and checks mutex version |
| Verify mise | Installs mise-managed Node 24 and the exact public npm package in an isolated configuration, then checks mutex version and mutex help |
| Verify Action | Uses releasetools/mutex@v1 for real and checks the version it reports |
The first release on a new major seeds release/<major> from main automatically.
Packaging is a script rather than workflow YAML, so you can see what a release would publish without cutting one:
npm run package:release
node publish/dist/main/index.js # reports the version it would report in CI
npm pack ./publish --dry-run # shows exactly what npm would receivegit fetch origin 'refs/tags/*:refs/tags/*'
git show --stat v1.4.0
gh api repos/releasetools/mutex/commits/v1 --jq .commit.verification.verified
npm view @releasetools/mutex@1.4.0 versionEach published commit's parent is the previous release, so release/v1 reads as a history of releases. The source it was built from is a Source-Commit: trailer rather than a parent.
The verify step failing means the release is published but broken, since v1 has to move before anything can use it. Fix forward with a new patch. If it says v1 ran mutex <older version>, the tag move had not reached GitHub's action cache yet and re-running that job is enough.
check-e2e-pin failing means you are releasing a major the verify step still pins to @v1. Write a second verify job for the new major and update PINNED. Nothing is published until then.
A version mismatch means package.json and the dispatched tag disagree. Nothing has been published.
If npm authentication fails after the GitHub release is created, correct the
bootstrap token or trusted-publisher settings and dispatch the same version
with overwrite-existing. npm publishing is idempotent: the workflow publishes
a missing package version and leaves an existing immutable one alone.
uses: releasetools/mutex@main does not work, and is not meant to. There is no dist/ there.
Copyright © 2025-2026 Mihai Bojin
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.