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 ofdependsOn: pull the listed targets into the plan and run them after this one (e.g. anotifytriggered bydeploy)..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 avalidate(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.