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
26 changes: 23 additions & 3 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions pkg/workflow/compiler_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Comment on lines +510 to +528
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The strategy extraction duplicates the same marshal/indent logic used for permissions just above. This kind of copy/paste tends to diverge over time (formatting, error handling, indentation rules). Consider factoring this into a small helper (e.g., marshalJobSection(name string, v any) (string, error)) and use it for both permissions and strategy to keep behavior consistent.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

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

@copilot refactor the strategy type in the JSON schema

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in 2140137 — extracted the inline strategy object definition into a named $defs/job_strategy type and replaced the inline definition with "$ref": "#/$defs/job_strategy", following the same pattern as other reusable types in the schema.


// Extract outputs for custom jobs
if outputs, hasOutputs := configMap["outputs"]; hasOutputs {
if outputsMap, ok := outputs.(map[string]any); ok {
Expand Down
73 changes: 73 additions & 0 deletions pkg/workflow/compiler_jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Comment on lines +2252 to +2273
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

This test only checks strings.Contains on the entire lock file, which is brittle and can give false positives (e.g., if "strategy:" appears elsewhere in the workflow) and it doesn't actually verify that simple_job doesn't get a strategy block. A more reliable assertion would be to yaml.Unmarshal the compiled lock YAML and then check that jobs.matrix_job.strategy exists with the expected subfields, and that jobs.simple_job has no strategy key.

Copilot uses AI. Check for mistakes.

// TestBuildCustomJobsRunsOnForms tests that runs-on string, array, and object forms
// are all correctly handled in buildCustomJobs.
func TestBuildCustomJobsRunsOnForms(t *testing.T) {
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading