Flows & Steps¶
A flow is a YAML file that describes an automation pipeline. Stepyard reads the file, resolves expressions, and executes each step in order (unless you use control flow to change the sequence).
Anatomy of a flow file¶
# yaml-language-server: $schema=../.stepyard/flow.schema.json (1)
name: example # (2)
description: What this flow does. # (3)
env: # (4)
LOG_LEVEL: info
BASE_URL: https://api.example.com
dotenv: .env # (5)
trigger: # (6)
uses: cron
with:
schedule: "0 9 * * 1-5"
steps: # (7)
- id: fetch # (8)
uses: http.request # (9)
with: # (10)
url: ${{ env.BASE_URL }}/data
method: GET
- id: process
uses: shell.run
with:
command: echo "${{ steps.fetch.output.body }}"
- Modeline for VS Code / Cursor - enables live autocompletion. Generated by
stepyard schema. name- machine-readable identifier. Used as the argument tostepyard run <name>.description- optional human description. Shown instepyard status.env- optional map of inline environment variable defaults (see Environment variables below).dotenv- optional path (or list of paths) to.envfiles to load (see Loading from a file below).trigger- optional. Omit for manually-run flows. See Triggers.steps- ordered list of execution units.id- unique identifier within the flow. Used to reference this step's output in later steps.uses- the node type to execute. Format:namespace.action.with- key/value map of inputs passed to the node. Values can be literals or${{ }}expressions.
Step fields¶
Every step supports the following top-level fields:
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | ✓ | Unique identifier within the flow |
uses |
string | ✓ (or steps) |
Node type, e.g. shell.run |
with |
mapping | Input arguments passed to the node | |
steps |
list | Nested sub-steps (group step, no uses) |
|
if |
expression | Skip this step when falsy | |
loop |
expression | Iterate over a list | |
while |
expression | Repeat while truthy | |
next |
string / expression | Override which step runs next | |
max_visits |
int | Max times this step can run (default: 1, 0 = unlimited) | |
continue_on_error |
bool | Don't stop the flow on failure | |
approval |
bool | Pause for human approval before running (via the built-in approval hook) | |
retry |
int / mapping | Automatic retry configuration | |
timeout |
string | Maximum execution time, e.g. "30s" or "5m" |
Retry shorthand¶
Set retry to an integer for a fixed number of attempts with default backoff:
- id: download_artifact
retry: 3
uses: http.download
with:
url: https://cdn.example.com/artifact.tar.gz
dest: ./artifacts/artifact.tar.gz
For custom delay and backoff, use a mapping - see Error Handling - retry.
What counts as failure?
shell.run always completes successfully and returns code in its output -
a non-zero exit does not mark the step failed. http.request returns
4xx/5xx responses as output without failing; connection errors still fail
the step. See Error Handling.
Outputs¶
After a step runs, its outputs are available to later steps via ${{ steps.<id>.output.<field> }}.
The available fields depend on the node. For example, shell.run produces:
| Field | Description |
|---|---|
stdout |
Combined standard output (stderr is merged here) |
stderr |
Always empty - stderr is merged into stdout |
code |
Exit code (0 = command succeeded; the step still completes even when code != 0) |
http.request produces:
| Field | Description |
|---|---|
status |
HTTP status code |
body |
Parsed JSON object or raw string |
headers |
Response headers dict |
See Built-in Nodes for all output fields.
Step status¶
After execution, each step is in one of these states:
| Status | Meaning |
|---|---|
completed |
Ran successfully |
failed |
Node raised an exception or returned a failed result (see note below) |
skipped |
The if condition was falsy |
waiting_for_approval |
A hook paused execution waiting for a human |
waiting_for_input |
A human.input node is waiting for a value |
cancelled |
The user or the system cancelled the run |
File layout¶
Stepyard looks for flows in the flows/ directory of your project root. The project root is the nearest ancestor directory that contains a .stepyard/ folder.
The name field in the YAML file determines the run name; the argument to stepyard run is the file stem (filename without .yaml). Only top-level files inside flows/ are resolved - subdirectory nesting is not yet supported.
Environment variables (env)¶
Declare environment variables once at the top of a flow with the env: key. No CLI flags or extra files needed - just add the block and run:
name: deploy
env:
ENVIRONMENT: staging
RETRY_COUNT: 3
steps:
- id: apply
uses: shell.run
with:
# $ENVIRONMENT is a real OS env var in the subprocess
command: kubectl apply -f k8s/$ENVIRONMENT/
- id: notify
if: "${{ env.ENVIRONMENT == 'production' }}"
uses: shell.run
with:
command: echo "Deployed to production"
How it works¶
- Values are strings. Numbers and booleans are coerced:
42→"42",true→"true". - Each value is set as a real OS environment variable before any step runs, so
shell.runsubprocesses can read them as$NAME(or%NAME%on Windows) without extra configuration. - Every
${{ env.NAME }}expression inwith,if,loop,while, andnextfields also resolves to the declared value. - Precedence: an existing shell/CI/
.envvariable with the same name is not overwritten. Flowenvacts as a default, which means you can always override declared values from the outside without touching the YAML.
Loading from a file (dotenv:)¶
Instead of (or alongside) inline env: values, point to a .env file with the dotenv: key:
name: deploy
dotenv: .env.staging # relative to the project root
steps:
- id: apply
uses: shell.run
with:
command: kubectl apply -f k8s/$ENVIRONMENT/
The file uses the standard key=value format (comments with # are ignored, values can be quoted):
ENVIRONMENT=staging
RETRY_COUNT=3
# DB_PASSWORD=secret ← keep real secrets out of dotenv committed to git
Load multiple files by passing a list - the first file in the list takes precedence when a key appears in more than one:
dotenv:
- .env.local # developer overrides - highest priority
- .env.staging # environment defaults
- .env # base defaults - lowest priority
Full precedence order (highest → lowest):
- OS / shell environment (CI variables,
export, etc.) - Inline
env:values dotenv:file values (first file wins across multiple files)
Paths are resolved relative to the project root directory. A warning is logged if a file is not found, but the run continues.
What belongs here vs vars¶
env: |
--var / --env-file |
|
|---|---|---|
| Declared in | the YAML file | command line at run time |
| Referenced as | $NAME or ${{ env.NAME }} |
${{ vars.NAME }} |
| Best for | non-secret defaults (URLs, flags, levels) | per-run or per-environment values |
| Commit to git? | yes (no secrets) | no |
Keep secrets out of env:
env: values live in the flow YAML file - don't use them for API keys, passwords, or tokens.
Use shell environment variables, a project .env file, or a secrets manager instead (see How to manage secrets).
Variables (vars)¶
You can pass arbitrary key/value pairs to a flow at runtime using --var:
Inside the flow, access them with ${{ vars.env }}:
Or load them from a .env file:
Group steps (no uses)¶
A step without uses is a group: it groups nested steps, optionally under an if or loop:
- id: deploy_per_region
loop: ${{ ["us-east-1", "eu-west-1", "ap-southeast-1"] }}
steps:
- id: apply
uses: shell.run
with:
command: kubectl apply -f k8s/ --context=${{ item }}
- id: verify
uses: http.request
with:
url: https://${{ item }}.myapp.com/healthz
Timeout¶
Limit how long a step can run with timeout. Accepts a duration string:
- id: slow_query
uses: shell.run
timeout: "2m" # "30s", "5m", "1h" - all supported
with:
command: psql ${{ env.DB_URL }} -c "SELECT * FROM big_table"
If the step exceeds the timeout, it is cancelled and marked failed.