Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions config/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,12 @@ type PipelineStepConfig struct {
Config map[string]any `json:"config,omitempty" yaml:"config,omitempty"`
OnError string `json:"on_error,omitempty" yaml:"on_error,omitempty"`
Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"`
// SkipIf is an optional Go template expression. When it evaluates to a
// truthy value (non-empty, not "false", not "0"), the step is skipped and
// the pipeline continues with the next step. Falsy or absent → execute.
SkipIf string `json:"skip_if,omitempty" yaml:"skip_if,omitempty"`
// If is the logical inverse of SkipIf: the step executes only when the
// template evaluates to truthy. Falsy or absent with no SkipIf → execute.
// When both SkipIf and If are set, SkipIf takes precedence.
Comment on lines +30 to +31
If string `json:"if,omitempty" yaml:"if,omitempty"`
}
10 changes: 10 additions & 0 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -966,10 +966,14 @@ func parseRoutePipelineSteps(stepsRaw []any) []config.PipelineStepConfig {
if name == "" || stepType == "" {
continue
}
skipIf, _ := stepMap["skip_if"].(string)
ifExpr, _ := stepMap["if"].(string)
cfgs = append(cfgs, config.PipelineStepConfig{
Name: name,
Type: stepType,
Config: stepConfig,
SkipIf: skipIf,
If: ifExpr,
})
}
return cfgs
Expand Down Expand Up @@ -1006,6 +1010,12 @@ func (e *StdEngine) buildPipelineSteps(pipelineName string, stepCfgs []config.Pi
if err != nil {
return nil, fmt.Errorf("step %q (type %s): %w", sc.Name, sc.Type, err)
}

// Wrap the step with skip_if / if guard when either field is set.
if sc.SkipIf != "" || sc.If != "" {
step = module.NewSkippableStep(step, sc.SkipIf, sc.If)
}

steps = append(steps, step)
}

Expand Down
95 changes: 95 additions & 0 deletions module/pipeline_step_skip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package module

import (
"context"
"strings"

"github.com/GoCodeAlone/workflow/interfaces"
)

// SkippableStep wraps a PipelineStep with optional skip_if / if guard expressions.
//
// skip_if: when the resolved template is truthy (non-empty, not "false", not "0"),
// the step is skipped. Falsy → execute.
//
// if: the logical inverse of skip_if. When the resolved template is truthy,
// the step executes. Falsy → skip.
//
// When both fields are set, skip_if takes precedence.
// When neither is set, the step always executes (backward compatible).
type SkippableStep struct {
inner interfaces.PipelineStep
skipIf string // Go template; truthy result → skip
ifExpr string // Go template; falsy result → skip
tmpl *TemplateEngine
}

// NewSkippableStep creates a SkippableStep wrapping inner.
// skipIf and ifExpr may be empty strings to disable the respective guard.
func NewSkippableStep(inner interfaces.PipelineStep, skipIf, ifExpr string) *SkippableStep {
return &SkippableStep{
inner: inner,
skipIf: skipIf,
ifExpr: ifExpr,
tmpl: NewTemplateEngine(),
}
}

// Name delegates to the wrapped step.
func (s *SkippableStep) Name() string {
return s.inner.Name()
}

// Execute evaluates skip_if / if guards and either skips or delegates to the
// wrapped step.
func (s *SkippableStep) Execute(ctx context.Context, pc *PipelineContext) (*interfaces.StepResult, error) {
// Evaluate skip_if (takes precedence when both are set)
if s.skipIf != "" {
val, err := s.tmpl.Resolve(s.skipIf, pc)
if err != nil {
// Template resolution errors are non-fatal: treat as falsy (execute).
val = ""
}
if isTruthy(val) {
return skippedResult("skip_if evaluated to true"), nil
}
}

// Evaluate if (inverse logic: falsy → skip)
if s.ifExpr != "" {
val, err := s.tmpl.Resolve(s.ifExpr, pc)
if err != nil {
// Template resolution errors are non-fatal: treat as falsy (skip).
val = ""
}
if !isTruthy(val) {
return skippedResult("if evaluated to false"), nil
Comment on lines +48 to +66
}
}

return s.inner.Execute(ctx, pc)
}

// isTruthy returns true when the resolved template value should cause a
// skip_if guard to trigger (or an if guard to execute).
// Falsy values: empty string, "false", "0".
// Everything else is truthy.
func isTruthy(val string) bool {
trimmed := strings.TrimSpace(val)
switch trimmed {
case "", "false", "0":
return false
default:
return true
}
}

// skippedResult builds the standard output for a step that was skipped by a guard.
func skippedResult(reason string) *interfaces.StepResult {
return &interfaces.StepResult{
Output: map[string]any{
"skipped": true,
"reason": reason,
Comment on lines +91 to +92
},
}
}
Loading
Loading