Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

yatr 🦬

Yet another task runner. But this one’s actually good.

🌐 yetanothertaskrunner.com · 📦 crates.io · 🐙 GitHub

yatr test          # Run tests
yatr build         # Build with dependencies
yatr --watch test  # Re-run on file changes

yatr is a fast, single-binary, polyglot task runner with a cache that actually tells the truth: content-addressed, output-restoring, and shareable across machines and CI.

Why yatr?

FeatureMakeJustcargo-makeyatr
Content-addressed caching⚠️
Captures & restores outputs
Remote / shared cache
Signed cache (anti-poisoning)
Affected (monorepo) detection⚠️
Toolchain management
Sandboxed WASM plugins
Config schema + LSP⚠️
Single binary, zero runtime depsN/A

It sits in the sweet spot: simpler than cargo-make, far more capable than just — a correct, fast, hermetic polyglot command runner.

The four things it gets right

  • Trusted — a content-addressed cache, cryptographically signed for sharing, with IO-tracing to catch under-declared outputs.
  • Fast — ~8 ms startup, a ready-queue scheduler, and affected-detection that skips what git says can’t have changed. (Benchmarks.)
  • Polyglot-complete — pin language toolchains so a fresh checkout runs green; write tasks in shell, Rhai, or WASM.
  • Delightful — a JSON Schema and a language server for yatr.toml, structured output, and profiling.

Stability

yatr is 1.0. The yatr.toml schema and the CLI surface are stable under semantic versioning: breaking either requires a 2.0. New keys and commands may still arrive in minor releases, but existing ones keep their meaning, and the cache and remote protocols survive across 1.x upgrades. The full history lives in the changelog.

The library/crate API and the WASM plugin ABI (yatr-plugin) remain pre-1.0 and may change while the plugin model evolves.

Installation

cargo install yatr

Or from source (latest main):

cargo install --git https://github.com/cargopete/yatr

Quick start

Create a yatr.toml in your project root:

[tasks.test]
desc = "Run tests"
run = ["cargo test"]

[tasks.build]
desc = "Build release"
depends = ["test"]
run = ["cargo build --release"]

Run tasks:

yatr test            # run 'test'
yatr build           # runs 'test' first, then 'build'
yatr run test build  # run multiple
yatr --dry-run build # show the plan without executing
yatr list            # list available tasks

Bare task names are shorthand for yatr run <task>.

Defining tasks

A task is a [tasks.<name>] table. It runs shell commands, a Rhai script, or a WASM plugin — exactly one of run / script / wasm.

[tasks.fmt]
desc = "Format and lint"
run = ["cargo fmt", "cargo clippy --fix --allow-dirty"]

Dependencies

[tasks.check]
desc = "Full check pipeline"
depends = ["fmt", "lint", "test"]   # run these first

Dependencies form a DAG; yatr runs each task as soon as its dependencies finish (a ready-queue scheduler), bounded by --parallel.

Parallel commands

[tasks.lint]
parallel = true
run = ["cargo fmt --check", "cargo clippy -- -D warnings", "cargo doc --no-deps"]

Environment, working dir, shell

[env]                       # global
RUST_LOG = "debug"

[tasks.migrate]
cwd = "./backend"
env = { DATABASE_URL = "postgres://localhost/dev" }
shell = true
run = ["diesel migration run"]

Long-running processes

[tasks.dev]
foreground = true           # inherit stdio; not cached
run = ["cargo watch -x run"]

Full task reference

FieldMeaning
descHuman description
run / script / wasmWhat to execute (mutually exclusive)
dependsTasks to run first
parallelRun run commands concurrently
env, cwd, shellEnvironment, working dir, shell mode
foregroundInherit stdio (dev servers); not cached
sources, outputsCaching inputs/outputs
watchFile patterns for yatr watch
no_cache, allow_failure, timeoutPer-task behaviour

Caching

yatr’s cache is content-addressed: a task’s cache key is the BLAKE3 hash of its commands, environment, working directory, and the contents of its declared sources. Unchanged inputs → a cache hit; changed inputs → a real run.

[tasks.build]
sources = ["src/**", "Cargo.toml", "Cargo.lock"]
outputs = ["target/release/app"]
run = ["cargo build --release"]

Outputs are captured and restored

On success, the files matched by outputs are stored in a content-addressed store. On a cache hit, they’re restored — delete target/, get a hit, and your artifacts come back. (Many runners “cache” only stdout and leave you with nothing on disk; yatr restores the real outputs.)

Cache correctness

A fast cache that’s occasionally wrong is worse than no cache. yatr keys on file contents (not mtimes, which git checkout and clock skew break), and can warn when a task writes outside what it declared:

yatr run --trace-io build   # "task 'build' wrote files not declared as `outputs`: …"

Managing the cache

yatr cache stats      # entries + size
yatr cache clear      # clear everything
yatr cache clear build  # clear one task
yatr cache path       # show the cache directory

Caching is on by default; disable per task with no_cache = true or globally with [settings] cache = false. To share hits across machines, see the remote cache.

Remote cache

Point yatr at a shared HTTP cache and a task built on one machine (or in CI) is restored on the next, instead of rebuilt.

[settings.remote_cache]
url = "https://cache.example.com/yatr"
token_env = "YATR_CACHE_TOKEN"   # optional bearer token, read from this env var
sign_key_env = "YATR_CACHE_KEY"  # optional signing secret (see below)
read = true                      # pull on a local miss (default: true)
write = true                     # push after a successful run (default: true)

It speaks a small REST protocol — GET/PUT/HEAD on <url>/ac/<key> (action results) and <url>/cas/<blob> (content blobs) — the same path layout as Bazel’s HTTP cache, so it works against an off-the-shelf blob store or a tiny server.

Keys are content-addressed and machine-portable: identical inputs produce identical keys regardless of checkout path, so a build on CI restores on your laptop. A flaky or unreachable remote is non-fatal — yatr warns and runs the task locally.

Integrity & signing

Downloaded CAS blobs are verified against their content digests, so a tampered blob is rejected automatically. To defend action results against a compromised cache (the “CREEP” cache-poisoning class), set sign_key_env to a shared secret: yatr signs each action result with a keyed BLAKE3 MAC and rejects any entry whose signature doesn’t verify under your key.

Keep secrets out of the committed config by using the *_env options rather than inline values.

REAPI interop

By default the remote cache speaks yatr’s own protocol (JSON action results + BLAKE3 blobs). Set protocol = "reapi" to instead speak the Bazel Remote Execution API HTTP cache — SHA-256 digests and a protobuf ActionResult — so an off-the-shelf server like bazel-remote or BuildBuddy can serve as yatr’s shared cache backend:

[settings.remote_cache]
url = "https://bazel-remote.example.com"
protocol = "reapi"

This shares cache entries across yatr instances via a standard REAPI server (it does not share entries with Bazel itself — the action keys differ). Signing is yatr-native and applies to the native protocol.

Monorepos

Affected detection

In a large repo you don’t want to run everything on every change. yatr affected <git-ref> lists the tasks touched by changes since a ref — a task is affected when one of its sources/watch globs matches a changed file, and the result propagates to everything that (transitively) depends on it.

yatr affected main                     # what would I need to run for this branch?
yatr affected HEAD~1 --format json     # machine-readable, for CI
yatr run --affected origin/main test lint build   # run only the affected ones

Caching gives you correctness (unchanged tasks are cache hits); affected detection adds speed at scale by not even considering tasks git says can’t have moved. A task that declares no sources is treated as always affected — declaring sources is what unlocks skipping.

Splitting config across files

Keep task definitions next to the code they build and compose them from a root yatr.toml with include:

# yatr.toml
include = ["frontend/yatr.toml", "backend/yatr.toml"]

[tasks.build-all]
depends = ["fe-build", "be-build"]   # tasks defined in the included files

Includes are resolved relative to the including file and merged recursively (cycles are detected). Tasks and env are composed; the root file’s settings are authoritative. A task defined in two files is an error — names are global.

Toolchains

Pin a language runtime and yatr downloads it once and puts it on the task PATH — a fresh checkout runs green with no manual installs.

[toolchain.node]
version = "20.11.0"
url = "https://nodejs.org/dist/v{version}/node-v{version}-{os}-{arch}.tar.gz"
bin = "node-v{version}-{os}-{arch}/bin"

[tasks.build]
run = ["node build.js"]   # uses the pinned node, wherever yatr runs

{version}, {os} (linux/darwin/win) and {arch} (x64/arm64) are substituted into the url and bin templates — matching the common Node-style release-asset naming.

Toolchains are cached under a local toolchains directory (override with YATR_TOOLCHAIN_DIR) and installed once, then reused. .tar.gz/.tgz archives are supported today.

This kills “works on my machine” across languages: the runtime your build needs is declared in yatr.toml and fetched on demand, the same on every developer machine and in CI.

WASM plugins

A task can be implemented by a WebAssembly plugin — write it in any language that compiles to wasm32, ship a single .wasm, and run it anywhere yatr runs:

[tasks.codegen]
wasm = "plugins/codegen.wasm"                         # local path
[tasks.shared]
wasm = "https://example.com/v1/plugin.wasm"            # …or an http(s) URL
[tasks.gh]
wasm = "github:owner/repo@v1.0.0/plugin.wasm"          # …or a GitHub release asset

Plugins are capability-sandboxed: they run in a pure-Rust interpreter (wasmi) with only yatr’s host ABI imported — no filesystem, network, or clock. A plugin that tries to import anything else fails to load, so even an untrusted remote plugin can’t escape. Remote plugins are downloaded once and cached (override with YATR_PLUGIN_DIR); plugin output is captured and cached like any task.

Host ABI

A plugin exports its memory and run() -> i32 (0 = success), and may import:

ImportSignatureEffect
emit(ptr, len)Append a UTF-8 string to the task’s output
log(ptr, len)Log an info message
input_len() -> i32Byte length of the task input
input_read(ptr) -> i32Copy the input (JSON {task, env}) into memory

Writing plugins in Rust

The yatr-plugin crate wraps the raw ABI so you write plain Rust:

#![allow(unused)]
fn main() {
yatr_plugin::plugin!({
    let input = yatr_plugin::input_string();  // {"task":...,"env":{...}}
    yatr_plugin::emit(&format!("hello from a plugin; input = {input}"));
    Ok(())
});
}
cargo build --release --target wasm32-unknown-unknown

Then point a task at the resulting .wasm.

Editor integration

JSON Schema

yatr ships a JSON Schema for yatr.toml, giving autocomplete, hover docs, and validation in any schema-aware editor.

yatr schema > yatr.schema.json   # regenerate any time

With taplo / the Even Better TOML VS Code extension, add a directive to the top of your yatr.toml:

#:schema ./yatr.schema.json

Language server

yatr lsp runs a language server over stdio, giving any LSP-capable editor live diagnostics (parse errors, validation errors, missing dependencies, cycles — as you type) and a task outline.

Point your editor’s LSP client at yatr lsp for yatr.toml. For example, in Neovim:

vim.lsp.start({ name = "yatr", cmd = { "yatr", "lsp" }, root_dir = vim.fn.getcwd() })

Machine-readable output

yatr run --json test               # structured per-task results + summary
yatr run --json --dry-run ci       # the execution plan as JSON
yatr run --profile trace.json ci   # a Chrome trace (chrome://tracing / Perfetto)

Benchmarks

Reproduce the numbers yourself:

benches/bench.sh   # times whichever of make/just/task are installed

It generates an identical single-task workload for each tool and times two stable scenarios: startup overhead, and a warm rebuild (the cache’s job).

Sample results

Apple-silicon laptop, June 2026 (min / mean ms — lower is better):

Toolstartupwarm rebuild
yatr8.4 / 8.99.5 / 10.0
make10.7 / 11.511.2 / 15.7
just (no caching)7.8 / 8.2131.4 / 145.6
  • Overhead is competitive — yatr’s single-binary startup beats make and matches just; no “task-runner tax”.
  • The cache earns its keep — a warm rebuild is a content-addressed cache hit, ~14× faster than a runner with no caching, and on par with make’s timestamp skip — but content-correct where make’s mtime cache is fragile.

Scheduler

The ready-queue scheduler starts each task the moment its dependencies finish, instead of waiting for the whole dependency “level”. On a DAG with a fast chain beside a slow sibling, that measured ~1.8× faster (791 ms → 430 ms).

Benchmarks prove yatr is fast; caching correctness, the remote cache, and affected detection are why it’s better — and don’t show up in a single local no-op.

CLI reference

yatr <COMMAND>

Commands:
  run       Run one or more tasks
  list      List available tasks
  watch     Watch for changes and re-run
  graph     Show the task dependency graph
  affected  List tasks affected by changes since a git ref
  cache     Manage the task cache
  init      Create a yatr.toml template
  check     Validate yatr.toml (referenced files, config smells)
  schema    Print the JSON Schema for yatr.toml
  lsp       Run the yatr.toml language server (LSP over stdio)

run

yatr run [TASKS]... [OPTIONS]
  --dry-run            Show the execution plan without running
  --force              Ignore the cache
  --parallel <N>       Limit parallelism (0 = auto)
  --shell              Use a shell to execute commands
  --json               Structured JSON output instead of human output
  --profile <PATH>     Write a Chrome trace of the run
  --affected <GIT_REF> Only run tasks affected by changes since the ref
  --trace-io           Warn when a task writes outside its declared `outputs`

Global options

  -c, --config <PATH>  Config file path
  -v, --verbose        Verbose output
  -q, --quiet          Suppress output
      --cwd <DIR>      Working directory
      --no-color       Disable colours

Examples

yatr graph --format dot build | dot -Tpng > graph.png
yatr list --format json
yatr watch --clear test
yatr cache stats