Skip to main content
Version: v5.6.1

Orchestration

Included from Basic tier

OpsChain work is authored in two YAML document kinds that share one orchestration language. A YAML action defines what one node does — the shell steps, composition and gates behind a verb like db:patch. A YAML workflow sequences work across many nodes — changes, stages, approval gates and nested workflows. Both compose through the same primitives (when:, for_each:, retries:, on_error:, typed inputs:/with:, step outputs) and the same expression layer: every ${{ … }} span is a CEL expression, compiled when the document is compiled or published — a bad expression is a named error before anything runs — and every runtime verdict is recorded on the step, so plans, chains and triage read ordinary tree facts. There is no scripting runtime inside the engine: expressions are pure (no I/O, no time, no randomness) and cost-bounded.

Every YAML block on this page is a complete, loadable document — an action file or a local workflow file — and the repository's tests compile each one through the engine's own schema, composition and expression checks, so these examples cannot drift from the engine.

The model​

  • An action runs as a change on one node, and its steps form the change's step tree.
  • A workflow runs as a workflow run whose change steps spawn real, individually-retryable changes; stages group steps sequentially or in parallel; wait steps park the run for a human.
  • Either document may fan out (for_each:), gate steps (when:), retry (retries:), declare error policy (on_error:), take typed run inputs (inputs:) and pass data forward (step outputs).

Authoring and running walkthroughs live in the runbooks: opschain docs getting-started/2-1-your-first-action (actions) and getting-started/4-1-anatomy-and-the-step-kinds (workflows). This page is the reference map of the language itself.

YAML actions​

One file under .opschain/actions/ defines one action; directories are namespaces (db/start.yaml → db:start). Each step carries exactly one of six kinds:

Step kindDeclaresDoes
run:a shell commandexecutes on the runner; may capture: stdout/stderr into outputs, set_properties:/remove_properties: on success, declare artifacts: collected at completion (opschain docs artifacts), pin image:/tools:, set timeout:
uses:an action referencecomposes another action into this tree — a sibling YAML action (inlined at compile), an actions.rb# action or a mintpress# catalog action (see the integration lanes below)
change:target + actioninvokes ANOTHER node's action as its own correlated child change (below)
wait:optional inputsparks the change for a human; governed sign-off belongs in approval policies (opschain docs change-governance) — the legacy requires_approval_from: key still makes it an approval gate and stays enforced
emit:a custom event typepublishes a typed event onto the event stream, with templated with: data
stage:strategy: + nested steps:groups steps sequential or parallel

Both actions and workflows take an optional one-line summary: beside description: — the two fields serve different audiences. summary: is an imperative one-liner (at most 120 characters, single line — a multi-line value is rejected at load by name) that actions list, workflows list and the GUI's action-button captions render; when it is absent, the first sentence of the description (else its first line, ellipsis-truncated) stands in, so existing definitions render readably with no churn. description: stays the full operator detail, printed by the show views and detail panes, never truncated.

A minimal composed pair — the callee declares typed inputs: and exports outputs:, the call site binds them with with: and reads the exports under its own step name:

# .opschain/actions/greet.yaml
action: greet
description: Declares a typed contract its call sites bind
inputs:
who: { type: string, required: true }
greeting: { type: string, default: hello }
outputs:
message: ${{ steps.say.outputs.message }}
steps:
- name: say
run: opschain step set-output message "${{ inputs.greeting }}, ${{ inputs.who }}"
# .opschain/actions/welcome.yaml
action: welcome
steps:
- name: greet_the_team
uses: greet
with: { who: ops }
- name: announce
run: echo "${{ steps.greet_the_team.outputs.message }}"

The binding validates when the action compiles — an unknown with: name, a missing required input, or a literal that violates the declared type (a non-number for a number, an enum violation, a secret that is not a secret-vault:// reference) is a named error before anything runs. Composition is cycle-checked and depth-capped; a composed callee's internal step names stay private to its subtree, and the call-site step republishes exactly the callee's declared outputs: exports (or, with outputs: passthrough, every internal step's outputs).

YAML workflows​

A workflow is a list of steps of four types — change (run an action on a target node), workflow (nest another stored workflow, by its catalog identity), wait and stage (nested children: ordered by run_as: sequential|parallel; action files spell the same knob strategy:). Locally a workflow file lives in .opschain/workflows/ and carries its identity as a workflow: <code> header; on the server, opschain workflows import stores the same definition as an immutable, per-project version.

# .opschain/workflows/env-start.yaml
workflow: env-start
name: Environment start
steps:
- type: change
name: start_database
target: /projects/acme/environments/dev/assets/database
action: database:start
- type: wait
name: confirm_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's target: is usually an asset, whose pinned template supplies the action catalog — a static action on an asset target is checked against that catalog at publish and at run creation. A target may also be a project or environment node: the child is then a discovered git-remote change, with optional git_remote_name: / git_rev: selecting the repository identity, and the action validates only when the step is reached (it is repository content). A change step may take targets: (a list) to fan the same action across several nodes, property_overrides: to shape the spawned change, and with: to supply the invoked action's (or nested workflow's) typed inputs — values are expressions rendered when the step is reached, validated by the nested run's own creation.

The shared primitives​

The full semantics of each primitive — timing, recorded verdicts, scope rules — live on the expressions page; this is the map.

  • when: — any step, in both document kinds, takes a CEL boolean evaluated at step entry. False records the step skipped (children included) with the verdict and evaluated operand values; an evaluation failure fails the step loudly.
  • Status functions and failure paths — success(), error(), error('<step>'), cancelled() and always() are legal in when: only, and read the enclosing scope's state. A step whose when: calls error()/always() becomes a failure-path step: the abort cascade spares it and dispatches it once its non-handler siblings settle — compensation and cleanup — while the run still finalises error (recorded as error_handled; the failure is never masked). A trailing when: always() step is the finally block. steps.<name>.status reads one sibling's outcome for surgical conditions.
  • retries: — {attempts, backoff} re-executes a failing step in place before any failure policy applies, every attempt recorded. Legal on run: and actions.rb# steps in action files, and on change/ workflow steps in workflows (each automatic retry spawns a fresh nested run). A step without its own policy inherits the orchestration.retries settings ladder (global → project → environment → asset, nearest wins), audited as each retry's policy_source. See settings.
  • on_error: — the inline error policy on run: steps and change steps. continue lets the scope proceed past the step's failure (the step still records error honestly — one bad fan-out element never stops a sweep); the object form {uses: <action>, with: {…}, then: fail|continue} names a compensating action, desugared into a sibling handler step gated by when: error('<step>') with the failed step's recorded error bound as error.step/error.status/error.message in handler scope. retries: exhaust first; then: fail (the default) keeps the run finishing error; a handler that itself fails is terminal, and handlers never nest.
  • for_each: — a list-valued CEL expression evaluated at the step's entry fans the step out: each element becomes an ordinary child step, binding item/index (or the as: alias and <as>_index) into everything the child evaluates — targets, actions, commands, with: values, nested conditions and nested fan-outs (which must bind distinct names). Legal on run: and stage: steps in action files and on change and stage steps in workflows; run_as: orders the expansion. An empty expansion records a skip; an expansion past the orchestration.fan_out_limit settings ladder (default 50) fails loudly naming the cap. A workflow template step's name: may carry the bindings (name: patch-${{ app.code }}), so each expanded execution identifies its element in the tree.
  • Typed run inputs — workflows and top-level actions declare inputs: (string, number, boolean, enum with values, secret — vault references only, masked everywhere, an empty value meaning "not supplied" — and list<string>), validated before the run exists and recorded on it. Supplied with -i key=value, the create APIs' inputs object, a trigger's templated action_params.inputs, or a calling step's with:. Defaults are literals or expressions over the run-creation scope (default: '${{ run.project }}').
  • Step outputs — a step publishes with opschain step set-output <key> <value> (--sensitive masks); later steps read ${{ steps.<name>.outputs.<key> }}. At workflow scope a change step rolls its nested run's root-scope outputs up for every later step, JSON-shaped values decode, and a fan-out parent exposes outputs.children — the ordered list of its expansions' outputs objects (status, asset_path, code, item/index and the published keys), so discovery steps can drive sweeps and report steps can filter results. Guard optional keys with has().
  • Wait steps and approvals — a wait step parks the run, when: makes it conditional, and it is answered with opschain runs continue|approve|reject. Governed sign-off belongs in approval policies (opschain docs change-governance): a policy at the owning path holds matching runs for approval before they start, with named approvers, quorum and audit. The legacy step key requires_approval_from: (user_names:/ldap_groups:) restricts who may answer the wait mid-run; it stays supported and enforced, is superseded by approval policies, and opschain approval-policies convert-step-gates inventories every use and creates the equivalent policies (your YAML is never edited).
  • estate('<node path>') — reads another node's converged properties from any expression, RBAC-checked at entry with the run creator's own read authority, snapshot-at-entry, every referenced property document's version recorded in the step's audit; secrets never cross (vault references arrive as reference strings). estate(<path>).assets / .environments enumerate the node's direct children as {code, name, path, properties} descriptors — the raw material for estate-driven fan-outs.
  • Plans — --plan (and the plan endpoints) compiles the same payload as create with no run created: everything decidable up front renders concretely (verdicts, resolved fan-out elements with their targets and actions), output-dependent spans render symbolically with the producing step named. Always advisory — the run re-evaluates at entry.

CEL out of the box​

Expressions evaluate over these namespaces (full table and scope rules on the expressions page):

NamespaceHolds
properties.*the step's merged properties view (project → environment → asset)
context.*the change run's step context document
steps.<name>.outputs.*, steps.<name>.statuspreceding steps' published outputs and (in when: only) a sibling's status
inputs.*the run's typed inputs at workflow/root scope; a composed call site's with: bindings inside the callee
run.*, node.*run metadata (run.id, run.action, run.project, run.metadata); the target node's path/code
error.*the failed step's step/status/message — bound only inside an on_error handler's scope
item/index (or as: aliases)the current fan-out element and position, inside a for_each: scope only

The function surface is deliberately small:

  • Standard CEL — comparisons, boolean logic, arithmetic, ternaries (a ? b : c), in, size(), type conversions (int(), string(), double()), and the comprehension macros has(), filter, map, exists, exists_one and all. Numeric comparisons work across int/double.
  • The CEL strings extension — split, join, replace, substring, trim, indexOf, lowerAscii/upperAscii and friends.
  • The CEL lists extension — list helpers such as slice and flatten.
  • estate('<node path>') — the cross-node properties read above.
  • chunk(list, n) (also list.chunk(n)) — consecutive sublists of at most n elements, in order: sweep in batches.
  • uniq(list) (also list.uniq()) — removes duplicates, first occurrences winning.
  • success() / error() / error('<step>') / cancelled() / always() — the scope-status functions, legal in when: only.

Evaluation is pure and cost-bounded; a missing key, a type error or a runaway comprehension fails the step loudly (guard optional data with has()), and operand values matching the run's mask set record as ******. Rendering into a command string: strings splice verbatim, booleans and numbers format plainly, null renders empty, lists and maps render as JSON.

Integration lanes​

uses: is one vocabulary over three backends, and the change: step / workflow change step extend the same reach across nodes. What runs where:

ReferenceBackendResolvedRunswith:
uses: <action>a sibling YAML actioninlined at compile, cycle-checkedthe callee's own steps, in this changeyes — binds the callee's declared inputs:
uses: actions.rb#<action>the repository's actions.rb (the Ruby SDK lane)format-checked at compile; the action resolves on the runneropschain-action <action> on a Ruby-capable (opschain-core) runnerno — actions.rb actions take no typed inputs
uses: mintpress#<ref>the asset's stored MintPress cataloggrafted at change creation, server-side; a dangling reference is a named 422the grafted subtree on the Ruby runner lane, exactly as the catalog action runs directlyno — MintPress actions are property-driven; use property_overrides
change: step / workflow change stepANOTHER node's catalogeagerly where static, authoritatively when reacheda correlated child change under the target's own properties, template pin, approvals and entitlementsyes — supplies the target action's typed inputs

YAML → actions.rb (the Ruby lane)​

The Ruby lane is only applicable to existing MintPress customers and those using the MintPress SDK or the packs:mintpress feature.

Declarative YAML action files are the default authoring lane — they compile, validate and run on any runner. actions.rb remains the SDK/compatibility lane, and uses: actions.rb#<action> bridges to it: the step dispatches the named actions.rb action onto a Ruby-capable runner (the action catalog marks such actions with runner_requirement: "ruby", and placement routes the work to a runner that carries the toolchain). retries: applies — the engine re-executes the bridge step in place — and a bridged action that reports child steps while it runs has those children adopted into the tree and executed on the same Ruby lane. A plain run: step that attempts the same is refused loudly: only Ruby-lane steps may report children.

# .opschain/actions/nightly_maintenance.yaml
action: nightly_maintenance
steps:
- name: quiesce
run: ./scripts/quiesce.sh
- name: rotate_keys
uses: actions.rb#security:rotate_keys
retries: { attempts: 2, backoff: 20s }

YAML → MintPress (uses: mintpress#<ref>)​

On a MintPress-backed asset (requires the packs:mintpress entitlement), a YAML action may graft one of the asset's stored MintPress catalog actions into its own tree. <ref> is a catalog full path (Binaries/jdk), the wire identity (mintmodel:jdk) or the bare action name, matched case-insensitively. The referenced subtree is grafted at change creation, server-side — the plan shows the grafted children, and they execute exactly as running the catalog action directly would. The MintPress validity gates apply unchanged, and the scheme needs the asset's stored catalog: project/environment changes, local runs and fan-out expansions refuse it by name. See the MintPress pack reference.

# .opschain/actions/provision_stack.yaml
action: provision_stack
steps:
- name: preflight_check
run: ./scripts/preflight.sh
- name: bring_up
uses: "mintpress#Startup"
- name: verify
run: ./scripts/verify.sh

Cross-node: the change: step​

Where uses: pulls work INTO this change (one tree, one approval envelope), a change: step pushes work OUT to another node as its own governed child change — the right shape when the foreign action must run under its own node's properties, template pin, approvals and entitlements. The target and action are literal in action files (named node path, action resolved against the TARGET's catalog — fan-out across targets stays a workflow capability); workflow change steps additionally accept dynamic target:/action: expressions, resolved and validated when the step is reached. The child renders as a hop in the change's correlation chain, the step completes with the child's outcome and republishes the child's root-scope outputs as ${{ steps.<name>.outputs.* }}, and child-of-child recursion is capped by the orchestration.change_depth_limit setting (default 5).

# .opschain/actions/release.yaml
action: release
inputs:
version: { type: string, default: latest }
steps:
- name: build
run: make dist
- name: deploy_db
change:
target: ../assets/database
action: db:deploy
property_overrides: { opschain: { release: "${{ inputs.version }}" } }
- name: verify_release
run: ./verify.sh

The worked example — a quarterly patch, end to end​

One scenario that exercises the whole language: discover what needs patching, gate on a human unless the run is unattended, sweep every non-production environment's applications in parallel, patch the project-level infrastructure with retries and a compensating rollback, and report — the per-asset work composing YAML, actions.rb and MintPress steps. First the per-asset action and its callees, authored in the application repository:

# .opschain/actions/preflight.yaml
action: preflight
description: Eligibility checks before patching
inputs:
severity: { type: enum, values: [low, high, critical], required: true }
outputs:
eligible: ${{ steps.decide.outputs.eligible }}
steps:
- name: decide
run: opschain step set-output eligible "$(patchctl eligible --severity ${{ inputs.severity }})"
# .opschain/actions/rollback.yaml
action: rollback
description: Compensating handler - restores the pre-patch snapshot
inputs:
reason: { type: string, default: '' }
steps:
- name: restore
run: patchctl rollback --reason "${{ inputs.reason }}"
# .opschain/actions/patch.yaml
action: patch
description: Patch one asset and verify it afterwards
inputs:
advisory_ids: { type: string, default: '' }
severity: { type: enum, values: [low, high, critical], default: high }
events:
emits: [patching:asset_patched]
outputs:
result: ${{ steps.verify.outputs.result }}
steps:
- name: preflight
uses: preflight
with:
severity: ${{ inputs.severity }}
- name: silence_monitoring
uses: actions.rb#monitoring:silence
retries: { attempts: 3, backoff: 10s }
- name: apply
run: patchctl apply --advisories "${{ inputs.advisory_ids }}"
when: steps.preflight.outputs.eligible == 'true'
timeout: 30m
capture: { stdout: true }
retries: { attempts: 2, backoff: 30s }
on_error:
uses: rollback
with:
reason: ${{ error.message }}
then: fail
- name: restart
uses: "mintpress#Startup"
- name: smoke_endpoints
run: curl --fail ${{ endpoint }}
for_each: ${{ properties.patching.endpoints }}
as: endpoint
run_as: parallel
on_error: continue
- name: verify
run: opschain step set-output result "$(patchctl status)"
set_properties:
asset: { patching.last_result: verified }
- name: snapshot_config
change:
target: ../assets/config-store
action: config:snapshot
property_overrides: { opschain: { reason: post-patch } }
- emit: patching:asset_patched
with:
severity: ${{ inputs.severity }}
- name: cleanup
run: patchctl cleanup
when: always()

Then the workflow that sweeps the estate with it:

# .opschain/workflows/quarterly-patch.yaml
workflow: quarterly-patch
name: Quarterly platform patch
description: Discover, gate, patch and report across the estate
inputs:
project: { type: string, default: '${{ run.project }}' }
environments: { type: list<string>, default: [] }
advisory_ids: { type: string, required: true }
severity: { type: enum, values: [low, high, critical], default: high }
unattended: { type: boolean, default: false }
steps:
- type: change
name: discover
target: /projects/acme/environments/dev/assets/fleet
action: fleet:discover
with:
severity: ${{ inputs.severity }}
- type: wait
name: approve_patch
when: ${{ !inputs.unattended }}
- type: stage
name: patch_environments
for_each: >-
${{ estate('/projects/' + inputs.project).environments
.filter(e, has(e.properties.Classification)
&& e.properties.Classification == 'Non-Production'
&& (size(inputs.environments) == 0
|| e.code in inputs.environments)) }}
as: env
run_as: parallel
children:
- type: change
name: patch-${{ app.code }}
for_each: >-
${{ estate(env.path).assets
.filter(a, has(a.properties.Asset_Type)
&& a.properties.Asset_Type == 'Software_Application') }}
as: app
run_as: parallel
target: ${{ app.path }}
action: patch
with:
advisory_ids: ${{ inputs.advisory_ids }}
severity: ${{ inputs.severity }}
on_error: continue
- type: change
name: patch_infrastructure
target: ${{ '/projects/' + inputs.project + '/assets/infrastructure' }}
action: infrastructure:patch
retries: { attempts: 2, backoff: 30s }
on_error:
uses: infrastructure:rollback
with:
reason: ${{ error.message }}
then: fail
- type: wait
name: confirm_reboot
when: >-
${{ has(steps.patch_infrastructure.outputs.reboot_required)
&& steps.patch_infrastructure.outputs.reboot_required }}
- type: workflow
name: report
target: /projects/acme/workflows/patch-report
with:
patched_assets: >-
${{ uniq(steps.patch_environments.outputs.children
.filter(c, c.status == 'success').map(c, c.asset_path)) }}
batches: ${{ chunk(steps.patch_environments.outputs.children.map(c, c.code), 5) }}
- type: change
name: notify_failure
target: /projects/acme/environments/dev/assets/fleet
action: fleet:notify
when: ${{ error() }}
- type: change
name: audit_record
target: /projects/acme/environments/dev/assets/fleet
action: fleet:audit
when: ${{ always() }}

Walking it through:

  • Typed inputs bind first. advisory_ids is required — a run without it is refused before anything is created; project defaults to '${{ run.project }}' (the owning project's code, bound once at run creation); environments is an optional narrowing list — empty means every Non-Production environment. All five surface to every step as inputs.*, and -i severity=critical at start overrides a default.
  • discover spawns a real change on the fleet asset, its with: supplying the target action's typed inputs. When it finalises, its root-scope outputs surface to every later step as steps.discover.outputs.*.
  • approve_patch is a conditional checkpoint: a manual run parks mid-flow for a human to resume; an unattended run (a trigger mapping ${{ event.* }} onto unattended: true) records the step as a skip with the verdict and operand values. To restrict WHO signs off — say the patch-approvers LDAP group — put an approval policy on the project naming the group as approvers (opschain docs change-governance): the policy holds the run for that group's approval before it starts, with quorum and audit.
  • patch_environments fans out twice from the estate. The stage enumerates estate(project).environments and keeps the Non-Production ones (the has() guard silently excludes unclassified nodes — drop it for the strict posture where an undeclared node halts the sweep); each environment copy fans out again over that environment's Software_Application assets. Both enumerations are RBAC-filtered with the run creator's read authority and recorded, with per-document property versions, in each step's audit. Each expanded change renders as patch-<asset> in the tree, targets app.path, runs the patch action with the run's inputs passed down through with: — and on_error: continue means one failing asset records error without stopping the sweep. The expansion is capped by the orchestration.fan_out_limit ladder (default 50).
  • Inside each spawned patch change the whole per-node language is at work: a composed preflight callee with a typed enum binding; an actions.rb# bridge step with retries (Ruby lane); the gated apply step reading the callee's exported output, capturing stdout, retrying twice and compensating through its on_error handler (rollback receives error.message in handler scope; then: fail keeps the failure honest); a grafted MintPress Startup subtree; a fan-out smoke test over properties.patching.endpoints; a property write on success; a change: step snapshotting a sibling node's config as a correlated child change; a custom event; and an always() cleanup.
  • patch_infrastructure targets a computed path — a project-level asset, so the infrastructure is patched once per run, after every environment stage settled (tree order is the happens-before guarantee). Its retries exhaust first; then the desugared patch_infrastructure.on_error handler runs the rollback action with the recorded error message; then: fail still finalises the run error with error_handled recorded.
  • confirm_reboot reads rolled-up outputs: the wait engages only when the patch change published reboot_required — has() keeps the condition defined when it did not.
  • report nests another stored workflow, its with: built from the fan-out parent's outputs.children — filter/map select the successfully patched asset paths, uniq deduplicates and chunk batches them in fives.
  • notify_failure and audit_record are the failure path and the finally block: error() dispatches the notification only when the scope failed, always() runs the audit regardless — both spared by the abort cascade, with the run's final status staying honest.

Preview the whole thing with opschain runs create workflow acme/quarterly-patch -i advisory_ids=ADV-123 --plan: input-driven verdicts and estate-driven expansions render concretely, with each child's resolved target and action; everything that depends on published outputs renders symbolically, naming the producing step.

  • expressions — the full ${{ … }} contract: spans, scopes, verdicts, fan-out, outputs roll-up and plans.
  • settings — the orchestration.* settings ladders (retries, fan_out_limit, change_depth_limit).
  • packs-mintpress — the MintPress graft and Ruby-lane child reporting in depth.
  • concepts — where orchestration sits in the wider vocabulary.
  • Runbooks: opschain docs getting-started/2-1-your-first-action and getting-started/4-1-anatomy-and-the-step-kinds — authoring walkthroughs, local runs and the error-path drill.