Skip to content

YAML pipeline reference

A pipeline document defines work that cryosleep runs as one durable workflow. Jobs in the same DAG layer run in parallel; each job is one durable boundary. There is no version marker to write: a file with jobs: at its top level is a pipeline, and the run it produces is recorded under the workflow type cryosleep/pipeline/v1.

This is not the same document as a canvas graph, which starts version: cryosleep-graph/v1 and is written as nodes and edges. Both compile to the same node graph at run time, and they overlap heavily, but neither converts into the other; see pipelines and graphs for which to reach for. To see a pipeline drawn as a graph, paste it into the read-only preview at /pipelines.

name: optional-display-name
tags: [ci, release] # applied to every run; merged with cryo submit --tag
if: 'event.ref == "refs/heads/main"' # trigger predicate; skip the run if false
concurrency: # at most one running run per group
group: deploy-prod
policy: queue # queue | cancel-running | cancel-queued | skip
queue_max: 100 # optional cap on waiting runs
throttle: # at most N runs START per period
limit: 20
period: 1m
env:
REGION: us-east # merged into every shell and script job as $REGION
vars:
REGION: us-east # constants for expressions: `${{ vars.REGION }}`
outputs: # what the run hands back to whoever ran it
sha: ${{ steps.build.output.sha }}
jobs:
build:
deploy:

tags:, if:, concurrency:, throttle:, env: and outputs: are optional. jobs: is required (use an empty map only for stub validation). Insertion order in jobs: is significant - it’s the tiebreaker when multiple jobs are simultaneously ready.

Top-level concurrency: (one running run per group)

Section titled “Top-level concurrency: (one running run per group)”

Admission control for shapes where two overlapping runs are wrong - deploys most of all. At most one run in a group runs at a time; the policy: decides what happens to the rest when a new run arrives into a busy group. It binds however the run was submitted - cryo submit, a handler routing a push at the pipeline, a schedule tick, a rerun, or a cryo spawn from a script.

One surface it does not bind: a child started by a workflow: job or a graph workflow node. Those go straight to the engine, so a document with a concurrency: block runs alongside the group’s holder when it is invoked as a child. Put the group on the parent instead.

  • queue (the default) - the new run waits in a FIFO queue behind the running one and starts, in order, when its turn comes. Nothing is cancelled; every submitted deploy eventually runs, oldest first.
  • cancel-running - the new run cancels the running member (superseded by <run-id> in its cancel reason) and takes over immediately. Newest wins; a stale deploy never finishes after a newer one.
  • cancel-queued - the new run supersedes the waiting backlog (any already-queued runs are dropped) and takes the single queue slot behind the running member. Useful when only the latest pending change matters but you don’t want to interrupt what’s live.
  • skip - no run is created at all. The submit answers 200 with {"status": "skipped"} and the group it lost to. The body still carries the id the server had minted; it is a correlation handle for that request and resolves to nothing, so cryo submit prints no id and exits 0.

skip is the singleton policy. The other three all produce a run eventually, so a noisy trigger against a slow group builds a backlog whichever one you pick. Reach for it where a second run would be noise: a reconcile that picks up the same state next time, a cache warm, a nightly that a manual trigger raced.

A cryo spawn of a document that skips fails instead. The calling script is waiting on a child id and there is none, so the error is the only honest answer - which also means a retry: around that spawn turns the policy into “wait for the group”, so don’t wrap one.

queue_max caps how many runs may wait (default 100). A submission that would exceed it is rejected rather than queued, so an unbounded backlog can’t build up. It has no effect under skip, which never queues.

group: takes ${{ … }}, which is how you get one running run per branch rather than one for the whole pipeline:

concurrency:
group: 'ci-${{ has(input.ref) ? input.ref : "manual" }}'
policy: cancel-running

It is rendered when the run is created, against the trigger that created it, so input and vars are the whole scope - admission decides whether a job runs at all, so there is no steps.* yet and naming one is an error rather than an empty group. A group that renders empty is refused too, since it would serialize unrelated runs together.

The has() is not decoration. A field that doesn’t exist is an error here, the same as anywhere else, and a manual cryo submit carries {"type": "manual"} with no ref - so the bare ${{ input.ref }} form works on a push and rejects the submit you were using to test it. Give the manual case a group of its own.

A queued run reports its state as queued with its position in the group, and shows up in the runs list like any other run. Cancel it to drop it from the queue before it starts. Every member (running or queued) carries the concurrency-group:<group> tag, so the runs list filters to a group with the existing tag filter.

The holder and the queue are durable: they survive a server restart, so a run that was waiting still starts when its turn comes.

A signal sent to a queued run is held for it. A run that hasn’t started has nothing to receive one, so cryo signal against it records the signal and hands it over the moment the run is promoted - repeated signals of the same name keep the latest payload, and different names all arrive. Approve a queued deploy the moment you decide, in other words, rather than waiting for it to start.

Top-level throttle: (how many runs start per period)

Section titled “Top-level throttle: (how many runs start per period)”

concurrency: above bounds how many run at once. This bounds how many start over time, across every run in the project. They are different questions: four at a time is still hundreds an hour when each one is quick, and a service that answers 429 is counting the second.

name: reindex # the budget's name, when `key:` is absent
throttle:
limit: 20
period: 1m
burst: 5 # optional, default 1
key: '${{ input.tenant }}' # optional; absent = one budget for this pipeline
policy: queue # queue (default) | drop
queue_max: 100 # queue policy only
jobs:

Twenty runs start per minute. The twenty-first waits for its slot and then starts; it is a real run that will happen, not a lost one.

  • burst: is what an idle stretch buys. The default of 1 is strict spacing - limit: 20, period: 1m starts one every three seconds and never two together. burst: 5 lets five arrive at once after a quiet patch, then paces the rest. Idleness never banks more than the burst, so a pipeline that ran nothing all night doesn’t release the night’s budget at once in the morning.
  • key: takes ${{ … }} and is rendered at submit against input and vars, exactly like concurrency.group - same scope, and the same reason for it. That is how one pipeline holds a budget per tenant rather than one for everybody. A key that renders empty is refused.
  • Without a key:, the budget is the document’s name: - one budget for this pipeline, and nothing else in the project shares it. A throttle: in a document with neither is refused, because there would be nothing to count under but “everybody”.
  • policy: drop refuses the submission instead of holding it: 429 with a Retry-After, and no run is created. Use it where the work is worthless late - a health probe, a cache warm - and a backlog is worse than a gap. The default queue is what you want for anything a person asked for.
  • queue_max: caps the waiting list (default 100); a submit past it is rejected rather than queued unboundedly.

A queued run reports queued and carries the throttle-key:<key> tag, so the runs list filters to one budget. Cancel it to drop it before it starts. The budget is durable - it survives a restart, and two server replicas share it.

Both blocks have a request-level twin, which is how work that is not a pipeline gets admission control - a polyglot script, a graph and a single node have no document to declare either in:

Terminal window
cryo submit probe.sh --concurrency-group probes --concurrency-policy skip
cryo submit reindex.sh --throttle 20/1m --throttle-key reindex

The request’s declaration beats the document’s. --throttle-key is required outside a pipeline: the key defaults to the document’s name:, and a script has none, so without it every keyless script in the project would share one budget.

A submit meets up to three of them, and the order is what makes the answers add up:

  1. --idempotency-key, if there is one. A redelivery is answered with the run the first one made, so it never reaches the two below and costs neither a rate token nor a queue slot.
  2. throttle: - how many may start. A run held here is waiting for a slot and has not met its group yet.
  3. concurrency: - how many may run at once. A run held here has already spent its rate token; the token is not handed back, because a queued run is a real run that will happen.

A run refused outright by the group - a full queue, or policy: skip - does get its token back, since no run exists to have spent it. And a run that queues on the rate meets its group later, when its slot comes: it is never waiting in both queues at once.

A rate the engine can’t honour as written is refused by cryo check and by the run itself rather than quietly becoming a different rate: a zero period, a limit finer than the millisecond the spacing is measured in (10000 per 1s), or queue_max: 0 alongside policy: queue. A limit that doesn’t divide the period rounds the spacing up, so the rate you wrote is a ceiling.

Both blocks can apply to one pipeline. The throttle is decided first, so “at most twenty a minute, and never two at once” is throttle: plus concurrency:, and a run waits for a slot before it waits for the group.

What it does not do: pace the items inside a single fan_out. That is the node’s own rate, which bounds one node’s dispatch. A pipeline can use both.

Top-level cancel_on: (abort on a bus event)

Section titled “Top-level cancel_on: (abort on a bus event)”
cancel_on:
events: [pr.closed]
jobs:

The run is cancelled when a matching event lands on the project bus. Matching is on the event type and its subject: the run inherits the subject of the event that started it, and cancel_on correlates to that subject automatically. So a CI run started by a push to acme/web has subject acme/web, and cancel_on: [pr.closed] cancels it only when acme/web’s pull request closes - a pr.closed on a different repo leaves it running. You write nothing about the correlation; the subject carries it (see Events and handlers).

A run started by a direct submit has no subject, so its cancel_on matches by type alone. And an event emitted without a subject (a plain cryo emit deploy.freeze) is a broadcast - it reaches every run watching that type, regardless of subject. That is how a project-wide “stop” signal works: emit it unkeyed and every in-flight run watching deploy.freeze cancels.

Registered when the run is submitted, so an event arriving at any point in the run’s life takes effect; one-shot per event type. The cancel reason records the event (cancel_on event "pr.closed").

A CEL expression evaluated server-side when the event is delivered, before any run is created. When it’s false the push is acknowledged and nothing runs - the declarative gate that pure-YAML CI does with on: / paths: / if:, here as one real expression language (the same engine as a job’s if:). No agent is involved in the decision.

The event is in scope as event:

Field Example
event.type push
event.ref refs/heads/main
event.sha full commit sha
event.repo repo addressing
event.sender pushing user
event.paths files the push touched, as a list
event.paths_known false when the forge didn’t report a file list
event.payload the delivery body, for anything not shaped above

A job’s if: reads these same fields under input: a triggered run’s input is the event that started it. See filtering one job.

Gate on the ref:

# only run CI on main or release branches
if: 'event.ref == "refs/heads/main" || event.ref.startsWith("refs/heads/release/")'

A reference to an unknown field (a typo) is an error, and an erroring predicate runs the pipeline and logs the failure - a broken guard is a loud misfire, never a push that vanishes. Evaluated only for event-triggered runs; a manual cryo submit has no triggering event and always runs.

Filtering on the files a push touched, without writing the guard yourself:

paths: [api/**, "*.rs", Cargo.lock] # run only if one of these changed
paths_ignore: [docs/**] # skip if they are ALL that changed

Both are ANDed with each other and with if:, and both read the same event scope at the same moment. Four pattern forms:

Pattern Matches
dir/** anything under that directory
*.ext / **/*.ext by extension, at any depth
**/name that filename in any directory, root included
Cargo.lock that exact path

A pattern using * in any other position is rejected at submit rather than compiled into something that quietly matches nothing. For anything richer, write the if: yourself.

paths_ignore: inverts per file, not per push, so a push touching docs/x.md and src/y.rs still runs. Skipping needs every file to match.

Both fail open when the forge reported no file list (event.paths_known is false). That is the reason to prefer the shorthand over the hand-rolled version: event.paths.exists(...) over the empty list a forge gives in that case is false, which stops building the repo while looking like a filter that works.

paths: decides whether the run happens. There is no shorthand for one job - rebuild the web bundle when web/ changed, always run lint - so write that job’s if: over input.paths:

jobs:
lint:
steps: [{ name: lint, run: make lint }]
web:
depends_on: [lint]
if: ${{ success() && (!has(input.paths_known) || !input.paths_known || input.paths.exists(p, p.startsWith("web/"))) }}
steps: [{ name: build, run: make web }]

Three parts:

  • success() - an if: replaces the implicit success() gate, so without this web builds on top of a failed lint. Every hand-written if: on a job with depends_on: needs it.
  • !has(input.paths_known) - a run with no push behind it carries no paths at all, and reading an absent key raises rather than answering false. Without this guard, cryo submit of the pipeline fails the run with No such key: paths - the filter works on every push and breaks the first time somebody runs it by hand. (CEL’s || absorbs the error once another operand is true, which is why this guard covers the input.paths read further along the same line.)
  • !input.paths_known - a forge sometimes reports no file list at all (a large push, a force push). paths is then empty, exists is false, and the job skips silently. Not knowing what changed is the moment to build everything, so the guard fails open. The top-level paths: shorthand applies this one for you.

Read the whole condition as “run unless we know it isn’t needed”.

Keep it on one line. A folded >- block whose continuation lines are indented keeps those newlines in the stored expression, where they show up in any error the condition raises.

startsWith covers a directory prefix; the other shapes the top-level paths: accepts are one expression each - p.endsWith(".rs") for an extension, p == "Cargo.lock" for an exact file, and p == "Cargo.toml" || p.endsWith("/Cargo.toml") for a filename at any depth.

For text you did not write - a model’s reply, a webhook field - the string helpers are trim(), lowerAscii(), upperAscii(), replace(from, to), indexOf(s), lastIndexOf(s), charAt(i) and substring(start), alongside CEL’s own size(), contains, startsWith, endsWith and matches(regex). Offsets count characters, so a non-ASCII string never hands back an index that splits one. split is not available yet.

A skipped job still satisfies its dependents, so a deploy that depends_on: [web] runs even when web was filtered out. Give it the same condition, or gate it on steps.web.status - see Dependencies.

Every job declares exactly one of: steps:, approval:, wait:, wait_for_event:, workflow:, script:. Plus optional shared fields:

build:
depends_on: [lint] # DAG edges
if: 'steps.lint.output.ok' # CEL expression - skip if false
concurrency: # serialize just this job across runs
group: deploy
retry: # re-run the job's activity on failure
attempts: 3 # total attempts including the first
backoff: 30s # delay before attempt 2; doubles after
max_backoff: 5m # ceiling on that doubling (optional)
exit_codes: [75] # retry only these exits (optional)
requires: [build] # extra agent capabilities to claim this job
agent: builder-2 # pin to one agent by id (see below)
timeout: 30m # wall-clock ceiling for the job's activity
continue_on_error: true # this job's failure doesn't fail the run
outputs:
tag: tag # name → key in the job's raw output
env:
BUILD_FLAVOR: release # job-level env (shell and script jobs)
credentials:
REGISTRY_PASS: zot-push # ENV_VAR → credential name (see below)
steps: # one of the six kinds

credentials: names the credentials a job may reach, as ENV_VAR: credential-name. Declare it at the top level to share across jobs; a job-level entry with the same variable wins, exactly as env: does.

Only the credential’s NAME is in the document and on the queue. The control plane resolves the value when an agent claims the job, masks it in the run’s logs, and hands the agent the value with no reference to where it came from. A name that does not resolve fails the job rather than running it unauthenticated, and a run triggered by an untrusted actor is refused credentials entirely - so a pull request from outside the project cannot reach one by declaring it, and a job that declares one fails rather than running with it missing.

That failure is the default because the alternative is worse: a step that runs anyway does something quietly different from what you wrote. Where a step genuinely works without its credential, say so and it runs:

jobs:
lint:
optional_when_untrusted: true # runs on fork PRs, without the token
steps:
- run: make lint

It is job-level only. A document-level default would combine with a document-level credentials: block to degrade the privileged job too, silently, which is the thing the refusal exists to prevent. It only waives the refusal - the credential is still withheld, so this is never a way for an outside contributor to obtain one. And it does nothing on a trusted run, where the value is injected as usual.

Note that a top-level credentials: block merges into every job, so one privileged token there stops the whole pipeline serving fork pull requests. Declare it on the job that needs it instead; that is the fix, rather than marking the whole document optional.

The value is a literal name, not an expression: it is resolved long after the document is rendered, so a per-run name could not be checked when you write it.

credentials:
GITHUB_TOKEN: forge-status # every job in the pipeline
jobs:
publish:
credentials:
REGISTRY_PASS: zot-push # this job as well

continue_on_error: true keeps the run’s final status green when this job fails, and lets its dependents run. The job’s own status stays failed, so an explicit status check still sees what happened. See continue_on_error: below.

retry:’s backoff doubles on every attempt after the second, so a long budget can spend a long time waiting - attempts: 6, backoff: 1m waits 1m, 2m, 4m, 8m, 16m, half an hour in total. max_backoff: caps any single wait, which keeps a generous retry budget from becoming a long outage.

A job that ran more than once says so: the run page marks the row ×3 and cryo logs prints the count under the step. It counts every reason the body ran again - a retry:, and a re-dispatch after the agent holding the job died without reporting. What cryo logs prints is the run that finished the step; the earlier ones are in the run’s raw byte stream, which cryo logs <run-id> --follow serves in full even after the run is over.

A control plane that restarts mid-ladder resumes the job with a fresh retry budget. The count comes through that with it, except in the gap between one run of the body ending and the next being dispatched, where a restart loses what came before. So a job that survived a deploy mid-retry occasionally reports fewer runs than it had; the output of all of them is in the stream either way.

exit_codes: narrows what is worth another attempt. Without it every failure is retried, so a command that already decided your input is wrong gets asked twice more, with the backoff in between:

deploy:
retry:
attempts: 4
backoff: 10s
exit_codes: [75] # EX_TEMPFAIL: the far end was busy
steps:
- name: ship
run: ./deploy.sh # exits 75 when the registry is throttling,
# 64 when the manifest is malformed

Exit 75 spends the budget; exit 64 fails on the first attempt, because another go at a malformed manifest produces the same malformed manifest.

Only a process exit is matched. A job that failed without one - the agent went away, the job hit its timeout:, something killed it - is never selectively retried: nothing chose that code, so a list of codes has nothing to say about it. An empty list is refused rather than read as “any”, since it names no code and would make attempts: a lie.

The same rule decides where the key is worth writing. A job that runs a process (steps:, script:) can carry it. A graph’s http node cannot: a failed request fails with a message and no exit code, so the condition never holds and the node stops retrying entirely. Saving one is refused, and cryo check reports it, so you hear about it while editing rather than during an incident. A graph saved before that check existed keeps running - drop the key and the node retries any failure.

The code is reported by the agent that ran the job, so during a rollout an agent still on an older build reports none, and a job with exit_codes: gets no retries until that agent updates. It fails on the first attempt rather than retrying something it shouldn’t, which is the safe direction, but a run that would have recovered doesn’t.

retry:, requires:, agent:, and timeout: apply to the activity-backed kinds (steps: and script:); they have no effect on approval:, wait:, or workflow: jobs, which run in the workflow process rather than on an agent. requires: adds capabilities on top of the job’s base one - shell for a steps: job, polyglot for a script: job - so the activity only dispatches to an agent advertising all of them.

agent: pins the job to one agent by id, narrowing on top of requires: rather than replacing it. Capabilities route work to whichever machine can do it, which is what you want almost always. Reach for agent: when the machine itself is the subject rather than the executor - reading one runner’s journal, draining it before maintenance, checking what its disk holds - because two runners advertising the same capabilities are otherwise indistinguishable to a job.

A pinned job waits for that agent and no other. It will not fail over to a healthy runner, and it stays pending for as long as the agent is offline, so pin deliberately and prefer requires: for ordinary work. On a matrix: job the pin covers every combination: the sweep chooses the work, not the machine.

jobs:
journal:
agent: builder-2
requires: [shell]
steps:
- name: read
run: journalctl -u cryo-agent -b -1 --no-pager | tail -100

timeout: (humantime, e.g. 30m) overrides the executing agent’s default activity ceiling (1h on unattended agents) in either direction; on expiry the agent kills the job’s process group and the activity fails (subject to retry:). For how these relate to the automatic recovery you get from durability, see retries and timeouts.

continue_on_error: (absorb a job’s failure)

Section titled “continue_on_error: (absorb a job’s failure)”

A job marked continue_on_error: true may fail without stopping the run. The failure is absorbed at that job: the run can still finish completed, and its dependents still run. That is what makes it different from a job that just failed, whose dependents are dropped by the implicit success() gate.

jobs:
lint:
continue_on_error: true # advisory: report it, don't block the ship
steps:
- { name: run, run: ./lint.sh }
deploy:
depends_on: [lint] # runs even when lint failed
steps:
- { name: go, run: ./deploy.sh }

The job’s own status stays truthful, so a downstream job can still react to it explicitly:

audit:
depends_on: [lint]
if: ${{ steps.lint.status == 'failed' }}
steps:
- { name: file, run: ./open-ticket.sh }

The two views are deliberately different. steps.lint.status reports what happened - one of success, skipped, failed. success() and failure() read what the job presents to its dependents, which is success once the failure is absorbed - so if: ${{ failure() }} on a dependent of an absorbing job does not fire. Use the explicit status comparison when you want to branch on an absorbed failure.

Job-level concurrency: (serialize one job across runs)

Section titled “Job-level concurrency: (serialize one job across runs)”
deploy:
depends_on: [build, test]
concurrency:
group: deploy
steps:

Where the top-level concurrency: gates whole runs at submit, a job’s concurrency: serializes just that job’s body across runs. The job takes a durable lease on group right before it runs and holds it until the run finishes; a second run that reaches the same job waits for the lease, while every other job in that run keeps going. So build and test of many runs proceed in parallel and only the deploy job takes turns - one deploy at a time without stalling the pipeline ahead of it.

The lease is project-scoped, and it’s the same lease a script takes with cryo lock <group>. A job’s concurrency: { group: deploy } and a script’s cryo lock deploy in the same project hold the same lock: whichever gets there first runs, the other waits. A waiting job shows up as awaiting-lease in cryo ls.

While it waits, the run is durably parked - a server restart doesn’t lose its place, and the lease frees the moment the holding run ends (or is reclaimed if that run vanishes). Only the FIFO queue order applies here; the top-level policy: / queue_max: knobs don’t (a job’s concurrency: takes only group:).

group: takes ${{ … }} holes, which is how the lease names the thing being protected rather than the pipeline:

deploy:
depends_on: [plan]
concurrency:
group: "deploy-${{ steps.plan.output.env }}"
steps:

That is one lease per environment: staging deploys take turns among themselves, production deploys among themselves, and the two never wait on each other. Per-record leases work the same way ("import-${{ input.account_id }}").

The group is rendered when the job is about to run, so it reads everything that exists by then - steps.* of upstream jobs, input, vars, run. That is later than the top-level concurrency.group, which is rendered at submit and has no steps.* to read.

Two ways it can go wrong. A field that isn’t there fails the job when the group renders (No such key: ref), so guard an optional one the way the top-level group does: ${{ has(input.ref) ? input.ref : "manual" }}. A group that renders empty is refused, since it would serialize unrelated runs together.

A matrix: job can’t read matrix. in its group: the lease is claimed once for the whole sweep, before any combination exists, so the hole is refused at submit rather than locking on a name that isn’t there yet.

The whole script - all steps stitched together - runs as ONE shell_run activity on one agent. Step markers make log segments splittable in the UI but there’s no per-step durable boundary.

build:
outputs:
tag: tag
steps:
- name: install
run: apt-get install -y curl
env:
DEBIAN_FRONTEND: noninteractive
- name: emit
run: |
set -e
cryo output set tag=v1.2.3

A step has name, run, optional env. Per-step env is exported inside the script’s per-step subshell, so it doesn’t leak into later steps. A step-level env: value is a literal string, and a ${{ … }} in one is rejected at submit rather than rendered. The value is quoted into the step script when the pipeline compiles, and the expression resolves later - so a rendered value containing a quote would break out of quoting the compiler wrote. Put an interpolated value in job- or workflow-level env:, which renders safely because each value becomes its own template rather than script text.

The same care applies to a value you splice into a step body yourself: ${{ input.ref }} written directly into a command lands unescaped. Read it from an env var inside the script ("$CI_TRIGGER_REF") instead.

A shell step stages job output with cryo output set:

build:
outputs:
tag: tag # expose the staged keys under these names
built: built
steps:
- name: emit
run: |
cryo output set tag="$(git describe --tags)" built=true
ship:
depends_on: [build]
if: ${{ steps.build.output.built }}
steps:
- name: deploy
run: ./deploy.sh "${{ steps.build.output.tag }}"

Two hops, and both are needed. cryo output set stages a key; outputs: is what publishes it. Stage a key the mapping doesn’t list and it stays inside the job - a downstream reference to it fails the run with steps.build.output.built` is not an output of job "build", which declares "tag" rather than resolving to empty.

Values are parsed as JSON when valid and kept as strings otherwise, so built=true reaches if: as a boolean rather than the string "true". They’re committed only if the job succeeds.

To add work decided at runtime, have a script: job generate a graph and run it with cryo call graph --definition (see Dynamic pipelines).

A shell step can also call cryo annotate to attach a markdown note to the run (build summaries, coverage tables, lint findings), and cryo emit to put an event on the project bus. Neither suspends the step, so both are allowed here even though the durable primitives below are not. See the annotations guide and events and handlers.

Two blocks, split by where the value is read.

env: is process configuration. It reaches the job’s process and a body reads it as $NAME. It never appears in an expression. An operator can also opt agent variables through to jobs, so the same name can hold different values on different agents.

vars: is document configuration. It is readable in any expression as vars.<NAME> and is never exported to a process. Values are JSON, so a number compares as a number.

env:
REGISTRY: ghcr.io # $REGISTRY in a step body
vars:
MIN_COVERAGE: 0.9 # ${{ vars.MIN_COVERAGE }} in a body or an if:
jobs:
gate:
if: 'vars.MIN_COVERAGE > 0.8'
steps:
- name: push
run: docker push "$REGISTRY/app"

The split is not a style preference. A job’s if: is evaluated before the job is handed to an agent, so at that moment there is no agent and no environment to read - and a value that can differ per agent could not produce the same decision when a run is replayed. Anything a gate reads therefore has to live in the pipeline itself.

Values that change from run to run are neither of these: they are the run’s input, read as input.<field>.

Every shell job gets:

  • CI=true
  • CI_RUN_ID=<workflow-id>
  • CI_JOB_NAME=<job-name>

Built-ins are written last so user env can’t shadow them.

${{ … }} is the only thing cryosleep reads in a run: body, and it is not valid shell, so nothing else in the script can be mistaken for it. Chart templates, Go templates and jinja pass through as written:

- name: render
run: helm template . --set tag="{{ .Values.image.tag }}"

An unclosed ${{ is an error, reported with the job and step named.

Project secrets (cryo secret set) are injected as env vars into every steps: and script: job; user-set env: wins on a name conflict. Injected secret values are masked in job output - chunk-wise in the live stream, completely in the captured output. The masking is best-effort: it catches echoed values (set -x, accidental prints), not transformed ones (base64 etc.).

Parks on a signal in the workflow process - no agent slot held. Approval submission JSON becomes the job’s “raw output”; the job’s outputs: mapping picks keys from it.

review:
depends_on: [build]
outputs:
env: env
note: comment
approval:
prompt: "Deploy to production?"
fields:
- { name: env, type: select, options: [staging, production] }
- { name: comment, type: text, required: false }

Field validation runs server-side before signaling the workflow: required, type-checked, select options, number min/max bounds.

Answered from the web UI’s inline form, or from a terminal with cryo approve <run-id>, which asks for each declared field. A graph’s approval node takes the same verb and the same field definitions.

A timer or a signal-wait. Workflow-process-only; no agent.

soak:
depends_on: [deploy]
wait:
duration: 30m # timer: sleep this long, then succeed
promote:
depends_on: [deploy]
wait:
signal: deploy-ok # block until this signal is delivered
timeout: 1h # optional bound on the block

duration: (humantime) is the timer; signal: is the block, released by cryo signal <run-id> deploy-ok. Declaring both, or neither, is rejected at submit.

timeout: (humantime) is a bound on the signal: form, never a kind of its own. Beside duration: it is rejected rather than ignored, since a timer is bounded already. Bounded waits covers what expiry does.

wait: signal:, wait_for_event: and approval: all park on a signal, and all three take a timeout: (humantime). Without one they wait forever; a run parked on an approval nobody answers is the usual way a pipeline goes quiet. wait: duration: is bounded already, so a timeout: beside it is rejected rather than ignored.

On expiry the job succeeds - it does not fail. A timeout is one of two planned outcomes, and for a soak it is the outcome you wanted, so failing would make a healthy two-week soak a red run. Branch on the output instead:

soak:
depends_on: [deploy]
wait_for_event: { event: alerts.fired, timeout: 14d }
rollback:
depends_on: [soak]
if: ${{ !steps.soak.output.timed_out }}
steps:
- { name: undo, run: ./deploy.sh staging --rollback }
promote:
depends_on: [soak]
if: ${{ steps.soak.output.timed_out }}
approval: { prompt: "Promote to production?", timeout: 3d }

timed_out appears only on a job that declares a timeout:. A job without one keeps exactly the output it has always produced, and reading timed_out on such a job is rejected at submit - so if you remove a timeout:, remove the if: that reads it in the same edit, and cryo check will tell you if you forget.

When the signal wins and its payload is an object - an approval submission, an event envelope - the flag is merged into that object, so an approval’s own fields stay where they were (steps.gate.output.decision, not …output.payload.decision). An approval field named timed_out is rejected, since the flag would overwrite whatever the approver submitted for it. A payload that is not an object has nowhere to merge into, so adding a timeout: reshapes it: a bare "hello" becomes {"timed_out": false, "payload": "hello"}.

The wait resolves once, atomically. A signal that arrives after the deadline has been recorded does not re-open the job on a later replay.

Block until a matching event lands on the project bus - the PR flow that waits for review events, the deploy that waits for a downstream system to report in. Workflow-process-only; no agent held while waiting.

soak:
depends_on: [deploy]
wait_for_event:
event: alerts.fired
timeout: 14d

Without one, the job takes the first event of its type, whatever it contains. when: is a predicate over the envelope; an event it declines does not resume the job, and the wait goes on:

hold:
wait_for_event:
event: order.placed
when: ${{ event.payload.qty > 10 }}

The scope is one name, event - the same envelope a handler’s --when and the top-level if: read. The ${{ … }} wrapper is optional: the predicate is evaluated in the control plane against an event this run has not seen, so it is never a hole resolved from the run’s own scope.

A downstream if: is not a substitute. It runs after the job resumed, so the event has already been taken and the wait cannot be resumed; when: is what keeps the job waiting for the one it wants. Declining costs the other listeners nothing - they still receive the event - and does not extend timeout:, which runs from the first park. A predicate that will not compile is refused by cryo check.

With no timeout: the job waits forever. With one, the job succeeds either way and its output says which happened, so a dependent can branch on the two outcomes. See bounded waits below - it is how “no alert fired for two weeks” is written, and that phrasing matters: wait_for_event: resumes when the event arrives, so on its own it gates what follows on the thing having gone wrong.

Under the hood this is a signal-wait on the well-known name event:<job-name>, with an event→signal bridge registered when the run is submitted. Two consequences: an event that arrives before the job starts (while earlier jobs run) still counts - signals are durable - and a human can unblock the job manually with cryo signal <run-id> event:<job-name>. The delivered signal payload is the event envelope. One-shot: the job resumes on the first matching event.

A job name that is not already a valid identifier is normalised for this: anything other than a letter, digit or _ becomes _, and a leading digit gets one prefixed. A wait-for-ship: job parks on event:wait_for_ship, so that is the name to signal by hand. cryo status <run-id> reports the name a parked run is waiting on, under substate.name.

A script: job’s cryo wait-event <type> is a wait of its own under a longer name, so a wait_for_event: job and any number of scripts can wait for one event type and each is handed the event when it arrives - see polyglot scripts.

Matching is keyed the same way cancel_on: is: the wait correlates to the run’s subject, so a run started for acme/web resumes on its alerts.fired, not another repo’s. An event emitted without a subject still resumes any run waiting on that type - so a human can always unblock a wait with a plain cryo emit alerts.fired. See Events and handlers.

Spawn a child workflow as the body of this job. The composition seam - see Composition.

canary:
depends_on: [build]
outputs:
verdict: verdict
workflow:
type: cryosleep/script/v1 # or cryosleep/pipeline/v1
script: |
#!/usr/bin/env bash
cryo step "smoke" -- bash -c 'echo OK'
cryo output set verdict=ok

script: is template-rendered against the parent’s ${{ steps.X.output.Y }} scope before submission. A child run starts clean, so the parent’s env: is not in the child’s process - render any value it needs in from the parent, or write it literally. For cryosleep/script/v1 it’s the polyglot script body; for cryosleep/pipeline/v1 it’s a YAML pipeline source, and what that child hands back is whatever its own document-level outputs: declares.

Instead of type: + script:, a workflow: job can call a graph or a single node-kind - the same targets a script’s call_graph / call_node and the cryo call CLI reach. The child’s output becomes the job’s output.

notify:
workflow:
node: # one node-kind
kind: matrix.send_message # a builtin or <connector>.<action>
config: { room: "!ops:example.com" }
input: { body: "deploy done" }
deploy:
workflow:
graph: # a stored graph by name
name: deploy
version: 3 # optional; omitted runs the published version
input: { ref: main }

Exactly one of type: / graph: / node: per job.

input: is rendered against this pipeline before the child starts, so it reads the parent’s scope (steps.*, vars.*, input.*) and the child receives plain values:

deploy:
depends_on: [build]
workflow:
graph: { name: deploy }
input:
ref: ${{ steps.build.output.sha }} # the value
note: "built ${{ steps.build.output.sha }}" # a string
retries: ${{ vars.MAX_RETRIES }} # stays a number

A hole that is the whole value keeps its JSON type, so retries reaches the child as a number. A hole with text around it can only produce a string. The same applies to a node: target’s config:.

References are checked at submit, so a hole here has to be a real one: a job that doesn’t exist, or a scope root that isn’t steps / vars / run / input, fails at submit rather than in the child. That includes text that only looks like a hole - "${{ github.sha }}" copied from elsewhere, or a chart value - which previously reached the child as literal text and now fails. Worth knowing for a handler or schedule, whose YAML is stored and recompiled on every delivery or tick: such a document starts failing when it next fires, not when you deploy.

An inline definition: is the exception: it passes through unrendered, so its own markers resolve in the child’s run rather than being filled in from the parent’s scope.

A node: target’s config: is written in the calling document, so everything in it is filled in from the calling document’s scope. That is usually what you want: url: ${{ steps.build.output.endpoint }} reads a job you can see.

Sometimes the value only exists in the child. A single node called on its own resolves its config against the input: you hand it, and to reach that you wrap the expression in $defer:

call_api:
workflow:
node:
kind: http
config:
url: { $defer: { $expr: "input.endpoint" } } # the child answers this
method: ${{ vars.HTTP_METHOD }} # this document answers this
input:
endpoint: https://api.example.com/v1/items

$defer hands its expression down one scope instead of resolving it here. The child’s scope holds only input and run, so a deferred expression may read those and nothing else - reaching for steps, vars or nodes is refused, since those belong to the document you wrote it in. If you need one of those values in the child, put it in the job’s input: and read it from there.

Run cryo check before you push. A pipeline’s markers are checked when the compiled graph is validated, which cryo check and cryo render do for you; submitting YAML straight to the API skips that, and a misplaced $defer then shows up as an unresolved object reaching the node instead of an error naming the document.

A $defer anywhere else is refused for the same reason: nothing below a plain node’s config, a graph: child’s input:, or a job’s own input: would ever answer it.

A durable polyglot script run inline as one script_run activity of this run - the full cryo toolkit (step, sleep, wait-signal, activity-*), with suspend-and-replay. Like workflow: but without a separate child run: the script’s durable boundaries are events in this run, and downstream jobs depends_on it like any other.

analyze:
depends_on: [build]
outputs:
verdict: verdict
script: |
#!/usr/bin/env bash
set -eu
score=$(cryo step "scan" -- bash -c 'echo 0.98')
cryo sleep 30s # suspends + replays in place
cryo output set verdict=ok score="$score"

The body is template-rendered against the parent scope. The script’s cryo output set values are the job’s raw output for outputs:. A non-zero exit fails the job. requires: adds capabilities on top of the base polyglot (e.g. pin to a build runner).

script: vs workflow: - both run a durable polyglot script. script: runs it inline in this run (lighter, one run page); use workflow: when you want a separate child run - for isolation, a long-lived independent unit, or a nested cryosleep/pipeline/v1.

When a hole is rendered, and what it can read

Section titled “When a hole is rendered, and what it can read”

A value is rendered at the last moment before it is needed, and can read exactly what exists by then. That is why some fields read more than others - not a per-field rule to memorise, just how far along the run is:

Moment In scope Fields
the event arrives, no run yet event top-level if:, paths:, paths_ignore:
the run is created, no job has run input, vars top-level concurrency.group
a job is about to dispatch + steps.*, run job if:, env:, approval.prompt, a job’s concurrency.group, a workflow: job’s input:/config:
one matrix combination + matrix the body, and requires:
every job has finished steps.* of all of them document-level outputs:

Reading something from a later row is an error naming the field, rather than an empty value. The top-level concurrency.group cannot see steps.* because admission decides whether any job runs at all; a job’s own group is rendered much later and reads them.

Two fields refuse a hole outright. Step env: is quoted into the step script before the expression resolves, so a rendered quote would break out of quoting the compiler wrote. wait.signal and wait_for_event.event are registered as subscriptions at submit and waited on later, so a hole would have to render the same in both places.

${{ ... }} expressions in step run: bodies, if: conditions, and workflow script: bodies evaluate CEL - the same expression language the canvas and graph surfaces use. A hole is compiled when the pipeline is submitted, so a syntax error or a malformed hole fails at save, naming the job or step it sits in.

References are resolved then too, against the document: a scope root that doesn’t exist (${{ jobs.build.outputs.tag }}), a job name nothing declares, a field other than status or output, and - for a job that declares outputs: - an output key it doesn’t list, are all rejected before the run starts. cryo check reports the same set offline. What is left for run time is the case the document can’t decide: an output key on a job that declares no outputs:, since only the job itself knows what it produced. That stays a hard error, as in Outputs.

Plain ${VAR} bash expansion is untouched; only the ${{ sequence is a hole.

Available variables:

  • vars.<NAME> - document constants declared in the top-level vars: block

  • steps.<name>.output.<key> - outputs from completed upstream jobs

  • steps.<name>.status - success / skipped / failed

  • input.<field> - the run’s canonical input. For an event-triggered run that is the shaped event (input.ref, input.sha, input.repo, input.paths); for a manual run it is whatever cryo submit --input '{"sha":"…"}' passed, or {"type": "manual"} when nothing did. It is the same value a job reads from $CRYO_INPUT_FILE, so a pipeline reads it directly rather than piping through jq, and it is never size-capped - an expression reaches into a full forge body. In scope in step bodies and job if:.

    This is where a value that changes from run to run belongs. vars: is for what does not change, and env: never reaches an expression.

  • event.<field> - in the top-level trigger if: only: the delivered event (type, ref, sha, repo, paths, payload, …)

Available functions in if::

  • success() - true if all upstream jobs succeeded
  • failure() - true if any upstream failed
  • cancelled() - true if any upstream was cancelled
  • always() - true (run unconditionally)

CEL brings list/map operations too - steps.scan.output.items.filter(i, i.ok), .startsWith("release/"), size(...), ternaries - so a predicate rarely needs more than one expression.

deploy:
if: ${{ success() && steps.build.output.branch.startsWith("release/") }}
steps:
- name: deploy
run: ./deploy --tag "${{ steps.build.output.tag }}"

Bare {{ ... }} is literal text - only ${{ ... }} is a CEL hole.

outputs: on a job maps <output_name> to a key in the job’s raw output. Source of the raw output:

Job kind Raw output source
steps: cryo output set values
approval: submission JSON
wait: empty (no outputs)
workflow: child workflow’s final output JSON
script: script’s cryo output set values (plus exit_code/duration_ms)

Downstream jobs reference selected outputs via ${{ steps.<this>.output.<output_name> }}. Naming a key the job’s outputs: block doesn’t declare is rejected at submit. Where there is no outputs: block to check against, the miss surfaces when the reference is evaluated, and it is a hard error - fail loud, don’t render empty.

Document-level outputs: (what the run returns)

Section titled “Document-level outputs: (what the run returns)”

A job’s outputs: moves a value between jobs inside the run. The document’s own outputs: block, written at the top level beside jobs:, is what the whole run hands back to whoever ran it: a workflow: job running this document as a child, or a graph’s workflow node running the pipeline a repo holds.

outputs:
sha: ${{ steps.build.output.sha }}
image: "registry.example.com/app:${{ steps.build.output.sha }}"
report:
passed: ${{ steps.test.output.passed }}
jobs:
build:
outputs:
sha: sha
steps:
- name: build
run: cryo output set sha="$(git rev-parse HEAD)"
test:
depends_on: [build]
outputs:
passed: passed
steps:
- name: test
run: ./test.sh && cryo output set passed=true

Values take ${{ … }} holes over the same steps.<job>.output.<key> scope a job’s if: reads, at any depth - nest them in maps and lists and the shape comes out the way you wrote it. A hole that is the whole value keeps its JSON type (passed arrives as a boolean); a hole with text around it can only produce a string.

The two blocks compose. The document reads what the jobs declare, so a job has to publish a key in its own outputs: before the document can hand it on, and cryo check names the job when it doesn’t.

A parent picks the names up the way it picks up any child output - with the outputs: mapping on the job that spawned the child:

release:
outputs:
image: image # the child's declared `image`
workflow:
type: cryosleep/pipeline/v1
script:

The run’s output carries every job’s status under nodes, and the declared outputs sit beside that map rather than under a key of their own. A caller therefore reads one as an ordinary output of the child (nodes.<child>.output.<name> from a graph), and the arrangement reserves a single name: an output called nodes is refused at submit.

Only a successful run produces them - a failed run carries no output at all, so a caller either gets every declared output or gets a failure. An output that can’t be computed fails the run and the error names the output, rather than completing with a hole the caller trips over later.

depends_on: [a, b] orders this job after both a and b. A job with no if: carries an implicit success() gate, so it is skipped when any dependency failed. A cleanup or notify job opts out of that gate with if: always() or if: failure().

A skipped job (via if: false) still satisfies its dependents’ depends_on. It reports steps.<name>.status == "skipped" and exposes no outputs, so gate on that status rather than reading one of its output keys - a reference to a key it never produced is a hard error, not an empty string.

A matrix: runs a job’s body once per combination of the values you declare, with the combination readable as ${{ matrix.<name> }}:

jobs:
test:
matrix:
values:
os: [ubuntu, macos]
rust: ["1.80", "1.81"]
max_parallel: 2
steps:
- name: test
run: cargo +${{ matrix.rust }} test --target ${{ matrix.os }}
publish:
depends_on: [test]
steps:
- name: publish
run: ./publish.sh

That is four runs of the body: (ubuntu, 1.80), (ubuntu, 1.81), (macos, 1.80), (macos, 1.81). Dimensions keep document order and the last one varies fastest, so the list reads the way you wrote it.

Values are JSON, not just strings. A dimension whose values are objects carries several related settings together, so one dimension describes a target rather than three that have to be kept in sync:

matrix:
values:
target:
- { os: ubuntu, runner: linux, flags: "--release" }
- { os: macos, runner: darwin, flags: "" }
steps:
- name: build
run: ./build.sh ${{ matrix.target.os }} ${{ matrix.target.flags }}

exclude: subtracts from the product. Each entry is a partial combination: every dimension it names has to match, and a dimension it omits is a wildcard.

matrix:
values:
os: [ubuntu, macos, windows]
rust: ["1.80", "1.81"]
exclude:
- { os: macos, rust: "1.80" } # that one pairing
- { os: windows } # every windows row

Six combinations become three: (ubuntu, 1.80), (ubuntu, 1.81), (macos, 1.81). The alternative is a guard inside the body, which still claims an agent to decide it has nothing to do.

An entry matches by value, so an object-valued dimension is named in full (- { target: { os: macos, runner: darwin, flags: "" } }). Dimension names are checked at submit: exclude: [{ platform: ubuntu }] on a matrix with no platform fails, listing the dimensions it does have. Values are not checked, so a misspelled one ({ os: windwos }) drops nothing and reports nothing; it shows up as a combination you thought you had removed. Excluding every combination is refused, since the job would then never run.

It stays one job. depends_on: [test] is one edge and waits for every combination; there is no test (ubuntu, 1.80) to name. That is deliberate - a downstream job can’t know which combinations exist, so making it name them would break every time the matrix changed. The job’s output is the list of per-combination outputs, in combination order.

  • max_parallel: caps how many run at once. Absent means no ceiling beyond the agents free to claim them.
  • rate: { limit, period } caps how many start per period - limit: 10, period: 1m dispatches ten combinations, waits out the rest of the minute, dispatches the next ten. Use it when the thing being called counts requests per minute; max_parallel: alone doesn’t bound that, since four at a time is still hundreds an hour if each is quick. The wait between windows is a durable timer, so a sweep paced over an hour holds no agent while it waits.
  • fail_fast: defaults to true - the first failing combination fails the job. false runs all of them and reports the failures together, which is what you want when the point is to see the whole grid.
  • retry:, requires: and timeout: apply per combination, not to the sweep: attempts: 3 retries the one combination that failed.

requires: can read the combination, which is how one job spans platforms instead of being copy-pasted per capability:

build:
matrix:
values:
target:
- { cap: linux-amd64, triple: x86_64-unknown-linux-gnu }
- { cap: linux-arm64, triple: aarch64-unknown-linux-gnu }
- { cap: darwin-arm64, triple: aarch64-apple-darwin }
requires: ["${{ matrix.target.cap }}"]
steps:
- name: build
run: cargo build --release --target ${{ matrix.target.triple }}

Each combination claims an agent advertising its own capability. Mixing literal and computed entries is fine (requires: ["${{ matrix.target.cap }}", nix]).

A hole in requires: on a job with no matrix: is rejected at submit. There would be nothing for it to read, and since capabilities match literally, an unresolved one would leave the job sitting unclaimed forever with nothing logged.

matrix: applies to the activity-backed kinds (steps: and script:). The parking kinds (approval:, wait:, wait_for_event:, workflow:) run in the workflow process and have nothing to spread across items, so a matrix on one is refused at compile time rather than at run time.

An empty dimension is also refused - it would make the whole product empty and the job would silently run zero times.

Under the hood a matrix job compiles to a single fan_out node, the same primitive a graph uses to spread work over a list. cryo check shows the compiled form.

matrix: covers the case where you know the combinations when you write the document. To decide work at runtime - fan out over something you just computed, or let a step generate the jobs to run - have a script: job compute a graph definition and run it as an awaited child with cryo call graph:

plan:
script: |
#!/usr/bin/env bash
set -eu
# Compute the work however you like, emit a graph definition, run it.
./generate-pipeline.py > pipeline.json
cryo call graph --definition pipeline.json
deploy:
depends_on: [plan]
steps:
- { name: deploy, run: ./deploy.sh }

The generated graph runs as its own durable child run. The plan job parks until that run finishes, cryo call graph prints its output, and deploy (which depends_on: [plan]) runs after the whole generated pipeline is done. Pass --definition - to read the definition from stdin instead of a file.

What the script emits is a cryosleep-graph/v1 definition: a JSON document with a version, a list of nodes, and optional edges. Each node is { "id", "type", "config" }; unknown fields are rejected.

{
"version": "cryosleep-graph/v1",
"nodes": [
{ "id": "build", "type": "shell",
"config": { "run": "cryo output set tag=v1.2.3" } },
{ "id": "ship", "type": "shell",
"config": { "run": { "$template": "echo shipping {{ nodes.build.output.tag }}" } } }
],
"edges": [ { "from": "build", "to": "ship" } ]
}

Values cross between generated nodes the same way they cross between jobs, with graph spelling: cryo output set stages a key and the next node reads nodes.<id>.output.<key> from a $template or $expr. ship logs shipping v1.2.3. There is no outputs: hop here - a node’s staged keys are its output.

--definition takes a graph, in JSON or YAML. A generated pipeline is a different document and is rejected here - cryo call graph runs graphs. Run cryo check <file> to validate a definition before wiring it into a run. Composition covers the node kinds and the rest of the format.

The child keeps its own run id: it has its own run page, carrying a breadcrumb back to this run, and cryo runs ls trails parent:<run-id> on it. Because it is a complete, self-contained definition, it is validated when it starts and reruns on its own, and the parent’s job set stays fixed.

A run’s job set is fixed when the run starts. Splicing jobs into a running pipeline is not supported; the computed child graph above is how work decided at run time gets run. That is also why matrix.values takes a literal list and refuses a ${{ … }} hole: the fan-out would have to exist before the job computing it has run. examples/computed-graph is the smallest complete version of the alternative.

  • Don’t use depends_on: to enforce execution-on-the-same-agent semantics across separate jobs. Each job is a separate activity and may land on different agents. If you need a shared filesystem or state across operations, put them in the same shell job’s steps:.
  • Don’t call durable primitives (cryo step / sleep / wait-signal / wait-event / activity-* / spawn / call / lock) from inside a YAML shell step. The agent rejects them with an error naming the alternatives: a shell step is atomic, so it can’t suspend and replay, and a durable call would lose its guarantees. Use a wait:, approval:, script:, or workflow: job for durable behaviour. Calls that never suspend do work in a shell step: cryo annotate, cryo state, cryo emit, cryo output set, cryo artifact, cryo status. Atomic and durable activities is the reason behind the split.
  • Don’t loop on cryo sleep for cron-shaped work - use Schedules instead.