Skip to content

Commit 74b2f82

Browse files
authored
Merge pull request #158 from buildkite-plugins/SUP-4417-better-step-validation
Adds steps validation to prevent failed pipeline uploads
2 parents f76e49f + a48aa59 commit 74b2f82

5 files changed

Lines changed: 468 additions & 3 deletions

File tree

README.md

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,48 @@ This is a sub-section that provides configuration for running commands or trigge
7272
- [Group](https://buildkite.com/docs/pipelines/configure/step-types/group-step)
7373
- [Conditionals](https://buildkite.com/docs/pipelines/conditionals)
7474

75-
:warning: This plugin may accept configurations that are not valid pipeline steps, this is a known issue to keep its code simple and flexible.
75+
#### Step Validation
76+
77+
The plugin validates all step configurations before uploading the pipeline. Invalid steps are automatically skipped with a warning logged to the build output.
78+
79+
**A valid step must have:**
80+
- A `command` or `commands` field (for command steps), OR
81+
- A `trigger` field (for trigger steps), OR
82+
- A `group` field with either:
83+
- An action (`command`, `commands`, or `trigger`) directly on the group, OR
84+
- Valid nested `steps`
85+
86+
**Invalid configurations that will be skipped:**
87+
88+
```yaml
89+
# ❌ Empty step - no action defined
90+
- path: "app/"
91+
config:
92+
label: "Deploy app" # Only has a label, no command/trigger
93+
94+
# ❌ Empty group - no action and no nested steps
95+
- path: "services/"
96+
config:
97+
group: "Deploy"
98+
# Missing: steps array or action
99+
```
100+
101+
**Valid configurations:**
102+
103+
```yaml
104+
# ✅ Valid - has command
105+
- path: "app/"
106+
config:
107+
label: "Deploy app"
108+
command: "echo deploying"
109+
110+
# ✅ Valid - group with nested steps
111+
- path: "services/"
112+
config:
113+
group: "Deploy"
114+
steps:
115+
- command: "deploy.sh"
116+
```
76117

77118
```yaml
78119
steps:
@@ -89,7 +130,7 @@ steps:
89130
- path: docker/
90131
config:
91132
group: docker/**
92-
steps:
133+
steps: # Required: groups must have either 'steps' or an action
93134
- plugins:
94135
- docker#latest:
95136
build: service
@@ -489,6 +530,41 @@ steps:
489530
command: "echo deploy-bar"
490531
```
491532

533+
## Troubleshooting
534+
535+
### "Skipping invalid step" warnings
536+
537+
If you see warnings like `Skipping invalid step: empty step configuration`, check that your step configuration includes:
538+
539+
1. For command steps: `command` or `commands` field
540+
2. For trigger steps: `trigger` field
541+
3. For group steps: `group` field with either `steps` array or an action
542+
543+
**Common issues:**
544+
545+
- Forgetting to add `command:` or `trigger:` inside the `config` block
546+
- Creating empty groups without nested steps
547+
- Using only metadata fields like `label`, `key`, or `env` without an action
548+
549+
**Example of fixing an invalid configuration:**
550+
551+
```yaml
552+
# ❌ Invalid - missing action
553+
- path: "app/"
554+
config:
555+
label: "Deploy app"
556+
env:
557+
- ENV=production
558+
559+
# ✅ Fixed - added command
560+
- path: "app/"
561+
config:
562+
label: "Deploy app"
563+
command: "deploy.sh"
564+
env:
565+
- ENV=production
566+
```
567+
492568
## Compatibility
493569

494570
| Elastic Stack | Agent Stack K8s | Hosted (Mac) | Hosted (Linux) | Notes |

pipeline.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,38 @@ func diff(command string) ([]string, error) {
126126
return paths, nil
127127
}
128128

129+
// filterValidSteps splits steps into valid and invalid
130+
func filterValidSteps(steps []Step) (valid []Step, invalid []Step) {
131+
valid = []Step{}
132+
invalid = []Step{}
133+
134+
for _, step := range steps {
135+
if step.isValid() {
136+
valid = append(valid, step)
137+
} else {
138+
invalid = append(invalid, step)
139+
}
140+
}
141+
return valid, invalid
142+
}
143+
144+
// logInvalidStep logs why a step is invalid
145+
func logInvalidStep(step Step) {
146+
context := "empty step configuration"
147+
148+
if step.Group != "" {
149+
if len(step.Steps) == 0 {
150+
context = fmt.Sprintf("group '%s' has no valid nested steps", step.Group)
151+
} else {
152+
context = fmt.Sprintf("group '%s' has invalid nested steps", step.Group)
153+
}
154+
} else if step.Label != "" {
155+
context = fmt.Sprintf("step with label '%s' has no command, trigger, or group", step.Label)
156+
}
157+
158+
log.Warnf("Skipping invalid step: %s. Steps must have at least one of: command, commands, trigger, or group with nested steps.", context)
159+
}
160+
129161
func stepsToTrigger(files []string, watch []WatchConfig) ([]Step, error) {
130162
steps := []Step{}
131163
var defaultStep *Step
@@ -192,7 +224,15 @@ func stepsToTrigger(files []string, watch []WatchConfig) ([]Step, error) {
192224
steps = append(steps, *defaultStep)
193225
}
194226

195-
return dedupSteps(steps), nil
227+
deduped := dedupSteps(steps)
228+
valid, invalid := filterValidSteps(deduped)
229+
230+
// Log all invalid steps with helpful context
231+
for _, step := range invalid {
232+
logInvalidStep(step)
233+
}
234+
235+
return valid, nil
196236
}
197237

198238
// matchPath checks if the file f matches the path p.

pipeline_test.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,3 +825,204 @@ func TestGeneratePipelineWithSecretsInGroup(t *testing.T) {
825825
assert.Contains(t, string(got), "- PROD_DB_HOST")
826826
assert.Contains(t, string(got), "- PROD_DB_PASS")
827827
}
828+
829+
func TestFilterValidSteps_AllValid(t *testing.T) {
830+
steps := []Step{
831+
{Command: "echo valid 1"},
832+
{Trigger: "valid-trigger"},
833+
{Commands: []string{"echo valid 2"}},
834+
}
835+
836+
valid, invalid := filterValidSteps(steps)
837+
838+
assert.Len(t, valid, 3)
839+
assert.Len(t, invalid, 0)
840+
}
841+
842+
func TestFilterValidSteps_AllInvalid(t *testing.T) {
843+
steps := []Step{
844+
{},
845+
{Label: "no command"},
846+
{Env: map[string]string{"KEY": "value"}},
847+
}
848+
849+
valid, invalid := filterValidSteps(steps)
850+
851+
assert.Len(t, valid, 0)
852+
assert.Len(t, invalid, 3)
853+
}
854+
855+
func TestFilterValidSteps_MixedValidInvalid(t *testing.T) {
856+
steps := []Step{
857+
{Command: "echo valid"},
858+
{},
859+
{Trigger: "valid-trigger"},
860+
{Label: "invalid - no command/trigger"},
861+
{Commands: []string{"echo also valid"}},
862+
}
863+
864+
valid, invalid := filterValidSteps(steps)
865+
866+
assert.Len(t, valid, 3)
867+
assert.Len(t, invalid, 2)
868+
assert.Equal(t, "echo valid", valid[0].Command)
869+
assert.Equal(t, "valid-trigger", valid[1].Trigger)
870+
assert.NotNil(t, valid[2].Commands)
871+
}
872+
873+
func TestFilterValidSteps_GroupSteps(t *testing.T) {
874+
steps := []Step{
875+
{
876+
Group: "valid-group",
877+
Steps: []Step{
878+
{Command: "echo test"},
879+
},
880+
},
881+
{
882+
Group: "empty-group",
883+
Steps: []Step{},
884+
},
885+
{
886+
Group: "invalid-nested-group",
887+
Steps: []Step{
888+
{Label: "no command"},
889+
},
890+
},
891+
}
892+
893+
valid, invalid := filterValidSteps(steps)
894+
895+
assert.Len(t, valid, 1)
896+
assert.Len(t, invalid, 2)
897+
assert.Equal(t, "valid-group", valid[0].Group)
898+
}
899+
900+
func TestStepsToTrigger_Issue83(t *testing.T) {
901+
// Integration test for issue #83 - empty step configuration
902+
watch := []WatchConfig{
903+
{
904+
Paths: []string{"some-path/**"},
905+
Step: Step{}, // Empty step - should be filtered
906+
},
907+
{
908+
Paths: []string{"other-path/**"},
909+
Step: Step{Command: "echo valid"},
910+
},
911+
}
912+
913+
changedFiles := []string{
914+
"some-path/file.txt",
915+
"other-path/file.txt",
916+
}
917+
918+
steps, err := stepsToTrigger(changedFiles, watch)
919+
920+
assert.NoError(t, err)
921+
assert.Len(t, steps, 1)
922+
assert.Equal(t, "echo valid", steps[0].Command)
923+
}
924+
925+
func TestStepsToTrigger_DefaultWithEmptyStep(t *testing.T) {
926+
// Test that empty default step is filtered
927+
watch := []WatchConfig{
928+
{
929+
Paths: []string{"app/"},
930+
Step: Step{Command: "echo app"},
931+
},
932+
{
933+
Default: struct{}{},
934+
Step: Step{}, // Empty default - should be filtered
935+
},
936+
}
937+
938+
changedFiles := []string{"unmatched/file.txt"}
939+
940+
steps, err := stepsToTrigger(changedFiles, watch)
941+
942+
assert.NoError(t, err)
943+
assert.Len(t, steps, 0)
944+
}
945+
946+
func TestStepsToTrigger_AllStepsInvalid(t *testing.T) {
947+
// Test that when all matched steps are invalid, empty array is returned
948+
watch := []WatchConfig{
949+
{
950+
Paths: []string{"path1/"},
951+
Step: Step{},
952+
},
953+
{
954+
Paths: []string{"path2/"},
955+
Step: Step{Label: "no command"},
956+
},
957+
}
958+
959+
changedFiles := []string{"path1/file.txt", "path2/file.txt"}
960+
961+
steps, err := stepsToTrigger(changedFiles, watch)
962+
963+
assert.NoError(t, err)
964+
assert.Len(t, steps, 0)
965+
}
966+
967+
func TestStepsToTrigger_EmptyGroupInConfig(t *testing.T) {
968+
// Test that empty group steps are filtered
969+
watch := []WatchConfig{
970+
{
971+
Paths: []string{"services/"},
972+
Step: Step{
973+
Group: "deploy",
974+
Steps: []Step{}, // Empty group
975+
},
976+
},
977+
}
978+
979+
changedFiles := []string{"services/main.go"}
980+
981+
steps, err := stepsToTrigger(changedFiles, watch)
982+
983+
assert.NoError(t, err)
984+
assert.Len(t, steps, 0)
985+
}
986+
987+
func TestStepsToTrigger_ValidAndInvalidStepsMixed(t *testing.T) {
988+
// Test that valid steps are kept while invalid are filtered
989+
watch := []WatchConfig{
990+
{
991+
Paths: []string{"app/"},
992+
Step: Step{Command: "echo valid app"},
993+
},
994+
{
995+
Paths: []string{"tests/"},
996+
Step: Step{}, // Invalid
997+
},
998+
{
999+
Paths: []string{"deploy/"},
1000+
Step: Step{Trigger: "deploy-pipeline"},
1001+
},
1002+
}
1003+
1004+
changedFiles := []string{
1005+
"app/main.go",
1006+
"tests/test.go",
1007+
"deploy/script.sh",
1008+
}
1009+
1010+
steps, err := stepsToTrigger(changedFiles, watch)
1011+
1012+
assert.NoError(t, err)
1013+
assert.Len(t, steps, 2)
1014+
1015+
// Verify we got the valid steps
1016+
hasAppStep := false
1017+
hasDeployStep := false
1018+
for _, step := range steps {
1019+
if step.Command == "echo valid app" {
1020+
hasAppStep = true
1021+
}
1022+
if step.Trigger == "deploy-pipeline" {
1023+
hasDeployStep = true
1024+
}
1025+
}
1026+
assert.True(t, hasAppStep)
1027+
assert.True(t, hasDeployStep)
1028+
}

plugin.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,37 @@ type Step struct {
9999
Steps []Step `yaml:"steps,omitempty"`
100100
}
101101

102+
// isValid checks if a step has required fields (command, trigger, or group with steps)
103+
func (s Step) isValid() bool {
104+
if s.Group != "" {
105+
return s.hasValidNesting()
106+
}
107+
return s.hasAction()
108+
}
109+
110+
// hasAction checks if a step has a command or trigger
111+
func (s Step) hasAction() bool {
112+
return s.Command != nil || s.Commands != nil || s.Trigger != ""
113+
}
114+
115+
// hasValidNesting validates group step nesting
116+
func (s Step) hasValidNesting() bool {
117+
if s.hasAction() {
118+
return true
119+
}
120+
121+
if len(s.Steps) > 0 {
122+
for _, nestedStep := range s.Steps {
123+
if !nestedStep.isValid() {
124+
return false
125+
}
126+
}
127+
return true
128+
}
129+
130+
return false
131+
}
132+
102133
// UnmarshalJSON handles both "artifacts" and "artifact_paths" field names for backward compatibility
103134
// Both fields are supported by the Buildkite API; "artifact_paths" is preferred per documentation
104135
func (step *Step) UnmarshalJSON(data []byte) error {

0 commit comments

Comments
 (0)