How zorb works
A short tour of the model behind zorb. Read this once and the rest of the docs will hang together.
For the field-by-field syntax, see Workflow format. For the CLI surface, see CLI reference. This page is the conceptual scaffolding.
The four nouns
zorb has four things, and only four. Everything else is a property of one of them.
Workflow ──► Tasks ──► Steps ──► (Shell command | Action)- Workflow — a single
zorb.ymlfile. Holds tasks and any cross-cutting config (env, defaults, pre-task secret loaders). - Task — a named, callable unit (
build,deploy,test). What you run from the CLI. - Step — one ordered unit of work inside a task. Either a shell command (
run:) or an action invocation (uses:), never both. - Action — a code file (JavaScript, TypeScript, Python) called by a
uses:step. Takes typed inputs, returns structured outputs.
A workflow can have many tasks; a task can have many steps; a step is exactly one thing. There is no parallel:, no needs:, no DAG. Steps run top-to-bottom and a non-zero exit fails the task.
The runtime, step by step
When you run zorb run <task>, zorb:
- Discovers
zorb.yml— searches the current directory and walks up, the same waygitfinds.git. The first match wins and becomes the workflow's working directory.zorb.yamlis accepted alongsidezorb.yml; if both exist in the same directory,zorb.ymlis used and a warning names the duplicate. - Parses and validates — strictly. Unknown keys, wrong types, duplicate step IDs all error before any step runs.
- Resolves inputs — types
--withpairs against the task's declaredinputs:and applies defaults for the rest. - Loads secrets — runs any pre-task
secrets:actions to populate the run-scoped secret table. - Runs steps in order — each step's subprocess inherits a freshly-built env, runs to completion (or hits its
timeout:), and exits. - Threads outputs forward — a step's outputs become available via
${{ steps.<id>.outputs.<key> }}in every following step in the same task. - Exits with 0 on success, the step's exit code on failure, 130 on
SIGINT, 143 onSIGTERM.
Each step is a fresh subprocess. There's no shared mutable state between steps other than what an action explicitly puts into outputs, setEnv, or setSecret.
Three kinds of step
Every step is one of:
# Shell — passes a string to $SHELL -c.
- run: npm test
# Action — invokes a code file as a subprocess.
- uses: ./scripts/version.action
with:
path: ./package.json
# Cross-file task — calls another zorb task by reference.
- uses: ./ops/zorb.deploy
with:
environment: productionThe distinction matters because each kind has its own subprocess model, output mechanism, and resolution rules — but all three share the same set of common fields (name, id, env, timeout, retries, backoff).
TIP
A shell step gets docker: to run inside a container. It's still a shell step — just one whose subprocess happens to be docker run instead of $SHELL -c.
How uses: resolves
uses: accepts three forms, in this resolution order:
- Cross-file workflow ref — the basename starts with
zorb., e.g../zorb.buildor./ops/zorb.deploy. Resolves to a task in the indicatedzorb.yml. The callee only sees inputs you pass viawith:. - Local action —
./relative/path.action. The runtime extension is detected from disk (.ts → .mjs → .cjs → .js → .py, first match wins). Writing the extension intouses:is an error. - NPM action —
@scope/package/pathorpackage/path. Resolved vianode_modulesrelative to the workflow.
The same resolver powers zorb use <action> from the command line, so anything you can write in uses: works there too.
Env, layered
Env is the single trickiest concept in zorb, and the one place where the model rewards reading carefully.
Step subprocesses never inherit process.env. A shell, docker, or action step sees only the env vars that flow through the workflow's declarative env stack. This is deliberate: it stops a workflow (or an action that workflow consumes) from scraping credentials out of the developer's shell.
The stack, lowest to highest precedence:
inline CLI env < defaults.run.env < workflow env < task env < step envdefaults.run.env only applies to shell steps (not actions). Everything else applies to all step types.
The inline CLI env is the only path from outside the workflow file into a step. It's built from:
--env-file <path>— read the file, populate the layer.-e KEY=VALUE— set inline (overrides--env-file).-e KEY(no value) — copyprocess.env[KEY]into the inline layer. IfKEYisn't set in the calling shell, the flag is silently skipped.
So zorb run release -e GITHUB_TOKEN is how you say "forward whatever GITHUB_TOKEN happens to be in my shell." If you don't say it explicitly, no shell-export reaches the step.
Expressions
${{ … }} is zorb's template syntax. It resolves to a value just before a step runs. Four namespaces are available:
inputs.<name>— task inputs from--with.env.<name>— env vars in scope at the point of evaluation.secrets.<name>— values from the run-scoped secret table.steps.<id>.outputs.<key>— outputs from earlier steps in the same task.
Expressions are valid in env: values (at every scope) and in with: blocks on uses: steps. They are not expanded inside run: strings — those are passed to the shell verbatim. To pull an expression into a shell command, declare it as an env var and read it natively ($TAG instead of ${{ steps.x.outputs.tag }}).
This separation keeps shell scripts readable and avoids two layers of substitution biting you at unexpected times.
See Expressions for the operator, function, and filter surface.
Outputs
Each step can produce structured outputs that later steps in the same task can read.
- id: version
run: |
echo "tag=v$(jq -r .version package.json)" >> "$ZORB_OUTPUT"
echo "sha=$(git rev-parse --short HEAD)" >> "$ZORB_OUTPUT"
- name: Tag
env:
TAG: ${{ steps.version.outputs.tag }}
SHA: ${{ steps.version.outputs.sha }}
run: git tag "$TAG" -m "Release $TAG ($SHA)"- Shell and docker steps write
key=valuelines (or heredoc-delimited multi-line values) to$ZORB_OUTPUT, a temp file path zorb sets in the step's env. - Actions return an object. The keys become outputs.
Outputs are referenced via ${{ steps.<id>.outputs.<key> }} in with: and env:. To consume one inside a run: script, map it to an env var first.
Secrets
Secrets are values that should be masked in step output. zorb has a small, deliberate model:
- A
secrets:block at the top of the workflow lists actions that load credentials before any task runs. The action callscontext.setSecret(name, value)for each one; zorb adds the value to a run-scoped table. - Inside a task, you reference the value via
${{ secrets.<name> }}inenv:orwith:. - Any exact-substring match of a registered secret in step stdout/stderr is replaced with
***before printing.
The whole secrets surface is built on top of actions. There's no special syntax for "this is a secret" — just call setSecret. A loader like @zorb/secrets/load-1password or @zorb/secrets/load-dotenv does the actual fetching.
What zorb is not
A short list of things people reach for that aren't here, and won't be:
- Conditional step logic — no
if:expressions, nocontinue-on-error. Put branching inside the script if you need it. - Parallel steps — steps in a task run sequentially. If you need parallel work, run multiple zorb invocations.
- Task DAGs /
needs:— composition is call-style only. Useuses: ./zorb.<task>as a step. - Multiple tasks per invocation —
zorb run a b cisn't a thing. One task per run. - Event triggers — zorb is local. There's no
on: push. Triggers belong in whatever CI system shells out tozorb run. - Windows — not yet. Shell defaults, Docker path mapping, and runner detection assume POSIX.
If you're reaching for one of these, either nest the logic inside an action (which is just code) or compose calls from the outside.
Next steps
- Creating workflows — turn the model into a
zorb.yml. - Creating shell steps — everything
run:can do. - Creating code steps — invoking actions and other workflows.
- Writing actions — author the code on the other side of
uses:. - CLI reference — every command and flag.