Skip to main content
Version: v5.6.1

4.1 Anatomy and the step kinds

Included from Basic tier

After this you can: read a workflow definition and name every step kind it may contain, and what each does.

Before you start — tick each one:

  • Lesson 2.2 — Namespaces, composition and inputs (opschain learn 2.2)

In the GUI: the Workflows tab on a project page lists the stored definitions; running one opens the same Run form as an action, typed inputs rendered as fields.

A change runs one action on one node. A workflow orchestrates many — start the database, then SAP, then the web tier; pause for an approval in the middle; fan one action across every asset of a kind. This lesson is a reading lesson: by the end you can open any workflow definition and say what each step does, where its target comes from, and how data moves between steps. Lesson 4.2 stores and runs one.

1. The four step kinds​

A workflow is a list of steps, each one of four types:

  • change — run an action on a target node (the workhorse).
  • workflow — nest another stored workflow.
  • wait — pause for a human. (Governed sign-off — named approvers, quorum, audit — is an approval policy on the server, not a key in the YAML: lesson 4.4 and lesson 10.1.)
  • stage — group nested steps and set their run_as: (sequential or parallel). (Workflows spell this knob run_as:; action files spell the same thing strategy: on their stage steps — lesson 2.1.)
name: Environment Start
description: Starts all assets in dependency order
steps:
- type: change
name: Start database
target: /projects/acme/environments/dev/assets/database
action: database:start
- type: wait
name: Confirm before the app tier
- type: stage
name: App tier
run_as: parallel
children:
- type: change
name: Start SAP
target: /projects/acme/environments/dev/assets/sap
action: sap:start
- type: change
name: Start Web
target: /projects/acme/environments/dev/assets/web
action: web:start

A change step may also take targets: (a list) to fan the same action across several nodes, and property_overrides: to shape each run.

2. Where a change step's action comes from​

An asset target runs its template's catalog actions (lesson 3.4). A static action on an asset target is checked against that catalog eagerly — at workflow publish and at run creation — so a typo is a named error up front instead of a mid-run step failure. (Targeting a MintPress-backed asset's catalog actions requires the packs:mintpress entitlement — opschain docs packs-mintpress.)

A change step's target: may also name a project or environment node. There is no template or stored catalog behind those nodes, so the child is a discovered git-remote change: the server pins a revision of the target project's repository, runs the named action, and the action's own execution reports the step tree — exactly what lesson 3.1's first change did. Two optional fields select the repository identity, both defaulted when absent:

- type: change
name: snapshot config
target: /projects/acme # or environments/dev, etc.
action: backup:export_config
git_remote_name: origin # default: the project's sole configured remote
git_rev: release-1 # default: HEAD (the remote's default branch)

At workflow publish and run creation the target must exist and have a configured git remote (a project with several remotes needs an explicit git_remote_name:); template_version: is a named error here, and git_remote_name:/git_rev: are named errors on an asset target, whose template supplies the git identity. The action itself is repository content discovered by running, so it is validated only when the step is reached — a dangling action fails the child change, which fails the step loudly. The child create authorises against the target node as the run's creator, exactly like a direct change create.

Declarative YAML action files are the default authoring lane — they compile, validate and run on any runner. An actions.rb is the SDK/compatibility lane, applicable only to existing MintPress customers and those using the MintPress SDK or the packs:mintpress feature: it needs a Ruby-capable runner, and the catalog marks such entries runner_requirement: "ruby".

3. Conditions: when:​

Any step may carry when: — a CEL boolean evaluated when the step is reached, over properties (plus run.*, node.* and estate()); false skips the step (and its children) with the verdict and operand values recorded on it. On a change step with a static target, properties (for when: and the step's with: values alike) is the target's full converged view — project → environment → asset, the git-committed layers below the stored ones, exactly what the spawned change's own steps will see — merged with the run's property_overrides; on wait, stage and dynamic-target steps it is the project's properties merged with the run's property_overrides:

- type: wait
name: Production sign-off
when: properties.env == 'production'

opschain docs expressions covers the expression language.

4. Typed run inputs​

A workflow may declare typed run inputs beside its steps — parameters the caller supplies when the run starts, validated before anything is created:

inputs:
environment: { type: enum, values: [dev, staging, production], required: true }
batch_size: { type: number, default: 2 }
dry_run: { type: boolean, default: false }
project: { type: string, default: '${{ run.project }}' }

Types are string, number, boolean, enum (with values), secret (vault references only — lesson 3.5) and list<string>. A default is a literal or an expression over the run-creation scope (run.* — the run.project default binds the owning project's code); it evaluates once when the run is created and is recorded like a supplied value. Steps read the resolved values as inputs.* — when: !inputs.dry_run is the canonical gate. Secret input values are masked as ****** in every run document. Inputs arrive from whoever starts the run: -i on the CLI (lesson 4.2), the create API's inputs object — or a trigger, whose action_params.inputs map may carry ${{ event.* }} templates resolved per firing (lesson 5.2).

5. Fan-out: for_each:​

A change or stage step may fan out with for_each: — a list expression evaluated when the step is reached; each element becomes an ordinary child step, binding item/index (or the as: alias and <as>_index). This is how a workflow runs over whatever the estate actually holds instead of a hardcoded list:

- type: stage
name: shutdown applications
for_each: ${{ inputs.environments }}
as: env
run_as: parallel
children:
- type: change
name: apps
for_each: ${{ estate('/projects/acme/environments/' + env).assets
.filter(a, a.properties.Asset_Type == 'Software_Application') }}
as: app
run_as: parallel
target: ${{ app.path }}
action: ${{ app.code + ':shutdown' }}

estate(...).assets/.environments list only the nodes the run's creator may read; an empty expansion records the step skipped, and an expansion past the orchestration.fan_out_limit setting (default 50) fails loudly. Dynamic target:/action: expressions resolve and validate when the step is reached — a rendered action must exist in the target's own action catalog. A template step's name: may carry the same expressions (name: ${{ env }}-apps), so each expanded execution is identifiable in the tree instead of apps/apps-1; a fan-out stage's own dynamic name: renders onto each stage copy the same way (name: check-${{ env.code }} → copies check-dev, check-prod), while the declared stage keeps the authored literal. Only change and stage steps fan out: a wait step gates people and a nested-workflow step pins one run, so neither takes for_each: — wrap them in a stage (a named compile error says so) — and a change step cannot combine for_each: with targets:. opschain docs expressions has the full rules (nested fan-outs need distinct as: names).

6. Data between steps: outputs and with:​

Steps pass data forward: an action that runs opschain step set-output <key> <value> publishes outputs, and when the spawned change finishes they surface to every later workflow step as steps.<step-name>.outputs.* — in when:, for_each:, dynamic targets/actions/names and property_overrides values alike. A value that is JSON (a list of targets, an object) decodes, so a discovery step can drive a fan-out:

- type: change
name: discover
target: /projects/acme/environments/dev/assets/db
action: fleet:discover # publishes targets=[{asset_path, code, …}]
- type: change
name: patch
run_as: parallel
for_each: ${{ steps.discover.outputs.targets }}
target: ${{ item.asset_path }}
action: ${{ item.code + ':patch' }}
- type: change
name: report
when: ${{ steps.patch.outputs.children.filter(c, c.result == 'patched').size() > 0 }}
target: /projects/acme/environments/dev/assets/db
action: fleet:report

A fan-out step's steps.<name>.outputs.children is the ordered list of its expanded executions' outputs (each with its status, asset_path and published keys); guard optional outputs with has(steps.<n>.outputs.<key>); outputs survive restarts and run-level retries — a carried successful step is never re-executed to recover them. Two helpers round out the list toolkit: uniq(list) deduplicates (first occurrences win) and chunk(list, n) splits into consecutive sublists of at most n — for_each: ${{ chunk(steps.discover.outputs.targets, 5) }} sweeps in batches of five. Outputs stay under a hard 64 KiB per-step budget — a step with a larger product to hand forward stores it as an artifact and passes the id (lesson 3.6).

Data also flows into a nested run as typed inputs: a change or workflow step may carry with:, binding the invoked action's (or nested workflow's) declared inputs: — values are expressions resolved when the step is reached, and the nested run's own creation validates them (an unknown name, missing required input or type mismatch fails the step with the named error). property_overrides coexist unchanged; with: is the typed, declared lane:

- type: change
name: patch
target: /projects/acme/environments/dev/assets/db
action: db:patch
with:
advisory_ids: ${{ steps.discover.outputs.ids }}
severity: high

Check yourself​

  • In the Environment Start definition above: which steps create work on other nodes, which one pauses for a person, and at what moment is each change step's action: validated — publish time or reach time — and why does that differ between the asset targets here and a project target?

What you learned​

  • A workflow is change, workflow, wait and stage steps; stages set run_as: sequential|parallel.
  • An asset target runs its template's catalog (validated eagerly); a project or environment target runs a discovered git-remote action (validated when reached).
  • when: skips a step with its verdict recorded; typed inputs: are validated before the run exists and read as inputs.*.
  • for_each: fans a change or stage over a list the estate or an earlier step produced; steps.<name>.outputs.* and with: move data between steps and into nested runs.

Next: lesson 4.2 — Store, browse, run (opschain learn 4.2), or back to your track (opschain learn).