diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go index 48a1d81f89c..bceef12a29c 100644 --- a/pkg/workflow/maintenance_workflow.go +++ b/pkg/workflow/maintenance_workflow.go @@ -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") + ` @@ -37,8 +38,10 @@ 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 + ` @@ -46,14 +49,31 @@ func generateInstallCLISteps(actionMode ActionMode, version string, actionTag st } // 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) @@ -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: @@ -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 diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go index f7ddc36398d..3947d0b928c 100644 --- a/pkg/workflow/maintenance_workflow_test.go +++ b/pkg/workflow/maintenance_workflow_test.go @@ -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) } @@ -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) } @@ -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) { @@ -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) + // 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) + } + }) +}