Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion pkg/cli/compile_compiler_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ func validateActionModeConfig(actionMode string) error {

mode := workflow.ActionMode(actionMode)
if !mode.IsValid() {
return fmt.Errorf("invalid action mode '%s'. Must be 'dev', 'release', or 'script'", actionMode)
return fmt.Errorf("invalid action mode '%s'. Must be 'dev', 'release', 'script', or 'action'", actionMode)
}

return nil
Expand Down
34 changes: 34 additions & 0 deletions pkg/cli/copilot_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ func getActionRef(actionMode workflow.ActionMode, version string, resolver workf
}
return "@" + version
}
if actionMode.IsAction() && version != "" && version != "dev" {
return "@" + version
}
return "@main"
}

Expand All @@ -36,6 +39,37 @@ func generateCopilotSetupStepsYAML(actionMode workflow.ActionMode, version strin
// Determine the action reference - use SHA-pinned or version tag in release mode, @main in dev mode
actionRef := getActionRef(actionMode, version, resolver)

if actionMode.IsAction() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@copilot reuse the same template, just update the action being used. See line 73

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in cb0eb57. generateCopilotSetupStepsYAML now uses a single template for both release and action modes — only the action repo path (github/gh-aw/actions/setup-cli vs github/gh-aw-actions/setup-cli) differs. Also fixed renderCopilotSetupUpdateInstructions which was incorrectly falling through to the curl command for action mode.

// Action mode: use setup-cli action from external gh-aw-actions repository
return fmt.Sprintf(`name: "Copilot Setup Steps"

# This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml

jobs:
# The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent
copilot-setup-steps:
runs-on: ubuntu-latest

# Set minimal permissions for setup steps
# Copilot Agent receives its own token with appropriate permissions
permissions:
contents: read

steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli%s
with:
version: %s
`, actionRef, version)
}

if actionMode.IsRelease() {
// Use the actions/setup-cli action in release mode
return fmt.Sprintf(`name: "Copilot Setup Steps"
Expand Down
10 changes: 9 additions & 1 deletion pkg/workflow/action_mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const (

// ActionModeScript runs setup.sh script from checked-out .github folder instead of using action steps
ActionModeScript ActionMode = "script"

// ActionModeAction references custom actions from the github/gh-aw-actions repository using the same release version
ActionModeAction ActionMode = "action"
)

// String returns the string representation of the action mode
Expand All @@ -30,7 +33,7 @@ func (m ActionMode) String() string {

// IsValid checks if the action mode is valid
func (m ActionMode) IsValid() bool {
return m == ActionModeDev || m == ActionModeRelease || m == ActionModeScript
return m == ActionModeDev || m == ActionModeRelease || m == ActionModeScript || m == ActionModeAction
}

// IsDev returns true if the action mode is development mode
Expand All @@ -48,6 +51,11 @@ func (m ActionMode) IsScript() bool {
return m == ActionModeScript
}

// IsAction returns true if the action mode is action mode (uses github/gh-aw-actions repo)
func (m ActionMode) IsAction() bool {
return m == ActionModeAction
}

// UsesExternalActions returns true (always true since inline mode was removed)
func (m ActionMode) UsesExternalActions() bool {
return true
Expand Down
75 changes: 71 additions & 4 deletions pkg/workflow/action_reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,27 @@ var actionRefLog = logger.New("workflow:action_reference")
const (
// GitHubOrgRepo is the organization and repository name for custom action references
GitHubOrgRepo = "github/gh-aw"

// GitHubActionsOrgRepo is the organization and repository name for the external gh-aw-actions repository
GitHubActionsOrgRepo = "github/gh-aw-actions"
)

// ResolveSetupActionReference resolves the actions/setup action reference based on action mode and version.
// This is a standalone helper function that can be used by both Compiler methods and standalone
// workflow generators (like maintenance workflow) that don't have access to WorkflowData.
//
// Parameters:
// - actionMode: The action mode (dev or release)
// - version: The version string to use for release mode
// - actionMode: The action mode (dev, release, or action)
// - version: The version string to use for release/action mode
// - actionTag: Optional override tag/SHA (takes precedence over version when in release mode)
// - resolver: Optional ActionSHAResolver for dynamic SHA resolution (can be nil for standalone use)
//
// Returns:
// - For dev mode: "./actions/setup" (local path)
// - For release mode with resolver: "github/gh-aw/actions/setup@<sha> # <version>" (SHA-pinned)
// - For release mode without resolver: "github/gh-aw/actions/setup@<version>" (tag-based, SHA resolved later)
// - Falls back to local path if version is invalid in release mode
// - For action mode: "github/gh-aw-actions/setup@<version>" (external actions repo, version-tagged)
// - Falls back to local path if version is invalid in release/action mode
func ResolveSetupActionReference(actionMode ActionMode, version string, actionTag string, resolver ActionSHAResolver) string {
localPath := "./actions/setup"

Expand All @@ -38,6 +42,27 @@ func ResolveSetupActionReference(actionMode ActionMode, version string, actionTa
return localPath
}

// Action mode - use external gh-aw-actions repository with version tag (no SHA pinning)
if actionMode == ActionModeAction {
// Use actionTag if provided, otherwise fall back to version
tag := actionTag
if tag == "" {
tag = version
}

// Check if tag is valid for action mode
if tag == "" || tag == "dev" {
actionRefLog.Print("WARNING: No release tag available in binary version (version is 'dev' or empty), falling back to local path")
return localPath
}

// Construct the remote reference: github/gh-aw-actions/setup@tag
actionRepo := GitHubActionsOrgRepo + "/setup"
remoteRef := fmt.Sprintf("%s@%s", actionRepo, tag)
actionRefLog.Printf("Action mode: using external actions repo reference: %s", remoteRef)
return remoteRef
}

// Release mode - convert to remote reference
if actionMode == ActionModeRelease {
actionPath := strings.TrimPrefix(localPath, "./")
Expand Down Expand Up @@ -83,10 +108,11 @@ func ResolveSetupActionReference(actionMode ActionMode, version string, actionTa
}

// resolveActionReference converts a local action path to the appropriate reference
// based on the current action mode (dev vs release).
// based on the current action mode (dev vs release vs action).
// If action-tag is specified in features, it overrides the mode check and enables release mode behavior.
// For dev mode: returns the local path as-is (e.g., "./actions/create-issue")
// For release mode: converts to SHA-pinned remote reference (e.g., "github/gh-aw/actions/create-issue@SHA # tag")
// For action mode: converts to version-tagged reference in external repo (e.g., "github/gh-aw-actions/create-issue@version")
func (c *Compiler) resolveActionReference(localActionPath string, data *WorkflowData) string {
// Check if action-tag is specified in features - if so, override mode and use release behavior
hasActionTag := false
Expand Down Expand Up @@ -114,6 +140,11 @@ func (c *Compiler) resolveActionReference(localActionPath string, data *Workflow
}
}

// Action mode - use external gh-aw-actions repository with version tag (no SHA pinning)
if c.actionMode == ActionModeAction && !hasActionTag {
return c.convertToExternalActionsRef(localActionPath, data)
}

// Use release mode if either actionMode is release OR action-tag is specified
if c.actionMode == ActionModeRelease || hasActionTag {
// Convert to tag-based remote reference for release
Expand Down Expand Up @@ -214,3 +245,39 @@ func (c *Compiler) convertToRemoteActionRef(localPath string, data *WorkflowData

return remoteRef
}

// convertToExternalActionsRef converts a local action path to a version-tagged reference
// in the external github/gh-aw-actions repository.
// Example: "./actions/create-issue" -> "github/gh-aw-actions/create-issue@v1.0.0"
func (c *Compiler) convertToExternalActionsRef(localPath string, data *WorkflowData) string {
// Strip the leading "./" prefix
actionPath := strings.TrimPrefix(localPath, "./")

// Strip the "actions/" prefix to get just the action name
// e.g., "actions/create-issue" -> "create-issue"
actionName := strings.TrimPrefix(actionPath, "actions/")

// Determine tag: use compiler actionTag or version
tag := c.actionTag
if tag == "" {
if data != nil && data.Features != nil {
if actionTagVal, exists := data.Features["action-tag"]; exists {
if actionTagStr, ok := actionTagVal.(string); ok && actionTagStr != "" {
tag = actionTagStr
}
}
}
}
if tag == "" {
tag = c.version
if tag == "" || tag == "dev" {
actionRefLog.Print("WARNING: No release tag available in binary version (version is 'dev' or empty)")
return ""
}
}

// Construct the external actions reference: github/gh-aw-actions/action-name@tag
remoteRef := fmt.Sprintf("%s/%s@%s", GitHubActionsOrgRepo, actionName, tag)
actionRefLog.Printf("Action mode: using external actions repo reference: %s", remoteRef)
return remoteRef
}
116 changes: 116 additions & 0 deletions pkg/workflow/compiler_custom_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func TestActionModeValidation(t *testing.T) {
{ActionModeDev, true},
{ActionModeRelease, true},
{ActionModeScript, true},
{ActionModeAction, true},
{ActionMode("invalid"), false},
{ActionMode(""), false},
}
Expand All @@ -41,6 +42,7 @@ func TestActionModeString(t *testing.T) {
{ActionModeDev, "dev"},
{ActionModeRelease, "release"},
{ActionModeScript, "script"},
{ActionModeAction, "action"},
}

for _, tt := range tests {
Expand Down Expand Up @@ -78,6 +80,11 @@ func TestCompilerSetActionMode(t *testing.T) {
if compiler.GetActionMode() != ActionModeScript {
t.Errorf("Expected action mode script, got %s", compiler.GetActionMode())
}

compiler.SetActionMode(ActionModeAction)
if compiler.GetActionMode() != ActionModeAction {
t.Errorf("Expected action mode action, got %s", compiler.GetActionMode())
}
}

// TestActionModeIsScript tests the IsScript() method
Expand All @@ -89,6 +96,7 @@ func TestActionModeIsScript(t *testing.T) {
{ActionModeDev, false},
{ActionModeRelease, false},
{ActionModeScript, true},
{ActionModeAction, false},
}

for _, tt := range tests {
Expand All @@ -100,6 +108,27 @@ func TestActionModeIsScript(t *testing.T) {
}
}

// TestActionModeIsAction tests the IsAction() method
func TestActionModeIsAction(t *testing.T) {
tests := []struct {
mode ActionMode
isAction bool
}{
{ActionModeDev, false},
{ActionModeRelease, false},
{ActionModeScript, false},
{ActionModeAction, true},
}

for _, tt := range tests {
t.Run(string(tt.mode), func(t *testing.T) {
if got := tt.mode.IsAction(); got != tt.isAction {
t.Errorf("ActionMode(%q).IsAction() = %v, want %v", tt.mode, got, tt.isAction)
}
})
}
}

// TestInlineActionModeCompilation tests workflow compilation with inline mode (default)
func TestInlineActionModeCompilation(t *testing.T) {
// Create a temporary directory for the test
Expand Down Expand Up @@ -331,3 +360,90 @@ func TestCheckoutActionsFolderDevModeAlwaysEmitsCheckout(t *testing.T) {
})
}
}

// TestResolveSetupActionReferenceActionMode tests that action mode resolves to the external gh-aw-actions repo
func TestResolveSetupActionReferenceActionMode(t *testing.T) {
ref := ResolveSetupActionReference(ActionModeAction, "v1.2.3", "", nil)
if ref != "github/gh-aw-actions/setup@v1.2.3" {
t.Errorf("Action mode should resolve to 'github/gh-aw-actions/setup@v1.2.3', got %q", ref)
}
}

// TestResolveSetupActionReferenceActionModeWithTag tests action mode with an explicit action tag
func TestResolveSetupActionReferenceActionModeWithTag(t *testing.T) {
ref := ResolveSetupActionReference(ActionModeAction, "v1.0.0", "v2.0.0", nil)
if ref != "github/gh-aw-actions/setup@v2.0.0" {
t.Errorf("Action mode with tag should resolve to 'github/gh-aw-actions/setup@v2.0.0', got %q", ref)
}
}

// TestResolveSetupActionReferenceActionModeDevVersion tests action mode falls back to local path for dev version
func TestResolveSetupActionReferenceActionModeDevVersion(t *testing.T) {
ref := ResolveSetupActionReference(ActionModeAction, "dev", "", nil)
if ref != "./actions/setup" {
t.Errorf("Action mode with dev version should fall back to './actions/setup', got %q", ref)
}
}

// TestCheckoutActionsFolderActionModeNoCheckout verifies that action mode does not generate a checkout step
func TestCheckoutActionsFolderActionModeNoCheckout(t *testing.T) {
compiler := NewCompilerWithVersion("v1.2.3")
compiler.SetActionMode(ActionModeAction)

lines := compiler.generateCheckoutActionsFolder(nil)
if len(lines) > 0 {
t.Error("Action mode should not generate a checkout step for actions folder")
}
}

// TestActionModeCompilation tests workflow compilation with action mode
func TestActionModeCompilation(t *testing.T) {
tempDir := t.TempDir()

workflowContent := `---
name: Test Action Mode
on: issues
safe-outputs:
create-issue:
max: 1
---

Test workflow with action 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)
}

compiler := NewCompilerWithVersion("v1.2.3")
compiler.SetActionMode(ActionModeAction)
compiler.SetNoEmit(false)

if err := compiler.CompileWorkflow(workflowPath); err != nil {
t.Fatalf("Compilation failed: %v", err)
}

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 it uses the external gh-aw-actions/setup action
if !strings.Contains(lockStr, "github/gh-aw-actions/setup@v1.2.3") {
t.Errorf("Action mode should use 'github/gh-aw-actions/setup@v1.2.3', lock file:\n%s", lockStr)
}

// Verify it does NOT use the internal gh-aw/actions/setup path
if strings.Contains(lockStr, "github/gh-aw/actions/setup@") {
t.Error("Action mode should NOT use 'github/gh-aw/actions/setup@', use external repo instead")
}

// Verify no local checkout step for actions folder
if strings.Contains(lockStr, "Checkout actions folder") {
t.Error("Action mode should NOT include a 'Checkout actions folder' step")
}
}
14 changes: 13 additions & 1 deletion pkg/workflow/maintenance_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ var maintenanceLog = logger.New("workflow:maintenance_workflow")
// generateInstallCLISteps generates YAML steps to install or build the gh-aw CLI.
// In dev mode: builds from source using Setup Go + Build gh-aw (./gh-aw binary available)
// In release mode: installs the released CLI via the setup-cli action (gh aw available)
// In action mode: installs the released CLI via the gh-aw-actions/setup-cli action (gh aw available)
func generateInstallCLISteps(actionMode ActionMode, version string, actionTag string) string {
if actionMode == ActionModeDev {
return ` - name: Setup Go
Expand All @@ -29,11 +30,22 @@ func generateInstallCLISteps(actionMode ActionMode, version string, actionTag st
`
}

// Release mode: use setup-cli action (consistent with copilot-setup-steps.yml)
cliTag := actionTag
if cliTag == "" {
cliTag = version
}

// Action mode: use setup-cli action from external gh-aw-actions repository
if actionMode == ActionModeAction {
return ` - name: Install gh-aw
uses: github/gh-aw-actions/setup-cli@` + cliTag + `
with:
version: ` + cliTag + `

`
}

// Release mode: use setup-cli action (consistent with copilot-setup-steps.yml)
return ` - name: Install gh-aw
uses: github/gh-aw/actions/setup-cli@` + cliTag + `
with:
Expand Down
Loading