diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 85878a485b4..a6e05b7ba27 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1776,9 +1776,8 @@ "description": "Job timeout in minutes" }, "strategy": { - "type": "object", - "description": "Matrix strategy for the job", - "additionalProperties": false + "$ref": "#/$defs/job_strategy", + "description": "Matrix strategy for the job. Defines multiple job configurations using matrix variables." }, "continue-on-error": { "type": "boolean", @@ -7576,6 +7575,27 @@ } ] }, + "job_strategy": { + "type": "object", + "description": "Matrix strategy for the job. Defines multiple job configurations using matrix variables.", + "properties": { + "matrix": { + "type": "object", + "description": "Matrix variables for creating job variants. Each key defines a dimension of the matrix, with values as arrays (e.g., {os: [ubuntu-latest, windows-latest]}). Use 'include' to add extra configurations and 'exclude' to remove specific combinations.", + "additionalProperties": true + }, + "fail-fast": { + "type": "boolean", + "description": "If true, GitHub cancels all in-progress jobs if any matrix job fails. Defaults to true." + }, + "max-parallel": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of jobs to run simultaneously when using a matrix strategy." + } + }, + "additionalProperties": false + }, "engine_config": { "examples": [ "claude", diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 011bd0dcdcd..53c911b2257 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -507,6 +507,26 @@ func (c *Compiler) buildCustomJobs(data *WorkflowData, activationJobCreated bool } } + // Extract strategy for custom jobs + if strategy, hasStrategy := configMap["strategy"]; hasStrategy { + if strategyMap, ok := strategy.(map[string]any); ok { + // Use goccy/go-yaml to marshal strategy + yamlBytes, err := yaml.Marshal(strategyMap) + if err != nil { + return fmt.Errorf("failed to convert strategy to YAML for job '%s': %w", jobName, err) + } + // Indent the YAML properly for job-level strategy + strategyYAML := string(yamlBytes) + lines := strings.Split(strings.TrimSpace(strategyYAML), "\n") + var formattedStrategy strings.Builder + formattedStrategy.WriteString("strategy:\n") + for _, line := range lines { + formattedStrategy.WriteString(" " + line + "\n") + } + job.Strategy = formattedStrategy.String() + } + } + // Extract outputs for custom jobs if outputs, hasOutputs := configMap["outputs"]; hasOutputs { if outputsMap, ok := outputs.(map[string]any); ok { diff --git a/pkg/workflow/compiler_jobs_test.go b/pkg/workflow/compiler_jobs_test.go index 8f6164f3476..d084c3894b3 100644 --- a/pkg/workflow/compiler_jobs_test.go +++ b/pkg/workflow/compiler_jobs_test.go @@ -2199,6 +2199,79 @@ func TestBuildCustomJobsSkipsPreActivationJob(t *testing.T) { } } +// TestBuildCustomJobsWithStrategy tests custom jobs with matrix strategy configuration +func TestBuildCustomJobsWithStrategy(t *testing.T) { + tmpDir := testutil.TempDir(t, "strategy-test") + + frontmatter := `--- +on: push +permissions: + contents: read +engine: copilot +strict: false +jobs: + matrix_job: + runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node: [18, 20] + fail-fast: false + max-parallel: 2 + steps: + - run: echo "matrix job" + simple_job: + runs-on: ubuntu-latest + steps: + - run: echo "simple job" +--- + +# Test Workflow + +Test content` + + testFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(testFile, []byte(frontmatter), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler() + if err := compiler.CompileWorkflow(testFile); err != nil { + t.Fatalf("CompileWorkflow() error: %v", err) + } + + // Read compiled output + lockFile := filepath.Join(tmpDir, "test.lock.yml") + content, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + yamlStr := string(content) + + // Verify matrix_job has strategy section + if !strings.Contains(yamlStr, "matrix_job:") { + t.Error("Expected matrix_job in compiled output") + } + if !strings.Contains(yamlStr, "strategy:") { + t.Error("Expected strategy section in compiled output") + } + if !strings.Contains(yamlStr, "matrix:") { + t.Error("Expected matrix section in compiled output") + } + if !strings.Contains(yamlStr, "fail-fast: false") { + t.Error("Expected fail-fast: false in compiled output") + } + if !strings.Contains(yamlStr, "max-parallel: 2") { + t.Error("Expected max-parallel: 2 in compiled output") + } + + // Verify simple_job has no strategy + if !strings.Contains(yamlStr, "simple_job:") { + t.Error("Expected simple_job in compiled output") + } +} + // TestBuildCustomJobsRunsOnForms tests that runs-on string, array, and object forms // are all correctly handled in buildCustomJobs. func TestBuildCustomJobsRunsOnForms(t *testing.T) { diff --git a/pkg/workflow/jobs.go b/pkg/workflow/jobs.go index 1415e7ec81b..1b33b173a11 100644 --- a/pkg/workflow/jobs.go +++ b/pkg/workflow/jobs.go @@ -24,6 +24,7 @@ type Job struct { TimeoutMinutes int Concurrency string // Job-level concurrency configuration Environment string // Job environment configuration + Strategy string // Job strategy configuration (matrix strategy) Container string // Job container configuration Services string // Job services configuration Env map[string]string // Job-level environment variables @@ -286,6 +287,11 @@ func (jm *JobManager) renderJob(job *Job) string { fmt.Fprintf(&yaml, " %s\n", job.RunsOn) } + // Add strategy section + if job.Strategy != "" { + fmt.Fprintf(&yaml, " %s\n", strings.TrimRight(job.Strategy, "\n")) + } + // Add environment section if job.Environment != "" { fmt.Fprintf(&yaml, " %s\n", job.Environment)