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