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

gates: [check]

tasks:
  lint:
    desc: check the style
    gate: [check]
    run: [ruff, check, .]

  test:
    desc: run the suite
    gate: [check]
    run: [pytest, -q]

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 lines in that file are doing the work. gates: [check] at the top says which groups this repository has; gate: [check] on a task says it is in one. xtask check then runs the group, in the order the file writes it — a gate set is not a task and needs none. Gate sets says why the whole tool exists for those two lines, and The keys is every key a task can have.

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, bin/xtask.dart (the last recipe is one), and run dart run :xtask <task>. Dart verbs is that file, the verb, and how the installed xtask hands a run to it.

Recipes

Each is a whole xtask.yaml, with a link to where its keys are explained.

Check the file and the workflow before a commit, in the gate CI runs too. The job is one step, xtask check. --check-ci

version: 1
gates: [check]
tasks:
  validate:
    desc: refuse a broken xtask.yaml
    gate: [check]
    run: [xtask, --validate]
  workflows:
    desc: fail if a CI step runs anything but a gate set
    gate: [check]
    run: [xtask, --check-ci]

Run the tests in every package that has some. containing:

version: 1
sets:
  tested: {include: [packages/*], containing: [test]}
tasks:
  test:
    desc: run each package's suite
    each: tested
    in: $each
    run: [dart, test]

Regenerate code, and fail if the committed copy was stale. unchanged:

version: 1
sets:
  generated: {include: ['lib/**.g.dart']}
tasks:
  codegen:
    desc: regenerate, and fail if that changed a committed file
    unchanged: generated
    run: [dart, run, build_runner, build, --delete-conflicting-outputs]

A step for one OS only. Elsewhere it is skipped, and a task that needs it runs as though it had passed. platforms:

version: 1
tasks:
  sign:
    desc: sign the macOS bundle
    platforms: [macos]
    run: [codesign, --sign, '-', build/App.app]

A token that must never reach the log. Run it as xtask upload -- v1.2.0 dist/app.tgz. secret-env:

version: 1
secret-env: [GH_TOKEN]
tasks:
  upload:
    desc: attach the files after -- to a GitHub release
    env-required: [GH_TOKEN]
    run: [gh, release, upload]

Logic of your own: a loop, a condition. A verb in bin/xtask.dart; the installed xtask regen hands the run to it. Dart verbs

version: 1
tasks:
  regen:
    desc: regenerate every proto, stopping at the first that fails
    do: regen
    args: [protos/a.proto, protos/b.proto]
Future<void> main(List<String> args) async {
  exitCode = await runXtask(args, verbs: {'regen': regen});
}

Future<int> regen(VerbContext context) async {
  for (final proto in context.args) {
    final code = await context.run(['protoc', '--dart_out=lib', proto]);
    if (code != 0) return code;
  }
  return 0;
}

Guides

Each is one page, and each opens by saying who it is for:

  • Gate sets, and CI — the groups a person and a CI job run, and --check-ci, which keeps the workflow from growing its own list back.
  • Running, and reading a run — what a run will do before it does it (--dry-run, --why, --graph), the summary it ends with, --keep-going, -j, colour, and --report as JSON.
  • Reference — every flag, every key, sets, the exit codes, and the schema for an editor.
  • Dart verbs — your own entry point, a verb, how the installed xtask hands a run to it, and testing one.
  • Internals and platforms — Ctrl-C and SIGTERM, what Windows changes, and where masking secrets stops.

Coming from make

a Makefile has here it is
a target with a recipe a task with run: — argv, one program per task
a recipe that branches, loops or pipes a verb (do:) in bin/xtask.dart
prerequisites needs:
$(MAKE) next at the end of a recipe then: [next]
a phony target that only lists others a gate set: gates: at the top, gate: on each task
VAR=x before a command env:
env -u VAR before a command env-unset:
a variable holding a file list a set, named by all: or each:
make help xtask --list
make -j xtask <task> -j <n>

Two things have no counterpart, on purpose: a file's timestamp deciding whether a recipe runs (see what it deliberately is not), and one target's output captured into another's command line (R1).

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. melos runs shell across packages; xtask runs 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. -j is 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.

License

MIT — see LICENSE.

Libraries

xtask
xtask — a task runner whose tasks are data.