diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 153be0a00b1..0dc6687dfcb 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -1543,6 +1543,32 @@ func TestValidateWithSchema_YAMLIntegerTypes(t *testing.T) { } } +func TestValidateMainWorkflowSchema_TimeoutMinutesTemplatableInteger(t *testing.T) { + t.Parallel() + + frontmatter := map[string]any{ + "name": "templated-timeout-test", + "on": map[string]any{ + "workflow_dispatch": map[string]any{}, + }, + "timeout-minutes": "${{ inputs.workflow_timeout }}", + "jobs": map[string]any{ + "build": map[string]any{ + "runs-on": "ubuntu-latest", + "timeout-minutes": "${{ inputs.job_timeout }}", + "steps": []any{ + map[string]any{"run": "echo hello"}, + }, + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/timeout-templatable.md") + if err != nil { + t.Fatalf("expected templated timeout-minutes values to pass schema validation, got: %v", err) + } +} + func TestMainWorkflowSchema_GitHubAllowedSupportsToolCallLimits(t *testing.T) { t.Parallel() diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 715a2077596..5da4750b36c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2481,8 +2481,7 @@ "description": "GitHub token permissions for this specific job. Overrides workflow-level permissions. Can be a string (shorthand) or object (detailed)." }, "timeout-minutes": { - "type": "integer", - "minimum": 1, + "$ref": "#/$defs/templatable_integer", "description": "Job timeout in minutes" }, "strategy": { @@ -2632,19 +2631,9 @@ "examples": ["self-hosted", "ubuntu-latest", "ubuntu-22.04"] }, "timeout-minutes": { + "$ref": "#/$defs/templatable_integer", "description": "Workflow timeout in minutes (GitHub Actions standard field). Defaults to 20 minutes for agentic workflows. Has sensible defaults and can typically be omitted. Custom runners support longer timeouts beyond the GitHub-hosted runner limit. Supports GitHub Actions expressions (e.g. '${{ inputs.timeout }}') for reusable workflow_call workflows.", - "oneOf": [ - { - "type": "integer", - "minimum": 1, - "examples": [5, 10, 30] - }, - { - "type": "string", - "pattern": "^\\$\\{\\{.*\\}\\}$", - "description": "GitHub Actions expression that resolves to an integer (e.g. '${{ inputs.timeout }}')" - } - ] + "examples": [5, 10, 30] }, "concurrency": { "description": "Concurrency control to limit concurrent workflow runs (GitHub Actions standard field). Supports two forms: simple string for basic group isolation, or object with cancel-in-progress option for advanced control. Agentic workflows enhance this with automatic per-engine concurrency policies (defaults to single job per engine across all workflows) and token-based rate limiting. Default behavior: workflows in the same group queue sequentially unless cancel-in-progress is true. See https://docs.github.com/en/actions/using-jobs/using-concurrency", diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 9abf086df67..261236f45c7 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -660,6 +660,18 @@ func (c *Compiler) buildCustomJobs(data *WorkflowData, activationJobCreated bool } case float64: job.TimeoutMinutes = int(v) + case string: + // isExpression validates full GitHub Actions expression syntax (${{ + // ... }}) and is defined in expression_patterns.go. + if isExpression(v) { + job.TimeoutMinutesExpression = v + } else { + return fmt.Errorf( + "job '%s' timeout-minutes must be an integer or a GitHub Actions expression (e.g. '${{ inputs.timeout }}'), got %q", + jobName, + v, + ) + } } } diff --git a/pkg/workflow/compiler_jobs_test.go b/pkg/workflow/compiler_jobs_test.go index 12e4fdf8062..6c2ca1a505b 100644 --- a/pkg/workflow/compiler_jobs_test.go +++ b/pkg/workflow/compiler_jobs_test.go @@ -3347,6 +3347,82 @@ func TestBuildCustomJobsAllNewFieldsViaWorkflowData(t *testing.T) { } } +func TestBuildCustomJobsTimeoutMinutesExpressionViaWorkflowData(t *testing.T) { + t.Parallel() + + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + + data := &WorkflowData{ + Name: "Test Workflow", + AI: "copilot", + RunsOn: "runs-on: ubuntu-latest", + Jobs: map[string]any{ + "templated_timeout_job": map[string]any{ + "runs-on": "ubuntu-latest", + "timeout-minutes": "${{ inputs.timeout }}", + "steps": []any{ + map[string]any{"run": "echo 'test'"}, + }, + }, + }, + } + + err := compiler.buildCustomJobs(data, false) + if err != nil { + t.Fatalf("buildCustomJobs() returned error: %v", err) + } + + job, exists := compiler.jobManager.GetJob("templated_timeout_job") + if !exists { + t.Fatal("Expected templated_timeout_job to be added") + } + + if job.TimeoutMinutesExpression != "${{ inputs.timeout }}" { + t.Errorf("TimeoutMinutesExpression = %q, want %q", job.TimeoutMinutesExpression, "${{ inputs.timeout }}") + } + if job.TimeoutMinutes != 0 { + t.Errorf("TimeoutMinutes = %d, want 0 when expression is used", job.TimeoutMinutes) + } + + var renderedBuf strings.Builder + compiler.jobManager.WriteJobsYAML(&renderedBuf) + rendered := renderedBuf.String() + if !strings.Contains(rendered, "timeout-minutes: ${{ inputs.timeout }}") { + t.Errorf("Expected templated timeout-minutes in rendered YAML, got:\n%s", rendered) + } +} + +func TestBuildCustomJobsTimeoutMinutesInvalidStringViaWorkflowData(t *testing.T) { + t.Parallel() + + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + + data := &WorkflowData{ + Name: "Test Workflow", + AI: "copilot", + RunsOn: "runs-on: ubuntu-latest", + Jobs: map[string]any{ + "invalid_timeout_job": map[string]any{ + "runs-on": "ubuntu-latest", + "timeout-minutes": "not-an-expression", + "steps": []any{ + map[string]any{"run": "echo 'test'"}, + }, + }, + }, + } + + err := compiler.buildCustomJobs(data, false) + if err == nil { + t.Fatal("expected error for non-expression timeout-minutes string") + } + if !strings.Contains(err.Error(), "timeout-minutes must be an integer or a GitHub Actions expression") { + t.Fatalf("expected timeout-minutes validation error, got: %v", err) + } +} + // TestPushRepoMemoryJobConditionalDetection verifies that push_repo_memory already uses // always() and buildDetectionPassedCondition() (accepting 'success' or 'skipped') when // detection is expression-controlled, so the job still runs when detection is skipped at runtime. diff --git a/pkg/workflow/jobs.go b/pkg/workflow/jobs.go index d85b61a88fb..805cd7b78d3 100644 --- a/pkg/workflow/jobs.go +++ b/pkg/workflow/jobs.go @@ -22,6 +22,7 @@ type Job struct { HasWorkflowRunSafetyChecks bool // If true, the job's if condition includes workflow_run safety checks Permissions string TimeoutMinutes int + TimeoutMinutesExpression string Concurrency string // Job-level concurrency configuration Environment string // Job environment configuration Strategy string // Job strategy configuration (matrix strategy) @@ -279,7 +280,10 @@ func (jm *JobManager) renderJobTo(b *strings.Builder, job *Job) { } // Add timeout-minutes if specified - if job.TimeoutMinutes > 0 { + if job.TimeoutMinutesExpression != "" { + // TimeoutMinutesExpression is validated when parsed from frontmatter in compiler_jobs.go. + fmt.Fprintf(b, " timeout-minutes: %s\n", job.TimeoutMinutesExpression) + } else if job.TimeoutMinutes > 0 { fmt.Fprintf(b, " timeout-minutes: %d\n", job.TimeoutMinutes) } diff --git a/pkg/workflow/jobs_test.go b/pkg/workflow/jobs_test.go index e0c0955c3a2..51e77802e74 100644 --- a/pkg/workflow/jobs_test.go +++ b/pkg/workflow/jobs_test.go @@ -52,6 +52,26 @@ func TestJobManager_AddJob(t *testing.T) { wantErr: true, errMsg: "uses a reusable workflow and cannot set timeout-minutes", }, + { + name: "reusable workflow caller cannot set templated timeout-minutes", + job: &Job{ + Name: "call-reusable-templated", + Uses: "./.github/workflows/reusable.yml", + TimeoutMinutesExpression: "${{ inputs.timeout }}", + }, + wantErr: true, + errMsg: "uses a reusable workflow and cannot set timeout-minutes", + }, + { + name: "cannot set timeout-minutes as both integer and expression", + job: &Job{ + Name: "invalid-timeout-job", + TimeoutMinutes: 10, + TimeoutMinutesExpression: "${{ inputs.timeout }}", + }, + wantErr: true, + errMsg: "has timeout-minutes set as both integer", + }, } for _, tt := range tests { diff --git a/pkg/workflow/jobs_validation.go b/pkg/workflow/jobs_validation.go index 15ac1d2c204..68000131c4c 100644 --- a/pkg/workflow/jobs_validation.go +++ b/pkg/workflow/jobs_validation.go @@ -29,13 +29,22 @@ func validateJobDefinition(job *Job) error { return errors.New("job definition cannot be nil") } - if job.Uses != "" && job.TimeoutMinutes > 0 { + if job.Uses != "" && (job.TimeoutMinutes > 0 || job.TimeoutMinutesExpression != "") { return fmt.Errorf( "job '%s' uses a reusable workflow and cannot set timeout-minutes; remove timeout-minutes from the caller job or move it into the called workflow", job.Name, ) } + if job.TimeoutMinutes > 0 && job.TimeoutMinutesExpression != "" { + return fmt.Errorf( + "job '%s' has timeout-minutes set as both integer (%d) and expression (%q); specify only one", + job.Name, + job.TimeoutMinutes, + job.TimeoutMinutesExpression, + ) + } + return nil }