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.

MethodSignatureEffect
.description(text)(text: string) => thisHuman-readable summary shown in --list.
.executes(fn)(fn: TargetFn) => thisThe body (required). Receives an optional TargetContext; may be sync or async.
.unlisted()() => thisHide a helper target from --list/--help; it can still be run by name or depended on.
.always()() => thisRun even after the build has already failed, for cleanup/teardown. Still waits for its own dependencies.
.readOnly()() => thisAdvertise the target query-only over MCP (readOnlyHint, not destructiveHint); exempt from --confirm-destructive. A hint about intent — the body still runs.
.dryRunnable()() => thisRun 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) => thisJoin 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.

MethodSignatureEffect
.dependsOn(...targets)(...t: Array<Target | Group>) => thisHard prerequisites; run first, transitively. Pulls targets into the plan.
.before(...targets)(...t: Target[]) => thisSoft ordering: run before these if both are planned. Never pulls targets in.
.after(...targets)(...t: Target[]) => thisSoft ordering: run after these if both are planned.
.dependentFor(...targets)(...t: Target[]) => thisReverse 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[]) => thisFail 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.

MethodSignatureEffect
.onlyWhen(condition)(c: () => boolean | Promise<boolean>) => thisRun only when the condition holds, else skip (dependents still run). May be async; repeatable — all must hold.
.whenSkipped(behavior)("run-dependencies" | "skip-dependencies") => thisWhen 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[]) => thisCache inputs: skip the target when these are unchanged. Repeatable.
.outputs(...paths)(...p: PathLike[]) => thisCache outputs: a hit also requires these to still exist. Repeatable.
.cacheKey(fn)(fn: () => string | Promise<string>) => thisAn extra, non-file input to the fingerprint (a parameter, tool version, git commit). Repeatable; may be async.
.produces(...paths)(...p: PathLike[]) => thisDeclare artifact paths this target produces (metadata).
.consumes(...targets)(...t: Array<Target | Group>) => thisDepend 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.

MethodSignatureEffect
.validateBefore(...v)(...v: Validation[]) => thisRun checks before the body; the first to throw skips it and fails the target.
.validateAfter(...v)(...v: Validation[]) => thisRun checks after a successful body; a throw fails the target.
.timeout(ms)(ms: number) => thisFail 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) => thisRetry the body up to times more attempts on failure, pausing delayMs between (default 0). The last error propagates once exhausted.
.proceedAfterFailure()() => thisKeep 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.

MethodSignatureEffect
.triggers(...targets)(...t: Target[]) => thisPull 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.

MethodSignatureEffect / see
.lock(configure)(c: Configure<LockSettings>) => thisHold 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>) => thisSuspend 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>) => thisFan 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)) => thisRegister 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[]) => thisOn 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) => thisBound 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.