Mutex
An advisory lock service for CI/CD workflows, available as a GitHub Action, a command-line tool, and an agent plugin. It helps prevent race conditions and ensures that critical sections of your pipeline are executed by only one job at a time.
How it works
This action uses a PostgreSQL database to manage locks. When a workflow job needs to acquire a lock, it communicates with the lock service. If the lock is available, it's granted, and the job proceeds. If not, the job can wait or fail, depending on your workflow configuration.
Features
- Advisory Locking: Create and manage locks within your GitHub Actions workflows.
- Pull Request Integration: Lock and release events are posted as PR comments.
- Slack Notifications: Choose if you want to be notified in your Slack channels about locking events.
- Easy Disabling: Skip locking for specific pull requests by:
- adding a
SKIP_MUTEXlabel - including
SKIP_MUTEXin the PR's description or comment - or defining
SKIP_MUTEX=1as an environment variable.
- adding a
Usage Example
Here is an example of how to use the mutex action in a workflow:
permissions:
contents: read
pull-requests: write
steps:
- name: Acquire Lock
uses: releasetools/mutex@v1
env:
MUTEX_DATABASE_URL: ${{ secrets.MUTEX_DATABASE_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
with:
command: "lock"
id: "my-resource"
slack-channel: "C12345678"
Any other workflows or actions using a mutex on the same lock id, will not run until the lock is released.
Command-line interface
The mutex CLI takes the same PostgreSQL-backed locks outside GitHub Actions, including from a laptop, cron job, or another CI system. A lock taken by the CLI excludes the Action and vice versa.
Install it 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.
mise installs it without npm, but its npm backend does not bring the Node runtime along, so ask for both in one command. The package is still below mise's download-count threshold, so approve it explicitly; allow_low_downloads applies to mutex and not to its dependencies:
mise use --global node@24 \
'npm:@releasetools/mutex[allow_low_downloads=true]@1'
mutex version
@1 follows the newest v1 release, and stops there rather than crossing into a future major on its own. allow_low_downloads needs mise 2026.8.8 or newer. With mise activated, mutex runs directly; without shell activation, run it through mise exec -- mutex.
mise ignores releases younger than minimum_release_age, which defaults to 24 hours, so on the day of a release mutex version reports the one before it. mise says so rather than leaving it a mystery:
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, because an exact version installs immediately - which is how this project'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.
For occasional use, set the connection string in the environment and run a command directly:
MUTEX_DATABASE_URL="postgres://..." mutex lock staging -- ./deploy.sh
The connection string is read from MUTEX_DATABASE_URL only. It is not accepted as a command-line argument or stored in a profile because arguments are visible in shell history and process listings. DATABASE_URL is not read.
CLI commands
| Command | |
|---|---|
mutex lock <id> | Acquire a lock, waiting up to --max-wait |
mutex lock <id> -- <program> | Acquire it, run the program, then release it |
mutex try-lock <id> | Acquire it in one attempt |
mutex unlock <id> | Release it |
mutex renew <id> | Extend a lock already held by the caller |
mutex status <id> | Show who holds it |
mutex list | List locks, expired ones included |
mutex prune | Delete locks that have already expired |
mutex profile [name] | Show the 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 a service manager |
mutex server status | Show the running server's version, protocol, log and pool |
mutex server stop | Gracefully stop it |
mutex help [command] | Show help |
mutex version | Print the version |
CLI options
| 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; -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 locks to show |
--no-renew | Do not renew while a wrapped program runs | |
--dry-run | On prune, list what would go and delete nothing | |
-p, --profile <name> | Default profile | Use one profile for this command without changing the file |
--json | Machine-readable output | |
-q, --quiet | Errors only | |
--verbose | Include debug output | |
-h, --help | Show help |
Profiles and the local server
With no profiles file, a valid MUTEX_DATABASE_URL gives zero-configuration direct database access. For frequent commands against a remote database, mutex server keeps a PostgreSQL connection pool warm and accepts the same operations over local TCP, avoiding a fresh database and TLS connection for every CLI process.
Run mutex profile once to configure it. On a terminal, mutex asks only for a working directory, suggests ${XDG_CONFIG_HOME:-$HOME/.config}/releasetools-mutex, generates profiles.toml, prints its contents and path to stderr, and opens an arrow-key selector. It uses localhost:5625 without prompting for another port.
[server]
mode = "server"
default = true
bind_address = "localhost:5625"
working_dir = "/home/alice/.config/releasetools-mutex"
[direct]
mode = "direct"
default = false
Exactly one profile is the default. default = false only keeps a profile from being chosen implicitly; it stays available through --profile. mutex profile direct makes a defined profile the default and clears the marker from the others in one write, and an unknown name fails and lists the defined ones. -p direct selects a profile for a single command. Selection is explicit: direct mode does not probe the server, and server mode does not fall back to the database.
This setting was called enabled before 1.4.0. An existing profiles.toml keeps working only after the rename.
Either kind of profile may also set ssl_negotiation, which decides 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, which saves a round trip on every connection mutex opens - about 25 ms against a hosted database. 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. sslnegotiation=direct in the connection string does the same thing, and the profile wins when both say something.
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, which costs one failed connection at startup against PostgreSQL 16 or older and nothing afterwards.
The server process changes to working_dir and requires MUTEX_DATABASE_URL in its environment. mutex does not read secret stores itself. mutex server start detaches and waits for PostgreSQL and TCP readiness; service managers should use foreground mutex server run -p server instead. The server keeps one database connection open rather than letting it lapse after ten seconds idle, so a lock asked for minutes after the last one no longer pays for a fresh handshake - about 180 ms against a hosted database.
Each server-side operation appends a line to <working_dir>/mutex-<profile>.log:
|2026-08-16T14:32:09.417Z|lock|deploy|alice|127.0.0.1|workstation.local|
The versioned, newline-delimited JSON protocol has no application authentication or TLS. It binds to localhost by default; a deployment that widens the bind address is responsible for IP ACLs. The mutex repository includes ready-to-edit systemd and rootless per-user macOS LaunchAgent templates. The LaunchAgent uses a user-owned wrapper to retrieve MUTEX_DATABASE_URL from dotsecenv at startup, rather than storing it in the plist.
Upgrading a running server
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. The protocol version is 2 from 1.4.0, so a server left running across the upgrade refuses lock commands until it is restarted:
mutex server stop && mutex server start
mutex server stop and mutex server status work whatever version the other end speaks. They are how a mismatch is seen and how it is fixed, so the restart never needs kill and a pid file.
mutex server status reports the version and protocol the running server was built with, and names this one alongside when the two differ. A server goes on running the code it started with; before 1.4.0 the only hint of an old one was a protocol number.
Wrapped programs, ownership, and renewal
mutex lock <id> -- <program> holds the lock for exactly as long as the program runs and releases it on every exit path. The program owns stdout, mutex reports on stderr, and the program's exit status becomes mutex's. SIGINT, SIGTERM, and SIGHUP are forwarded while mutex retains enough time to release the lock. Unless --no-renew is set, mutex renews the lock every --expiration / 3 seconds while the program runs.
Acquisition is decided by expiry. Ownership controls who may unlock or renew: anyone can modify an unowned lock, while a named lock requires the same --owner. There is no force option; deliberately breaking a named lock means passing its current owner. renew never takes a missing or expired lock and never shortens an existing lease.
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 again
What 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.
Query commands write to stdout; acting commands write results to stderr. --json writes machine-readable output to stdout except while wrapping a program, which owns stdout.
CLI exit codes
| Code | |
|---|---|
0 | Success; for status, the lock is held |
1 | Error |
2 | Usage error |
3 | Missing or unusable configuration |
4 | Not acquired, or not held |
5 | Another owner holds the lock and was not named |
126 | The wrapped program could not be run |
127 | The wrapped program was not found |
While wrapping a program, its exit status is returned instead.
Agent plugin
The mutex agent plugin is a pair of agent skills and a helper they run: mutex carries the judgement around taking a lock, and naming decides which lock an operation takes and what it is called. Ask an agent to guard some work and it takes a lock around it, hands it back when the work is done, and speaks up before the lease runs out. It is deliberately narrow: it never volunteers a lock, never breaks somebody else's, never runs mutex server, mutex profile or mutex prune on your behalf, and never reads the connection string.
It lives in releasetools/agent-plugins, which is where to change it, and it carries a version of its own rather than the CLI's.
Installing it installs no mutex command and supplies no connection string. Install the CLI first, and set MUTEX_DATABASE_URL yourself; /mutex:preflight reports whether the lock table is reachable, and what is missing when it is not.
Claude Code and Codex install it from the marketplace:
claude plugin marketplace add releasetools/agent-plugins
claude plugin install mutex@ReleaseTools
codex plugin marketplace add releasetools/agent-plugins
codex plugin add mutex@ReleaseTools
Hermes, Gemini and Antigravity read a skills directory rather than a plugin manifest, so they get a copy of the same files. It travels in the npm package, so a global CLI installation is all there is to fetch:
node "$(npm root -g)/@releasetools/mutex/scripts/install-agent-skills.mjs"
--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 agent instead of all of them.
That copy is a snapshot. The CLI package carries the plugin as it stood when that CLI version was released, so a command added to the marketplace since then reaches these agents with the next CLI release rather than straight away. --check says whether a copy is behind, and re-running the installer moves it forward.
Seven commands appear in the slash menu:
| 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] | Who holds a lock, and what this session holds |
/mutex:renew <id> [seconds] | Extend a lock before it lapses |
/mutex:unlock <id> | Hand it back |
/mutex:callsign <kind> [args] | The lock id for a resource, derived rather than composed |
/mutex:help | What the plugin does, and what it will not |
/mutex:callsign exists because two agents guarding the same resource have to arrive at the same lock id, and composing one by hand is how they end up with two. It derives an id once the resource is known; it does not decide which resource an operation should lock. When its arguments describe an operation instead - reviewing something or deploying somewhere - it reads the naming skill first, which decides whether a lock is needed and what it guards. It reads the origin remote for the kinds that name something in a repository - issue, pr, branch, release and the rest - and takes none for kinds like env, db or dns. It takes no lock and looks nothing up; it answers with an id, and rejects one that breaks a naming rule rather than letting it through.
Locks the plugin takes last an hour by default rather than the CLI's minute. A conversation 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.
Configuration
Prerequisites
- PostgreSQL database: This action requires access to a PostgreSQL database to store lock information. You can use any standard Postgres provider. If you need a free one for getting started, consider using Neon.
Environment Variables
The action supports the following environment variables (env:).
MUTEX_DATABASE_URL
Connection string for a PostgreSQL database. The action will create a table named releasetools_mutex if it doesn't exist. If the role specified in the connection string cannot create tables, ensure such a table exists. You can find the schema definition in database.ts.
The name is prefixed on purpose: frameworks, ORMs and PaaS providers all set DATABASE_URL, and they set it to the application's own database rather than the one holding locks. Up to 1.2.2 the action read DATABASE_URL as well and warned when it did; from 1.3.0 it is not read at all, so a workflow still passing it fails with MUTEX_DATABASE_URL not found.
MUTEX_OWNER
The default for --owner, so a CI run or a shell session names itself once instead of on every command. mutex list reads it too, and answers with that owner's locks unless --owner says otherwise; --owner '' names nobody and lists them all. Only the CLI reads it, and the Action takes its owner input instead.
GITHUB_TOKEN
The action needs access to the GitHub API. It can be passed via ${{ secrets.GITHUB_TOKEN }}. The workflow needs additional permissions:
permissions:
contents: read
pull-requests: write
SLACK_BOT_TOKEN
The Slack Bot Token for sending notifications. It requires the chat:write permission, and the associated bot must be invited to the specified slack-channel, otherwise it will fail to post.
What sslmode means here
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 warned that pg v9 will adopt libpq's meaning instead - an upgrade that would quietly weaken every connection string saying require. Deciding here is what makes that upgrade a no-op, and it is why the warning no longer prints on every command.
Certificates named by sslrootcert, sslcert and sslkey are loaded as usual, so a private CA keeps working. uselibpqcompat=true 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.
A connection to a database that is not local and carries no TLS now warns, instead of sending the password and every lock in the clear silently.
Action Inputs
The action can be configured using inputs (with:).
command
Required. The command to execute: lock or unlock.
release still works as a synonym for unlock and logs a warning, so older workflows keep running. It will be removed in a future major version.
id
Required. A unique identifier for the lock.
reason
Optional reason for taking the lock. Useful to provide context regarding which service took the lock and why.
owner
Optional name for whoever is taking the lock, such as ${{ github.repository }}@${{ github.run_id }}. Only the same owner can unlock or renew a named lock; leave it unset and the lock stays unowned, which anyone can release. The action reuses it for explicit unlock steps and for the automatic release at the end of a job.
expiration
Lock expiration in seconds from current time. Defaults to 60 seconds in the future.
max-wait
Maximum time in seconds to wait to acquire the lock, before failing.
If not specified, it defaults to -1 which results in using the specified expiration as a timeout for the current run.
poll-interval
Allows changing the polling interval. Useful for long-duration locks.
auto-release
Used to signal if a lock should be automatically released when the workflow job ends. Defaults to true.
disable-pr-updates
By default, a comment will be posted on the Pull Request running the action, when locks are acquired or released.
Set it to true to never post comments on PRs.
slack-channel
Required for Slack notifications. The Slack channel to post updates to (e.g., C12345678).
Setting it is what turns Slack notifications on; leave it out and none are sent, whether or not a token is around.
The bot that owns the SLACK_BOT_TOKEN should be a member of this channel.
See Slack API docs for channel ID formats.
Action Outputs
status
locked, released, failed or skipped.
version
The version of the action that ran. The release workflow asserts it against the tag being released, since uses: ...@v1 resolves through GitHub's caches and cannot otherwise be proven fresh.
Advanced Usage
Multiple Locks
You can use multiple locks in the same workflow:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Acquire Database Lock
uses: releasetools/mutex@v1
env:
MUTEX_DATABASE_URL: ${{ secrets.MUTEX_DATABASE_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
command: "lock"
id: "database-migration"
reason: "Running database migrations"
- name: Acquire Deployment Lock
uses: releasetools/mutex@v1
env:
MUTEX_DATABASE_URL: ${{ secrets.MUTEX_DATABASE_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
command: "lock"
id: "production-deployment"
reason: "Deploying to production"
# Your deployment steps here
- name: Unlock the deployment
uses: releasetools/mutex@v1
env:
MUTEX_DATABASE_URL: ${{ secrets.MUTEX_DATABASE_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
command: "unlock"
id: "production-deployment"
- name: Unlock the database
uses: releasetools/mutex@v1
env:
MUTEX_DATABASE_URL: ${{ secrets.MUTEX_DATABASE_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
command: "unlock"
id: "database-migration"
Conditional Locking
Skip locking based on conditions:
- name: Acquire Lock
if: ${{ !contains(github.event.pull_request.labels.*.name, 'SKIP_MUTEX') }}
uses: releasetools/mutex@v1
# ... configuration
Development
All contributions are welcome!
-
Clone the repository:
git clone https://github.com/releasetools/mutex.git
cd mutex -
Install dependencies and pre-commit hooks:
npm install
npm run prepare
The main entry point is main.ts, which handles lock or unlock actions. A post-job script in post.ts handles automatic lock release if enabled.
You can learn about creating GitHub actions in this tutorial.
Releasing
A release is a workflow run rather than a tag. main holds source only: the bundle is built during the release and published to the release/v1 branch, which the version tags point at.
Add the notes for the new version to RELEASE.md, merge that, then dispatch:
gh workflow run release.yaml -f version=v1.4.0
The workflow bumps package.json itself and pushes it to main, publishes the built tree as a commit signed by GitHub, points v1.4.0 and the floating v1 at it, creates the GitHub release from the notes, and then uses releasetools/mutex@v1 for real to check it reports the version just released.
Troubleshooting
Common Issues
Database Connection Errors
- Ensure your
MUTEX_DATABASE_URLis correct and the database is accessible - Verify that the database user has sufficient permissions to create tables
- Check that the PostgreSQL server is running and accepting connections
Permission Errors
- Make sure your workflow has the required permissions:
permissions:
contents: read
pull-requests: write
Slack Integration Issues
- Verify that the
SLACK_BOT_TOKENhaschat:writepermissions - Ensure the bot is added to the specified channel
- Check that the channel ID format is correct
Getting Help
If you encounter issues:
- Check the GitHub Issues for similar problems
- Review the action logs in your GitHub workflow
- Verify your configuration against the examples above
- Open a new issue with detailed information about your setup
License
Copyright © 2024 ReleaseTools
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.