diff --git a/.gitignore b/.gitignore index 418c5acdaeb..d6687747ac5 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,5 @@ test-inline-*.lock.yml conclusion/ detection/ +test-script-mode.md +test-script-mode.lock.yml diff --git a/pkg/cli/compile_compiler_setup.go b/pkg/cli/compile_compiler_setup.go index aaab92af66b..3138957b820 100644 --- a/pkg/cli/compile_compiler_setup.go +++ b/pkg/cli/compile_compiler_setup.go @@ -142,7 +142,7 @@ func validateActionModeConfig(actionMode string) error { mode := workflow.ActionMode(actionMode) if !mode.IsValid() { - return fmt.Errorf("invalid action mode '%s'. Must be 'inline', 'dev', or 'release'", actionMode) + return fmt.Errorf("invalid action mode '%s'. Must be 'dev', 'release', or 'script'", actionMode) } return nil diff --git a/pkg/workflow/action_mode.go b/pkg/workflow/action_mode.go index 39657efe1e7..6eeeeaea971 100644 --- a/pkg/workflow/action_mode.go +++ b/pkg/workflow/action_mode.go @@ -18,6 +18,9 @@ const ( // ActionModeRelease references custom actions using SHA-pinned remote paths (release mode) ActionModeRelease ActionMode = "release" + + // ActionModeScript runs setup.sh script from checked-out .github folder instead of using action steps + ActionModeScript ActionMode = "script" ) // String returns the string representation of the action mode @@ -27,7 +30,7 @@ func (m ActionMode) String() string { // IsValid checks if the action mode is valid func (m ActionMode) IsValid() bool { - return m == ActionModeDev || m == ActionModeRelease + return m == ActionModeDev || m == ActionModeRelease || m == ActionModeScript } // IsDev returns true if the action mode is development mode @@ -40,6 +43,11 @@ func (m ActionMode) IsRelease() bool { return m == ActionModeRelease } +// IsScript returns true if the action mode is script mode +func (m ActionMode) IsScript() bool { + return m == ActionModeScript +} + // UsesExternalActions returns true (always true since inline mode was removed) func (m ActionMode) UsesExternalActions() bool { return true diff --git a/pkg/workflow/cache.go b/pkg/workflow/cache.go index 4044877018d..044a80ba34d 100644 --- a/pkg/workflow/cache.go +++ b/pkg/workflow/cache.go @@ -632,14 +632,11 @@ func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetection // Add setup step to copy scripts at the beginning var setupSteps []string setupActionRef := c.resolveActionReference("./actions/setup", data) - if setupActionRef != "" { + if setupActionRef != "" || c.actionMode.IsScript() { // For dev mode (local action path), checkout the actions folder first setupSteps = append(setupSteps, c.generateCheckoutActionsFolder(data)...) - setupSteps = append(setupSteps, " - name: Setup Scripts\n") - setupSteps = append(setupSteps, fmt.Sprintf(" uses: %s\n", setupActionRef)) - setupSteps = append(setupSteps, " with:\n") - setupSteps = append(setupSteps, fmt.Sprintf(" destination: %s\n", SetupActionDestination)) + setupSteps = append(setupSteps, c.generateSetupStep(setupActionRef, SetupActionDestination)...) } // Prepend setup steps to all cache steps diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 05e2e5dda24..9e0de3c8090 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -134,6 +134,29 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath return errors.New(formattedErr) } + // Check for action-mode feature flag override + if workflowData.Features != nil { + if actionModeVal, exists := workflowData.Features["action-mode"]; exists { + if actionModeStr, ok := actionModeVal.(string); ok && actionModeStr != "" { + mode := ActionMode(actionModeStr) + if !mode.IsValid() { + formattedErr := console.FormatError(console.CompilerError{ + Position: console.ErrorPosition{ + File: markdownPath, + Line: 1, + Column: 1, + }, + Type: "error", + Message: fmt.Sprintf("invalid action-mode feature flag '%s'. Must be 'dev', 'release', or 'script'", actionModeStr), + }) + return errors.New(formattedErr) + } + log.Printf("Overriding action mode from feature flag: %s", mode) + c.SetActionMode(mode) + } + } + } + // Validate dangerous permissions log.Printf("Validating dangerous permissions") if err := validateDangerousPermissions(workflowData); err != nil { diff --git a/pkg/workflow/compiler_activation_jobs.go b/pkg/workflow/compiler_activation_jobs.go index 578d2405c72..966ed33451a 100644 --- a/pkg/workflow/compiler_activation_jobs.go +++ b/pkg/workflow/compiler_activation_jobs.go @@ -33,12 +33,9 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec // For dev mode (local action path), checkout the actions folder first // This requires contents: read permission steps = append(steps, c.generateCheckoutActionsFolder(data)...) - needsContentsRead := c.actionMode.IsDev() && len(c.generateCheckoutActionsFolder(data)) > 0 + needsContentsRead := (c.actionMode.IsDev() || c.actionMode.IsScript()) && len(c.generateCheckoutActionsFolder(data)) > 0 - steps = append(steps, " - name: Setup Scripts\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef)) - steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination)) + steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...) // Set permissions if checkout is needed (for local actions in dev mode) if needsContentsRead { @@ -340,10 +337,7 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate // For dev mode (local action path), checkout the actions folder first steps = append(steps, c.generateCheckoutActionsFolder(data)...) - steps = append(steps, " - name: Setup Scripts\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef)) - steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination)) + steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...) // Add timestamp check for lock file vs source file using GitHub API // No checkout step needed - uses GitHub API to check commit times @@ -578,14 +572,11 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( // Add setup action steps at the beginning of the job setupActionRef := c.resolveActionReference("./actions/setup", data) - if setupActionRef != "" { + if setupActionRef != "" || c.actionMode.IsScript() { // For dev mode (local action path), checkout the actions folder first steps = append(steps, c.generateCheckoutActionsFolder(data)...) - steps = append(steps, " - name: Setup Scripts\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef)) - steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination)) + steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...) } // Find custom jobs that depend on pre_activation - these are handled by the activation job diff --git a/pkg/workflow/compiler_custom_actions_test.go b/pkg/workflow/compiler_custom_actions_test.go index 8c48b1cf2a0..592c9a0c17d 100644 --- a/pkg/workflow/compiler_custom_actions_test.go +++ b/pkg/workflow/compiler_custom_actions_test.go @@ -14,9 +14,9 @@ func TestActionModeValidation(t *testing.T) { mode ActionMode valid bool }{ - // Removed ActionModeInline as it no longer exists {ActionModeDev, true}, {ActionModeRelease, true}, + {ActionModeScript, true}, {ActionMode("invalid"), false}, {ActionMode(""), false}, } @@ -36,9 +36,9 @@ func TestActionModeString(t *testing.T) { mode ActionMode want string }{ - // Removed ActionModeInline as it no longer exists {ActionModeDev, "dev"}, {ActionModeRelease, "release"}, + {ActionModeScript, "script"}, } for _, tt := range tests { @@ -71,6 +71,31 @@ func TestCompilerSetActionMode(t *testing.T) { if compiler.GetActionMode() != ActionModeDev { t.Errorf("Expected action mode dev, got %s", compiler.GetActionMode()) } + + compiler.SetActionMode(ActionModeScript) + if compiler.GetActionMode() != ActionModeScript { + t.Errorf("Expected action mode script, got %s", compiler.GetActionMode()) + } +} + +// TestActionModeIsScript tests the IsScript() method +func TestActionModeIsScript(t *testing.T) { + tests := []struct { + mode ActionMode + isScript bool + }{ + {ActionModeDev, false}, + {ActionModeRelease, false}, + {ActionModeScript, true}, + } + + for _, tt := range tests { + t.Run(string(tt.mode), func(t *testing.T) { + if got := tt.mode.IsScript(); got != tt.isScript { + t.Errorf("ActionMode(%q).IsScript() = %v, want %v", tt.mode, got, tt.isScript) + } + }) + } } // TestScriptRegistryWithAction tests registering scripts with action paths @@ -316,3 +341,76 @@ Test fallback to inline mode. t.Error("Expected fallback to 'actions/github-script@' when action path not found") } } + +// TestScriptActionModeCompilation tests workflow compilation with script mode +func TestScriptActionModeCompilation(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create a test workflow file with action-mode: script feature flag + workflowContent := `--- +name: Test Script Mode +on: workflow_dispatch +features: + action-mode: "script" +permissions: + contents: read +--- + +Test workflow with script mode. +` + + workflowPath := tempDir + "/test-workflow.md" + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write test workflow: %v", err) + } + + // Compile with script mode (will be overridden by feature flag) + compiler := NewCompiler(false, "", "1.0.0") + compiler.SetNoEmit(false) + + if err := compiler.CompileWorkflow(workflowPath); err != nil { + t.Fatalf("Compilation failed: %v", err) + } + + // Read the generated lock file + lockPath := stringutil.MarkdownToLockFile(workflowPath) + lockContent, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockStr := string(lockContent) + + // Verify script mode behavior: + // 1. Checkout should use repository: githubnext/gh-aw + if !strings.Contains(lockStr, "repository: githubnext/gh-aw") { + t.Error("Expected 'repository: githubnext/gh-aw' in checkout step for script mode") + } + + // 2. Checkout should target path: /tmp/gh-aw/actions-source + if !strings.Contains(lockStr, "path: /tmp/gh-aw/actions-source") { + t.Error("Expected 'path: /tmp/gh-aw/actions-source' in checkout step for script mode") + } + + // 3. Checkout should use shallow clone (depth: 1) + if !strings.Contains(lockStr, "depth: 1") { + t.Error("Expected 'depth: 1' in checkout step for script mode (shallow checkout)") + } + + // 4. Setup step should run bash script instead of using "uses:" + if !strings.Contains(lockStr, "bash /tmp/gh-aw/actions-source/actions/setup/setup.sh") { + t.Error("Expected setup script to run bash directly in script mode") + } + + // 5. Setup step should have INPUT_DESTINATION environment variable + if !strings.Contains(lockStr, "INPUT_DESTINATION: /opt/gh-aw/actions") { + t.Error("Expected INPUT_DESTINATION environment variable in setup step for script mode") + } + + // 6. Should not use "uses:" for setup action in script mode + setupActionPattern := "uses: ./actions/setup" + if strings.Contains(lockStr, setupActionPattern) { + t.Error("Expected script mode to NOT use 'uses: ./actions/setup' but instead run bash script directly") + } +} diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index c06c13ce799..9559e9b1c41 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -41,14 +41,11 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa // Add setup action to copy JavaScript files setupActionRef := c.resolveActionReference("./actions/setup", data) - if setupActionRef != "" { + if setupActionRef != "" || c.actionMode.IsScript() { // For dev mode (local action path), checkout the actions folder first steps = append(steps, c.generateCheckoutActionsFolder(data)...) - steps = append(steps, " - name: Setup Scripts\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef)) - steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination)) + steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...) } // Add artifact download steps after setup diff --git a/pkg/workflow/compiler_yaml_helpers.go b/pkg/workflow/compiler_yaml_helpers.go index 7fd9b2b7d28..a1361b64212 100644 --- a/pkg/workflow/compiler_yaml_helpers.go +++ b/pkg/workflow/compiler_yaml_helpers.go @@ -101,7 +101,7 @@ func generatePlaceholderSubstitutionStep(yaml *strings.Builder, expressionMappin // // Returns a slice of strings that can be appended to a steps array, where each // string represents a line of YAML for the checkout step. Returns nil if: -// - Not in dev mode +// - Not in dev or script mode // - action-tag feature is specified (uses remote actions instead) func (c *Compiler) generateCheckoutActionsFolder(data *WorkflowData) []string { // Check if action-tag is specified - if so, we're using remote actions @@ -114,19 +114,35 @@ func (c *Compiler) generateCheckoutActionsFolder(data *WorkflowData) []string { } } - // Only generate checkout in dev mode (local actions) - if !c.actionMode.IsDev() { - return nil + // Script mode: checkout .github folder from githubnext/gh-aw to /tmp/gh-aw/actions-source/ + if c.actionMode.IsScript() { + return []string{ + " - name: Checkout actions folder\n", + fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")), + " with:\n", + " repository: githubnext/gh-aw\n", + " sparse-checkout: |\n", + " actions\n", + " path: /tmp/gh-aw/actions-source\n", + " depth: 1\n", + " persist-credentials: false\n", + } } - return []string{ - " - name: Checkout actions folder\n", - fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")), - " with:\n", - " sparse-checkout: |\n", - " actions\n", - " persist-credentials: false\n", + // Dev mode: checkout local actions folder + if c.actionMode.IsDev() { + return []string{ + " - name: Checkout actions folder\n", + fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")), + " with:\n", + " sparse-checkout: |\n", + " actions\n", + " persist-credentials: false\n", + } } + + // Release mode or other modes: no checkout needed + return nil } // generateGitHubScriptWithRequire generates a github-script step that loads a module using require(). @@ -147,3 +163,33 @@ func generateGitHubScriptWithRequire(scriptPath string) string { return script.String() } + +// generateSetupStep generates the setup step based on the action mode. +// In script mode, it runs the setup.sh script directly from the checked-out source. +// In other modes (dev/release), it uses the setup action. +// +// Parameters: +// - setupActionRef: The action reference for setup action (e.g., "./actions/setup" or "githubnext/gh-aw/actions/setup@sha") +// - destination: The destination path where files should be copied (e.g., SetupActionDestination) +// +// Returns a slice of strings representing the YAML lines for the setup step. +func (c *Compiler) generateSetupStep(setupActionRef string, destination string) []string { + // Script mode: run the setup.sh script directly + if c.actionMode.IsScript() { + return []string{ + " - name: Setup Scripts\n", + " run: |\n", + " bash /tmp/gh-aw/actions-source/actions/setup/setup.sh\n", + " env:\n", + fmt.Sprintf(" INPUT_DESTINATION: %s\n", destination), + } + } + + // Dev/Release mode: use the setup action + return []string{ + " - name: Setup Scripts\n", + fmt.Sprintf(" uses: %s\n", setupActionRef), + " with:\n", + fmt.Sprintf(" destination: %s\n", destination), + } +} diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go index f8b45ecd9d2..8fa5fca4314 100644 --- a/pkg/workflow/notify_comment.go +++ b/pkg/workflow/notify_comment.go @@ -47,14 +47,11 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa // Add setup step to copy scripts setupActionRef := c.resolveActionReference("./actions/setup", data) - if setupActionRef != "" { + if setupActionRef != "" || c.actionMode.IsScript() { // For dev mode (local action path), checkout the actions folder first steps = append(steps, c.generateCheckoutActionsFolder(data)...) - steps = append(steps, " - name: Setup Scripts\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef)) - steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination)) + steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...) } // Add GitHub App token minting step if app is configured diff --git a/pkg/workflow/publish_assets.go b/pkg/workflow/publish_assets.go index 62d7e376bcc..637e7f00118 100644 --- a/pkg/workflow/publish_assets.go +++ b/pkg/workflow/publish_assets.go @@ -93,14 +93,11 @@ func (c *Compiler) buildUploadAssetsJob(data *WorkflowData, mainJobName string, // Add setup step to copy scripts setupActionRef := c.resolveActionReference("./actions/setup", data) - if setupActionRef != "" { + if setupActionRef != "" || c.actionMode.IsScript() { // For dev mode (local action path), checkout the actions folder first preSteps = append(preSteps, c.generateCheckoutActionsFolder(data)...) - preSteps = append(preSteps, " - name: Setup Scripts\n") - preSteps = append(preSteps, fmt.Sprintf(" uses: %s\n", setupActionRef)) - preSteps = append(preSteps, " with:\n") - preSteps = append(preSteps, fmt.Sprintf(" destination: %s\n", SetupActionDestination)) + preSteps = append(preSteps, c.generateSetupStep(setupActionRef, SetupActionDestination)...) } // Step 1: Checkout repository diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index 2e7efa4edb3..2c3fb8b686a 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -553,14 +553,11 @@ func (c *Compiler) buildPushRepoMemoryJob(data *WorkflowData, threatDetectionEna // Add setup step to copy scripts setupActionRef := c.resolveActionReference("./actions/setup", data) - if setupActionRef != "" { + if setupActionRef != "" || c.actionMode.IsScript() { // For dev mode (local action path), checkout the actions folder first steps = append(steps, c.generateCheckoutActionsFolder(data)...) - steps = append(steps, " - name: Setup Scripts\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef)) - steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination)) + steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...) } // Add checkout step to configure git (without checking out files) diff --git a/pkg/workflow/threat_detection.go b/pkg/workflow/threat_detection.go index 53b207025d7..04d62fa96fa 100644 --- a/pkg/workflow/threat_detection.go +++ b/pkg/workflow/threat_detection.go @@ -151,14 +151,11 @@ func (c *Compiler) buildThreatDetectionSteps(data *WorkflowData, mainJobName str // Add setup action steps at the beginning of the job setupActionRef := c.resolveActionReference("./actions/setup", data) - if setupActionRef != "" { + if setupActionRef != "" || c.actionMode.IsScript() { // For dev mode (local action path), checkout the actions folder first steps = append(steps, c.generateCheckoutActionsFolder(data)...) - steps = append(steps, " - name: Setup Scripts\n") - steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef)) - steps = append(steps, " with:\n") - steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination)) + steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...) } // Step 1: Download agent artifacts