Skip to content

Commit 07929d9

Browse files
authored
feat(spec): add support for negate condition (#1451)
* **New Features** * Added an optional "negate" flag for preconditions at both DAG and step levels to invert condition evaluation. * API and schema updated to include the new negate field. * **Tests** * Expanded test coverage for negated preconditions across DAGs, steps, commands, environment variables, and error scenarios.
1 parent 882b6eb commit 07929d9

11 files changed

Lines changed: 440 additions & 215 deletions

File tree

api/v2/api.gen.go

Lines changed: 154 additions & 151 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v2/api.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2572,6 +2572,9 @@ components:
25722572
expected:
25732573
type: string
25742574
description: "Expected result of the condition evaluation"
2575+
negate:
2576+
type: boolean
2577+
description: "If true, inverts the condition result (run when condition does NOT match)"
25752578
error:
25762579
type: string
25772580
description: "Error message if the condition is not met"

internal/core/condition.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type Condition struct {
1515

1616
Condition string // Condition to evaluate
1717
Expected string // Expected value
18+
Negate bool // Negate the condition result (run when condition does NOT match)
1819
errorMessage string // Error message if the condition is not met
1920
}
2021

@@ -24,10 +25,12 @@ func (c *Condition) MarshalJSON() ([]byte, error) {
2425
return json.Marshal(struct {
2526
Condition string `json:"condition,omitempty"`
2627
Expected string `json:"expected,omitempty"`
28+
Negate bool `json:"negate,omitempty"`
2729
ErrorMessage string `json:"error,omitempty"`
2830
}{
2931
Condition: c.Condition,
3032
Expected: c.Expected,
33+
Negate: c.Negate,
3134
ErrorMessage: c.errorMessage,
3235
})
3336
}
@@ -38,13 +41,15 @@ func (c *Condition) UnmarshalJSON(data []byte) error {
3841
var tmp struct {
3942
Condition string `json:"condition,omitempty"`
4043
Expected string `json:"expected,omitempty"`
44+
Negate bool `json:"negate,omitempty"`
4145
ErrorMessage string `json:"error,omitempty"`
4246
}
4347
if err := json.Unmarshal(data, &tmp); err != nil {
4448
return err
4549
}
4650
c.Condition = tmp.Condition
4751
c.Expected = tmp.Expected
52+
c.Negate = tmp.Negate
4853
c.errorMessage = tmp.ErrorMessage
4954
return nil
5055
}

internal/core/spec/builder.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,13 @@ func parsePrecondition(ctx BuildContext, precondition any) ([]*core.Condition, e
808808
}
809809
ret.Condition = val
810810

811+
case "negate":
812+
val, ok := vv.(bool)
813+
if !ok {
814+
return nil, core.NewValidationError("preconditions", vv, ErrPreconditionNegateMustBeBool)
815+
}
816+
ret.Negate = val
817+
811818
default:
812819
return nil, core.NewValidationError("preconditions", key, fmt.Errorf("%w: %s", ErrPreconditionHasInvalidKey, key))
813820

internal/core/spec/builder_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,19 @@ preconditions:
813813
assert.Len(t, th.Preconditions, 1)
814814
assert.Equal(t, &core.Condition{Condition: "test -f file.txt", Expected: "true"}, th.Preconditions[0])
815815
})
816+
t.Run("PreconditionsWithNegate", func(t *testing.T) {
817+
data := []byte(`
818+
preconditions:
819+
- condition: "${STATUS}"
820+
expected: "success"
821+
negate: true
822+
`)
823+
dag, err := spec.LoadYAML(context.Background(), data)
824+
require.NoError(t, err)
825+
th := DAG{t: t, DAG: dag}
826+
assert.Len(t, th.Preconditions, 1)
827+
assert.Equal(t, &core.Condition{Condition: "${STATUS}", Expected: "success", Negate: true}, th.Preconditions[0])
828+
})
816829
t.Run("MaxActiveRuns", func(t *testing.T) {
817830
data := []byte(`
818831
maxActiveRuns: 5
@@ -1611,6 +1624,25 @@ steps:
16111624
assert.Len(t, th.Steps[0].Preconditions, 1)
16121625
assert.Equal(t, &core.Condition{Condition: "test -f file.txt", Expected: "true"}, th.Steps[0].Preconditions[0])
16131626
})
1627+
t.Run("StepPreconditionsWithNegate", func(t *testing.T) {
1628+
t.Parallel()
1629+
1630+
data := []byte(`
1631+
steps:
1632+
- name: "step_with_negate"
1633+
command: "echo hello"
1634+
preconditions:
1635+
- condition: "${STATUS}"
1636+
expected: "success"
1637+
negate: true
1638+
`)
1639+
dag, err := spec.LoadYAML(context.Background(), data)
1640+
require.NoError(t, err)
1641+
th := DAG{t: t, DAG: dag}
1642+
assert.Len(t, th.Steps, 1)
1643+
assert.Len(t, th.Steps[0].Preconditions, 1)
1644+
assert.Equal(t, &core.Condition{Condition: "${STATUS}", Expected: "success", Negate: true}, th.Steps[0].Preconditions[0])
1645+
})
16141646
t.Run("RepeatPolicyExitCode", func(t *testing.T) {
16151647
t.Parallel()
16161648

internal/core/spec/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ var (
88
ErrInvalidScheduleType = errors.New("invalid schedule type")
99
ErrDotEnvMustBeStringOrArray = errors.New("dotenv must be a string or an array of strings")
1010
ErrPreconditionValueMustBeString = errors.New("precondition value must be a string")
11+
ErrPreconditionNegateMustBeBool = errors.New("precondition negate must be a boolean")
1112
ErrPreconditionHasInvalidKey = errors.New("precondition has invalid key")
1213
ErrPreconditionMustBeArrayOrString = errors.New("precondition must be a string or an array of strings")
1314
ErrInvalidStepData = errors.New("invalid step data")

internal/integration/config_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,3 +756,102 @@ steps:
756756
"STEP_DIR": stepWorkDir,
757757
})
758758
}
759+
760+
// TestPreconditionNegate verifies that preconditions with negate:true work correctly.
761+
// When negate is true, the step runs when the condition does NOT match.
762+
func TestPreconditionNegate(t *testing.T) {
763+
t.Parallel()
764+
765+
th := test.Setup(t)
766+
767+
t.Run("NegateSkipsWhenConditionMatches", func(t *testing.T) {
768+
t.Parallel()
769+
770+
// When negate:true and condition matches expected, step should be skipped
771+
dag := th.DAG(t, `type: graph
772+
env:
773+
- STATUS: success
774+
steps:
775+
- command: echo "always runs"
776+
output: OUT1
777+
- command: echo "should skip"
778+
output: OUT2
779+
preconditions:
780+
- condition: "${STATUS}"
781+
expected: "success"
782+
negate: true
783+
`)
784+
agent := dag.Agent()
785+
agent.RunSuccess(t)
786+
dag.AssertOutputs(t, map[string]any{
787+
"OUT1": "always runs",
788+
"OUT2": "", // Should be empty because step was skipped
789+
})
790+
})
791+
792+
t.Run("NegateRunsWhenConditionDoesNotMatch", func(t *testing.T) {
793+
t.Parallel()
794+
795+
// When negate:true and condition does NOT match expected, step should run
796+
dag := th.DAG(t, `type: graph
797+
env:
798+
- STATUS: failure
799+
steps:
800+
- command: echo "always runs"
801+
output: OUT1
802+
- command: echo "should run"
803+
output: OUT2
804+
preconditions:
805+
- condition: "${STATUS}"
806+
expected: "success"
807+
negate: true
808+
`)
809+
agent := dag.Agent()
810+
agent.RunSuccess(t)
811+
dag.AssertOutputs(t, map[string]any{
812+
"OUT1": "always runs",
813+
"OUT2": "should run",
814+
})
815+
})
816+
817+
t.Run("NegateWithCommandExitCode", func(t *testing.T) {
818+
t.Parallel()
819+
820+
// When negate:true with a command, step runs when command fails (non-zero exit)
821+
dag := th.DAG(t, `type: graph
822+
steps:
823+
- command: echo "should run"
824+
output: OUT1
825+
preconditions:
826+
- condition: "false"
827+
negate: true
828+
`)
829+
agent := dag.Agent()
830+
agent.RunSuccess(t)
831+
dag.AssertOutputs(t, map[string]any{
832+
"OUT1": "should run",
833+
})
834+
})
835+
836+
t.Run("DAGLevelNegate", func(t *testing.T) {
837+
t.Parallel()
838+
839+
// DAG-level precondition with negate - should run when condition doesn't match
840+
dag := th.DAG(t, `
841+
env:
842+
- ENV_TYPE: development
843+
preconditions:
844+
- condition: "${ENV_TYPE}"
845+
expected: "production"
846+
negate: true
847+
steps:
848+
- command: echo "dag ran"
849+
output: OUT1
850+
`)
851+
agent := dag.Agent()
852+
agent.RunSuccess(t)
853+
dag.AssertOutputs(t, map[string]any{
854+
"OUT1": "dag ran",
855+
})
856+
})
857+
}

internal/runtime/condition.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package runtime
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os/exec"
78

@@ -45,14 +46,32 @@ func EvalConditions(ctx context.Context, shell []string, cond []*core.Condition)
4546

4647
// EvalCondition evaluates the condition and returns the actual value.
4748
// It returns an error if the evaluation failed or the condition is invalid.
49+
// If c.Negate is true, the result is inverted: the condition passes when it
50+
// would normally fail, and vice versa.
4851
func EvalCondition(ctx context.Context, shell []string, c *core.Condition) error {
52+
var err error
4953
switch {
5054
case c.Condition != "" && c.Expected != "":
51-
return matchCondition(ctx, c)
55+
err = matchCondition(ctx, c)
5256

5357
default:
54-
return evalCommand(ctx, shell, c)
58+
err = evalCommand(ctx, shell, c)
5559
}
60+
61+
// Apply negation if needed
62+
if c.Negate {
63+
if err == nil {
64+
return fmt.Errorf("%w: condition matched but negate is true", ErrConditionNotMet)
65+
}
66+
// Only invert logical "not met" failures; keep evaluation/runtime errors.
67+
if errors.Is(err, ErrConditionNotMet) {
68+
return nil
69+
}
70+
// Evaluation or runtime error - don't swallow it
71+
return err
72+
}
73+
74+
return err
5675
}
5776

5877
// matchCondition evaluates the condition and checks if it matches the expected value.

0 commit comments

Comments
 (0)