Relationships
#2537 A forEach step's dependsOn condition is never evaluated, so its iterations run when the condition is false
Opened by hammz · 9/25/2026· Shipped 9/25/2026
Summary
A forEach step's own step-level dependsOn condition is never evaluated. The dependency still orders the step (its iterations start after the step it depends on), but its iterations then run whatever the condition says. A plain step with the same dependsOn is skipped correctly.
This makes a forEach step gated on condition: { type: failed }, such as a per-target rollback or cleanup, run on every successful run. A forEach step gated on succeeded runs after its dependency failed.
This is the reverse direction of #1780. #1780 was a plain step depending on a forEach step (never ran). This one is a forEach step depending on any step (always runs).
Steps to reproduce
jobs:
- name: main
steps:
- name: deploy
task: { type: model_method, modelType: command/shell, modelName: df-deploy, methodName: execute, inputs: { run: "true" } }
- name: rollback-plain
dependsOn: [{ step: deploy, condition: { type: failed } }]
task: { type: model_method, modelType: command/shell, modelName: df-rb-plain, methodName: execute, inputs: { run: "echo ROLLBACK-PLAIN-RAN" } }
- name: rollback-each
dependsOn: [{ step: deploy, condition: { type: failed } }]
forEach: { item: e, in: '${{ ["a","b"] }}' }
task: { type: model_method, modelType: command/shell, modelName: df-rb-each, methodName: execute, inputs: { run: "echo ROLLBACK-EACH-RAN-${{ self.e }}" } }swamp workflow validate passes. swamp workflow run gives:
main │ skipped (dependency) <- rollback-plain, correct
rollback-each[1] │ ROLLBACK-EACH-RAN-b <- ran although deploy succeeded
rollback-each[0] │ ROLLBACK-EACH-RAN-aFinal state: deploy succeeded, rollback-plain skipped, rollback-each-a and rollback-each-b succeeded, run succeeded.
The same happens with condition: { type: succeeded } when the dependency fails. With precheck running false, a plain step depending on it is skipped (dependency), but the forEach step's iterations run and succeed.
Expected
Each forEach iteration is gated by the template step's dependsOn conditions, as a plain step is. When a condition is not met, every iteration is skipped with reason dependency.
Likely cause
WorkflowExecutionService.runStep() in src/domain/workflows/execution_service.ts skips the trigger-condition check for expanded iterations:
// Check if step's trigger condition is met (skip for forEach-expanded steps
// as they don't have the same dependencies structure)
if (!forEachVar || !forEachVar.name) {
const shouldRun = this.shouldStepRun(step, jobRun);The skip dates from the original forEach feature (#232). The ordering graph maps the template's dependencies onto the expanded names, so iterations wait for the dependency, but shouldStepRun() is never called for them.
Suggested fix
Evaluate the template step's dependsOn for expanded iterations too, using shouldStepRun(originalStep, jobRun), and skip the iteration with reason dependency when it returns false. Since #1780, JobRun.getStatus() aggregates a forEach template's iteration statuses, so a condition that references another forEach step resolves as well.
Tests to add:
- a forEach step gated on
failedof a step that succeeded is skipped; - a forEach step gated on
succeededof a step that failed is skipped; - a forEach step gated on another forEach step.
Also check resume and retry, which reset and re-run expanded iterations.
Environment
swamp 20260925.013241.0-sha.4489c40d, Linux x86_64, local repo, command/shell models. Found while end-to-end testing #2502.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.