Authoring targets
A target is declared as a class field on a
Build subclass, built up through the
fluent TargetBuilder that target() returns. Every
configuration method is chainable and returns this; the only
thing required before a target can run is a body, set with
.executes(...). This page is the authoritative index of what a
target can declare — each method, its exact signature, and where the deeper
features are documented in full.
import { Build, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";
class Ci extends Build {
lint = target()
.description("Lint sources")
.executes(async () => {
await DenoTasks.lint();
});
test = target()
.description("Run the test suite")
.dependsOn(this.lint)
.executes(async (ctx) => {
// ctx is optional: runId, target, signal, state, signals, dryRun
await DenoTasks.test((s) => s.allowAll());
});
}
await run(Ci);
Dependencies are passed as sibling references
(this.lint), never as strings: the framework maps each builder
back to its field name during discovery. Because class fields initialise
top-to-bottom, a target can only reference siblings declared above
it — a forward reference is undefined at runtime (and
TypeScript flags it as TS2729).
The body may take an optional TargetContext argument
(ctx): ctx.runId, ctx.target,
an AbortSignal at ctx.signal that is aborted on
cancellation, a durable ctx.state handle (and
ctx.stateOf(target) for another target's), the
ctx.signals map of received external signals, and
ctx.dryRun. A zero-argument body stays valid. See
Run context.
Declaration
The basics: what the target is — its summary, its body, whether it
is visible, and how it advertises itself. group() creates a
parallel batch; a target joins one with
.partOf(group) and its members then run concurrently with one
another even in an otherwise sequential build (each still waiting for its own
dependencies). Passing the group to another target's
.dependsOn(...) depends on every member at once.
| Method | Signature | Effect |
|---|---|---|
.description(text) | (text: string) => this | Human-readable summary shown in --list. |
.executes(fn) | (fn: TargetFn) => this | The body (required). Receives an optional TargetContext; may be sync or async. |
.unlisted() | () => this | Hide a helper target from --list/--help; it can still be run by name or depended on. |
.always() | () => this | Run even after the build has already failed, for cleanup/teardown. Still waits for its own dependencies. |
.readOnly() | () => this | Advertise the target query-only over MCP (readOnlyHint, not destructiveHint); exempt from --confirm-destructive. A hint about intent — the body still runs. |
.dryRunnable() | () => this | Run the body under --dry-run (instead of skipping it) with the $ shell in echo mode — each command prints its resolved argv and returns empty success without spawning a process. |
.partOf(group) | (group: Group) => this | Join a parallel batch created by group(). |
checks = group(); // declare the batch above the members that join it
clean = target().executes(/* … */);
lint = target().dependsOn(this.clean).partOf(this.checks).executes(/* … */);
format = target().dependsOn(this.clean).partOf(this.checks).executes(/* … */);
deploy = target()
.dependsOn(this.checks) // one edge; waits for lint and format
.executes(/* … */); .dryRunnable() is opt-in because Zuke can only intercept
$/Command — any other side effect
(writing a file, calling an API directly) still happens under a dry run.
A body whose control flow depends on a command's output should branch on
ctx.dryRun rather than trust the echoed (empty) result.
Dependencies & ordering
.dependsOn(...) declares hard prerequisites:
they are pulled into the plan and run first, transitively. Ordering methods
(.before()/.after()) are soft:
they only reorder targets that are already in the plan and never
pull new ones in. Both .dependsOn() and .consumes()
accept a group, which expands to every member.
| Method | Signature | Effect |
|---|---|---|
.dependsOn(...targets) | (...t: Array<Target | Group>) => this | Hard prerequisites; run first, transitively. Pulls targets into the plan. |
.before(...targets) | (...t: Target[]) => this | Soft ordering: run before these if both are planned. Never pulls targets in. |
.after(...targets) | (...t: Target[]) => this | Soft ordering: run after these if both are planned. |
.dependentFor(...targets) | (...t: Target[]) => this | Reverse of dependsOn: each listed target gains this one as a dependency, so this runs before them without editing them. Declare the listed targets above this one. |
.requires(...params) | (...p: Parameter[]) => this | Fail the target (naming the parameter) unless each listed parameter resolved to a value. For a parameter that is optional build-wide but mandatory here. |
lint = target()
.description("Lint sources")
.after(this.restore) // if restore is in the plan, run after it
.before(this.test) // if test is in the plan, run before it
.executes(async () => {
await DenoTasks.lint();
}); External ordering lives on the Build, not the
target. Override extraEdges(targets) to return
[before, after] pairs statically, or the async
orderWith(targets) to load them per run (read a
monorepo's dependency graph, hit an API). Both merge, share the same rules
(an edge whose endpoints aren't both in the run is ignored; a cycle is a
friendly error), and are honoured by runs and by zuke cancel's
reverse-order walk — but neither shows in the static
graph/--list views. See
Orchestration → ordering.
Conditional execution & caching I/O
.onlyWhen(condition) gates whether a target runs; the rest
declare the target's cache footprint. A target that
declares .inputs(...) becomes incremental — Zuke fingerprints
those paths (SHA-256, directories hashed recursively) and reports the target
cached when the fingerprint is unchanged since the last
successful run and every declared .outputs(...) still exists.
| Method | Signature | Effect |
|---|---|---|
.onlyWhen(condition) | (c: () => boolean | Promise<boolean>) => this | Run only when the condition holds, else skip (dependents still run). May be async; repeatable — all must hold. |
.whenSkipped(behavior) | ("run-dependencies" | "skip-dependencies") => this | When skipped by a condition, also skip dependencies no other planned target needs. The condition is evaluated up front, so it must not rely on run-time state. |
.inputs(...paths) | (...p: PathLike[]) => this | Cache inputs: skip the target when these are unchanged. Repeatable. |
.outputs(...paths) | (...p: PathLike[]) => this | Cache outputs: a hit also requires these to still exist. Repeatable. |
.cacheKey(fn) | (fn: () => string | Promise<string>) => this | An extra, non-file input to the fingerprint (a parameter, tool version, git commit). Repeatable; may be async. |
.produces(...paths) | (...p: PathLike[]) => this | Declare artifact paths this target produces (metadata). |
.consumes(...targets) | (...t: Array<Target | Group>) => this | Depend on the producers and use their produces artifacts — an alias of dependsOn that reads as artifact use. |
compile = target()
.inputs("src", "deno.json") // re-run only when these change…
.outputs("dist") // …or when dist is missing
.cacheKey(() => this.configuration.value) // …or when config changes
.executes(async () => {
await DenoTasks.run((s) => s.script("build.ts"));
});
A target with no inputs and no cache keys always runs. Fingerprints live in
.zuke/cache.json; a cached/skipped target counts as satisfied,
so its dependents still run. See Caching
for the fingerprint algorithm, the cache
key, and the output semantics in full.
Lifecycle & validation
Checks around the body and how failure is handled. A
Validation is any object with a
validate(ctx) method: .validateBefore() runs its
checks before the body (a throw skips the body and fails the target);
.validateAfter() runs them after a successful body. Both are
repeatable and order-preserving, and a cached/skipped target runs neither.
.timeout() and .retry() compose — the timeout is
per attempt.
| Method | Signature | Effect |
|---|---|---|
.validateBefore(...v) | (...v: Validation[]) => this | Run checks before the body; the first to throw skips it and fails the target. |
.validateAfter(...v) | (...v: Validation[]) => this | Run checks after a successful body; a throw fails the target. |
.timeout(ms) | (ms: number) => this | Fail the body if it runs longer than ms (per attempt). A timed-out body can't be cancelled — it keeps running in the background, but its result is ignored. |
.retry(times, delayMs?) | (times: number, delayMs = 0) => this | Retry the body up to times more attempts on failure, pausing delayMs between (default 0). The last error propagates once exhausted. |
.proceedAfterFailure() | () => this | Keep running the rest of the build if this target fails. The build still reports failure, and this target's own dependents are skipped. |
const noSecrets: Validation = {
name: "no-secrets",
validate: async () => {/* scan the diff; throw on a hit */},
};
deploy = target()
.validateBefore(noSecrets) // a throw skips the body and fails the target
.executes(async () => {/* … */}); flaky = target()
.timeout(30_000) // each attempt may run up to 30s…
.retry(2, 1_000) // …retried twice, 1s apart, on failure
.proceedAfterFailure() // don't abort the rest of the build if it still fails
.executes(async () => {
await fetch("https://example.com/health");
}); @zuke/ai ships AI reviewers
(securityReviewer(...), …) that implement
Validation — attach one the same way to gate the build on a
model-assessed score. See AI review.
Triggers & CD
.triggers(...) is the inverse of .dependsOn(...):
running this target pulls the listed targets into the plan and runs
them after it — a deploy that triggers a
notify, say. Unlike .before()/.after()
(which only reorder), it adds targets to the plan.
| Method | Signature | Effect |
|---|---|---|
.triggers(...targets) | (...t: Target[]) => this | Pull these into the plan and run them after this target. |
Continuous delivery is generated from the build rather than
declared on a target: cicd() binds a provider-agnostic
pipeline (with optional per-target fanOut jobs) to an output
path — see CI generation. Timezone-aware
cron schedules live on that pipeline's triggers — see
Schedules.
Resilience & orchestration
The long-running and self-healing surface. Each of these has a dedicated
page — the row here is the signature and a one-line summary; follow the
cross-link for the full model. The three settings-lambda methods
(.lock, .waitsFor, .forEach) take a
Configure<Settings> function, run after parameters
resolve, so the lambda may read this.<param>.value.
.lock, .waitsFor, and .onCancel
require a state store.
| Method | Signature | Effect / see |
|---|---|---|
.lock(configure) | (c: Configure<LockSettings>) => this | Hold a cross-run lock while this target runs; a second run fails with a LockConflictError. Set the key/TTL/conflict message in the lambda. See Locks. |
.waitsFor(configure) | (c: Configure<WaitSettings>) => this | Suspend the run at this gate until an external event; resume later in another process. Set .on(trigger), .timeout(), .onTimeout(). See Orchestration → suspend/resume. |
.forEach(items, factory, configure?) | (items: () => readonly Item[], factory: ForEachFactory<Item>, c?: Configure<ForEachSettings>) => this | Fan out over a runtime list: per item, build an ordered sub-target pipeline; items run with bounded concurrency and per-item failure isolation. See Orchestration → fan-out. |
.onCancel(compensation) | (c: Target | (() => Target)) => this | Register a compensation that undoes this target when the run is cancelled — runs iff this target succeeded, in reverse order. Use the thunk form for a target declared below. See Orchestration → compensation. |
.recoverWith(...r) | (...r: Remediation[]) => this | On failure, hand the error to a remediation that may repair it and ask (via { retry: true }) to re-run the body. See Self-healing. |
.recoverAttempts(n) | (n: number) => this | Bound how many fix-then-rerun cycles are tried (default 1, clamped to at least 1). |
import { externalSignal, target } from "jsr:@zuke/core";
awaitApproval = target()
.dependsOn(this.deploy)
.waitsFor((s) =>
s.on(externalSignal("testing-approved"))
.timeout("72h")
.onTimeout(() => this.rollback)
)
.onCancel(() => this.rollback); // undo the deploy if the run is cancelled
A Remediation is any object with a
remediate(ctx) method that returns
{ retry: boolean; summary?: string }; it runs only after the
body fails, receives the failure (a CommandError with
stderr when the target failed through the shell), and the real
build command is the verifier. The AI fixer in
@zuke/ai implements it — see
Self-healing → AI fixer.
For the full framework surface beyond the builder — components, assertions,
FileTasks, HTTP and compression helpers, host detection, and the
Build lifecycle hooks (onStart/onFinish)
— see Concepts and
the standard library.