Write or edit a Zuke build
A build is a class that extends Build. Each target is a class field
created with target() and made runnable with await run(MyBuild) at the
bottom of zuke.ts (no import.meta.main guard — run no-ops on import).
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("Type-check and test")
.dependsOn(this.lint)
.executes(async () => {
await DenoTasks.test((s) => s.allowAll().coverage("cov_profile"));
});
// A field named `default` runs when no target is named on the CLI.
default = target().dependsOn(this.test).executes(() => {});
}
await run(CI);
Non-negotiable rules
- Dependencies are
this.<field>references, never strings..dependsOn(this.lint), not.dependsOn("lint")— so renames and typos are compile-time errors. - A target may only depend on siblings declared above it. Class fields
initialise top-to-bottom; a forward reference is
undefinedand is reported as an error (TypeScript also flags it,TS2729). Order fields so dependencies come first. - Check the package catalogue before writing any command.
llms.txt's## Packagescatalogue (raw: https://raw.githubusercontent.com/zuke-build/zuke/master/llms.txt) and the package table inreferences/cheatsheet.mdare the only ways to answer "does a@zuke/<tool>wrapper exist for this CLI?" — per-packagedeno doc jsr:@zuke/<pkg>only describes a package whose name you already know; it cannot tell you a wrapper exists. Whatever runs in an.executes(...)body drives an external tool through its namespaced*Tasksobject, configured with a settings lambda that mirrors the real CLI's flags —DenoTasks,NpmTasks,DockerTasks,GitTasks, and 30+ more — never a rawDeno.Commandor shell string.jsr:@zuke/cmd(CmdTasks.exec) or the$shell fromjsr:@zuke/core/shellis the last resort, reached for only once the catalogue confirms no typed wrapper exists — using it for a tool that has a@zuke/<tool>package is a bug, not a style choice: it discards typed flags, argv purity, and tool resolution. (If a build delegates its side effects to your own tested modules behind injected clients, the wrapper rule still governs whatever those modules run in the target body.) - A body is required, unless the target is one of the four forms that
replace it: a
service(), a.forEach()fan-out, a.waitsFor()gate, or a target declaring only.effect(...). Otherwise set.executes(...); it may be sync or async, and its return value is ignored —.executes(() => DenoTasks.lint())is fine as-is; never wrap a single wrapper call in anasyncblock just to discard its result.
Find the exact signature first
Before calling any task or settings method, confirm the real shape — but first
confirm a wrapper exists at all: deno doc needs a package name to target, so
it cannot answer "does one exist for this tool?"; only the catalogue
(llms.txt's ## Packages list or the cheatsheet table below) can.
- One package — prefer this in a consumer repo, once you know its name:
deno doc jsr:@zuke/<package>(e.g.deno doc jsr:@zuke/deno). It resolves the version the project actually has installed, so it cannot describe an API that version lacks. - Whole surface:
llms-full.txt(index:llms.txt) — at the repo root in the Zuke repo itself. From a consumer repo, fetch https://raw.githubusercontent.com/zuke-build/zuke/master/llms-full.txt (index: https://raw.githubusercontent.com/zuke-build/zuke/master/llms.txt). Both trackmaster, so they can document symbols that are merged but not yet in any published release. Use them for breadth — which packages and tasks exist — and confirm a signature withdeno docbefore relying on it. - A quick map of the most common methods and task objects is in
references/cheatsheet.mdnext to this file — read it when wiring targets, then verify specifics against the sources above.
Workflow for a change
- Read the existing
zuke.tsto learn the targets already declared and their order. - Identify the tool you need and look up its
*Tasksobject and settings methods (cheatsheet →deno doc/llms-full.txt). - Add or edit the target field. Place it below every target it depends on.
Wire dependencies with
this.<field>. - Validate:
./zuke --listshows it;./zuke <target> --dry-runpreviews the plan;./zuke <target>runs it.
Common building blocks (see the cheatsheet for details)
- Parallel batches:
group()+.partOf(this.group)run members concurrently; depend on the group to wait for all of them. - Reusable bundles: a component is a function returning related targets;
assign it to a field and reference members as
this.release.publish. - Long-lived processes:
service()models a process that must stay running while dependents execute (dev server, database, mock API). Declared and depended on like a target, but with a.start(...)/.readyWhen(...)lifecycle instead of.executes(...); the executor starts it, waits until ready, then stops it in afinallyso it never leaks. See the cheatsheet. - Authorization by role:
.requiresRole("operator")gates running a target over MCP, enforced across the whole plan it would execute;override mcpAuthorize(identity, call)decides the rest. See the cheatsheet. - Target context & cancellation: a body may take a context —
.executes((ctx) => …)— withctx.runId,ctx.initiator(who asked for the run, unchanged by a resume),ctx.target,ctx.signal(anAbortSignalfired when the run is cancelled; a plain$`…`in the body isSIGTERM'd automatically),ctx.state,ctx.dryRun,ctx.plan()(the run's planned shape —targets,includes(name),dependenciesOf(name)— so a body can ask whetherdeploywas part of what was asked for; it reports the plan, never what will actually execute, which isctx.outcomeOf(name)), andctx.reportSummary({ … })(key: valuenotes on the target's own row of the Build Summary; every test-runner wrapper —DenoTasks.test,VitestTasks.run,JestTasks.run,BunTasks.test,NodeTasks.test,PlaywrightTasks.test,CypressTasks.run— reports its test counts there by itself, and the ambientreportSummarydoes the same from code with noctx). Zero-argument bodies keep working unchanged. Cancel a run programmatically by passing{ signal }toexecute. See the cheatsheet. - Caching:
.inputs(...)/.outputs(...)make a target incremental. Add a remote store to share results across machines (fresh CI, teammates);--affectedruns only targets changed since a git base;--no-cache/--no-remote-cachebypass them. A restore is confined to the target's declared.outputs(...)(and never.git/.zuke); a refused archive is a cache miss with a warning, not a failure. A cancelled run keeps its cache unless a compensation actually rolled something back. - Durable run state: persist a run's status and per-target metadata to a
pluggable
StateStoreso it survives the process — turn it on with--state,ZUKE_STATE_DIR/ZUKE_STATE_URL, oroverride stateStore(). EveryZUKE_*_URLbackend must behttps:(loopback exempt;ZUKE_ALLOW_INSECURE_URL=1opts out). In a body,ctx.state.set({ … })/ctx.state.get()records per-target metadata (JSON, never secrets — secret parameters and redacted values are excluded).setawaits the write;ctx.state.trySet({ … })is the same write reportingtruewhen it reached the store andfalsewhen it was dropped — use it before an irreversible step that depends on the value. A store-less build and a compensation body always seetrue(nothing durable behind them). Inspect persisted runs afterwards withzuke runs list(filter by--status/--target/--since/--limit) andzuke runs show <id>(--jsonon both). Prune old ones withzuke runs prune --keep <age> --keep-last <n>(only terminal runs; never suspended/running). A run whose process is killed is picked up byzuke resume --check, which reaps it — its lease tells a dead holder from a slow one — and resumes it in the same sweep. A process that merely looked dead and then finds its lease taken over stops, running no compensations and settling nothing: the run is the new holder's now.override deadline()gives a run a wall-clock budget ("45m", or milliseconds) that survives suspension; an abandoned run found past it is settledfailedwith its compensations instead of resumed. On a shared store, setZUKE_BUILD_ID(or rely onGITHUB_REPOSITORY) so each build only recovers its own runs — a resume runs this build's bodies against whatever record it is given, and a templatedzuke.tslooks identical to the shape checks. See the cheatsheet. - Cross-run locks:
.lock((s) => s.lockKey(...).withTtl("4h"))— a settings lambda — gives a target an exclusive claim across runs/machines; a second run wanting the same key fails with aLockConflictErrornaming the holder, or queues when the target adds.waitUpTo("30m")(paced by.pollEvery). The lambda runs after params resolve, so the key can readthis.<param>.value. The lock releases when the target settles and expires after the TTL if the holder is killed. Needs a state store (a build with.lock()enables the filesystem store by default). See the cheatsheet. - External-event waits:
.waitsFor((s) => s.on(externalSignal("approved")).timeout("72h"))makes a target a gate with no body: the run proceeds past it only when the trigger is satisfied, otherwise it suspends (state saved, exits 0) to be resumed later in a fresh process. Triggers:externalSignal(name)(payload read viactx.signals) andresumeWhen(predicate). Continue it withzuke resume <id> --signal <name> [--data <json>](or--checkfor predicate waits/timeouts) — exactly-once, re-running only the not-yet-succeeded targets. Needs a state store. See the cheatsheet /docs/orchestration.md. - Cancellation & compensation:
.onCancel(() => this.rollback)registers a compensation that runs iff this target succeeded when the run is later cancelled — compensations run in reverse order, and the compensation body'sctx.stateexposes the original target's persisted metadata (so a rollback reads what the deploy recorded). Cancel withzuke cancel <id>(or Ctrl-C, or the MCPcancel_runtool). Idempotent; a timed-out wait can route itsonTimeouthere ("cancel-run"or a named target). Needs a state store. Seedocs/orchestration.md. - Durable side effects:
.effect(name, fn)records the intent to runfnbefore it runs, so a resume re-drives an effect a dead process left owed. Effects run after the body, in declaration order; a target may declare effects and no body. The guarantee is at-least-once, so write bodies that tolerate a repeat (an upsert, not an append), and read what the effect acts on fromctx.staterather than looking up "the current value" — a re-drive happens later, against a world that moved on. Needs a state store (enabled automatically). See the cheatsheet /docs/orchestration.md. - Fan-out over a list:
.forEach(() => this.repos.value, (repo) => ({ checks: target()…, deploy: target()… }), (s) => s.concurrency(3).continueOnItemFailure())runs the same pipeline over a runtime list — items concurrent, each item's stages sequential. Sub-targets are materialised at run time (parent[item].stage), each a first-class row in the summary and the run record;continueOnItemFailure()isolates a failed item. An.onCancel(...)on a fan-out stage runs per item on cancel (item-scopedctx.state, reverse order). Seedocs/orchestration.md. - Typed inputs:
parameter("...")(with.number()/.boolean()/.options(...)/.secret()/.required()), read asthis.x.value, gated with.requires(this.x)..array()composes and comes last:.options(...).array()validates each element,.number().array()→number[], and a required list is.required().array()(required before array —.array().required()does not typecheck). A parameter may not be named so that it renders as a built-in CLI flag (actor,actorKind,limit,target,output, …) or as an MCP control key (dryRun,confirm,operatorToken) — the build refuses to load, naming the field. The flag is one dash per lower-to-upper transition, and a digit ends a run of capitals, soskipE2Egives--skip-e2-e; name itskipE2eor declare.flag("--skip-e2e"), which replaces the derived spelling everywhere. - Secrets from a manager:
parameter(...).secret().from(source)sources a value at run time (e.g.execSecret(...)shelling out to a secret CLI) and redacts it from all of Zuke's output. See the cheatsheet. - Provisioning tools:
ToolTasks.install((s) => …)/toolchain((t) => …)fetch pinned, checksum-verified release binaries so a build is hermetic, andt.npm({ name, version, bin? })/ToolTasks.npm(...)provision a version-pinned, cached npm-registry package (needsnpmonPATH); hand the returned path to a wrapper's.toolPath(...). In a Node monorepo, resolve a wrapper's binary fromnode_modules/.binnpx-style instead —.fromNodeModules()on the settings (orZUKE_TOOL_RESOLUTION=node_modulesrepo-wide) walks up for the local shim and falls back to PATH;.fromPath()forces PATH and an explicit.toolPath(...)always wins. See the cheatsheet /docs/tools.md. - Code-first CI:
cicd({ provider: "github" })generates and verifies the workflow YAML from the build. - Operate the build from an agent:
zuke mcpserves the build over MCP so an AI client can list, inspect, and (with--allow-run) run targets — on stdio, or over HTTP with--http <host:port>(loopback by default; a non-loopback bind needs aZUKE_MCP_TOKENbearer token or anmcpAuth()/mcpIdentity()authenticator, else the server exits 1). With a state store it also exposeslist_runs/show_run(+signal_run,resume_checkandcancel_run). Tier access with--allow-run=<globs>(an allow-list over invocation — invoking a target runs its dependencies, and the read tools narrow to the allow-listed targets' closure),--protect <globs>+ZUKE_OPERATOR_TOKEN(enforced over a run's whole plan, so a protected target reached as a dependency still needs the token), and--confirm-destructive; mark inspect-only targets.readOnly(). Mutating/denied calls are audited — read the trail on the host withzuke runs show mcp-audit; it is deliberately not readable over MCP. A registry-backed server (zuke registerthenzuke mcp --registry) instead serves every registered pipeline live, each as arun:<buildId>:<target>tool that takes the build's declared parameters (secrets excluded, validated, forwarded to the spawn) — see the cheatsheet. Because the registry names where a build launches from, a descriptor with a remote entry module is refused unless its origin is inZUKE_REGISTRY_LAUNCH_HOSTS, and acommandlocation is refused unless its program is inZUKE_REGISTRY_LAUNCH_COMMANDS(the registry writer picks the program and its arguments);zuke registerwrites a local module, so both only affect a hand-authored or second-party entry. For a shared, multi-user endpoint,override mcpAuth()authenticates a trusted caller per request — an asyncauthenticate(ctx)returning{ actor, kind?, roles?, via? }or anMcpAuthReject({ status, error, detail?, challenge? }), so a refused HTTP request answers that status withWWW-Authenticateinstead of a200.override mcpIdentity()is the sugar for the proxy-header case (a sync hook reading a header; any throw rejects), adapted onto the same path — declare one or the other, never both, or the server exits 1. Either overrides the client-reported actor and flows to the audit trail, run records, lock holders, and a registry-spawned child'sZUKE_ACTOR/ZUKE_ACTOR_KIND/ZUKE_ACTOR_ROLES. Both are fail-closed: a throw, a non-object, or an empty actor refuses the request, and nothing runs.override mcpProtectedResource()publishes the RFC 9728 metadata document and names it in every challenge, so a client that has no token can discover the identity provider — Zuke is the resource only, and hosts no OAuth endpoints of its own. See the cheatsheet. - AI review & self-healing (
@zuke/ai): gate a target on a structured LLM review of the diff (securityReviewer(...)etc. via.validateBefore), or attachaiFixer(...)with.recoverWith(...)so a failing target is diagnosed and (opt-in) auto-fixed, with a committable PR suggestion. OverriderecoverWith()on the build to apply one fixer to every target. A reviewer can go deeper and hold a discussion:.conventionsFile("AGENTS.md")(judged against the project's rules, read from the diff base),.fileContext()(whole changed files, not bare hunks),.verify()(adversarial re-check of every finding), and.discussion()(maintainers refute a finding by replying with its id — or, with.discussion((d) => d.threads()), by replying in the finding's own line-anchored review thread; accepted dismissals persist instead of resurfacing, including when the model rewords the finding — only platform-verified maintainer comments ever reach the model, on GitHub, GitLab, Azure DevOps and Bitbucket alike). See the cheatsheet's AI section. - Wait on an external GitHub workflow (
@zuke/gh): in a.waitsFor(...)gate,s.on(githubWorkflow((g) => g.repo("o/r").workflow("e2e.yml")))dispatches a workflow in another repo and suspends until it finishes; read the per-job result withreadWorkflowResult(ctx.stateOf("<gate>")). Correlates by arun-name:marker by default, or.correlate("created-window")for a workflow you can't modify; fails fast (.discoveryTimeout(...)) if the run never correlates. The dispatched workflow has a contract: declare the marker input (zuke_marker, or rename via.markerInput(...)), echo it as its entirerun-name:(equality, not substring), and receive any of itsrequired: trueinputs via.inputs(...)— see the cheatsheet's receiving-workflow contract. Triggers are extensible — write your own against the exportedWaitTrigger/WaitContext. - OpenTelemetry export (
@zuke/otel): registerotel((s) => s.endpoint(…))as a plugin (run(MyBuild, { plugins: [otel(…)] })) to ship run/target spans andzuke.run.started/zuke.run.suspended/zuke.runscounters as OTLP/HTTP JSON. Needs a state store; the trace id is derived from the run id, so a suspend/resume across processes is one trace. Config falls back to the standardOTEL_*env vars, and it is inert with no endpoint. Dependency-free.