Cross-run locks
A lock claims an exclusive resource across runs, processes, even machines — "one deploy of repo X at a time." It lives in the same state store as durable run state. Acquisition is contended-fails-fast: no queue, no waiting — the loser gets actionable guidance instead.
.lock() authoring
Call .lock(configure) on a target to guard it with a lock. The
configure lambda runs after params resolve, so the key can read
this.<param>.value:
// target.ts: lock(configure: Configure<LockSettings>): this
export class LockSettings {
lockKey(...parts: Array<string | number>): this; // sanitised + joined
key(key: string): this; // set directly (must be filename-safe)
withTtl(ttl: string | number): this; // "4h"/"30m" or ms
onConflict(render: (holder: LockHolder) => string): this;
} import { Build, parameter, target } from "jsr:@zuke/core";
class CD extends Build {
repo = parameter("service to deploy");
promote = target()
.lock((s) =>
s.lockKey("deploy", this.repo.value)
.withTtl("4h")
.onConflict((h) =>
`${this.repo.value} is being deployed by ${h.actor} ` +
`(run ${h.runId}, since ${h.since}). Wait, then retry.`))
.executes(async (ctx) => {/* … */});
} lockKey()
lockKey(...parts) builds a safe key by joining its parts with
-:
export function lockKey(...parts: Array<string | number>): string; // non-[A-Za-z0-9._-]→"_", drop empty, join "-"
Every character outside [A-Za-z0-9._-] becomes _
and empty parts are dropped, so s.lockKey("deploy", this.repo.value)
always yields a filename-safe key regardless of what the parameter holds.
LockHolder
export interface LockHolder { actor: string; runId: string; since: string; runUrl?: string }
export class LockConflictError extends Error { readonly holder: LockHolder; /* message = guidance */ }
export type LockResult = { ok: true; token: string } | { ok: false; holder: LockHolder }; LockHolder identifies who currently holds a lock — used both
in onConflict's guidance message and in the raw
LockResult a store returns on a failed acquisition.
TTL, heartbeat & release
.withTtl("4h" | "30m" | ms) bounds a lock whose holder
disappears — it is not a hard limit on the operation
itself. A live holder auto-renews the lock at half the
TTL, so a long-running deploy under a short TTL never loses its
lock while it's still alive. Kill the process (kill -9) and
renewals simply stop — the lock frees once the TTL elapses.
The lock is released in a finally on settle — success,
failure, or cancellation — so the TTL only backstops a killed process, it
isn't the normal release path. Acquisition is atomic: two runs racing a
free key resolve to exactly one winner, and an expired lock is taken over
atomically by the next acquirer.
LockConflictError & conflict output
There's no dedicated lock CLI. On a conflict, the target fails with a
LockConflictError whose message is the rendered
onConflict guidance; the run exits non-zero, the failure
footer prints that guidance, and the target is recorded failed
in the run record with the guidance as its error. Release a
lock held by a run you control with zuke cancel <runId>.
Requires a state store
Locks live in the state store — a build that calls .lock()
turns on the .zuke/runs filesystem store by default if
nothing else configured one. Calling .lock() with state
explicitly disabled fails with a friendly error rather than silently
not locking. Point the build at an HTTP-backed store (see
FileSystem vs HTTP backends) to share
locks across machines — the server is authoritative for expiry there.
HTTP lock endpoints
| Verb + path | Behavior |
|---|---|
POST /locks/:key | 201 { token } on acquisition, or 409 plus the current LockHolder. |
PUT /locks/:key | Renew. 409/404 means the token was lost. |
DELETE /locks/:key | Release. 404 is not an error. |
Key safety
lockKey(...) always sanitizes its input — prefer it.
s.key(literal) sets the key directly and bypasses that
sanitization, so a caller using it is responsible for keeping the value
filename-safe.