Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
30 changes: 25 additions & 5 deletions pkg/workflow/maintenance_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ var maintenanceLog = logger.New("workflow:maintenance_workflow")
// 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 {
// When resolver is non-nil, attempts to resolve the setup-cli action to a SHA-pinned reference.
func generateInstallCLISteps(actionMode ActionMode, version string, actionTag string, resolver ActionSHAResolver) string {
if actionMode == ActionModeDev {
return ` - name: Setup Go
uses: ` + GetActionPin("actions/setup-go") + `
Expand All @@ -37,23 +38,42 @@ func generateInstallCLISteps(actionMode ActionMode, version string, actionTag st

// Action mode: use setup-cli action from external gh-aw-actions repository
if actionMode == ActionModeAction {
actionRepo := GitHubActionsOrgRepo + "/setup-cli"
ref := resolveActionRef(actionRepo, cliTag, resolver)
return ` - name: Install gh-aw
uses: github/gh-aw-actions/setup-cli@` + cliTag + `
uses: ` + ref + `
with:
version: ` + cliTag + `

`
}

// Release mode: use setup-cli action (consistent with copilot-setup-steps.yml)
actionRepo := GitHubOrgRepo + "/actions/setup-cli"
ref := resolveActionRef(actionRepo, cliTag, resolver)
return ` - name: Install gh-aw
uses: github/gh-aw/actions/setup-cli@` + cliTag + `
uses: ` + ref + `
with:
version: ` + cliTag + `

`
}

// resolveActionRef attempts to resolve an action repo@tag to a SHA-pinned reference
// using the provided resolver. If the resolver is nil or resolution fails, it returns
// the tag-based reference (repo@tag).
func resolveActionRef(actionRepo, tag string, resolver ActionSHAResolver) string {
if resolver != nil && tag != "" && tag != "dev" {
sha, err := resolver.ResolveSHA(actionRepo, tag)
if err != nil {
maintenanceLog.Printf("Failed to resolve SHA for %s@%s: %v, falling back to tag reference", actionRepo, tag, err)
} else if sha != "" {
return formatActionReference(actionRepo, sha, tag)
}
}
return actionRepo + "@" + tag
}

// getCLICmdPrefix returns the CLI command prefix based on action mode.
// In dev mode: "./gh-aw" (local binary built from source)
// In release mode: "gh aw" (installed via gh extension)
Expand Down Expand Up @@ -312,7 +332,7 @@ jobs:

`)

yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag))
yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver))
yaml.WriteString(` - name: Run operation
uses: ` + GetActionPin("actions/github-script") + `
env:
Expand Down Expand Up @@ -399,7 +419,7 @@ jobs:

`)

yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag))
yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver))
yaml.WriteString(` - name: Compile workflows
run: |
` + getCLICmdPrefix(actionMode) + ` compile --validate --verbose
Expand Down
95 changes: 92 additions & 3 deletions pkg/workflow/maintenance_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) {

func TestGenerateInstallCLISteps(t *testing.T) {
t.Run("dev mode generates Setup Go and Build gh-aw steps", func(t *testing.T) {
result := generateInstallCLISteps(ActionModeDev, "v1.0.0", "")
result := generateInstallCLISteps(ActionModeDev, "v1.0.0", "", nil)
if !strings.Contains(result, "Setup Go") {
t.Errorf("Dev mode should include Setup Go step, got:\n%s", result)
}
Expand All @@ -438,7 +438,7 @@ func TestGenerateInstallCLISteps(t *testing.T) {
})

t.Run("release mode generates setup-cli action step", func(t *testing.T) {
result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "")
result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "", nil)
if !strings.Contains(result, "github/gh-aw/actions/setup-cli@v1.0.0") {
t.Errorf("Release mode should use setup-cli action with version, got:\n%s", result)
}
Expand All @@ -451,11 +451,52 @@ func TestGenerateInstallCLISteps(t *testing.T) {
})

t.Run("release mode uses actionTag over version", func(t *testing.T) {
result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "v2.0.0")
result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "v2.0.0", nil)
if !strings.Contains(result, "setup-cli@v2.0.0") {
t.Errorf("Release mode should use actionTag v2.0.0, got:\n%s", result)
}
})

t.Run("release mode with resolver uses SHA-pinned setup-cli reference", func(t *testing.T) {
tmpDir := t.TempDir()
cache := NewActionCache(tmpDir)
cache.Set("github/gh-aw/actions/setup-cli", "v1.0.0", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
resolver := NewActionResolver(cache)

result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "", resolver)
expectedRef := "github/gh-aw/actions/setup-cli@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1.0.0"
if !strings.Contains(result, expectedRef) {
t.Errorf("Release mode with resolver should use SHA-pinned setup-cli reference %q, got:\n%s", expectedRef, result)
}
// Must not contain the bare mutable tag
if strings.Contains(result, "setup-cli@v1.0.0") {
t.Errorf("Release mode with resolver must not use mutable tag setup-cli@v1.0.0, got:\n%s", result)
}
})

t.Run("action mode with resolver uses SHA-pinned setup-cli reference", func(t *testing.T) {
tmpDir := t.TempDir()
cache := NewActionCache(tmpDir)
cache.Set("github/gh-aw-actions/setup-cli", "v1.0.0", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
resolver := NewActionResolver(cache)

result := generateInstallCLISteps(ActionModeAction, "v1.0.0", "", resolver)
expectedRef := "github/gh-aw-actions/setup-cli@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v1.0.0"
if !strings.Contains(result, expectedRef) {
t.Errorf("Action mode with resolver should use SHA-pinned setup-cli reference %q, got:\n%s", expectedRef, result)
}
// Must not contain the bare mutable tag
if strings.Contains(result, "setup-cli@v1.0.0") {
t.Errorf("Action mode with resolver must not use mutable tag setup-cli@v1.0.0, got:\n%s", result)
}
})

t.Run("release mode without resolver falls back to tag reference", func(t *testing.T) {
result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "", nil)
if !strings.Contains(result, "github/gh-aw/actions/setup-cli@v1.0.0") {
t.Errorf("Release mode without resolver should fall back to tag reference, got:\n%s", result)
}
})
}

func TestGetCLICmdPrefix(t *testing.T) {
Expand Down Expand Up @@ -541,3 +582,51 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) {
}
})
}

func TestGenerateMaintenanceWorkflow_SetupCLISHAPinning(t *testing.T) {
setupCLISHA := "cccccccccccccccccccccccccccccccccccccccc"

workflowDataListWithResolver := func(resolver *ActionResolver) []*WorkflowData {
return []*WorkflowData{
{
Name: "test-workflow",
ActionResolver: resolver,
ActionPinWarnings: make(map[string]bool),
SafeOutputs: &SafeOutputsConfig{
CreateIssues: &CreateIssuesConfig{
Expires: 48,
},
},
},
}
}

t.Run("release mode with resolver SHA-pins setup-cli in run_operation", func(t *testing.T) {
tmpDir := t.TempDir()
cache := NewActionCache(tmpDir)
cache.Set("github/gh-aw/actions/setup-cli", "v1.0.0", setupCLISHA)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

TestGenerateMaintenanceWorkflow_SetupCLISHAPinning constructs a real ActionResolver but only seeds the cache for setup-cli. Since GenerateMaintenanceWorkflow also calls ResolveSetupActionReference(..., resolver), this test can trigger a cache miss and attempt a real gh api call for github/gh-aw/actions/setup@v1.0.0, making the unit test non-hermetic/flaky in offline environments. Seed the cache for the setup action too, or use a lightweight fake ActionSHAResolver implementation that only resolves the refs under test and returns an error otherwise (without shelling out).

Suggested change
cache.Set("github/gh-aw/actions/setup-cli", "v1.0.0", setupCLISHA)
cache.Set("github/gh-aw/actions/setup-cli", "v1.0.0", setupCLISHA)
cache.Set("github/gh-aw/actions/setup", "v1.0.0", "dddddddddddddddddddddddddddddddddddddddd")

Copilot uses AI. Check for mistakes.
// Also seed the setup action to keep the test hermetic (GenerateMaintenanceWorkflow
// calls ResolveSetupActionReference with the same resolver, which would otherwise
// attempt a real gh api call on a cache miss).
cache.Set("github/gh-aw/actions/setup", "v1.0.0", "dddddddddddddddddddddddddddddddddddddddd")
resolver := NewActionResolver(cache)

err := GenerateMaintenanceWorkflow(workflowDataListWithResolver(resolver), tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml"))
if err != nil {
t.Fatalf("Expected maintenance workflow to be generated: %v", err)
}
yaml := string(content)
expectedRef := "github/gh-aw/actions/setup-cli@" + setupCLISHA + " # v1.0.0"
if !strings.Contains(yaml, expectedRef) {
t.Errorf("Expected SHA-pinned setup-cli reference %q in generated workflow, got:\n%s", expectedRef, yaml)
}
// Bare tag must not appear
if strings.Contains(yaml, "setup-cli@v1.0.0") {
t.Errorf("Generated workflow must not use mutable tag setup-cli@v1.0.0; got:\n%s", yaml)
}
})
}
Loading