Core concepts

Five ideas cover almost everything in Zuke: targets, dependencies, the $ shell, tool wrappers, and parameters. Each one is just TypeScript, so your editor, type-checker, and debugger all work as usual.

Targets

A target is a unit of work — compile, test, publish. You define one as a class field with target() and a fluent chain:

compile = target()
  .description("Type-check & bundle")     // shown in --list
  .dependsOn(this.clean, this.restore)    // typed references
  .executes(async () => {
    // ordinary async TypeScript
  });

.description() documents the target, .dependsOn() wires prerequisites, and .executes() holds the action — an ordinary async function.

Dependencies

Targets reference each other with this.target — real values, not strings. Zuke builds the dependency graph, then runs targets in topological order, executing each one exactly once even when several targets share a prerequisite.

class MyBuild extends Build {
  clean   = target().executes(async () => { await $`rm -rf dist`; });
  restore = target().executes(async () => { await DenoTasks.cache(); });

  compile = target()
    .dependsOn(this.clean, this.restore)   // runs both first, once each
    .executes(async () => { /* ... */ });

  test = target()
    .dependsOn(this.compile)
    .executes(async () => { await DenoTasks.test(); });
}

Because dependencies are typed references, renaming a target updates every dependant automatically and a typo is a compile error — not a build that silently does the wrong thing.

Groups & parallelism

A group() is a parallel batch. A target joins one with .partOf(group); members of the same group run concurrently with one another — even when the build is otherwise sequential — each still waiting for its own dependencies. Pass the group to another target's .dependsOn(...) to depend on every member at once.

class MyBuild extends Build {
  checks = group();   // a parallel batch — declare it above its members

  clean     = target().executes(/* ... */);
  lint      = target().dependsOn(this.clean).partOf(this.checks).executes(/* ... */);
  format    = target().dependsOn(this.clean).partOf(this.checks).executes(/* ... */);
  typecheck = target().dependsOn(this.clean).partOf(this.checks).executes(/* ... */);

  deploy = target()
    .dependsOn(this.checks)   // waits for lint, format, and typecheck
    .executes(/* ... */);
}

Here clean runs first, then lint/format/ typecheck run together, then deploy. Grouping is a property of the members, so they batch whenever they run — no --parallel flag needed. (Ungrouped targets stay serialized unless you opt the whole build into --parallel.)

Incremental caching

A target that declares inputs becomes incremental: Zuke fingerprints those files (SHA-256 of their contents, directories hashed recursively) and skips the target — reporting it cached — when the fingerprint is unchanged since the last successful run and every declared output still exists. Otherwise it runs and refreshes the fingerprint.

compile = target()
  .inputs("src", "deno.json")   // re-run only when these change…
  .outputs("dist")              // …or when dist is missing
  .executes(async () => {
    await DenoTasks.run((s) => s.script("build.ts"));
  });

Fingerprints live in <repo root>/.zuke/cache.json (git-ignored). A target with no inputs and no cache keys always runs; pass --no-cache to rebuild everything. Add a non-file value (a parameter, tool version, git commit) to the fingerprint with .cacheKey(fn) — which on its own makes a target cacheable.

See the dedicated Caching page for the full picture — the fingerprint algorithm, how adding or removing a file invalidates a target, corrupt-store tolerance, the remote cache that shares built outputs across machines, --affected for git-aware monorepo runs, and the separate AI response cache (aiCache) that reuses an identical review or fix call instead of re-paying the model.

Conditional execution

.onlyWhen(condition) runs the target only when the condition holds; otherwise it's skipped (and its dependents still run). The predicate may be async and can read resolved parameters or the environment. It's repeatable — all conditions must hold. Pair it with .timeout(ms) and .retry(times, delayMs?) to bound and re-attempt flaky work:

deploy = target()
  .onlyWhen(() => this.environment.value === "production")
  .executes(/* ... */);

flaky = target()
  .timeout(30_000)   // each attempt may take up to 30s…
  .retry(2, 1_000)   // …retried twice, 1s apart, on failure
  .executes(async () => { await $`curl -fsSL https://example.com/health`; });

More target options

The fluent builder carries the rest of a target's behaviour:

  • .triggers(...targets) — the inverse of dependsOn: pull the listed targets into the plan and run them after this one (e.g. a notify triggered by deploy).
  • .dependentFor(...targets) — declare this target as a prerequisite of others without editing them.
  • .requires(...params) — fail the target unless each listed parameter resolved to a value.
  • .proceedAfterFailure() — if this target fails, keep running the rest of the build (the build still reports failure).
  • .always() — run even after the build has already failed, for cleanup/teardown.
  • .unlisted() — hide a helper target from --list; it can still be run by name or depended on.
  • .produces(...paths) / .consumes(...targets) — declare artifact paths a target produces, and depend on the producers.
  • .validateBefore(...) / .validateAfter(...) — attach a validation (any object with a validate(ctx) method) that runs before or after the body; a throw fails the target. This is the seam AI code review plugs into.

Use zuke <target> --dry-run to print every target that would run — honouring --skip and onlyWhen conditions — without executing any body or touching the cache.

For a dependency that's a long-lived process rather than a step that finishes — a dev server, a database, a mock API — service() declares a target Zuke starts, waits until it's ready, keeps alive while its dependents run, and tears down when the build ends.

The $ shell

The $ tagged template from @zuke/core/shell runs processes with sensible defaults. Interpolated values are escaped, so untrusted input can't break out of a command:

import { $ } from "jsr:@zuke/core/shell";

await $`deno test -A`;                       // throws on non-zero exit
const sha = await $`git rev-parse HEAD`.text(); // trimmed stdout
const code = await $`flaky-cmd`.noThrow().code(); // exit code, never throws

// Interpolations are escaped — no shell injection:
const branch = "feature/$(rm -rf /)";
await $`git checkout ${branch}`;             // treated as a single literal arg

Chain .text() for trimmed stdout, .code() for the exit status, or .noThrow() to handle failures yourself.

Tool wrappers

Rather than hand-write CLI strings, reach for the typed @zuke/* wrappers. Each exposes a fluent, discoverable API over a real tool — Deno, npm, Bun, Docker, Kubernetes, Helm, Vite, Playwright, Terraform, AI coding agents (Claude Code, Codex, Gemini) and 30+ more — all published to JSR:

import { NpmTasks } from "jsr:@zuke/npm";
import { DockerTasks } from "jsr:@zuke/docker";

await NpmTasks.ci();                              // npm ci
await NpmTasks.run((s) => s.script("build"));     // npm run build
await DockerTasks.build((s) => s.tag("app:latest").file("Dockerfile"));

Parameters

Builds often need inputs — a configuration, a version, a target environment. Parameters give you typed values with defaults, sourced from flags or environment variables:

class MyBuild extends Build {
  // expose typed parameters via flags or environment
  configuration = parameter("Build configuration").default("Debug");

  compile = target()
    .executes(async () => {
      await DenoTasks.check((s) => s.paths("mod.ts"));
      console.log(`Built in ${this.configuration.value} mode`);
    });
}

Mark a sensitive value with .secret() and Zuke redacts it from every log, summary, and error; add .from(execSecret(...)) or .from(fileSecret(...)) to source it from a secret manager (1Password, Vault, gcloud, a mounted file) at run time, behind any flag or env var. See Secrets for the full picture.

Code-first CI/CD

Pipelines are part of the build, not a separate YAML file you maintain by hand. Declare a cicd() field and Zuke generates the workflow for your provider — GitHub Actions, GitLab CI, or Azure Pipelines:

import { Build, cicd, target } from "jsr:@zuke/core";

class MyBuild extends Build {
  // declare a pipeline as a build field
  ci = cicd({
    provider: "github",                       // or "gitlab" | "azure"
    pipeline: {
      jobs: [{
        matrix: { os: ["ubuntu-latest", "macos-latest"] },
        steps: [{ name: "Test", run: "./zuke test" }],
      }],
    },
  });

  test = target().executes(async () => { await DenoTasks.test(); });
}

Running any target regenerates the declared files, and on CI Zuke verifies the committed files are still current. You can also write or check them explicitly:

zuke generate-ci          # write all declared pipeline files
zuke generate-ci --check  # CI gate: fail if committed files are stale

A portable subset keeps providers in sync: run steps map everywhere, while provider-specific uses steps render only where they apply. The output is paste-ready, correctly-quoted YAML.

Fanned-out jobs — one CI job per target

Instead of one job that runs the whole build, fanOut turns each target into its own CI job, wired together with needs: edges that mirror the targets' dependsOn. Independent targets then run in parallel on the CI provider, and the build's own dependency graph shapes the workflow — something a hand-written YAML can't stay in sync with:

class CI extends Build {
  lint  = target().inputs("src").outputs("dist/lint").executes(/* … */);
  test  = target().dependsOn(this.lint).inputs("src").executes(/* … */);
  build = target().dependsOn(this.lint).inputs("src").outputs("dist").executes(/* … */);

  // One CI job per target, wired by dependsOn — independent jobs run in parallel.
  ci = cicd({
    provider: "github",
    fanOut: { env: { ZUKE_REMOTE_CACHE_DIR: "/mnt/zuke-cache" } },
  });
}

Each job runs only its own target (./zuke <target>); its dependencies run in their own jobs, so pair fan-out with the remote cache to restore their outputs instead of rebuilding them. Pass fanOut: true for the defaults, or options to set the per-job command, setupSteps, runsOn, env, or includeUnlisted. Targets with no body, and unlisted ones, are omitted.

Announcements

Builds often need to tell a team what happened — build passed, package published, service deployed. AnnounceTasks posts that status to Slack, Microsoft Teams, and Discord straight from a target. It follows the same settings-lambda shape as the tool wrappers, but runs no subprocess — it POSTs the platform-native payload over fetch:

import { AnnounceTasks, Build, parameter, target } from "jsr:@zuke/core";

class MyBuild extends Build {
  // the webhook embeds a secret — never hard-code it
  slack = parameter("Slack incoming-webhook URL").secret().required();

  deploy = target()
    .requires(this.slack)
    .executes(async () => {
      // ... deploy ...
      await AnnounceTasks.slack((s) =>
        s.webhook(this.slack.value)
          .title("Deploy")
          .text("Shipped api@1.4.0 to production.")
          .success()                                   // accent colour + ✅
          .field("Service", "api")                     // repeatable detail
          .link("Release notes", "https://example.com/r/1.4.0")
      );
    });
}

Set the destination with .webhook(url) and the message with .text(), .title(), a level (.success() / .failure() / .warning() / .info()) that drives the accent colour and icon, repeatable .field(name, value) details, and a .link(text, url) action.

A webhook URL embeds the secret that authorises posting, so source it from a parameter().secret() — Zuke masks the resolved value in CI output. Each platform also speaks an API/bot mode via .bot(): Slack chat.postMessage, the Discord REST API, and Microsoft Teams through Graph.

// Slack Web API (chat.postMessage) instead of a webhook
await AnnounceTasks.slack((s) =>
  s.bot().token(this.slackToken.value).channel("#builds")
    .text("Published @acme/api@1.4.0").success()
);

// Discord REST API · Teams via Microsoft Graph
await AnnounceTasks.discord((s) => s.bot().token(t).channel(id).text(""));
await AnnounceTasks.teams((s) => s.bot().token(t).team(id).channel(c).text(""));

Extending Zuke

Zuke is extended along three seams — custom tool wrappers, reusable components, and lifecycle plugins — none of which require a fork.

Custom tool wrappers

Need a tool Zuke doesn't wrap yet? Extend ToolSettings from @zuke/core/tooling, implement defaultTool() and buildArgs(), and you get the same fluent, typed API as the built-in wrappers:

import { type Configure, runSettings, ToolSettings } from "jsr:@zuke/core/tooling";

class MyToolSettings extends ToolSettings {
  #args: string[] = [];
  fast(): this { this.#args.push("--fast"); return this; }

  protected override defaultTool() { return "mytool"; }
  protected override buildArgs() { return ["build", ...this.#args]; }
}

export const MyToolTasks = {
  build: (configure?: Configure<MyToolSettings>) =>
    runSettings(new MyToolSettings(), configure),
};

// → await MyToolTasks.build((s) => s.fast().cwd("app"));

Keep buildArgs() pure so argv construction stays unit-testable; the base class hands you shared chainers like env, cwd, noThrow, and quiet.

Reusable components

A component is just a function that returns a bundle of related targets. Assign it to a build field; discovery recurses into the bundle and names each target with a dotted path (release.publish), runnable as zuke release.publish and shown in the graph. Components compose, nest, and take options:

// A component is a function that returns a bundle of related targets.
function releasable(opts: { registry: string }) {
  const pack = target().executes(/* ... */);
  const publish = target()
    .dependsOn(pack)
    .executes(async () => { await $`npm publish --registry ${opts.registry}`; });
  return { pack, publish };
}

class MyBuild extends Build {
  release = releasable({ registry: "https://registry.npmjs.org" });
  // runnable as `zuke release.publish`; reference it across the build:
  deploy = target().dependsOn(this.release.publish).executes(/* ... */);
}

Lifecycle plugins

A plugin is a plain object implementing any of the lifecycle hooks — onStart, onTargetStart, onTargetEnd, onFinish. Pass plugins to run() (or execute()) and each hook runs alongside the build's own methods. Plugins observe — they report, time, or notify — they don't change the plan or a target's result:

import { type Plugin, run } from "jsr:@zuke/core";

const timing: Plugin = {
  name: "timing",
  onTargetEnd: (target, status) => console.log(`${target}: ${status}`),
  onFinish: (result) => console.log(`done: ${result.ok ? "ok" : "failed"}`),
};

await run(MyBuild, { plugins: [timing] });

From here, browse the Core library for the built-in helpers, or the examples to see these concepts combined into complete, real-world build files.