A GitHub Action for distributed mutex locking using git refs. Serialize concurrent workflow jobs to prevent race conditions.
The Problem: Multiple workflow runs (e.g., semantic-release across services in a monorepo) try to push to the same branch simultaneously, causing git push conflicts.
The Solution: Use git refs as atomic locks. A ref can only be created once — if it already exists, the create call returns 422, making it a natural mutex. Jobs acquire the lock before the critical section and release it when done.
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: acquire lock
id: lock
uses: DND-IT/action-lock@v0
with:
action: acquire
lock_name: release
token: ${{ secrets.GITHUB_TOKEN }}
# ... do your critical section work ...
- name: release lock
if: always()
uses: DND-IT/action-lock@v0
with:
action: release
lock_name: release
token: ${{ secrets.GITHUB_TOKEN }}| Input | Description | Required | Default |
|---|---|---|---|
action |
Lock action: acquire or release |
Yes | |
lock_name |
Name of the lock (used as ref name under refs/locks/) |
Yes | |
timeout |
Maximum time in seconds to wait for lock acquisition | No | 300 |
poll_interval |
Seconds between lock acquisition attempts | No | 10 |
stale_threshold |
Seconds after which a lock is considered stale and can be force-acquired. Set to 0 to disable stale detection. |
No | 600 |
fail_on_timeout |
Fail the step if the lock cannot be acquired within timeout. Set to false to skip gracefully. |
No | true |
token |
GitHub token with contents:write permission |
Yes |
| Output | Description |
|---|---|
acquired |
Whether the lock was successfully acquired (true/false) |
lock_ref |
The full git ref used for the lock (e.g., refs/locks/release) |
-
Acquire: Creates a git ref
refs/locks/<lock_name>pointing to the current commit SHA. If the ref already exists (HTTP 422), the lock is held by another process — the action retries with exponential backoff until timeout. -
Stale Detection: If a lock has been held longer than
stale_thresholdseconds (based on the commit date of the SHA), it's automatically removed and re-acquired. This prevents deadlocks from crashed workflows. -
Release: Deletes the git ref. Idempotent — releasing a non-existent lock is a no-op.
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: acquire release lock
uses: DND-IT/action-lock@v0
with:
action: acquire
lock_name: semantic-release
token: ${{ secrets.GITHUB_TOKEN }}
- name: run semantic release
uses: cycjimmy/semantic-release-action@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: release lock
if: always()
uses: DND-IT/action-lock@v0
with:
action: release
lock_name: semantic-release
token: ${{ secrets.GITHUB_TOKEN }}Hold a lock for the entire lifetime of a PR — the dev environment is exclusively yours until the PR is closed. The main branch workflow waits for the lock before applying.
PR workflow (.github/workflows/terraform-pr.yaml):
on:
pull_request:
types: [opened, synchronize, reopened]
paths: ['terraform/**']
jobs:
apply:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: acquire dev lock
uses: DND-IT/action-lock@v0
with:
action: acquire
lock_name: terraform-dev
stale_threshold: 0 # never expire — held until PR closes
token: ${{ secrets.GITHUB_TOKEN }}
- name: terraform apply
run: terraform apply -auto-approvePR cleanup workflow (.github/workflows/terraform-pr-cleanup.yaml):
on:
pull_request:
types: [closed]
paths: ['terraform/**']
jobs:
unlock:
runs-on: ubuntu-latest
steps:
- name: release dev lock
uses: DND-IT/action-lock@v0
with:
action: release
lock_name: terraform-dev
token: ${{ secrets.GITHUB_TOKEN }}Main branch workflow (.github/workflows/terraform-deploy.yaml):
on:
push:
branches: [main]
paths: ['terraform/**']
jobs:
apply:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: acquire dev lock
id: lock
uses: DND-IT/action-lock@v0
with:
action: acquire
lock_name: terraform-dev
timeout: 0
fail_on_timeout: false
token: ${{ secrets.GITHUB_TOKEN }}
- name: terraform apply
if: steps.lock.outputs.acquired == 'true'
run: terraform apply -auto-approve
- name: release lock
if: steps.lock.outputs.acquired == 'true'
uses: DND-IT/action-lock@v0
with:
action: release
lock_name: terraform-dev
token: ${{ secrets.GITHUB_TOKEN }}This action is written in Go and runs as a Docker container.
go build -o action-lock ./cmd/action-lockgo test -v -race ./...This action uses release-please for automated versioning based on conventional commits.
The release workflow automatically updates version aliases:
v0— Always points to the latest v0.x.x releasev0.1— Always points to the latest v0.1.x release
- uses: DND-IT/action-lock@v0 # Always gets latest v0.x.x
- uses: DND-IT/action-lock@v0.1 # Always gets latest v0.1.x
- uses: DND-IT/action-lock@v0.1.0 # Pinned to specific versionMIT