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 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.