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?
| Feature | Make | Just | cargo-make | yatr |
|---|---|---|---|---|
| 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 deps | N/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
| Field | Meaning |
|---|---|
desc | Human description |
run / script / wasm | What to execute (mutually exclusive) |
depends | Tasks to run first |
parallel | Run run commands concurrently |
env, cwd, shell | Environment, working dir, shell mode |
foreground | Inherit stdio (dev servers); not cached |
sources, outputs | Caching inputs/outputs |
watch | File patterns for yatr watch |
no_cache, allow_failure, timeout | Per-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:
| Import | Signature | Effect |
|---|---|---|
emit | (ptr, len) | Append a UTF-8 string to the task’s output |
log | (ptr, len) | Log an info message |
input_len | () -> i32 | Byte length of the task input |
input_read | (ptr) -> i32 | Copy 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):
| Tool | startup | warm rebuild |
|---|---|---|
| yatr | 8.4 / 8.9 | 9.5 / 10.0 |
| make | 10.7 / 11.5 | 11.2 / 15.7 |
| just (no caching) | 7.8 / 8.2 | 131.4 / 145.6 |
- Overhead is competitive — yatr’s single-binary startup beats
makeand matchesjust; 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 wheremake’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