diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 5d4b5020b6..52d5786e28 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6689,6 +6689,11 @@ "action-version": { "type": "string", "description": "Version of the setup action to use (e.g., 'v4', 'v5'). Overrides the default action version." + }, + "if": { + "type": "string", + "description": "Optional GitHub Actions if condition to control when the runtime setup step runs. Supports standard GitHub Actions expression syntax. Useful for conditionally installing runtimes based on file presence (e.g., \"hashFiles('go.mod') != ''\" to install Go only when go.mod exists).", + "examples": ["hashFiles('go.mod') != ''", "hashFiles('package.json') != ''", "hashFiles('requirements.txt') != '' || hashFiles('pyproject.toml') != ''", "hashFiles('uv.lock') != ''", "github.event_name == 'workflow_dispatch'"] } }, "additionalProperties": false diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index d488b37399..d5e75baaf1 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -12,6 +12,7 @@ var frontmatterTypesLog = logger.New("workflow:frontmatter_types") // RuntimeConfig represents the configuration for a single runtime type RuntimeConfig struct { Version string `json:"version,omitempty"` // Version of the runtime (e.g., "20" for Node, "3.11" for Python) + If string `json:"if,omitempty"` // Optional GitHub Actions if condition (e.g., "hashFiles('go.mod') != ''") } // RuntimesConfig represents the configuration for all runtime environments @@ -261,29 +262,39 @@ func parseRuntimesConfig(runtimes map[string]any) (*RuntimesConfig, error) { continue } - versionAny, hasVersion := configMap["version"] - if !hasVersion { - continue + // Extract version (optional) + var version string + if versionAny, hasVersion := configMap["version"]; hasVersion { + // Convert version to string + switch v := versionAny.(type) { + case string: + version = v + case int: + version = fmt.Sprintf("%d", v) + case float64: + if v == float64(int(v)) { + version = fmt.Sprintf("%d", int(v)) + } else { + version = fmt.Sprintf("%g", v) + } + default: + continue + } } - // Convert version to string - var version string - switch v := versionAny.(type) { - case string: - version = v - case int: - version = fmt.Sprintf("%d", v) - case float64: - if v == float64(int(v)) { - version = fmt.Sprintf("%d", int(v)) - } else { - version = fmt.Sprintf("%g", v) + // Extract if condition (optional) + var ifCondition string + if ifAny, hasIf := configMap["if"]; hasIf { + if ifStr, ok := ifAny.(string); ok { + ifCondition = ifStr } - default: - continue } - runtimeConfig := &RuntimeConfig{Version: version} + // Create runtime config with both version and if condition + runtimeConfig := &RuntimeConfig{ + Version: version, + If: ifCondition, + } // Map to specific runtime field switch runtimeID { @@ -675,22 +686,76 @@ func runtimesConfigToMap(config *RuntimesConfig) map[string]any { result := make(map[string]any) if config.Node != nil { - result["node"] = map[string]any{"version": config.Node.Version} + nodeMap := map[string]any{} + if config.Node.Version != "" { + nodeMap["version"] = config.Node.Version + } + if config.Node.If != "" { + nodeMap["if"] = config.Node.If + } + if len(nodeMap) > 0 { + result["node"] = nodeMap + } } if config.Python != nil { - result["python"] = map[string]any{"version": config.Python.Version} + pythonMap := map[string]any{} + if config.Python.Version != "" { + pythonMap["version"] = config.Python.Version + } + if config.Python.If != "" { + pythonMap["if"] = config.Python.If + } + if len(pythonMap) > 0 { + result["python"] = pythonMap + } } if config.Go != nil { - result["go"] = map[string]any{"version": config.Go.Version} + goMap := map[string]any{} + if config.Go.Version != "" { + goMap["version"] = config.Go.Version + } + if config.Go.If != "" { + goMap["if"] = config.Go.If + } + if len(goMap) > 0 { + result["go"] = goMap + } } if config.UV != nil { - result["uv"] = map[string]any{"version": config.UV.Version} + uvMap := map[string]any{} + if config.UV.Version != "" { + uvMap["version"] = config.UV.Version + } + if config.UV.If != "" { + uvMap["if"] = config.UV.If + } + if len(uvMap) > 0 { + result["uv"] = uvMap + } } if config.Bun != nil { - result["bun"] = map[string]any{"version": config.Bun.Version} + bunMap := map[string]any{} + if config.Bun.Version != "" { + bunMap["version"] = config.Bun.Version + } + if config.Bun.If != "" { + bunMap["if"] = config.Bun.If + } + if len(bunMap) > 0 { + result["bun"] = bunMap + } } if config.Deno != nil { - result["deno"] = map[string]any{"version": config.Deno.Version} + denoMap := map[string]any{} + if config.Deno.Version != "" { + denoMap["version"] = config.Deno.Version + } + if config.Deno.If != "" { + denoMap["if"] = config.Deno.If + } + if len(denoMap) > 0 { + result["deno"] = denoMap + } } if len(result) == 0 { diff --git a/pkg/workflow/frontmatter_types_test.go b/pkg/workflow/frontmatter_types_test.go index 395e9e3342..50b53405a1 100644 --- a/pkg/workflow/frontmatter_types_test.go +++ b/pkg/workflow/frontmatter_types_test.go @@ -1332,3 +1332,180 @@ func TestTypedConfigsBackwardCompatibility(t *testing.T) { } }) } + +func TestParseRuntimesConfigWithIfCondition(t *testing.T) { + tests := []struct { + name string + runtimes map[string]any + expected map[string]RuntimeConfig + }{ + { + name: "runtime with if condition", + runtimes: map[string]any{ + "go": map[string]any{ + "version": "1.25", + "if": "hashFiles('go.mod') != ''", + }, + }, + expected: map[string]RuntimeConfig{ + "go": { + Version: "1.25", + If: "hashFiles('go.mod') != ''", + }, + }, + }, + { + name: "runtime with only if condition", + runtimes: map[string]any{ + "uv": map[string]any{ + "if": "hashFiles('uv.lock') != ''", + }, + }, + expected: map[string]RuntimeConfig{ + "uv": { + Version: "", + If: "hashFiles('uv.lock') != ''", + }, + }, + }, + { + name: "multiple runtimes with if conditions", + runtimes: map[string]any{ + "go": map[string]any{ + "version": "1.25", + "if": "hashFiles('go.mod') != ''", + }, + "python": map[string]any{ + "version": "3.11", + "if": "hashFiles('requirements.txt') != ''", + }, + "node": map[string]any{ + "version": "20", + "if": "hashFiles('package.json') != ''", + }, + }, + expected: map[string]RuntimeConfig{ + "go": { + Version: "1.25", + If: "hashFiles('go.mod') != ''", + }, + "python": { + Version: "3.11", + If: "hashFiles('requirements.txt') != ''", + }, + "node": { + Version: "20", + If: "hashFiles('package.json') != ''", + }, + }, + }, + { + name: "runtime without if condition", + runtimes: map[string]any{ + "node": map[string]any{ + "version": "20", + }, + }, + expected: map[string]RuntimeConfig{ + "node": { + Version: "20", + If: "", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := parseRuntimesConfig(tt.runtimes) + if err != nil { + t.Fatalf("parseRuntimesConfig failed: %v", err) + } + + for runtimeID, expectedConfig := range tt.expected { + var actualConfig *RuntimeConfig + switch runtimeID { + case "node": + actualConfig = config.Node + case "python": + actualConfig = config.Python + case "go": + actualConfig = config.Go + case "uv": + actualConfig = config.UV + case "bun": + actualConfig = config.Bun + case "deno": + actualConfig = config.Deno + } + + if actualConfig == nil { + t.Errorf("Runtime %s not found in config", runtimeID) + continue + } + + if actualConfig.Version != expectedConfig.Version { + t.Errorf("Runtime %s version: got %q, want %q", runtimeID, actualConfig.Version, expectedConfig.Version) + } + + if actualConfig.If != expectedConfig.If { + t.Errorf("Runtime %s if condition: got %q, want %q", runtimeID, actualConfig.If, expectedConfig.If) + } + } + }) + } +} + +func TestRuntimesConfigToMapWithIfCondition(t *testing.T) { + config := &RuntimesConfig{ + Go: &RuntimeConfig{ + Version: "1.25", + If: "hashFiles('go.mod') != ''", + }, + Python: &RuntimeConfig{ + Version: "3.11", + If: "hashFiles('requirements.txt') != ''", + }, + Node: &RuntimeConfig{ + Version: "20", + }, + } + + result := runtimesConfigToMap(config) + + // Check Go runtime + goMap, ok := result["go"].(map[string]any) + if !ok { + t.Fatal("go runtime not found in result") + } + if goMap["version"] != "1.25" { + t.Errorf("go version: got %v, want 1.25", goMap["version"]) + } + if goMap["if"] != "hashFiles('go.mod') != ''" { + t.Errorf("go if condition: got %v, want hashFiles('go.mod') != ''", goMap["if"]) + } + + // Check Python runtime + pythonMap, ok := result["python"].(map[string]any) + if !ok { + t.Fatal("python runtime not found in result") + } + if pythonMap["version"] != "3.11" { + t.Errorf("python version: got %v, want 3.11", pythonMap["version"]) + } + if pythonMap["if"] != "hashFiles('requirements.txt') != ''" { + t.Errorf("python if condition: got %v, want hashFiles('requirements.txt') != ''", pythonMap["if"]) + } + + // Check Node runtime (no if condition) + nodeMap, ok := result["node"].(map[string]any) + if !ok { + t.Fatal("node runtime not found in result") + } + if nodeMap["version"] != "20" { + t.Errorf("node version: got %v, want 20", nodeMap["version"]) + } + if _, hasIf := nodeMap["if"]; hasIf { + t.Error("node should not have if condition in map") + } +} diff --git a/pkg/workflow/runtime_definitions.go b/pkg/workflow/runtime_definitions.go index 3a4514b5ee..5b959182d7 100644 --- a/pkg/workflow/runtime_definitions.go +++ b/pkg/workflow/runtime_definitions.go @@ -25,6 +25,7 @@ type RuntimeRequirement struct { Version string // Empty string means use default ExtraFields map[string]any // Additional 'with' fields from user's setup step (e.g., cache settings) GoModFile string // Path to go.mod file for Go runtime (Go-specific) + IfCondition string // Optional GitHub Actions if condition } // knownRuntimes is the list of all supported runtime configurations (alphabetically sorted by ID) diff --git a/pkg/workflow/runtime_integration_test.go b/pkg/workflow/runtime_integration_test.go index ed3557d346..1ba9eb2144 100644 --- a/pkg/workflow/runtime_integration_test.go +++ b/pkg/workflow/runtime_integration_test.go @@ -366,3 +366,163 @@ Test workflow that uses Go without go.mod file. t.Error("Should not use go-version-file when go.mod doesn't exist") } } + +func TestRuntimeIfConditionIntegration(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectedIfGo bool + expectedIfPy bool + expectedIfNode bool + expectedGoIf string + expectedPyIf string + expectedNodeIf string + }{ + { + name: "go runtime with hashFiles if condition", + frontmatter: map[string]any{ + "name": "test-workflow", + "engine": "copilot", + "runtimes": map[string]any{ + "go": map[string]any{ + "version": "1.25", + "if": "hashFiles('go.mod') != ''", + }, + }, + }, + expectedIfGo: true, + expectedGoIf: "hashFiles('go.mod') != ''", + }, + { + name: "multiple runtimes with different if conditions", + frontmatter: map[string]any{ + "name": "test-workflow", + "engine": "copilot", + "runtimes": map[string]any{ + "go": map[string]any{ + "version": "1.25", + "if": "hashFiles('go.mod') != ''", + }, + "python": map[string]any{ + "version": "3.11", + "if": "hashFiles('requirements.txt') != '' || hashFiles('pyproject.toml') != ''", + }, + "node": map[string]any{ + "version": "20", + "if": "hashFiles('package.json') != ''", + }, + }, + }, + expectedIfGo: true, + expectedIfPy: true, + expectedIfNode: true, + expectedGoIf: "hashFiles('go.mod') != ''", + expectedPyIf: "hashFiles('requirements.txt') != '' || hashFiles('pyproject.toml') != ''", + expectedNodeIf: "hashFiles('package.json') != ''", + }, + { + name: "runtime with only if condition, no version", + frontmatter: map[string]any{ + "name": "test-workflow", + "engine": "copilot", + "runtimes": map[string]any{ + "uv": map[string]any{ + "if": "hashFiles('uv.lock') != ''", + }, + }, + }, + // Note: We're not tracking UV in this test, but it would have the if condition + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := ParseFrontmatterConfig(tt.frontmatter) + if err != nil { + t.Fatalf("Failed to parse frontmatter: %v", err) + } + + // Verify typed runtimes are parsed with if conditions + if tt.expectedIfGo && config.RuntimesTyped != nil && config.RuntimesTyped.Go != nil { + if config.RuntimesTyped.Go.If != tt.expectedGoIf { + t.Errorf("Go if condition: got %q, want %q", config.RuntimesTyped.Go.If, tt.expectedGoIf) + } + } + + if tt.expectedIfPy && config.RuntimesTyped != nil && config.RuntimesTyped.Python != nil { + if config.RuntimesTyped.Python.If != tt.expectedPyIf { + t.Errorf("Python if condition: got %q, want %q", config.RuntimesTyped.Python.If, tt.expectedPyIf) + } + } + + if tt.expectedIfNode && config.RuntimesTyped != nil && config.RuntimesTyped.Node != nil { + if config.RuntimesTyped.Node.If != tt.expectedNodeIf { + t.Errorf("Node if condition: got %q, want %q", config.RuntimesTyped.Node.If, tt.expectedNodeIf) + } + } + + // Apply runtime overrides to simulate the workflow compilation process + requirements := make(map[string]*RuntimeRequirement) + if config.Runtimes != nil { + applyRuntimeOverrides(config.Runtimes, requirements) + } + + // Verify requirements have if conditions + if tt.expectedIfGo { + if goReq, exists := requirements["go"]; exists { + if goReq.IfCondition != tt.expectedGoIf { + t.Errorf("Go requirement if condition: got %q, want %q", goReq.IfCondition, tt.expectedGoIf) + } + } else { + t.Error("Go requirement not found") + } + } + + if tt.expectedIfPy { + if pyReq, exists := requirements["python"]; exists { + if pyReq.IfCondition != tt.expectedPyIf { + t.Errorf("Python requirement if condition: got %q, want %q", pyReq.IfCondition, tt.expectedPyIf) + } + } else { + t.Error("Python requirement not found") + } + } + + if tt.expectedIfNode { + if nodeReq, exists := requirements["node"]; exists { + if nodeReq.IfCondition != tt.expectedNodeIf { + t.Errorf("Node requirement if condition: got %q, want %q", nodeReq.IfCondition, tt.expectedNodeIf) + } + } else { + t.Error("Node requirement not found") + } + } + + // Generate setup steps and verify they contain the if conditions + var reqSlice []RuntimeRequirement + for _, req := range requirements { + reqSlice = append(reqSlice, *req) + } + + steps := GenerateRuntimeSetupSteps(reqSlice) + allSteps := "" + for _, step := range steps { + for _, line := range step { + allSteps += line + "\n" + } + } + + if tt.expectedIfGo && !strings.Contains(allSteps, tt.expectedGoIf) { + t.Errorf("Generated steps do not contain expected Go if condition %q\nGot:\n%s", tt.expectedGoIf, allSteps) + } + + if tt.expectedIfPy && !strings.Contains(allSteps, tt.expectedPyIf) { + t.Errorf("Generated steps do not contain expected Python if condition %q\nGot:\n%s", tt.expectedPyIf, allSteps) + } + + if tt.expectedIfNode && !strings.Contains(allSteps, tt.expectedNodeIf) { + t.Errorf("Generated steps do not contain expected Node if condition %q\nGot:\n%s", tt.expectedNodeIf, allSteps) + } + }) + } +} diff --git a/pkg/workflow/runtime_overrides.go b/pkg/workflow/runtime_overrides.go index 269979e813..dd09543d06 100644 --- a/pkg/workflow/runtime_overrides.go +++ b/pkg/workflow/runtime_overrides.go @@ -38,6 +38,9 @@ func applyRuntimeOverrides(runtimes map[string]any, requirements map[string]*Run actionRepo, _ := configMap["action-repo"].(string) actionVersion, _ := configMap["action-version"].(string) + // Extract if condition from config + ifCondition, _ := configMap["if"].(string) + // Find or create runtime requirement if existing, exists := requirements[runtimeID]; exists { // Override version for existing requirement @@ -46,6 +49,12 @@ func applyRuntimeOverrides(runtimes map[string]any, requirements map[string]*Run existing.Version = version } + // Override if condition if specified + if ifCondition != "" { + runtimeSetupLog.Printf("Setting if condition for runtime %s: %s", runtimeID, ifCondition) + existing.IfCondition = ifCondition + } + // If action-repo or action-version is specified, create a custom Runtime if actionRepo != "" || actionVersion != "" { runtimeSetupLog.Printf("Applying custom action config for runtime %s: repo=%s, version=%s", runtimeID, actionRepo, actionVersion) @@ -106,8 +115,9 @@ func applyRuntimeOverrides(runtimes map[string]any, requirements map[string]*Run // If runtime is known or we have custom action configuration, create a new requirement if runtime != nil { requirements[runtimeID] = &RuntimeRequirement{ - Runtime: runtime, - Version: version, + Runtime: runtime, + Version: version, + IfCondition: ifCondition, } } // If runtime is unknown and no action-repo specified, skip it (user might have typo) diff --git a/pkg/workflow/runtime_setup_test.go b/pkg/workflow/runtime_setup_test.go index f4cc9b0c64..c90b68cd6d 100644 --- a/pkg/workflow/runtime_setup_test.go +++ b/pkg/workflow/runtime_setup_test.go @@ -786,3 +786,133 @@ func TestDeduplicatePreservesUserNodeVersion(t *testing.T) { t.Error("Expected deduplicated steps to contain user's version '16'") } } + +func TestGenerateRuntimeSetupStepsWithIfCondition(t *testing.T) { + tests := []struct { + name string + requirements []RuntimeRequirement + expectSteps int + checkContent []string + }{ + { + name: "generates go setup with if condition", + requirements: []RuntimeRequirement{ + { + Runtime: findRuntimeByID("go"), + Version: "1.25", + IfCondition: "hashFiles('go.mod') != ''", + }, + }, + expectSteps: 2, // setup + GOROOT capture + checkContent: []string{ + "Setup Go", + "actions/setup-go@", + "go-version: '1.25'", + "if: hashFiles('go.mod') != ''", + }, + }, + { + name: "generates uv setup with if condition", + requirements: []RuntimeRequirement{ + { + Runtime: findRuntimeByID("uv"), + Version: "", + IfCondition: "hashFiles('uv.lock') != ''", + }, + }, + expectSteps: 1, + checkContent: []string{ + "Setup uv", + "astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86", + "if: hashFiles('uv.lock') != ''", + }, + }, + { + name: "generates python setup with if condition", + requirements: []RuntimeRequirement{ + { + Runtime: findRuntimeByID("python"), + Version: "3.11", + IfCondition: "hashFiles('requirements.txt') != '' || hashFiles('pyproject.toml') != ''", + }, + }, + expectSteps: 1, + checkContent: []string{ + "Setup Python", + "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065", + "python-version: '3.11'", + "if: hashFiles('requirements.txt') != '' || hashFiles('pyproject.toml') != ''", + }, + }, + { + name: "generates node setup with if condition", + requirements: []RuntimeRequirement{ + { + Runtime: findRuntimeByID("node"), + Version: "20", + IfCondition: "hashFiles('package.json') != ''", + }, + }, + expectSteps: 1, + checkContent: []string{ + "Setup Node.js", + "actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238", + "node-version: '20'", + "if: hashFiles('package.json') != ''", + }, + }, + { + name: "generates multiple runtimes with different if conditions", + requirements: []RuntimeRequirement{ + { + Runtime: findRuntimeByID("go"), + Version: "1.25", + IfCondition: "hashFiles('go.mod') != ''", + }, + { + Runtime: findRuntimeByID("python"), + Version: "3.11", + IfCondition: "hashFiles('requirements.txt') != ''", + }, + { + Runtime: findRuntimeByID("node"), + Version: "20", + IfCondition: "hashFiles('package.json') != ''", + }, + }, + expectSteps: 4, // go setup + GOROOT capture + python setup + node setup + checkContent: []string{ + "Setup Go", + "if: hashFiles('go.mod') != ''", + "Setup Python", + "if: hashFiles('requirements.txt') != ''", + "Setup Node.js", + "if: hashFiles('package.json') != ''", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + steps := GenerateRuntimeSetupSteps(tt.requirements) + + if len(steps) != tt.expectSteps { + t.Errorf("Expected %d steps, got %d", tt.expectSteps, len(steps)) + } + + // Join all steps into a single string for content checking + allSteps := "" + for _, step := range steps { + for _, line := range step { + allSteps += line + "\n" + } + } + + for _, content := range tt.checkContent { + if !strings.Contains(allSteps, content) { + t.Errorf("Expected steps to contain %q\nGot:\n%s", content, allSteps) + } + } + }) + } +} diff --git a/pkg/workflow/runtime_step_generator.go b/pkg/workflow/runtime_step_generator.go index 7b2a35c053..158f2b595e 100644 --- a/pkg/workflow/runtime_step_generator.go +++ b/pkg/workflow/runtime_step_generator.go @@ -65,8 +65,8 @@ func GenerateSerenaLanguageServiceSteps(tools *ToolsConfig) []GitHubActionStep { func generateSetupStep(req *RuntimeRequirement) GitHubActionStep { runtime := req.Runtime version := req.Version - runtimeStepGeneratorLog.Printf("Generating setup step for runtime: %s, version=%s", runtime.ID, version) - runtimeSetupLog.Printf("Generating setup step for runtime: %s, version=%s", runtime.ID, version) + runtimeStepGeneratorLog.Printf("Generating setup step for runtime: %s, version=%s, if=%s", runtime.ID, version, req.IfCondition) + runtimeSetupLog.Printf("Generating setup step for runtime: %s, version=%s, if=%s", runtime.ID, version, req.IfCondition) // Use default version if none specified if version == "" { version = runtime.DefaultVersion @@ -90,6 +90,11 @@ func generateSetupStep(req *RuntimeRequirement) GitHubActionStep { fmt.Sprintf(" uses: %s", actionRef), } + // Add if condition if specified + if req.IfCondition != "" { + step = append(step, fmt.Sprintf(" if: %s", req.IfCondition)) + } + // Special handling for Go when go-mod-file is explicitly specified if runtime.ID == "go" && req.GoModFile != "" { step = append(step, " with:")