Zuke write build
Write or edit a Zuke build (zuke.ts) — the code-first, strongly-typed build system for Deno/TypeScript. Use when adding or changing targets, wiring dependencies, calling a tool wrapper (DenoTasks, NpmTasks, DockerTasks, ...), generating CI, or authoring/refactoring a zuke.ts build file. For first-time project scaffolding, use the zuke-setup skill instead.From its SKILL.md
npx -y skills add zuke-build/zuke --skill zuke-write-buildAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
16.9 KB, as published. Nobody here has run it
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. - Target context & cancellation: a body may take a context —
.executes((ctx) => …)— withctx.runId,ctx.target,ctx.signal(anAbortSignalfired when the run is cancelled; a plain$`…`in the body isSIGTERM'd automatically),ctx.state, andctx.dryRun. 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). 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). - 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). 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;zuke registerwrites a local module, so this only affects a hand-authored or second-party entry. For a shared, multi-user endpoint,override mcpIdentity()resolves a trusted caller per request from an authenticating proxy's header (it overrides the client-reported actor and flows to the audit trail, run records, and lock holders; a throwing hook rejects the request). - 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.