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
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
7 changes: 6 additions & 1 deletion pkg/cli/pr_code_quality_reviewer_workflow_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ package cli
import (
"os"
"path/filepath"
"regexp"
"testing"

"github.com/github/gh-aw/pkg/gitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// explicitMainAgentModelPattern matches a top-level, provider-qualified model
// declaration (for example "model: openai/gpt-5.4") in the workflow frontmatter.
var explicitMainAgentModelPattern = regexp.MustCompile(`(?m)^model: \S+/\S+$`)

func TestPRCodeQualityReviewerWorkflowSubAgentModelContract(t *testing.T) {
t.Parallel()
repoRoot, err := gitutil.FindGitRoot()
Expand All @@ -24,7 +29,7 @@ func TestPRCodeQualityReviewerWorkflowSubAgentModelContract(t *testing.T) {
require.NoError(t, err, "Should read pr-code-quality-reviewer workflow")

text := string(content)
assert.Contains(t, text, "model: copilot/gpt-5.4", "Main agent should use an explicit Copilot model")
assert.Regexp(t, explicitMainAgentModelPattern, text, "Main agent should use an explicit provider-qualified model")
assert.Contains(t, text, "## agent: `grumpy-coder`", "Workflow should define the grumpy-coder sub-agent")
assert.Contains(t, text, "model: small", "Sub-agent should use the portable small alias")
assert.NotContains(t, text, "model: inherited", "Sub-agent should not inherit an unsupported tier-specific model")
Expand Down
91 changes: 66 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,67 @@ 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.

}

// parseStepTimeoutMinutes converts a YAML `timeout-minutes` value into a positive
// number of minutes. Values that are not positive integers within the platform int
// range are ignored (returning 0, which omits the field from the rendered step).
func parseStepTimeoutMinutes(val any) int {
switch v := val.(type) {
case int:
if v > 0 {
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 > 0 && v <= uint64(math.MaxInt) {
return int(v)
}
case float64:
// float64 loses integer precision near MaxInt on 64-bit platforms, so treat
// values at or above the rounded float boundary as out of range. Only
// integral values are accepted so fractional timeouts are not truncated.
if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) {
return 0
}
if v >= 1 && 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.

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

// Clone creates a deep copy of the WorkflowStep
func (s *WorkflowStep) Clone() *WorkflowStep {
clone := &WorkflowStep{
Expand Down
41 changes: 41 additions & 0 deletions pkg/workflow/step_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package workflow

import (
"math"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -663,6 +664,46 @@ 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},
{"negative int", -5, 0},
{"zero int", 0, 0},
{"negative int64", int64(-10), 0},
{"zero uint64", uint64(0), 0},
{"negative float64", float64(-20), 0},
{"fractional float64", 1.9, 0},
{"out of range float64", math.MaxFloat64, 0},
{"NaN float64", math.NaN(), 0},
{"negative string", "-25", 0},
{"zero string", "0", 0},
{"non-numeric string", "abc", 0},
{"bool", true, 0},
{"nil", nil, 0},
}

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