Skip to content

Commit 1c32af7

Browse files
Copilotpelikhan
andauthored
Add action-mode: script feature flag for bash script execution (#9763)
* Initial plan * Initial analysis complete - planning action-mode: script feature Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Implement action-mode: script feature flag - Add ActionModeScript constant and validation - Update generateCheckoutActionsFolder for script mode checkout - Create generateSetupStep helper for script vs action execution - Add feature flag override support in compiler - Update all setup step generation sites - Add tests for script mode validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add integration test for script mode and update gitignore - Add TestScriptActionModeCompilation integration test - Verify script mode checkout behavior - Verify script execution instead of action usage - Add test files to gitignore Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Complete action-mode: script feature implementation All tests passing for action mode script feature: - ActionMode validation tests ✅ - Script mode integration test ✅ - Manual compilation and verification ✅ - Code formatted and validated ✅ Feature successfully implements script mode that: - Checks out githubnext/gh-aw actions to /tmp/gh-aw/actions-source/ - Runs setup.sh script directly with INPUT_DESTINATION env var - Works via frontmatter feature flag or CLI --action-mode=script - Fully tested and documented Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add action-mode: script to dev.md workflow - Enable script mode via features.action-mode flag - Workflow uses script mode to checkout and run setup.sh - Already configured for staged mode safe outputs - Posts poems to issues as comments (preview mode) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Add shallow checkout (depth: 1) to script mode - Add depth: 1 to script mode checkout for faster cloning - Keep persist-credentials: false (already set) - Update test to verify shallow checkout parameter - Recompile dev.md workflow with shallow checkout Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Revert dev.md to use dev mode with local actions/setup - Remove action-mode: script feature flag from dev.md - Workflow now uses dev mode (default) with local actions/setup path - Compiled lock file uses 'uses: ./actions/setup' instead of bash script - Checkout uses local repository with sparse-checkout for actions folder - Tests pass successfully Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Revert wording change: use "folder" for both dev and script modes - Changed script mode checkout step name from "Checkout actions source" back to "Checkout actions folder" - Both dev and script modes now use consistent "folder" naming - No functional changes, only step name consistency - Tests pass successfully Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: Peli de Halleux <pelikhan@users.noreply.github.com>
1 parent 318a6d4 commit 1c32af7

13 files changed

Lines changed: 209 additions & 59 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,5 @@ test-inline-*.lock.yml
165165
conclusion/
166166
detection/
167167

168+
test-script-mode.md
169+
test-script-mode.lock.yml

pkg/cli/compile_compiler_setup.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ func validateActionModeConfig(actionMode string) error {
142142

143143
mode := workflow.ActionMode(actionMode)
144144
if !mode.IsValid() {
145-
return fmt.Errorf("invalid action mode '%s'. Must be 'inline', 'dev', or 'release'", actionMode)
145+
return fmt.Errorf("invalid action mode '%s'. Must be 'dev', 'release', or 'script'", actionMode)
146146
}
147147

148148
return nil

pkg/workflow/action_mode.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ const (
1818

1919
// ActionModeRelease references custom actions using SHA-pinned remote paths (release mode)
2020
ActionModeRelease ActionMode = "release"
21+
22+
// ActionModeScript runs setup.sh script from checked-out .github folder instead of using action steps
23+
ActionModeScript ActionMode = "script"
2124
)
2225

2326
// String returns the string representation of the action mode
@@ -27,7 +30,7 @@ func (m ActionMode) String() string {
2730

2831
// IsValid checks if the action mode is valid
2932
func (m ActionMode) IsValid() bool {
30-
return m == ActionModeDev || m == ActionModeRelease
33+
return m == ActionModeDev || m == ActionModeRelease || m == ActionModeScript
3134
}
3235

3336
// IsDev returns true if the action mode is development mode
@@ -40,6 +43,11 @@ func (m ActionMode) IsRelease() bool {
4043
return m == ActionModeRelease
4144
}
4245

46+
// IsScript returns true if the action mode is script mode
47+
func (m ActionMode) IsScript() bool {
48+
return m == ActionModeScript
49+
}
50+
4351
// UsesExternalActions returns true (always true since inline mode was removed)
4452
func (m ActionMode) UsesExternalActions() bool {
4553
return true

pkg/workflow/cache.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -632,14 +632,11 @@ func (c *Compiler) buildUpdateCacheMemoryJob(data *WorkflowData, threatDetection
632632
// Add setup step to copy scripts at the beginning
633633
var setupSteps []string
634634
setupActionRef := c.resolveActionReference("./actions/setup", data)
635-
if setupActionRef != "" {
635+
if setupActionRef != "" || c.actionMode.IsScript() {
636636
// For dev mode (local action path), checkout the actions folder first
637637
setupSteps = append(setupSteps, c.generateCheckoutActionsFolder(data)...)
638638

639-
setupSteps = append(setupSteps, " - name: Setup Scripts\n")
640-
setupSteps = append(setupSteps, fmt.Sprintf(" uses: %s\n", setupActionRef))
641-
setupSteps = append(setupSteps, " with:\n")
642-
setupSteps = append(setupSteps, fmt.Sprintf(" destination: %s\n", SetupActionDestination))
639+
setupSteps = append(setupSteps, c.generateSetupStep(setupActionRef, SetupActionDestination)...)
643640
}
644641

645642
// Prepend setup steps to all cache steps

pkg/workflow/compiler.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,29 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath
134134
return errors.New(formattedErr)
135135
}
136136

137+
// Check for action-mode feature flag override
138+
if workflowData.Features != nil {
139+
if actionModeVal, exists := workflowData.Features["action-mode"]; exists {
140+
if actionModeStr, ok := actionModeVal.(string); ok && actionModeStr != "" {
141+
mode := ActionMode(actionModeStr)
142+
if !mode.IsValid() {
143+
formattedErr := console.FormatError(console.CompilerError{
144+
Position: console.ErrorPosition{
145+
File: markdownPath,
146+
Line: 1,
147+
Column: 1,
148+
},
149+
Type: "error",
150+
Message: fmt.Sprintf("invalid action-mode feature flag '%s'. Must be 'dev', 'release', or 'script'", actionModeStr),
151+
})
152+
return errors.New(formattedErr)
153+
}
154+
log.Printf("Overriding action mode from feature flag: %s", mode)
155+
c.SetActionMode(mode)
156+
}
157+
}
158+
}
159+
137160
// Validate dangerous permissions
138161
log.Printf("Validating dangerous permissions")
139162
if err := validateDangerousPermissions(workflowData); err != nil {

pkg/workflow/compiler_activation_jobs.go

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,9 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec
3333
// For dev mode (local action path), checkout the actions folder first
3434
// This requires contents: read permission
3535
steps = append(steps, c.generateCheckoutActionsFolder(data)...)
36-
needsContentsRead := c.actionMode.IsDev() && len(c.generateCheckoutActionsFolder(data)) > 0
36+
needsContentsRead := (c.actionMode.IsDev() || c.actionMode.IsScript()) && len(c.generateCheckoutActionsFolder(data)) > 0
3737

38-
steps = append(steps, " - name: Setup Scripts\n")
39-
steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef))
40-
steps = append(steps, " with:\n")
41-
steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination))
38+
steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...)
4239

4340
// Set permissions if checkout is needed (for local actions in dev mode)
4441
if needsContentsRead {
@@ -340,10 +337,7 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
340337
// For dev mode (local action path), checkout the actions folder first
341338
steps = append(steps, c.generateCheckoutActionsFolder(data)...)
342339

343-
steps = append(steps, " - name: Setup Scripts\n")
344-
steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef))
345-
steps = append(steps, " with:\n")
346-
steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination))
340+
steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...)
347341

348342
// Add timestamp check for lock file vs source file using GitHub API
349343
// No checkout step needed - uses GitHub API to check commit times
@@ -578,14 +572,11 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) (
578572

579573
// Add setup action steps at the beginning of the job
580574
setupActionRef := c.resolveActionReference("./actions/setup", data)
581-
if setupActionRef != "" {
575+
if setupActionRef != "" || c.actionMode.IsScript() {
582576
// For dev mode (local action path), checkout the actions folder first
583577
steps = append(steps, c.generateCheckoutActionsFolder(data)...)
584578

585-
steps = append(steps, " - name: Setup Scripts\n")
586-
steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef))
587-
steps = append(steps, " with:\n")
588-
steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination))
579+
steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...)
589580
}
590581

591582
// Find custom jobs that depend on pre_activation - these are handled by the activation job

pkg/workflow/compiler_custom_actions_test.go

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ func TestActionModeValidation(t *testing.T) {
1414
mode ActionMode
1515
valid bool
1616
}{
17-
// Removed ActionModeInline as it no longer exists
1817
{ActionModeDev, true},
1918
{ActionModeRelease, true},
19+
{ActionModeScript, true},
2020
{ActionMode("invalid"), false},
2121
{ActionMode(""), false},
2222
}
@@ -36,9 +36,9 @@ func TestActionModeString(t *testing.T) {
3636
mode ActionMode
3737
want string
3838
}{
39-
// Removed ActionModeInline as it no longer exists
4039
{ActionModeDev, "dev"},
4140
{ActionModeRelease, "release"},
41+
{ActionModeScript, "script"},
4242
}
4343

4444
for _, tt := range tests {
@@ -71,6 +71,31 @@ func TestCompilerSetActionMode(t *testing.T) {
7171
if compiler.GetActionMode() != ActionModeDev {
7272
t.Errorf("Expected action mode dev, got %s", compiler.GetActionMode())
7373
}
74+
75+
compiler.SetActionMode(ActionModeScript)
76+
if compiler.GetActionMode() != ActionModeScript {
77+
t.Errorf("Expected action mode script, got %s", compiler.GetActionMode())
78+
}
79+
}
80+
81+
// TestActionModeIsScript tests the IsScript() method
82+
func TestActionModeIsScript(t *testing.T) {
83+
tests := []struct {
84+
mode ActionMode
85+
isScript bool
86+
}{
87+
{ActionModeDev, false},
88+
{ActionModeRelease, false},
89+
{ActionModeScript, true},
90+
}
91+
92+
for _, tt := range tests {
93+
t.Run(string(tt.mode), func(t *testing.T) {
94+
if got := tt.mode.IsScript(); got != tt.isScript {
95+
t.Errorf("ActionMode(%q).IsScript() = %v, want %v", tt.mode, got, tt.isScript)
96+
}
97+
})
98+
}
7499
}
75100

76101
// TestScriptRegistryWithAction tests registering scripts with action paths
@@ -316,3 +341,76 @@ Test fallback to inline mode.
316341
t.Error("Expected fallback to 'actions/github-script@' when action path not found")
317342
}
318343
}
344+
345+
// TestScriptActionModeCompilation tests workflow compilation with script mode
346+
func TestScriptActionModeCompilation(t *testing.T) {
347+
// Create a temporary directory for the test
348+
tempDir := t.TempDir()
349+
350+
// Create a test workflow file with action-mode: script feature flag
351+
workflowContent := `---
352+
name: Test Script Mode
353+
on: workflow_dispatch
354+
features:
355+
action-mode: "script"
356+
permissions:
357+
contents: read
358+
---
359+
360+
Test workflow with script mode.
361+
`
362+
363+
workflowPath := tempDir + "/test-workflow.md"
364+
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
365+
t.Fatalf("Failed to write test workflow: %v", err)
366+
}
367+
368+
// Compile with script mode (will be overridden by feature flag)
369+
compiler := NewCompiler(false, "", "1.0.0")
370+
compiler.SetNoEmit(false)
371+
372+
if err := compiler.CompileWorkflow(workflowPath); err != nil {
373+
t.Fatalf("Compilation failed: %v", err)
374+
}
375+
376+
// Read the generated lock file
377+
lockPath := stringutil.MarkdownToLockFile(workflowPath)
378+
lockContent, err := os.ReadFile(lockPath)
379+
if err != nil {
380+
t.Fatalf("Failed to read lock file: %v", err)
381+
}
382+
383+
lockStr := string(lockContent)
384+
385+
// Verify script mode behavior:
386+
// 1. Checkout should use repository: githubnext/gh-aw
387+
if !strings.Contains(lockStr, "repository: githubnext/gh-aw") {
388+
t.Error("Expected 'repository: githubnext/gh-aw' in checkout step for script mode")
389+
}
390+
391+
// 2. Checkout should target path: /tmp/gh-aw/actions-source
392+
if !strings.Contains(lockStr, "path: /tmp/gh-aw/actions-source") {
393+
t.Error("Expected 'path: /tmp/gh-aw/actions-source' in checkout step for script mode")
394+
}
395+
396+
// 3. Checkout should use shallow clone (depth: 1)
397+
if !strings.Contains(lockStr, "depth: 1") {
398+
t.Error("Expected 'depth: 1' in checkout step for script mode (shallow checkout)")
399+
}
400+
401+
// 4. Setup step should run bash script instead of using "uses:"
402+
if !strings.Contains(lockStr, "bash /tmp/gh-aw/actions-source/actions/setup/setup.sh") {
403+
t.Error("Expected setup script to run bash directly in script mode")
404+
}
405+
406+
// 5. Setup step should have INPUT_DESTINATION environment variable
407+
if !strings.Contains(lockStr, "INPUT_DESTINATION: /opt/gh-aw/actions") {
408+
t.Error("Expected INPUT_DESTINATION environment variable in setup step for script mode")
409+
}
410+
411+
// 6. Should not use "uses:" for setup action in script mode
412+
setupActionPattern := "uses: ./actions/setup"
413+
if strings.Contains(lockStr, setupActionPattern) {
414+
t.Error("Expected script mode to NOT use 'uses: ./actions/setup' but instead run bash script directly")
415+
}
416+
}

pkg/workflow/compiler_safe_outputs_job.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,11 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa
4141

4242
// Add setup action to copy JavaScript files
4343
setupActionRef := c.resolveActionReference("./actions/setup", data)
44-
if setupActionRef != "" {
44+
if setupActionRef != "" || c.actionMode.IsScript() {
4545
// For dev mode (local action path), checkout the actions folder first
4646
steps = append(steps, c.generateCheckoutActionsFolder(data)...)
4747

48-
steps = append(steps, " - name: Setup Scripts\n")
49-
steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef))
50-
steps = append(steps, " with:\n")
51-
steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination))
48+
steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...)
5249
}
5350

5451
// Add artifact download steps after setup

pkg/workflow/compiler_yaml_helpers.go

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func generatePlaceholderSubstitutionStep(yaml *strings.Builder, expressionMappin
101101
//
102102
// Returns a slice of strings that can be appended to a steps array, where each
103103
// string represents a line of YAML for the checkout step. Returns nil if:
104-
// - Not in dev mode
104+
// - Not in dev or script mode
105105
// - action-tag feature is specified (uses remote actions instead)
106106
func (c *Compiler) generateCheckoutActionsFolder(data *WorkflowData) []string {
107107
// Check if action-tag is specified - if so, we're using remote actions
@@ -114,19 +114,35 @@ func (c *Compiler) generateCheckoutActionsFolder(data *WorkflowData) []string {
114114
}
115115
}
116116

117-
// Only generate checkout in dev mode (local actions)
118-
if !c.actionMode.IsDev() {
119-
return nil
117+
// Script mode: checkout .github folder from githubnext/gh-aw to /tmp/gh-aw/actions-source/
118+
if c.actionMode.IsScript() {
119+
return []string{
120+
" - name: Checkout actions folder\n",
121+
fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")),
122+
" with:\n",
123+
" repository: githubnext/gh-aw\n",
124+
" sparse-checkout: |\n",
125+
" actions\n",
126+
" path: /tmp/gh-aw/actions-source\n",
127+
" depth: 1\n",
128+
" persist-credentials: false\n",
129+
}
120130
}
121131

122-
return []string{
123-
" - name: Checkout actions folder\n",
124-
fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")),
125-
" with:\n",
126-
" sparse-checkout: |\n",
127-
" actions\n",
128-
" persist-credentials: false\n",
132+
// Dev mode: checkout local actions folder
133+
if c.actionMode.IsDev() {
134+
return []string{
135+
" - name: Checkout actions folder\n",
136+
fmt.Sprintf(" uses: %s\n", GetActionPin("actions/checkout")),
137+
" with:\n",
138+
" sparse-checkout: |\n",
139+
" actions\n",
140+
" persist-credentials: false\n",
141+
}
129142
}
143+
144+
// Release mode or other modes: no checkout needed
145+
return nil
130146
}
131147

132148
// generateGitHubScriptWithRequire generates a github-script step that loads a module using require().
@@ -147,3 +163,33 @@ func generateGitHubScriptWithRequire(scriptPath string) string {
147163

148164
return script.String()
149165
}
166+
167+
// generateSetupStep generates the setup step based on the action mode.
168+
// In script mode, it runs the setup.sh script directly from the checked-out source.
169+
// In other modes (dev/release), it uses the setup action.
170+
//
171+
// Parameters:
172+
// - setupActionRef: The action reference for setup action (e.g., "./actions/setup" or "githubnext/gh-aw/actions/setup@sha")
173+
// - destination: The destination path where files should be copied (e.g., SetupActionDestination)
174+
//
175+
// Returns a slice of strings representing the YAML lines for the setup step.
176+
func (c *Compiler) generateSetupStep(setupActionRef string, destination string) []string {
177+
// Script mode: run the setup.sh script directly
178+
if c.actionMode.IsScript() {
179+
return []string{
180+
" - name: Setup Scripts\n",
181+
" run: |\n",
182+
" bash /tmp/gh-aw/actions-source/actions/setup/setup.sh\n",
183+
" env:\n",
184+
fmt.Sprintf(" INPUT_DESTINATION: %s\n", destination),
185+
}
186+
}
187+
188+
// Dev/Release mode: use the setup action
189+
return []string{
190+
" - name: Setup Scripts\n",
191+
fmt.Sprintf(" uses: %s\n", setupActionRef),
192+
" with:\n",
193+
fmt.Sprintf(" destination: %s\n", destination),
194+
}
195+
}

pkg/workflow/notify_comment.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,11 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa
4747

4848
// Add setup step to copy scripts
4949
setupActionRef := c.resolveActionReference("./actions/setup", data)
50-
if setupActionRef != "" {
50+
if setupActionRef != "" || c.actionMode.IsScript() {
5151
// For dev mode (local action path), checkout the actions folder first
5252
steps = append(steps, c.generateCheckoutActionsFolder(data)...)
5353

54-
steps = append(steps, " - name: Setup Scripts\n")
55-
steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActionRef))
56-
steps = append(steps, " with:\n")
57-
steps = append(steps, fmt.Sprintf(" destination: %s\n", SetupActionDestination))
54+
steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination)...)
5855
}
5956

6057
// Add GitHub App token minting step if app is configured

0 commit comments

Comments
 (0)