xtask
A task runner whose tasks are data.
Write an xtask.yaml, and run xtask <task>. It resolves the dependency
graph, runs each task once, in declared order, without a shell.
That is the whole of it, and the repository does not have to be a Dart one:
the engine starts programs, so [pytest, -q] is as ordinary a task as
[dart, test]. Later you may want to pin the engine's version in a
pubspec.yaml instead of installing it, or to write a task in Dart rather than
name a program — Using it from Dart is those two steps,
and neither is needed to start.
The point is not convenience. It is that a repository stops keeping the same
list twice. A Makefile names the commands, the CI workflow names them again,
and a third copy usually lives in a contributing guide — and the copies drift
the first time somebody is in a hurry. Here CI stops naming commands at all: a
job runs one task, and what that task is made of lives in one file.
Start here
Install the engine once. Your repository needs no pubspec.yaml and no Dart in
it at all — only the Dart SDK on the machine doing the installing:
dart install xtask
What lands on the PATH is a command called xtask.
Then write xtask.yaml at the repository root. This is a whole one:
version: 1
tasks:
lint:
desc: check the style
gate: [check]
run: [ruff, check, .]
test:
desc: run the suite
gate: [check]
run: [pytest, -q]
check:
desc: everything that must pass before work is called done
collects: check
Those two are a Python repository's tools; put in whatever your own repository already runs. Naming a program that is not installed is not a silent pass — the run stops and says which one:
error: task `lint`: `ruff` is not installed, or is not on PATH — nothing
runnable by that name in the 39 directories on PATH
And run it:
xtask check
That runs both, in the order they are written, and it is what a person types
before calling work done — and also the whole of the CI job, the same command,
because there is only one list and it is not in either of them. xtask --list
prints the tasks with their descriptions.
Two keys in that file are doing the work, and they point in opposite
directions. gate: [check] is written on a check and means I am a member of
the group called check. collects: check is written on the task you type
and means I run that whole group, in the order the file writes it. A task
named check collecting a gate named check is the ordinary case and not a
cycle — Gate sets says why, and why the
whole tool exists for those two keys.
Using it from Dart
Two steps, and a Dart repository is the only kind that can take them. Neither is needed to start, and a repository that never takes either is using the tool exactly as intended.
Depending on it instead
A Dart repository can depend on xtask rather than installing it, and then the
version is written down in pubspec.yaml instead of being whatever that
machine happens to have. The command becomes:
dart run xtask:xtask <task>
Your own entry point
The second, and the only thing here that needs Dart code, is a verb: a function of your own for a job with real logic in it. An engine somebody else shipped cannot contain your function, so you hand it over from a file of yours:
dart run :xtask <task>
The colon is the whole difference between the last two, and it is easy to read
past: what is written to the left of it is which package the executable comes
from, and an empty left side means yours. Without a bin/xtask.dart of your
own the short spelling fails with Could not find bin/xtask.dart in package <yours>, which is a truthful error and a baffling one if nobody said the file
was optional.
dart install xtask puts a real xtask on the PATH, compiled, and for a
repository whose tasks are all run: that is the pleasant way to work.
It stops working the moment a project registers a verb, and that is the
design rather than a limitation. What gets installed is this package's own
entry point, and it passes no verbs, because it cannot know yours: do: notify
then meets "the engine ships no project verbs", correctly, since the notify
the file means is a Dart function in your repository and not in the tool. That
is why the entry point belongs to the project — a global install is the engine,
and dart run :xtask is the engine plus what you wrote. The second thing is
also pinned by your pubspec.yaml, where a globally installed tool is a version
of its own that no repository can see.
dart run pays the JIT's start-up — around half a second, every invocation. It
is nothing against a gate that spends seconds inside a test runner, and it is
the entire cost of --gate-members, --why or --dry-run, which is where a
shell loop or a file being written notices it. dart compile exe bin/xtask.dart removes it. The binary still reads xtask.yaml at run time, so
tasks, gates and sets keep changing without recompiling; verbs are Dart, so a
binary holds the ones it was built with and wants rebuilding after one changes.
This repository keeps its own invocation as the aot task rather than a second
copy in this file — xtask --dry-run aot prints it.
The rest of this README writes the short spelling, because this repository has
a bin/xtask.dart. If you installed the engine and wrote no Dart, read every
dart run :xtask below as plain xtask: the flags and the file are the same,
and only the way the program is reached differs.
import 'dart:io';
import 'package:xtask/xtask.dart';
Future<void> main(List<String> args) async {
// Assigned, not discarded. `runXtask` answers with the exit code below, and
// `=> runXtask(args)` throws it away — the process then reports success for
// every outcome, including the two that mean the file is wrong.
exitCode = await runXtask(
args,
verbs: {
'regen': regen,
},
);
}
A verb is ordinary Dart — testable, typed, debuggable:
Future<int> regen(VerbContext context) async {
context.log('regenerating ${context.args.length} files');
// context.args `args:`, then the expanded `argv-from`, then anything
// the command line passed after `--`
// context.env this machine's environment, with `env:` winning a clash
// context.workingDirectory
return 0;
}
The engine ships no project verbs — regen above is one repository's
business, not the tool's. Its only built-in is remove.
The file name is the declaration: dart run :xtask resolves to
bin/xtask.dart and nothing else, so no manifest entry names it.
The command
xtask <task> run a task and everything it needs
xtask <task> -- <args> and pass those arguments to its body
xtask <task> --keep-going report every failure, not just the first
xtask <task> --parallel run independent tasks at once — which costs
seeing their output as it arrives
xtask --list every task, with its description
xtask --list --gate <name> only the tasks in that gate set
xtask --gate-members <name> the tasks in that gate set, one per line
xtask --why <task> what puts that task in a plan, and by which
`needs:` or `then:`
xtask --validate parse and check the file; run nothing
xtask --check-ci does the CI file still run the gate sets?
xtask --dry-run <task> print the resolved plan; run nothing
xtask --emit-schema print the JSON Schema for this file format
xtask --version print which engine this is
xtask above is whichever spelling you arrived at — the installed command,
dart run xtask:xtask, or dart run :xtask. The flags are the same in all
three. The file is looked for from the current directory upwards, so the
command works from a subdirectory and every path inside the file stays relative
to the repository root.
Everything after -- reaches the body of the named task, after its args:
and its expanded argv-from, and nothing else in the plan sees it — so
xtask test -- -n "one test" narrows the tests without also handing -n to
the formatter. A task with no body of its own is refused rather than
swallowing them.
Seeing what will happen
Two questions the tool answers without running anything. Both are cheap, and both are the fastest way to find out that a file says something other than what you meant.
--dry-run shows what will actually happen, not what is written — sets
expanded, $each substituted, and the executable resolved on this machine.
The task names below are this repository's own, from xtask.yaml:
$ xtask --dry-run check
plan: format, analyze, test, check
format
run /opt/homebrew/bin/dart format --output=none --set-exit-if-changed .
in /home/you/xtask
analyze
run /opt/homebrew/bin/dart analyze --fatal-infos
in /home/you/xtask
...
Because it resolves them, --dry-run answers 3 when a program is not
installed yet, naming the one it could not find. That is the same answer a real
run would give, one step earlier — which is worth knowing before you read it as
a bug on a machine where the tools are not set up.
--why answers the other direction — not "what does this run" but "why does
this run at all". It names each entry point that reaches the task and spells
the route edge by edge, saying which kind each edge is, because "it runs before
this" and "it runs after this" are opposite answers:
$ xtask --why lint
check
check needs lint
Gate sets, and CI
A gate set is named after who runs it — one person's command, or one CI
job's. A task lists the sets it belongs to; a composite collects: a set and
therefore needs every task in it, in the order they appear in the file (cheap
gates before slow ones).
collects: names one set, not a list — a composite is the thing you type,
and one command gathering two unrelated sets is two commands wearing one name.
And the composite named after its own set, which the first example writes, is
not the cycle it looks like: the engine drops a composite from the members it
gathers, because gathering a set does not mean gathering yourself.
That is the whole mechanism for removing the duplicate list. A CI job runs one invocation:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
- run: xtask check
Run-once still holds, because a job is one invocation. Parallelism is preserved, because it comes from the jobs the CI system already schedules. And a failure is still legible: on a host that folds output — GitHub Actions today — each task is a collapsible section, and the failing one is annotated with the command and the directory, so the line that says a task failed is also the line that reproduces it.
--check-ci keeps that arrangement from rotting. It reads every file under
.github/workflows — GitHub Actions is the only host it knows, and a
repository without that directory is told so rather than passed — and compares
what it finds with the gate sets in both directions: a run: step that is not
one invocation of one gate set is refused, because that is exactly how the
duplicate list grows back — somebody writes - run: dart analyze instead of
adding a task. A gate set no job runs is reported rather than refused: gate
sets are named after who runs them, and that is the jobs plus the people,
which nothing in the file distinguishes.
It does not generate the workflow. Doing that would mean generating the
checkout, the toolchain and the artifact upload too, which needs a template
inside xtask.yaml — and templating is where an expression language starts.
The workflow file still owns what must exist before anything runs: the
checkout, the toolchain, a browser driver. xtask owns what runs. There is no
key that installs something; the one thing there is, is a precondition check:
web-e2e:
desc: browser e2e for the web binding
gate: [ci-web]
env-required: [CHROMEDRIVER]
in: packages/lake
run: [dart, test, test/web/web_e2e_test.dart]
which turns "a browser test failed somewhere inside" into "task web-e2e
requires CHROMEDRIVER, which is not set".
Reading a run
What a run tells you while it happens and when it ends — and what changes about that when you ask it to go faster.
A run has one duration, which answers nothing on its own, and a CI job that is one invocation has only that one. So the run prints what each task took at the end — after the last section, because a line inside a fold is invisible in exactly the state somebody is in when they want a number:
format 0.4s
analyze 2.3s
test 11.7s
total 14.4s
--keep-going is for the local loop. A gate that stops at the first failure
makes you fix, rerun, fix, rerun — the same argument --validate is built on,
which is why it collects every problem rather than throwing at the first. With
the flag, independent tasks still run and the run ends with a summary:
failed lint (exit 1)
failed unit (exit 1)
skipped check — needs `lint`, which did not pass
A task whose requirement failed does not run: its own failure would be a consequence of the first one. It is named as skipped rather than dropped, because a task that silently did not happen reads exactly like one that passed.
It is off by default, because a pipeline wants the earliest possible red rather than a broken run read to the end.
--parallel runs tasks that do not depend on each other at once, up to the
number of processors or --parallel=N. It is not the default, and the
reason is a real cost rather than caution: normally a task's output passes
through as it arrives and each task is a section that folds, and two tasks
writing to one terminal at once break both — the transcript belongs to neither.
So a parallel run collects each task's output and prints it whole when that
task ends. You get the answer sooner and you watch it happen less — and it says
so on the first line, because a run that goes quiet for eight seconds without
explaining itself is indistinguishable from one that has hung.
The summary then says both numbers, because they answer different questions:
lint 1.0s
unit 1.0s
types 1.0s
total 3.1s spent, 1.1s taken
Declaration order still decides which of the ready tasks starts first — cheap gates before slow ones — but nothing makes them finish in that order. A failure stops what has not started; it does not reach into what is running, because killing a task would leave whatever it was half-way through in whatever state that half is. Whichever way it ran, the summary then names what did not run:
failed format (exit 1)
skipped analyze — the run stopped at an earlier failure
skipped check — needs `format`, which did not pass
Exit codes
An exit code is not a success flag; it is the shortest possible bug report.
| code | meaning |
|---|---|
0 |
everything asked for ran and passed |
1 |
a task ran and failed |
2 |
the file was refused — a bad document, an unknown key, a cycle, a dangling reference, a set that expands to nothing |
3 |
a task's executable was not found |
4 |
a task's body succeeded and one of its then: continuations failed |
A 4 stops the run exactly as a 1 does: what has not started does not
start, what is running is left alone, and the summary names the rest. The code
says which of the three endings happened, not how much of the plan was
abandoned — those are different questions and --keep-going is the one that
answers the second.
With --keep-going and more than one failure, the code is the first
failure's. A code is a report about one failure, and a run with three cannot
honestly claim to be about all of them; the summary is where the others are.
3 is separate because "Dart is not installed on this machine" and "the code is
broken" are repaired by different people, and one code sends both to the same
one.
A verb's exit code is what the run answers with — it is your Dart, written
against this table, whose constants the package exports as ExitCode so a
verb can name the reason rather than the digit; and the built-in remove answers 2 for a path outside the
repository because that means the file is wrong. A program started by run:
has never heard of this table, so its code goes in the message and the run
answers 1.
4 exists because a publish followed by a verification has three endings,
not two: nothing was published, everything passed, or the upload happened and
the check after it is red. Collapsing the third into 1 tells a pipeline the
publish failed, which is false and unrecoverable in the wrong direction — the
registry will not accept that version again.
The keys
| key | meaning |
|---|---|
desc |
required, one line, what --list prints |
run |
an external program as argv — the program, then its arguments, each its own entry. Never a command line; nothing splits a string and no shell sees it |
do |
a verb: remove, or one this project registered |
args |
extra arguments appended to the body |
argv-from |
a set whose members are appended as arguments, already expanded |
each |
a set whose members the body runs once per, sequentially |
in |
where the body runs, relative to the root — or the literal $each |
env |
environment for this task only |
env-required |
variables that must already be set, checked before the body runs |
needs |
direct requirements, run before this task, once per invocation |
then |
continuations, run after this task's body |
gate |
the gate sets this task belongs to |
collects |
names a gate set this task is the composite of |
timeout |
seconds a run: body may take before it is killed — per member under each: |
timeout: is asked of the process, not waited out by the engine: the body is
sent SIGTERM, given a moment to write what it has, and then SIGKILL. What it
does not do is reach the process's own children — Windows has job objects,
POSIX has process groups, and neither is what Dart exposes — so a task that
spawns a server and hangs may leave the server behind. A do: cannot carry a
timeout: at all: a verb is a Dart function, nothing outside it can stop one,
and a limit that passed while the verb kept writing to disk would be worse than
none. That is refused when the file is read, not discovered at runtime.
The example at the top uses four keys because four is what that repository
needs. Here is one using the rest — a release, which is where needs:,
then: and a verb all earn their keep at once:
version: 1
sets:
packages:
include: [packages/*]
tasks:
build:
desc: build every package
each: packages
in: $each
run: [dart, run, build_runner, build, --delete-conflicting-outputs]
publish:
desc: publish, and announce it only if that worked
needs: [build]
then: [announce]
env-required: [PUB_TOKEN]
timeout: 600
run: [dart, pub, publish, --force]
announce:
desc: post the release note
do: notify
argv-from: packages
each: runs the body once per member, sequentially, with in: $each putting
each run in that member's own directory. needs: is "before, and once however
many tasks ask for it"; then: is "after, and only if the body worked" —
which is the whole reason exit code 4 exists, because publish succeeding
and announce failing is a third ending and not a failure to publish.
env-required: is checked before that task's body runs — not at the start of
the run — so a missing token is a sentence rather than a broken upload. do:
names a verb the project wrote in Dart and handed to runXtask, and
argv-from: hands it the expanded set as arguments.
A set is a list of members or a glob with exclusions, expanded by the engine rather than by a shell, in a deterministic order:
sets:
packages: [packages/lake, packages/lake_cli]
sources:
include: ['{templates,packages}/**/*.lake']
exclude: ['**/test_data/**']
A set that expands to nothing is an error: a task given no files checked nothing, and a gate that examined nothing is worse than no gate.
Editor support
xtask --emit-schema prints a JSON Schema for the file format. Generate it into
your repository and point at it with a relative path, so a fresh clone needs
neither the network nor a per-person editor setting:
xtask --emit-schema > xtask.schema.json
# yaml-language-server: $schema=./xtask.schema.json
version: 1
The schema knows the shape of the file: it completes a task's keys, and
underlines dsec: or a gate: written as a string, while you type. Everything
that needs the graph or the filesystem — a cycle, a needs: pointing at
nothing, an orphan gate, a glob matching nothing, an unregistered verb — is what
--validate answers. A schema catches a mistyped key; --validate catches
a mistyped name.
The schema describes one version of the engine, which is why it is generated into your repository rather than fetched from a URL.
Three rules
Not style. Each prevents a failure that has already happened somewhere.
R1 — no control flow in the file. No conditionals, no branching, no shell, no capturing one command's output to feed another. A task that needs a condition becomes a verb. The moment the file can ask "did that work?", it is a programming language with no debugger and no types.
R2 — no inheritance. A task is read completely from its own keys. This costs repetition and buys the property that what is written is what happens — and it keeps the engine from growing precedence order and "where did this value come from" tooling.
R3 — a built-in primitive is total and argument-driven. It takes paths or
values and performs an effect; it never branches on the result of anything.
remove: [paths] qualifies, test -f X && Y does not. This is what stops the
primitive list from becoming a portable shell — the failure the npm ecosystem
took, one package per utility (rimraf, mkdirp, cross-env, shx), all of
them existing only because package.json scripts are shell.
What it deliberately is not
- Not a build system. No up-to-date checks, no artifact graph, no caching. An expensive task solves that inside its own verb.
- Not a package manager and not a monorepo tool.
melosruns shell across packages;xtaskruns a graph without one. They do not overlap. - Not parallel by default. Tasks run in order, one at a time, and
parallelism belongs to the CI system, which already has it.
--parallelis there for the local loop and costs watching the output arrive. - No plugins, no dynamic loading, no expression language. Verbs are code the project links; everything else is data.
- No templating or interpolation beyond
$each. The moment a value can be computed in the file, R1 is gone.
Windows
run: is argv, and the engine resolves the program itself — walking PATH,
honouring PATHEXT, and knowing that CreateProcess cannot start a .bat or
a .cmd however it is asked. A shim goes through the shell because there is no
other way; an argument that the shell would reinterpret is refused with the
character named, rather than passed through to mean something else.
Windows has no argv. CreateProcess takes one string, and the runtime at the
other end splits it again — so an array is a promise somebody has to keep by
quoting. Dart's Process does that, by the rules CommandLineToArgvW reads
back, and a task written as a list arrives as that list: a path with a space in
it stays one argument and is not two. Where quoting is not enough the engine
refuses rather than hopes, and that is the batch shim above — cmd.exe parses
the line a second time, after the quoting, by rules of its own.
License
MIT — see LICENSE.
Libraries
- xtask
xtask— a task runner whose tasks are data.