diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index a556be3af36..e54b6dc573c 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -55,7 +55,7 @@ func UpdateActions(allowMajor, verbose, disableReleaseBump bool) error { // Track updates var updatedActions []string - var failedActions []string + var failedActions []actionUpdateFailure var skippedActions []string // Snapshot entries before iteration to avoid mutating the map mid-loop. @@ -82,7 +82,7 @@ func UpdateActions(allowMajor, verbose, disableReleaseBump bool) error { if verbose { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to check %s: %v", entry.Repo, err))) } - failedActions = append(failedActions, entry.Repo) + failedActions = append(failedActions, actionUpdateFailure{name: entry.Repo, err: err.Error()}) continue } @@ -139,8 +139,8 @@ func UpdateActions(allowMajor, verbose, disableReleaseBump bool) error { if len(failedActions) > 0 { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to check %d action(s):", len(failedActions)))) - for _, action := range failedActions { - fmt.Fprintf(os.Stderr, " %s\n", action) + for _, f := range failedActions { + fmt.Fprintf(os.Stderr, " %s: %s\n", f.name, f.err) } fmt.Fprintln(os.Stderr, "") } @@ -182,6 +182,10 @@ func getLatestActionRelease(repo, currentVersion string, allowMajor, verbose boo } return latestRelease, latestSHA, nil } + // Include the gh output in the error for better diagnostics + if trimmed := strings.TrimSpace(outputStr); trimmed != "" { + return "", "", fmt.Errorf("failed to fetch releases: %w: %s", err, trimmed) + } return "", "", fmt.Errorf("failed to fetch releases: %w", err) } diff --git a/pkg/cli/update_types.go b/pkg/cli/update_types.go index eb841a4da45..62b9af2b953 100644 --- a/pkg/cli/update_types.go +++ b/pkg/cli/update_types.go @@ -12,3 +12,9 @@ type updateFailure struct { Name string Error string } + +// actionUpdateFailure represents a failed GitHub Action update check +type actionUpdateFailure struct { + name string + err string +} diff --git a/pkg/cli/update_workflows.go b/pkg/cli/update_workflows.go index e297df67499..c44f7aa52b6 100644 --- a/pkg/cli/update_workflows.go +++ b/pkg/cli/update_workflows.go @@ -324,7 +324,8 @@ func updateWorkflow(wf *workflowWithSource, allowMajor, force, verbose bool, eng updateLog.Printf("Updating workflow: name=%s, source=%s, force=%v, noMerge=%v", wf.Name, wf.SourceSpec, force, noMerge) if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("\nUpdating workflow: "+wf.Name)) + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Updating workflow: "+wf.Name)) fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Source: "+wf.SourceSpec)) } diff --git a/pkg/workflow/github_cli.go b/pkg/workflow/github_cli.go index ec78318a0e5..51c315cc5af 100644 --- a/pkg/workflow/github_cli.go +++ b/pkg/workflow/github_cli.go @@ -4,8 +4,11 @@ package workflow import ( "context" + "errors" + "fmt" "os" "os/exec" + "strings" "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" @@ -73,6 +76,26 @@ func ExecGHContext(ctx context.Context, args ...string) *exec.Cmd { return setupGHCommand(ctx, args...) } +// enrichGHError enriches an error returned from a gh CLI command with the +// stderr output captured in *exec.ExitError. When cmd.Output() (stdout-only +// capture) fails, Go populates ExitError.Stderr with the command's stderr, +// which typically contains the human-readable error message from gh. +// This function appends that message to the error so callers see useful +// diagnostics instead of a bare "exit status 1". +func enrichGHError(err error) error { + if err == nil { + return nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { + stderr := strings.TrimSpace(string(exitErr.Stderr)) + if stderr != "" { + return fmt.Errorf("%w: %s", err, stderr) + } + } + return err +} + // runGHWithSpinnerContext executes a gh CLI command with context support, a spinner, // and returns the output. This is the core implementation for RunGHContext. func runGHWithSpinnerContext(ctx context.Context, spinnerMessage string, combined bool, args ...string) ([]byte, error) { @@ -88,6 +111,7 @@ func runGHWithSpinnerContext(ctx context.Context, spinnerMessage string, combine output, err = cmd.CombinedOutput() } else { output, err = cmd.Output() + err = enrichGHError(err) } spinner.Stop() return output, err @@ -96,7 +120,8 @@ func runGHWithSpinnerContext(ctx context.Context, spinnerMessage string, combine if combined { return cmd.CombinedOutput() } - return cmd.Output() + output, err := cmd.Output() + return output, enrichGHError(err) } // runGHWithSpinner executes a gh CLI command with a spinner and returns the output. @@ -114,6 +139,7 @@ func runGHWithSpinner(spinnerMessage string, combined bool, args ...string) ([]b output, err = cmd.CombinedOutput() } else { output, err = cmd.Output() + err = enrichGHError(err) } spinner.Stop() return output, err @@ -122,7 +148,8 @@ func runGHWithSpinner(spinnerMessage string, combined bool, args ...string) ([]b if combined { return cmd.CombinedOutput() } - return cmd.Output() + output, err := cmd.Output() + return output, enrichGHError(err) } // RunGH executes a gh CLI command with a spinner and returns the stdout output. diff --git a/pkg/workflow/github_cli_test.go b/pkg/workflow/github_cli_test.go index 5d13e632055..9e47015ee98 100644 --- a/pkg/workflow/github_cli_test.go +++ b/pkg/workflow/github_cli_test.go @@ -4,6 +4,7 @@ package workflow import ( "context" + "errors" "os" "os/exec" "slices" @@ -384,3 +385,35 @@ func TestRunGHWithSpinnerHelperExists(t *testing.T) { }) } } + +// TestEnrichGHError tests that enrichGHError appends stderr from *exec.ExitError +func TestEnrichGHError(t *testing.T) { + t.Run("nil error unchanged", func(t *testing.T) { + assert.NoError(t, enrichGHError(nil), "nil error should remain nil") + }) + + t.Run("non-ExitError unchanged", func(t *testing.T) { + err := errors.New("plain error") + assert.Equal(t, err, enrichGHError(err), "non-ExitError should be returned unchanged") + }) + + t.Run("ExitError with no stderr unchanged", func(t *testing.T) { + // Run a command that exits non-zero without producing stderr + cmd := exec.Command("sh", "-c", "exit 1") + _, cmdErr := cmd.Output() + require.Error(t, cmdErr, "command should fail") + enriched := enrichGHError(cmdErr) + // With no stderr, the error should be equivalent to the original + assert.Equal(t, cmdErr.Error(), enriched.Error(), "ExitError with empty stderr should match original error message") + }) + + t.Run("ExitError with stderr gets stderr appended", func(t *testing.T) { + // Run a command that exits non-zero and writes to stderr + cmd := exec.Command("sh", "-c", "echo 'not found' >&2; exit 1") + _, cmdErr := cmd.Output() + require.Error(t, cmdErr, "command should fail") + enriched := enrichGHError(cmdErr) + assert.Contains(t, enriched.Error(), "not found", "enriched error should contain stderr output") + assert.Contains(t, enriched.Error(), "exit status 1", "enriched error should still contain original error") + }) +}