Skip to content

Commit ab09cb6

Browse files
authored
Implement dev vs release mode action references with CLI flag control (#5985)
1 parent 44d6d08 commit ab09cb6

10 files changed

Lines changed: 715 additions & 9 deletions

‎.github/workflows/daily-team-status.lock.yml‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cmd/gh-aw/main.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ Examples:
192192
` + constants.CLIExtensionPrefix + ` compile --dependabot --force # Force overwrite existing dependabot.yml`,
193193
RunE: func(cmd *cobra.Command, args []string) error {
194194
engineOverride, _ := cmd.Flags().GetString("engine")
195+
actionMode, _ := cmd.Flags().GetString("action-mode")
195196
validate, _ := cmd.Flags().GetBool("validate")
196197
watch, _ := cmd.Flags().GetBool("watch")
197198
dir, _ := cmd.Flags().GetString("dir")
@@ -222,6 +223,7 @@ Examples:
222223
MarkdownFiles: args,
223224
Verbose: verbose,
224225
EngineOverride: engineOverride,
226+
ActionMode: actionMode,
225227
Validate: validate,
226228
Watch: watch,
227229
WorkflowDir: workflowDir,
@@ -426,6 +428,7 @@ Use "` + constants.CLIExtensionPrefix + ` help all" to show help for all command
426428

427429
// Add AI flag to compile and add commands
428430
compileCmd.Flags().StringP("engine", "e", "", "Override AI engine (claude, codex, copilot, custom)")
431+
compileCmd.Flags().String("action-mode", "", "Action script inlining mode (inline, dev, release). Auto-detected if not specified")
429432
compileCmd.Flags().Bool("validate", false, "Enable GitHub Actions workflow schema validation, container image validation, and action SHA validation")
430433
compileCmd.Flags().BoolP("watch", "w", false, "Watch for changes to workflow files and recompile automatically")
431434
compileCmd.Flags().String("dir", "", "Workflow directory (default: .github/workflows)")

‎pkg/cli/compile_command.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ type CompileConfig struct {
175175
Actionlint bool // Run actionlint linter on generated .lock.yml files
176176
JSONOutput bool // Output validation results as JSON
177177
RefreshStopTime bool // Force regeneration of stop-after times instead of preserving existing ones
178+
ActionMode string // Action script inlining mode: inline, dev, or release
178179
}
179180

180181
// CompilationStats tracks the results of workflow compilation
@@ -308,6 +309,21 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
308309
compileLog.Print("Stop time refresh enabled: will regenerate stop-after times")
309310
}
310311

312+
// Set action mode if specified
313+
if config.ActionMode != "" {
314+
mode := workflow.ActionMode(config.ActionMode)
315+
if !mode.IsValid() {
316+
return nil, fmt.Errorf("invalid action mode '%s'. Must be 'inline', 'dev', or 'release'", config.ActionMode)
317+
}
318+
compiler.SetActionMode(mode)
319+
compileLog.Printf("Action mode set to: %s", mode)
320+
} else {
321+
// Use auto-detection
322+
mode := workflow.DetectActionMode()
323+
compiler.SetActionMode(mode)
324+
compileLog.Printf("Action mode auto-detected: %s", mode)
325+
}
326+
311327
if watch {
312328
// Watch mode: watch for file changes and recompile automatically
313329
// For watch mode, we only support a single file for now

‎pkg/workflow/action_mode.go‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package workflow
22

3+
import (
4+
"os"
5+
"strings"
6+
)
7+
38
// ActionMode defines how JavaScript is embedded in workflow steps
49
type ActionMode string
510

@@ -9,6 +14,9 @@ const (
914

1015
// ActionModeDev references custom actions using local paths (development mode)
1116
ActionModeDev ActionMode = "dev"
17+
18+
// ActionModeRelease references custom actions using SHA-pinned remote paths (release mode)
19+
ActionModeRelease ActionMode = "release"
1220
)
1321

1422
// String returns the string representation of the action mode
@@ -18,5 +26,46 @@ func (m ActionMode) String() string {
1826

1927
// IsValid checks if the action mode is valid
2028
func (m ActionMode) IsValid() bool {
21-
return m == ActionModeInline || m == ActionModeDev
29+
return m == ActionModeInline || m == ActionModeDev || m == ActionModeRelease
30+
}
31+
32+
// DetectActionMode determines the appropriate action mode based on environment
33+
// Returns ActionModeRelease if running from main branch or release tag,
34+
// ActionModeDev for PR/local development, or ActionModeInline as fallback.
35+
// Can be overridden with GH_AW_ACTION_MODE environment variable.
36+
func DetectActionMode() ActionMode {
37+
// Check for explicit override via environment variable
38+
if envMode := os.Getenv("GH_AW_ACTION_MODE"); envMode != "" {
39+
mode := ActionMode(envMode)
40+
if mode.IsValid() {
41+
return mode
42+
}
43+
}
44+
45+
// Check GitHub Actions context
46+
githubRef := os.Getenv("GITHUB_REF")
47+
githubEventName := os.Getenv("GITHUB_EVENT_NAME")
48+
49+
// Release mode conditions:
50+
// 1. Running on a release branch (refs/heads/release*)
51+
// 2. Running on a release tag (refs/tags/*)
52+
// 3. Running on a release event
53+
if strings.HasPrefix(githubRef, "refs/heads/release") ||
54+
strings.HasPrefix(githubRef, "refs/tags/") ||
55+
githubEventName == "release" {
56+
return ActionModeRelease
57+
}
58+
59+
// Dev mode conditions:
60+
// 1. Running on a PR (refs/pull/*)
61+
// 2. Running locally (no GITHUB_REF)
62+
// 3. Running on any other branch (including main)
63+
if strings.HasPrefix(githubRef, "refs/pull/") ||
64+
githubRef == "" ||
65+
strings.HasPrefix(githubRef, "refs/heads/") {
66+
return ActionModeDev
67+
}
68+
69+
// Fallback to inline mode for backwards compatibility
70+
return ActionModeInline
2271
}

‎pkg/workflow/action_reference.go‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package workflow
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/githubnext/gh-aw/pkg/logger"
8+
)
9+
10+
var actionRefLog = logger.New("workflow:action_reference")
11+
12+
const (
13+
// GitHubOrgRepo is the organization and repository name for custom action references
14+
GitHubOrgRepo = "githubnext/gh-aw"
15+
)
16+
17+
// resolveActionReference converts a local action path to the appropriate reference
18+
// based on the current action mode (dev vs release).
19+
// For dev mode: returns the local path as-is (e.g., "./actions/create-issue")
20+
// For release mode: converts to SHA-pinned remote reference (e.g., "githubnext/gh-aw/actions/create-issue@SHA # tag")
21+
// For inline mode: returns empty string to fallback to inline mode
22+
func (c *Compiler) resolveActionReference(localActionPath string, data *WorkflowData) string {
23+
switch c.actionMode {
24+
case ActionModeDev:
25+
// Return local path as-is for development
26+
actionRefLog.Printf("Dev mode: using local action path: %s", localActionPath)
27+
return localActionPath
28+
29+
case ActionModeRelease:
30+
// Convert to SHA-pinned remote reference for release
31+
remoteRef := convertToRemoteActionRef(localActionPath)
32+
if remoteRef == "" {
33+
actionRefLog.Printf("WARNING: Could not resolve remote reference for %s", localActionPath)
34+
return ""
35+
}
36+
actionRefLog.Printf("Release mode: using remote action reference: %s", remoteRef)
37+
return remoteRef
38+
39+
case ActionModeInline:
40+
// Return empty to fallback to inline mode
41+
actionRefLog.Print("Inline mode: returning empty to use inline JavaScript")
42+
return ""
43+
44+
default:
45+
actionRefLog.Printf("WARNING: Unknown action mode %s, returning empty", c.actionMode)
46+
return ""
47+
}
48+
}
49+
50+
// convertToRemoteActionRef converts a local action path to a tag-based remote reference
51+
// that will be resolved to a SHA later in the release pipeline using action pins.
52+
// Example: "./actions/create-issue" -> "githubnext/gh-aw/actions/create-issue@v1.0.0"
53+
func convertToRemoteActionRef(localPath string) string {
54+
// Strip the leading "./" if present
55+
actionPath := strings.TrimPrefix(localPath, "./")
56+
57+
// Get the current release tag
58+
tag := GetCurrentGitTag()
59+
if tag == "" {
60+
actionRefLog.Print("WARNING: No git tag available for release mode")
61+
return ""
62+
}
63+
64+
// Construct the remote reference with tag: githubnext/gh-aw/actions/name@tag
65+
// The SHA will be resolved later by action pinning infrastructure
66+
remoteRef := fmt.Sprintf("%s/%s@%s", GitHubOrgRepo, actionPath, tag)
67+
actionRefLog.Printf("Using tag-based reference: %s (SHA will be resolved via action pins)", remoteRef)
68+
69+
return remoteRef
70+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package workflow
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func TestConvertToRemoteActionRef(t *testing.T) {
9+
// Save original environment
10+
origRef := os.Getenv("GITHUB_REF")
11+
defer func() {
12+
if origRef != "" {
13+
os.Setenv("GITHUB_REF", origRef)
14+
} else {
15+
os.Unsetenv("GITHUB_REF")
16+
}
17+
}()
18+
19+
t.Run("local path with ./ prefix and tag", func(t *testing.T) {
20+
os.Setenv("GITHUB_REF", "refs/tags/v1.2.3")
21+
22+
ref := convertToRemoteActionRef("./actions/create-issue")
23+
expected := "githubnext/gh-aw/actions/create-issue@v1.2.3"
24+
if ref != expected {
25+
t.Errorf("Expected %q, got %q", expected, ref)
26+
}
27+
})
28+
29+
t.Run("local path without ./ prefix and tag", func(t *testing.T) {
30+
os.Setenv("GITHUB_REF", "refs/tags/v1.0.0")
31+
32+
ref := convertToRemoteActionRef("actions/create-issue")
33+
expected := "githubnext/gh-aw/actions/create-issue@v1.0.0"
34+
if ref != expected {
35+
t.Errorf("Expected %q, got %q", expected, ref)
36+
}
37+
})
38+
39+
t.Run("nested action path with tag", func(t *testing.T) {
40+
os.Setenv("GITHUB_REF", "refs/tags/v2.0.0")
41+
42+
ref := convertToRemoteActionRef("./actions/nested/action")
43+
expected := "githubnext/gh-aw/actions/nested/action@v2.0.0"
44+
if ref != expected {
45+
t.Errorf("Expected %q, got %q", expected, ref)
46+
}
47+
})
48+
49+
t.Run("no tag returns empty", func(t *testing.T) {
50+
os.Setenv("GITHUB_REF", "refs/heads/main")
51+
52+
ref := convertToRemoteActionRef("./actions/create-issue")
53+
if ref != "" {
54+
t.Errorf("Expected empty string without tag, got %q", ref)
55+
}
56+
})
57+
}
58+
59+
func TestResolveActionReference(t *testing.T) {
60+
// Save original environment
61+
origRef := os.Getenv("GITHUB_REF")
62+
defer func() {
63+
if origRef != "" {
64+
os.Setenv("GITHUB_REF", origRef)
65+
} else {
66+
os.Unsetenv("GITHUB_REF")
67+
}
68+
}()
69+
70+
tests := []struct {
71+
name string
72+
actionMode ActionMode
73+
localPath string
74+
githubRef string
75+
expectedRef string
76+
shouldBeEmpty bool
77+
description string
78+
}{
79+
{
80+
name: "dev mode",
81+
actionMode: ActionModeDev,
82+
localPath: "./actions/create-issue",
83+
expectedRef: "./actions/create-issue",
84+
description: "Dev mode should return local path",
85+
},
86+
{
87+
name: "release mode with tag",
88+
actionMode: ActionModeRelease,
89+
localPath: "./actions/create-issue",
90+
githubRef: "refs/tags/v1.0.0",
91+
expectedRef: "githubnext/gh-aw/actions/create-issue@v1.0.0",
92+
description: "Release mode should return tag-based reference",
93+
},
94+
{
95+
name: "release mode without tag",
96+
actionMode: ActionModeRelease,
97+
localPath: "./actions/create-issue",
98+
githubRef: "refs/heads/main",
99+
shouldBeEmpty: true,
100+
description: "Release mode without tag should return empty",
101+
},
102+
{
103+
name: "inline mode",
104+
actionMode: ActionModeInline,
105+
localPath: "./actions/create-issue",
106+
shouldBeEmpty: true,
107+
description: "Inline mode should return empty string",
108+
},
109+
}
110+
111+
for _, tt := range tests {
112+
t.Run(tt.name, func(t *testing.T) {
113+
// Set up environment
114+
if tt.githubRef != "" {
115+
os.Setenv("GITHUB_REF", tt.githubRef)
116+
} else {
117+
os.Unsetenv("GITHUB_REF")
118+
}
119+
120+
compiler := NewCompiler(false, "", "1.0.0")
121+
compiler.SetActionMode(tt.actionMode)
122+
123+
data := &WorkflowData{}
124+
ref := compiler.resolveActionReference(tt.localPath, data)
125+
126+
if tt.shouldBeEmpty {
127+
if ref != "" {
128+
t.Errorf("%s: expected empty string, got %q", tt.description, ref)
129+
}
130+
} else {
131+
if ref != tt.expectedRef {
132+
t.Errorf("%s: expected %q, got %q", tt.description, tt.expectedRef, ref)
133+
}
134+
}
135+
})
136+
}
137+
}

0 commit comments

Comments
 (0)