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 become discrete argv entries — they are never spliced into a shell string, and no shell is involved at all — so there is no shell-injection surface to escape around:

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. See the core library for the full member list, the 8 MiB capture cap, and the argument-injection caveat that argv safety does not cover.

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, Azure Pipelines, or Bitbucket. The provider is the only required field: cicd({ provider: "github" }) alone declares a workflow at .github/workflows/ci.yml that runs ./zuke on push/PR to main. Override only what else you need:

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" | "bitbucket"
    pipeline: {
      jobs: [{
        matrix: { os: ["ubuntu-latest", "macos-latest"] },
        steps: [{ name: "Test", run: "./zuke test" }],
      }],
    },
  });

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

Every generated GitHub job opens with the published zuke-build/zuke action as its prelude — pinned by SHA with its version comment — so the workflow hardens the runner and checks out before it runs anything. Pass bootstrap: false on a job or the pipeline to opt out, or supply your own pin.

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

zuke generate-ci

Or check them as a CI gate — failing if the committed files are stale:

zuke generate-ci --check

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.