Skip to main content
Version: 1.6

The for command

dispat for runs a script once for each item of a list. It is the shell's own for x in ...; do ...; done, spelled so that it means the same thing under every shell your configuration names. The list is a set of words you type, the packages, spaces or versioning groups you select, or the packages that changed.

Everything dispat runs is a shell command. A loop written inside one is the first thing that breaks when the shell setting moves, because the loop syntax belongs to the shell rather than to dispat.

for pkg in core web api; do npm publish "$pkg"; done

That line runs under sh and bash. It does not run under cmd /C, and it does not survive being pasted into a JSON config file without care. The same work as a dispat loop reads:

dispat for core web api --do 'npm publish "$DISPAT_ITEM"'

dispat splits nothing and interprets nothing. Each item is one argument, and each --do script is one shell string, run through the shell your config names.

Which command do I want

You want toUse
run a script in every changed package, in order (--since all for every package)dispat run <script>
run one declared script, once, right heredispat exec <script>
choose between shell commands based on a conditiondispat if <cond>
run one shell command per item of a listdispat for <item>...

dispat run knows about your monorepo. It computes a plan, finds which packages changed, and runs the script in each of them in dependency order; --since all widens the sweep to every package, changed or not. dispat exec ignores the dependency graph. It looks up one script by name and runs it.

dispat for sits between the two. Like dispat run it can visit every changed package, but it runs the shell text you typed rather than a script each package declares, one item at a time, in the folder you invoked it from, with the item described by DISPAT_* variables. Use dispat run when each package says what the work is; use dispat for when the command line does, or when the list is not packages at all.

Where the list comes from

A loop takes exactly one source. Two sources would leave one of the lists silently unvisited, so naming two is a usage error.

dispat for <item>... --do <script>
dispat for -p <globs> --do <script>
dispat for -s <globs> --do <script>
dispat for -g <globs> --do <script>
dispat for --changed [--since <rev>] [--consumers] [-p|-s|-g <globs>] --do <script>
dispat for --unchanged [--since <rev>] [--consumers] [-p|-s|-g <globs>] --do <script>
dispat for --since <rev> [--consumers] [-p|-s|-g <globs>] --do <script>
SourceThe list is
positional itemsthe words you typed, in that order
--package, -pthe packages the terms name, in discovery order
--space, -sthe spaces themselves, by name
--group, -gthe versioning groups themselves, by name
--changedthe changed packages, in dependency order
--unchangedthe packages --changed leaves out, in dependency order
--since <rev>--changed --since <rev>, spelled as dispat run spells it

The -s and -g sources iterate over the spaces and the groups themselves, not over the packages inside them. A loop over the three packages of a space is not the job a loop over the space is, and --since all -s libs is how you ask for the first: the window that selects every package, narrowed to the space.

The selection flags mean two things

Under --changed, --unchanged or --since, the -p, -s and -g flags stop being the source and become the narrowing they are on every other command. This is the same composition dispat if --changed uses.

dispat for -s libs --do 'echo "$DISPAT_ITEM"' # one item: the space
dispat for --changed -s libs --do 'echo "$DISPAT_ITEM"' # the changed packages of libs

Without a window flag, naming two of them at once has no meaning and is refused. With one, they compose as usual.

The iterator variables

Each iteration exports the item and its position. A package, space or group item also exports what it is.

VariableInValue
DISPAT_ITEMevery loopThe item: the word, or the package, space or group name.
DISPAT_INDEXevery loopThe item's position, counting from 0.
DISPAT_TOTALevery loopThe number of items in the list.
DISPAT_PACKAGEpackages, changed, unchangedThe package name, the same variable a release stage reads.
DISPAT_SPACEpackages, spaces, changed, unchangedThe space the item belongs to, or the space itself.
DISPAT_DIRpackages, spaces, changed, unchangedThe item's own folder, absolute.
DISPAT_GROUPpackages, groups, changed, unchangedThe versioning group, or the group itself.

The names are the release environment's own, so a script written for a stage runs unchanged inside a loop.

DISPAT_GROUP is left unset, not empty, for a package that versions on its own. An independent package is not a member of a group called "", and ${DISPAT_GROUP+x} is how a shell tells the two apart. A group item always carries it.

These variables are added after everything else, so nothing can shadow them. A DISPAT_ITEM left over in the environment by an enclosing run loses to the loop's own, which is what makes a loop safe to nest inside a release stage.

Where each iteration runs

Every iteration runs in the folder you invoked the command from. No item is entered. A relative path inside the script therefore means one thing for the whole list.

dispat for -p '*' --do 'echo "$DISPAT_ITEM" >> covered.txt'

That appends to one file, in the invocation folder, once per package. The item's own folder is exported as DISPAT_DIR instead, so a script that wants it says so.

dispat for -p '*' --do 'cd "$DISPAT_DIR" && npm pack'

Use --in to move the whole loop somewhere else. It takes a folder path or any of the place names.

dispat for a b --do 'make "$DISPAT_ITEM"' --in ./build
dispat for a b --do 'make "$DISPAT_ITEM"' --in pkg:core

Running several scripts per item

--do is repeatable. The scripts of one item are that item's sequence: they run in order, and the first one that fails ends the item.

dispat for core web \
--do 'npm run build --workspace "$DISPAT_ITEM"' \
--do 'npm publish --workspace "$DISPAT_ITEM"'

The publish runs only if the build succeeded, for each item on its own.

Failure and exit codes

The loop is sequential. A shell's for runs one body at a time, a script written against one is entitled to assume it, and concurrency over a selection is what dispat run already is.

The first failing item ends the loop, and its exit code becomes the command's. This keeps the loop transparent in a pipeline.

$ dispat for a b c --do '[ "$DISPAT_ITEM" != b ] || exit 7'
$ echo $?
7

Pass --keep-going to run the remaining items anyway. The command still reports the first failure's code, because a later item succeeding says nothing about the one that did not.

The --on-failure flag runs one script after a failed loop and replaces the exit code with its own. It runs once for the loop, not once per failing item, and it runs even when Ctrl-C ended the loop, so your cleanup still gets a chance.

dispat for -p '*' --do 'deploy "$DISPAT_ITEM"' \
--on-failure 'notify-slack "deploy failed"; exit 1'

An exit code of 2 means the command line itself was invalid. A 1 means dispat could not do what you asked: a term matching no package, a folder that is not there, a --changed it cannot evaluate, or --require-items refusing an empty list.

An empty list

A list with nothing in it runs the body zero times and exits 0. This is what for x in $EMPTY does in a shell, and it holds for every source. A --changed window with nothing pending is an answer, not a failure.

$ dispat for --do 'echo never'
$ echo $?
0

Pass --require-items when the list holding something was the point. An empty iteration then exits 1 instead, which is the CI gate.

dispat for --changed --require-items --do 'dispat exec smoke --for "pkg:$DISPAT_ITEM"'

A term that matches no package at all is always an error, with or without the flag. A loop that quietly ran zero times is how a typo hides.

Changed and unchanged packages

The --changed source covers what a release would cover. It is the same window, filter and consumer expansion every sweeping command uses, so a loop and the run it accompanies never disagree about what changed.

dispat for --changed --since origin/main --do 'echo "$DISPAT_ITEM changed"'

Without --since, the window is the release window, which holds the packages with something pending. Pass --since all to select every package. The --consumers flag expands the window downstream, so a provider's consumers join the list.

The --unchanged source is the exact complement. Every package is in one of the two loops and never in both, whatever narrowing you apply, so the pair covers the repository between them.

dispat for --unchanged --do 'echo "$DISPAT_ITEM is up to date"'

This is the half a sweep never reaches, which is where a staleness report, a coverage check or a nightly rebuild belongs.

What it costs

A literal list needs no config file and no git repository. dispat reads nothing, asks GitHub nothing, and runs your scripts through /bin/sh -c. This is what makes the command cheap enough to call from inside another script.

Naming pkg:, space: or root in --in reads the config file, because only a configuration knows where a package lives. Every other source reads it too, because which packages exist is a question about the monorepo. The --changed, --unchanged and --since sources read the git repository as well, and cost what dispat status costs.

Once the config file is read, the loop body runs through the shell your config names rather than through /bin/sh -c. That is the whole point of the command: one loop, one meaning, whichever shell the repository builds with.

Flags

--do <script>

The script each item runs. Repeatable, run in order per item, stopping at the first failure.

--keep-going

Run the remaining items after one fails. The exit code is still the first failure's.

--require-items

Exit 1 when the iteration is empty, instead of 0.

--unchanged

Iterate over the packages the --changed window leaves out.

--changed

Iterate over the changed packages.

--since <rev>

Count changes from this revision instead of the release window; all selects every package. On its own it means --changed --since.

--consumers

Expand the window to everything downstream of the changes.

-p, -s, -g

The list of packages, spaces or groups; under a window flag, the narrowing instead.

--in <folder>

Run every iteration in this folder: a path, or any place name.

--on-failure <script>

Run this once after a failed loop, and exit with its code instead.

The command forwards nothing after --. The --do scripts are shell text you already write in full, so there is nothing a forwarded argument would reach that a script could not say itself.