Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
11 changes: 7 additions & 4 deletions .github/workflows/visual-regression-checker.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions .github/workflows/visual-regression-checker.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ network:
- playwright
- local
- node
jobs:
agent:
timeout-minutes: 15
safe-outputs:
timeout-minutes: 10
add-comment:
max: 1
timeout-minutes: 15
Expand All @@ -54,10 +58,12 @@ steps:

- name: Install dependencies
working-directory: ./docs
timeout-minutes: 5
run: npm ci

- name: Build documentation
working-directory: ./docs
timeout-minutes: 5
run: npm run build

- name: Start docs server
Expand All @@ -70,6 +76,7 @@ steps:

- name: Wait for server readiness
# runner-guard:ignore RGS-012 -- loopback-only port/readiness checks for the docs server started in this job; no external network or secrets are involved.
timeout-minutes: 2
run: |
MAX_WAIT=90
WAITED=0
Expand Down
80 changes: 55 additions & 25 deletions pkg/workflow/step_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"maps"
"math"
"strconv"

"github.com/github/gh-aw/pkg/importinpututil"
Expand Down Expand Up @@ -117,34 +118,13 @@ func MapToStep(stepMap map[string]any) (*WorkflowStep, error) {
step.With = with
}
if env, ok := stepMap["env"].(map[string]any); ok {
// Convert map[string]any to map[string]string
step.Env = make(map[string]string)
for k, v := range env {
if strVal, ok := v.(string); ok {
step.Env[k] = strVal
} else if v != nil {
// Arrays and maps are serialized as JSON so that shell consumers
// (e.g. jq --argjson) receive valid JSON. This handles both the
// []any / map[string]any case returned by encoding/json and the
// typed-slice case (e.g. []string) returned by goccy/go-yaml.
step.Env[k] = marshalEnvValue(v)
}
}
step.Env = parseStepEnv(env)
}
if continueOnError, ok := stepMap["continue-on-error"]; ok {
switch value := continueOnError.(type) {
case bool:
templatableValue := TemplatableBool(strconv.FormatBool(value))
step.ContinueOnError = &templatableValue
case string:
if value == "true" || value == "false" || isExpression(value) {
templatableValue := TemplatableBool(value)
step.ContinueOnError = &templatableValue
}
}
step.ContinueOnError = parseStepContinueOnError(continueOnError)
}
if timeoutMinutes, ok := stepMap["timeout-minutes"].(int); ok {
step.TimeoutMinutes = timeoutMinutes
if timeoutMinutesVal, ok := stepMap["timeout-minutes"]; ok {
step.TimeoutMinutes = parseStepTimeoutMinutes(timeoutMinutesVal)
}

stepType := "unknown"
Expand All @@ -157,6 +137,56 @@ func MapToStep(stepMap map[string]any) (*WorkflowStep, error) {
return step, nil
}

func parseStepEnv(env map[string]any) map[string]string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pkg/workflow/step_types.go:140: yagni: three one-caller parsing helpers for env/continue-on-error/timeout. Inline the small switch logic in MapToStep and keep the mapping local.

result := make(map[string]string)
for k, v := range env {
if strVal, ok := v.(string); ok {
result[k] = strVal
} else if v != nil {
result[k] = marshalEnvValue(v)
}
}
return result
}

func parseStepContinueOnError(val any) *TemplatableBool {
switch value := val.(type) {
case bool:
templatableValue := TemplatableBool(strconv.FormatBool(value))
return &templatableValue
case string:
if value == "true" || value == "false" || isExpression(value) {
templatableValue := TemplatableBool(value)
return &templatableValue
}
}
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The int case in parseStepTimeoutMinutes lacks the > 0 lower-bound guard present in the int64 and float64 branches, so a negative value like timeout-minutes: -5 passes through silently.

💡 Suggested fix + missing test
case int:
    if v > 0 {
        return v
    }

The string branch via strconv.Atoi has the same gap — a "-5" string returns a negative int. Both should return 0 (treated as absent) or produce a validation error.

A regression test covering invalid inputs (negative ints, negative strings, zero) would prevent this from regressing.

@copilot please address this.

}

func parseStepTimeoutMinutes(val any) int {
switch v := val.(type) {
case int:
return v
case int64:
if v > 0 && v <= int64(math.MaxInt) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The int case does not guard against zero or negative values, unlike the int64, uint64, and float64 branches which all check v > 0. A zero or negative timeout would be silently accepted and may behave unexpectedly.

Suggested fix:

case int:
    if v > 0 {
        return v
    }

@copilot please address this.

return int(v)
}
case uint64:
if v <= uint64(math.MaxInt) {
return int(v)
}
case float64:
if v > 0 && v <= float64(math.MaxInt) {
return int(v)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string case parses via strconv.Atoi but does not reject negative values like "-5". strconv.Atoi("-5") returns -5, nil so a negative string timeout would be silently accepted.

Suggested fix:

case string:
    if n, err := strconv.Atoi(v); err == nil && n > 0 {
        return n
    }

@copilot please address this.

Comment on lines +183 to +192
case string:
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return 0
}

// Clone creates a deep copy of the WorkflowStep
func (s *WorkflowStep) Clone() *WorkflowStep {
clone := &WorkflowStep{
Expand Down
27 changes: 27 additions & 0 deletions pkg/workflow/step_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,33 @@ func TestSliceToSteps_RoundTrip(t *testing.T) {
}
}

func TestMapToStep_TimeoutMinutesNumericTypes(t *testing.T) {
tests := []struct {
name string
val any
want int
}{
{"int", 5, 5},
{"int64", int64(10), 10},
{"uint64", uint64(15), 15},
{"float64", float64(20), 20},
{"string", "25", 25},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stepMap := map[string]any{
"name": "Test",
"run": "echo test",
"timeout-minutes": tt.val,
}
step, err := MapToStep(stepMap)
require.NoError(t, err)
assert.Equal(t, tt.want, step.TimeoutMinutes)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The new test TestMapToStep_TimeoutMinutesNumericTypes only covers happy-path values; there are no cases for invalid inputs (negative numbers, zero, unsupported types like bool). The gaps in the guard logic above mean these aren't caught.

💡 Suggested additional test cases
{"negative int",    -1,       0},
{"zero",            0,        0},
{"negative float",  float64(-3), 0},
{"negative string", "-5",     0},
{"bool (unsupported)", true,  0},

Having these in the table would have caught the missing lower-bound guard before the PR was submitted.

@copilot please address this.

})
}
}

func TestMapToStep_InvalidTypes(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading