Step states

A step ends in exactly one of ten states, never a boolean, because “did it pass” hides information a build system should surface. This is the full set the wire protocol declares, and all ten happen today.

StateHow a step ends thereWhat its dependents get
succeededPassed without ever failingThey run
recoveredFailed at least once, then passed on retryThey run
cachedA Pure() step hit the local action cache: skipped entirely, recorded outputs restoredThey run
failedRan, failed, and exhausted any retriesskipped_upstream_failed, unless the step declared ContinueOnError
timed_outAn attempt outlived the step’s Timeoutskipped_upstream_failed, unless the step declared ContinueOnError
cancelledThe run was cancelled before the step could finishNothing further is dispatched; the run ends cancelled
panickedA senro.Func step’s registered function panicked; the panic is caught and reported rather than crashing the runskipped_upstream_failed, unless the step declared ContinueOnError
skipped_upstream_failedA step it depends on failedskipped_upstream_failed, transitively
skipped_conditionIts When condition was not met at run startskipped_condition, transitively. ContinueOnError does not rescue them
skipped_manualAn operator took it out of a live run with step.skipskipped_manual, transitively. ContinueOnError does not rescue them

cached is a real hit, not a placeholder: the step is not run and its recorded outputs are restored from the action cache. See Caching a step.

recovered is not succeeded

A step that failed an attempt and then passed settles as recovered. The two are deliberately kept apart: collapsing them is how flaky infrastructure stays invisible for months, with a build that needed three attempts looking identical to one that needed one.

A run full of recovered steps is still a passing run. It is a passing run that is telling you something. See Retries.

skipped_condition is not skipped_upstream_failed

A step can end up not running for two very different reasons, and senro records which.

Something broke → skipped_upstream_failed

verify.Step("build", exec.Command("make", "build"))
verify.Step("test", exec.Command("make", "test")).Needs("build")

build fails. test never runs, and settles as skipped_upstream_failed. The run ends failed, because something did.

Nothing broke → skipped_condition

deploy := p.Workflow("deploy", senro.When(senro.Branch("main")))
deploy.Step("apply", exec.Command("./deploy.sh"))
deploy.Step("smoke", exec.Command("./smoke.sh")).Needs("apply")

On a pull request, apply is gated off and settles as skipped_condition. smoke settles the same way. The run ends succeeded, because nothing failed: your deploy was not supposed to run on a pull request, and it did not.

That is the whole point of the distinction. A pull request’s run stays green when its main-only deploy does not fire.

Side by side

Something brokeNothing broke
What happened upstreamA step failed, timed_out or panickedA step was gated off by When, or skipped by an operator with step.skip
Dependents end asskipped_upstream_failedThe same state as the cause: skipped_condition or skipped_manual
The run endspartial or failedsucceeded
Does ContinueOnError rescue them?Yes. That is what it is for.No.

Why ContinueOnError only helps on the left

ContinueOnError says “run my dependents even though I failed, against what I did produce”. It is about surviving a failure.

A skipped_condition step produced nothing at all, because it never ran. There is no output to run against, and nothing was blamed, so there is nothing for ContinueOnError to excuse.

See Conditions and Control operations.

How far a failure travels

  • Only downstream. A failing step settles its direct dependents, and theirs in turn. Unrelated branches are not cancelled: they run to completion, so a failure produces one clear report instead of a half-explored graph.
  • A skip does not poison the graph either. Only the transitive dependents of a skipped_condition or skipped_manual step are affected.

The run’s own rollup

run.finished carries a status and a per-state count of the steps:

{"type":"run.finished","payload":{"status":"failed",
  "steps":{"failed":1,"skipped_upstream_failed":1,"succeeded":1}}}

Five statuses exist, in this precedence, strongest first:

Run statusWhen
cancelledAny step is cancelled. It outranks failure, since a step that failed while the run was being torn down says nothing useful about the workload
failedAny step is failed, timed_out or panicked
partialNothing failed, but some step is skipped_upstream_failed
succeeded_with_recoveryNothing above, and some step is recovered
succeededEverything else. cached, skipped_condition and skipped_manual all roll up clean

Where to go next