Skip to content

Commit aa70602

Browse files
authored
Enforce explicit job and step timeouts on Visual Regression Checker workflow (#56980)
1 parent 62e07e6 commit aa70602

5 files changed

Lines changed: 127 additions & 30 deletions

File tree

.github/workflows/visual-regression-checker.lock.yml

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

.github/workflows/visual-regression-checker.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ network:
3535
- playwright
3636
- local
3737
- node
38+
jobs:
39+
agent:
40+
timeout-minutes: 15
3841
safe-outputs:
42+
timeout-minutes: 10
3943
add-comment:
4044
max: 1
4145
timeout-minutes: 15
@@ -54,10 +58,12 @@ steps:
5458

5559
- name: Install dependencies
5660
working-directory: ./docs
61+
timeout-minutes: 5
5762
run: npm ci
5863

5964
- name: Build documentation
6065
working-directory: ./docs
66+
timeout-minutes: 5
6167
run: npm run build
6268

6369
- name: Start docs server
@@ -70,6 +76,7 @@ steps:
7076
7177
- name: Wait for server readiness
7278
# 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.
79+
timeout-minutes: 2
7380
run: |
7481
MAX_WAIT=90
7582
WAITED=0

pkg/cli/pr_code_quality_reviewer_workflow_contract_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@ package cli
55
import (
66
"os"
77
"path/filepath"
8+
"regexp"
89
"testing"
910

1011
"github.com/github/gh-aw/pkg/gitutil"
1112
"github.com/stretchr/testify/assert"
1213
"github.com/stretchr/testify/require"
1314
)
1415

16+
// explicitMainAgentModelPattern matches a top-level, provider-qualified model
17+
// declaration (for example "model: openai/gpt-5.4") in the workflow frontmatter.
18+
var explicitMainAgentModelPattern = regexp.MustCompile(`(?m)^model: \S+/\S+$`)
19+
1520
func TestPRCodeQualityReviewerWorkflowSubAgentModelContract(t *testing.T) {
1621
t.Parallel()
1722
repoRoot, err := gitutil.FindGitRoot()
@@ -24,7 +29,7 @@ func TestPRCodeQualityReviewerWorkflowSubAgentModelContract(t *testing.T) {
2429
require.NoError(t, err, "Should read pr-code-quality-reviewer workflow")
2530

2631
text := string(content)
27-
assert.Contains(t, text, "model: copilot/gpt-5.4", "Main agent should use an explicit Copilot model")
32+
assert.Regexp(t, explicitMainAgentModelPattern, text, "Main agent should use an explicit provider-qualified model")
2833
assert.Contains(t, text, "## agent: `grumpy-coder`", "Workflow should define the grumpy-coder sub-agent")
2934
assert.Contains(t, text, "model: small", "Sub-agent should use the portable small alias")
3035
assert.NotContains(t, text, "model: inherited", "Sub-agent should not inherit an unsupported tier-specific model")

pkg/workflow/step_types.go

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"maps"
7+
"math"
78
"strconv"
89

910
"github.com/github/gh-aw/pkg/importinpututil"
@@ -117,34 +118,13 @@ func MapToStep(stepMap map[string]any) (*WorkflowStep, error) {
117118
step.With = with
118119
}
119120
if env, ok := stepMap["env"].(map[string]any); ok {
120-
// Convert map[string]any to map[string]string
121-
step.Env = make(map[string]string)
122-
for k, v := range env {
123-
if strVal, ok := v.(string); ok {
124-
step.Env[k] = strVal
125-
} else if v != nil {
126-
// Arrays and maps are serialized as JSON so that shell consumers
127-
// (e.g. jq --argjson) receive valid JSON. This handles both the
128-
// []any / map[string]any case returned by encoding/json and the
129-
// typed-slice case (e.g. []string) returned by goccy/go-yaml.
130-
step.Env[k] = marshalEnvValue(v)
131-
}
132-
}
121+
step.Env = parseStepEnv(env)
133122
}
134123
if continueOnError, ok := stepMap["continue-on-error"]; ok {
135-
switch value := continueOnError.(type) {
136-
case bool:
137-
templatableValue := TemplatableBool(strconv.FormatBool(value))
138-
step.ContinueOnError = &templatableValue
139-
case string:
140-
if value == "true" || value == "false" || isExpression(value) {
141-
templatableValue := TemplatableBool(value)
142-
step.ContinueOnError = &templatableValue
143-
}
144-
}
124+
step.ContinueOnError = parseStepContinueOnError(continueOnError)
145125
}
146-
if timeoutMinutes, ok := stepMap["timeout-minutes"].(int); ok {
147-
step.TimeoutMinutes = timeoutMinutes
126+
if timeoutMinutesVal, ok := stepMap["timeout-minutes"]; ok {
127+
step.TimeoutMinutes = parseStepTimeoutMinutes(timeoutMinutesVal)
148128
}
149129

150130
stepType := "unknown"
@@ -157,6 +137,67 @@ func MapToStep(stepMap map[string]any) (*WorkflowStep, error) {
157137
return step, nil
158138
}
159139

140+
func parseStepEnv(env map[string]any) map[string]string {
141+
result := make(map[string]string)
142+
for k, v := range env {
143+
if strVal, ok := v.(string); ok {
144+
result[k] = strVal
145+
} else if v != nil {
146+
result[k] = marshalEnvValue(v)
147+
}
148+
}
149+
return result
150+
}
151+
152+
func parseStepContinueOnError(val any) *TemplatableBool {
153+
switch value := val.(type) {
154+
case bool:
155+
templatableValue := TemplatableBool(strconv.FormatBool(value))
156+
return &templatableValue
157+
case string:
158+
if value == "true" || value == "false" || isExpression(value) {
159+
templatableValue := TemplatableBool(value)
160+
return &templatableValue
161+
}
162+
}
163+
return nil
164+
}
165+
166+
// parseStepTimeoutMinutes converts a YAML `timeout-minutes` value into a positive
167+
// number of minutes. Values that are not positive integers within the platform int
168+
// range are ignored (returning 0, which omits the field from the rendered step).
169+
func parseStepTimeoutMinutes(val any) int {
170+
switch v := val.(type) {
171+
case int:
172+
if v > 0 {
173+
return v
174+
}
175+
case int64:
176+
if v > 0 && v <= int64(math.MaxInt) {
177+
return int(v)
178+
}
179+
case uint64:
180+
if v > 0 && v <= uint64(math.MaxInt) {
181+
return int(v)
182+
}
183+
case float64:
184+
// float64 loses integer precision near MaxInt on 64-bit platforms, so treat
185+
// values at or above the rounded float boundary as out of range. Only
186+
// integral values are accepted so fractional timeouts are not truncated.
187+
if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) {
188+
return 0
189+
}
190+
if v >= 1 && v < float64(math.MaxInt) {
191+
return int(v)
192+
}
193+
case string:
194+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
195+
return n
196+
}
197+
}
198+
return 0
199+
}
200+
160201
// Clone creates a deep copy of the WorkflowStep
161202
func (s *WorkflowStep) Clone() *WorkflowStep {
162203
clone := &WorkflowStep{

pkg/workflow/step_types_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package workflow
44

55
import (
6+
"math"
67
"testing"
78

89
"github.com/stretchr/testify/assert"
@@ -663,6 +664,46 @@ func TestSliceToSteps_RoundTrip(t *testing.T) {
663664
}
664665
}
665666

667+
func TestMapToStep_TimeoutMinutesNumericTypes(t *testing.T) {
668+
tests := []struct {
669+
name string
670+
val any
671+
want int
672+
}{
673+
{"int", 5, 5},
674+
{"int64", int64(10), 10},
675+
{"uint64", uint64(15), 15},
676+
{"float64", float64(20), 20},
677+
{"string", "25", 25},
678+
{"negative int", -5, 0},
679+
{"zero int", 0, 0},
680+
{"negative int64", int64(-10), 0},
681+
{"zero uint64", uint64(0), 0},
682+
{"negative float64", float64(-20), 0},
683+
{"fractional float64", 1.9, 0},
684+
{"out of range float64", math.MaxFloat64, 0},
685+
{"NaN float64", math.NaN(), 0},
686+
{"negative string", "-25", 0},
687+
{"zero string", "0", 0},
688+
{"non-numeric string", "abc", 0},
689+
{"bool", true, 0},
690+
{"nil", nil, 0},
691+
}
692+
693+
for _, tt := range tests {
694+
t.Run(tt.name, func(t *testing.T) {
695+
stepMap := map[string]any{
696+
"name": "Test",
697+
"run": "echo test",
698+
"timeout-minutes": tt.val,
699+
}
700+
step, err := MapToStep(stepMap)
701+
require.NoError(t, err)
702+
assert.Equal(t, tt.want, step.TimeoutMinutes)
703+
})
704+
}
705+
}
706+
666707
func TestMapToStep_InvalidTypes(t *testing.T) {
667708
tests := []struct {
668709
name string

0 commit comments

Comments
 (0)