From a2866e4748f5f1a3abc12d209b5ddaa89f6113bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 04:30:52 +0000 Subject: [PATCH 1/7] Initial plan From 573872b79962473f4152871bfcd867d8daa95820 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 04:38:04 +0000 Subject: [PATCH 2/7] refactor: move ensureGitAttributes to compile_post_processing Move ensureGitAttributes() and stageGitAttributesIfChanged() from git.go to compile_post_processing.go. These functions are specific to workflow compilation post-processing, not general git operations. - Moved ensureGitAttributes() to compile_post_processing.go - Moved stageGitAttributesIfChanged() to compile_post_processing.go - Updated logging to use compilePostProcessingLog instead of gitLog - Added os/exec and strings imports to compile_post_processing.go - All tests passing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/compile_post_processing.go | 88 ++++++++++++++++++++++++++++++ pkg/cli/git.go | 84 ---------------------------- 2 files changed, 88 insertions(+), 84 deletions(-) diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go index dd6f53df5c..7e61f6c8ee 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -31,7 +31,9 @@ package cli import ( "fmt" "os" + "os/exec" "path/filepath" + "strings" "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/logger" @@ -145,6 +147,92 @@ func updateGitAttributes(successCount int, actionCache *workflow.ActionCache, ve return nil } +// ensureGitAttributes ensures that .gitattributes contains the entry to mark .lock.yml files as generated +func ensureGitAttributes() error { + compilePostProcessingLog.Print("Ensuring .gitattributes is updated") + gitRoot, err := findGitRoot() + if err != nil { + return err // Not in a git repository, skip + } + + gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") + lockYmlEntry := ".github/workflows/*.lock.yml linguist-generated=true merge=ours" + requiredEntries := []string{lockYmlEntry} + + // Read existing .gitattributes file if it exists + var lines []string + if content, err := os.ReadFile(gitAttributesPath); err == nil { + lines = strings.Split(string(content), "\n") + compilePostProcessingLog.Printf("Read existing .gitattributes with %d lines", len(lines)) + } else { + compilePostProcessingLog.Print("No existing .gitattributes file found") + } + + modified := false + for _, required := range requiredEntries { + found := false + for i, line := range lines { + trimmedLine := strings.TrimSpace(line) + if trimmedLine == required { + found = true + break + } + // Check for old format entries that need updating + if strings.HasPrefix(trimmedLine, ".github/workflows/*.lock.yml") && required == lockYmlEntry { + compilePostProcessingLog.Print("Updating old .gitattributes entry format") + lines[i] = lockYmlEntry + found = true + modified = true + break + } + } + + if !found { + compilePostProcessingLog.Printf("Adding new .gitattributes entry: %s", required) + if len(lines) > 0 && lines[len(lines)-1] != "" { + lines = append(lines, "") + } + lines = append(lines, required) + modified = true + } + } + + // Remove old campaign.g.md entries if they exist (they're now in .gitignore) + for i := len(lines) - 1; i >= 0; i-- { + trimmedLine := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmedLine, ".github/workflows/*.campaign.g.md") { + compilePostProcessingLog.Print("Removing obsolete .campaign.g.md .gitattributes entry") + lines = append(lines[:i], lines[i+1:]...) + modified = true + } + } + + if !modified { + compilePostProcessingLog.Print(".gitattributes already contains required entries") + return nil + } + + // Write back to file with owner-only read/write permissions (0600) for security best practices + content := strings.Join(lines, "\n") + if err := os.WriteFile(gitAttributesPath, []byte(content), 0600); err != nil { + compilePostProcessingLog.Printf("Failed to write .gitattributes: %v", err) + return fmt.Errorf("failed to write .gitattributes: %w", err) + } + + compilePostProcessingLog.Print("Successfully updated .gitattributes") + return nil +} + +// stageGitAttributesIfChanged stages .gitattributes if it was modified +func stageGitAttributesIfChanged() error { + gitRoot, err := findGitRoot() + if err != nil { + return err + } + gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") + return exec.Command("git", "-C", gitRoot, "add", gitAttributesPath).Run() +} + // saveActionCache saves the action cache after all compilations func saveActionCache(actionCache *workflow.ActionCache, verbose bool) error { if actionCache == nil { diff --git a/pkg/cli/git.go b/pkg/cli/git.go index ae2375f289..c8814f6670 100644 --- a/pkg/cli/git.go +++ b/pkg/cli/git.go @@ -153,91 +153,7 @@ func stageWorkflowChanges() { } } -// ensureGitAttributes ensures that .gitattributes contains the entry to mark .lock.yml files as generated -func ensureGitAttributes() error { - gitLog.Print("Ensuring .gitattributes is updated") - gitRoot, err := findGitRoot() - if err != nil { - return err // Not in a git repository, skip - } - - gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") - lockYmlEntry := ".github/workflows/*.lock.yml linguist-generated=true merge=ours" - requiredEntries := []string{lockYmlEntry} - - // Read existing .gitattributes file if it exists - var lines []string - if content, err := os.ReadFile(gitAttributesPath); err == nil { - lines = strings.Split(string(content), "\n") - gitLog.Printf("Read existing .gitattributes with %d lines", len(lines)) - } else { - gitLog.Print("No existing .gitattributes file found") - } - - modified := false - for _, required := range requiredEntries { - found := false - for i, line := range lines { - trimmedLine := strings.TrimSpace(line) - if trimmedLine == required { - found = true - break - } - // Check for old format entries that need updating - if strings.HasPrefix(trimmedLine, ".github/workflows/*.lock.yml") && required == lockYmlEntry { - gitLog.Print("Updating old .gitattributes entry format") - lines[i] = lockYmlEntry - found = true - modified = true - break - } - } - - if !found { - gitLog.Printf("Adding new .gitattributes entry: %s", required) - if len(lines) > 0 && lines[len(lines)-1] != "" { - lines = append(lines, "") - } - lines = append(lines, required) - modified = true - } - } - // Remove old campaign.g.md entries if they exist (they're now in .gitignore) - for i := len(lines) - 1; i >= 0; i-- { - trimmedLine := strings.TrimSpace(lines[i]) - if strings.HasPrefix(trimmedLine, ".github/workflows/*.campaign.g.md") { - gitLog.Print("Removing obsolete .campaign.g.md .gitattributes entry") - lines = append(lines[:i], lines[i+1:]...) - modified = true - } - } - - if !modified { - gitLog.Print(".gitattributes already contains required entries") - return nil - } - - // Write back to file with owner-only read/write permissions (0600) for security best practices - content := strings.Join(lines, "\n") - if err := os.WriteFile(gitAttributesPath, []byte(content), 0600); err != nil { - gitLog.Printf("Failed to write .gitattributes: %v", err) - return fmt.Errorf("failed to write .gitattributes: %w", err) - } - - gitLog.Print("Successfully updated .gitattributes") - return nil -} - -// stageGitAttributesIfChanged stages .gitattributes if it was modified -func stageGitAttributesIfChanged() error { - gitRoot, err := findGitRoot() - if err != nil { - return err - } - gitAttributesPath := filepath.Join(gitRoot, ".gitattributes") - return exec.Command("git", "-C", gitRoot, "add", gitAttributesPath).Run() -} // ensureLogsGitignore ensures that .github/aw/logs/.gitignore exists to ignore log files func ensureLogsGitignore() error { From 59f10d62e37ff26e82cf6db9741596f2f6b85ca8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 04:39:32 +0000 Subject: [PATCH 3/7] refactor: move confirmPushOperation to interactive Move confirmPushOperation() from git.go to interactive.go. This function is user interaction logic, not a core git operation. - Moved confirmPushOperation() to interactive.go - Updated logging to use interactiveLog instead of gitLog - Removed unused huh import from git.go - All tests passing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/git.go | 44 ----------------------------------------- pkg/cli/interactive.go | 45 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 44 deletions(-) diff --git a/pkg/cli/git.go b/pkg/cli/git.go index c8814f6670..6a58272103 100644 --- a/pkg/cli/git.go +++ b/pkg/cli/git.go @@ -7,7 +7,6 @@ import ( "path/filepath" "strings" - "github.com/charmbracelet/huh" "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/logger" ) @@ -627,46 +626,3 @@ func checkOnDefaultBranch(verbose bool) error { } // confirmPushOperation prompts the user to confirm push operation (skips in CI) -func confirmPushOperation(verbose bool) error { - gitLog.Print("Checking if user confirmation is needed for push operation") - - // Skip confirmation in CI environments - if IsRunningInCI() { - gitLog.Print("Running in CI, skipping user confirmation") - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Running in CI - skipping confirmation prompt")) - } - return nil - } - - // Prompt user for confirmation - gitLog.Print("Prompting user for push confirmation") - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("This will commit and push changes to the remote repository.")) - - var confirmed bool - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Do you want to proceed with commit and push?"). - Description("This will stage all changes, commit them, and push to the remote repository"). - Value(&confirmed), - ), - ).WithAccessible(console.IsAccessibleMode()) - - if err := form.Run(); err != nil { - gitLog.Printf("Confirmation prompt failed: %v", err) - return fmt.Errorf("confirmation prompt failed: %w", err) - } - - if !confirmed { - gitLog.Print("User declined push operation") - return fmt.Errorf("push operation cancelled by user") - } - - gitLog.Print("User confirmed push operation") - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Push operation confirmed")) - } - return nil -} diff --git a/pkg/cli/interactive.go b/pkg/cli/interactive.go index 77d4e98251..171d9157f2 100644 --- a/pkg/cli/interactive.go +++ b/pkg/cli/interactive.go @@ -508,3 +508,48 @@ func (b *InteractiveWorkflowBuilder) compileWorkflow(verbose bool) error { return nil } + +// confirmPushOperation prompts the user to confirm push operation (skips in CI) +func confirmPushOperation(verbose bool) error { + interactiveLog.Print("Checking if user confirmation is needed for push operation") + + // Skip confirmation in CI environments + if IsRunningInCI() { + interactiveLog.Print("Running in CI, skipping user confirmation") + if verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Running in CI - skipping confirmation prompt")) + } + return nil + } + + // Prompt user for confirmation + interactiveLog.Print("Prompting user for push confirmation") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("This will commit and push changes to the remote repository.")) + + var confirmed bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Do you want to proceed with commit and push?"). + Description("This will stage all changes, commit them, and push to the remote repository"). + Value(&confirmed), + ), + ).WithAccessible(console.IsAccessibleMode()) + + if err := form.Run(); err != nil { + interactiveLog.Printf("Confirmation prompt failed: %v", err) + return fmt.Errorf("confirmation prompt failed: %w", err) + } + + if !confirmed { + interactiveLog.Print("User declined push operation") + return fmt.Errorf("push operation cancelled by user") + } + + interactiveLog.Print("User confirmed push operation") + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Push operation confirmed")) + } + return nil +} From f94b7c4f65c8e0b839c64079e057b1e9e58131d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 04:41:16 +0000 Subject: [PATCH 4/7] refactor: move validateCompileConfig to compile_config Move validateCompileConfig() from compile_validation.go to compile_config.go. This function validates CLI configuration flags, not workflow content, so it belongs with the CompileConfig struct definition. - Moved validateCompileConfig() to compile_config.go - Updated logging to use compileConfigLog instead of compileValidationLog - Added fmt and path/filepath imports to compile_config.go - All tests passing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/compile_config.go | 36 +++++++++++++++++++++++++++++++++++ pkg/cli/compile_validation.go | 32 ------------------------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/pkg/cli/compile_config.go b/pkg/cli/compile_config.go index e689f4d589..3d41ce8fc0 100644 --- a/pkg/cli/compile_config.go +++ b/pkg/cli/compile_config.go @@ -1,6 +1,9 @@ package cli import ( + "fmt" + "path/filepath" + "github.com/githubnext/gh-aw/pkg/logger" "github.com/githubnext/gh-aw/pkg/stringutil" ) @@ -108,3 +111,36 @@ func sanitizeValidationResults(results []ValidationResult) []ValidationResult { return sanitized } + +// validateCompileConfig validates the configuration flags before compilation +// This is extracted for faster testing without full compilation +func validateCompileConfig(config CompileConfig) error { + compileConfigLog.Printf("Validating compile config: files=%d, dependabot=%v, purge=%v, workflowDir=%s", len(config.MarkdownFiles), config.Dependabot, config.Purge, config.WorkflowDir) + + // Validate dependabot flag usage + if config.Dependabot { + if len(config.MarkdownFiles) > 0 { + compileConfigLog.Print("Config validation failed: dependabot flag with specific files") + return fmt.Errorf("--dependabot flag cannot be used with specific workflow files") + } + if config.WorkflowDir != "" && config.WorkflowDir != ".github/workflows" { + compileConfigLog.Printf("Config validation failed: dependabot with custom dir: %s", config.WorkflowDir) + return fmt.Errorf("--dependabot flag cannot be used with custom --dir") + } + } + + // Validate purge flag usage + if config.Purge && len(config.MarkdownFiles) > 0 { + compileConfigLog.Print("Config validation failed: purge flag with specific files") + return fmt.Errorf("--purge flag can only be used when compiling all markdown files (no specific files specified)") + } + + // Validate workflow directory path + if config.WorkflowDir != "" && filepath.IsAbs(config.WorkflowDir) { + compileConfigLog.Printf("Config validation failed: absolute path in workflowDir: %s", config.WorkflowDir) + return fmt.Errorf("--dir must be a relative path, got: %s", config.WorkflowDir) + } + + compileConfigLog.Print("Config validation successful") + return nil +} diff --git a/pkg/cli/compile_validation.go b/pkg/cli/compile_validation.go index 16c6c5ecf4..e9b9f54702 100644 --- a/pkg/cli/compile_validation.go +++ b/pkg/cli/compile_validation.go @@ -188,35 +188,3 @@ func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData return nil } -// validateCompileConfig validates the configuration flags before compilation -// This is extracted for faster testing without full compilation -func validateCompileConfig(config CompileConfig) error { - compileValidationLog.Printf("Validating compile config: files=%d, dependabot=%v, purge=%v, workflowDir=%s", len(config.MarkdownFiles), config.Dependabot, config.Purge, config.WorkflowDir) - - // Validate dependabot flag usage - if config.Dependabot { - if len(config.MarkdownFiles) > 0 { - compileValidationLog.Print("Config validation failed: dependabot flag with specific files") - return fmt.Errorf("--dependabot flag cannot be used with specific workflow files") - } - if config.WorkflowDir != "" && config.WorkflowDir != ".github/workflows" { - compileValidationLog.Printf("Config validation failed: dependabot with custom dir: %s", config.WorkflowDir) - return fmt.Errorf("--dependabot flag cannot be used with custom --dir") - } - } - - // Validate purge flag usage - if config.Purge && len(config.MarkdownFiles) > 0 { - compileValidationLog.Print("Config validation failed: purge flag with specific files") - return fmt.Errorf("--purge flag can only be used when compiling all markdown files (no specific files specified)") - } - - // Validate workflow directory path - if config.WorkflowDir != "" && filepath.IsAbs(config.WorkflowDir) { - compileValidationLog.Printf("Config validation failed: absolute path in workflowDir: %s", config.WorkflowDir) - return fmt.Errorf("--dir must be a relative path, got: %s", config.WorkflowDir) - } - - compileValidationLog.Print("Config validation successful") - return nil -} From 2fd01c59e8786ef258b10d72a94bd539d2b7247c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 04:42:22 +0000 Subject: [PATCH 5/7] refactor: move validateActionYml to validators Move validateActionYml() from actions_build_command.go to validators.go. This is a reusable validation function that should be centralized with other validators for better discoverability. - Moved validateActionYml() to validators.go - Added fmt, os, and path/filepath imports to validators.go - Simplified function documentation - All tests passing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/actions_build_command.go | 46 -------------------------------- pkg/cli/validators.go | 42 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 46 deletions(-) diff --git a/pkg/cli/actions_build_command.go b/pkg/cli/actions_build_command.go index fed4a96bab..a3e4edbfce 100644 --- a/pkg/cli/actions_build_command.go +++ b/pkg/cli/actions_build_command.go @@ -147,52 +147,6 @@ func getActionDirectories(actionsDir string) ([]string, error) { return dirs, nil } -// validateActionYml validates that an action.yml file exists and contains required fields. -// -// This validation function is co-located with the actions build command because: -// - It's specific to GitHub Actions custom action structure -// - It's only called during the actions build process -// - It validates action metadata before bundling JavaScript -// -// The function validates: -// - action.yml file exists in the action directory -// - Required fields are present (name, description, runs) -// - Basic action metadata structure is valid -// -// This follows the principle that domain-specific validation belongs in domain files. -func validateActionYml(actionPath string) error { - ymlPath := filepath.Join(actionPath, "action.yml") - - if _, err := os.Stat(ymlPath); os.IsNotExist(err) { - return fmt.Errorf("action.yml not found") - } - - content, err := os.ReadFile(ymlPath) - if err != nil { - return fmt.Errorf("failed to read action.yml: %w", err) - } - - contentStr := string(content) - - // Check required fields - requiredFields := []string{"name:", "description:", "runs:"} - for _, field := range requiredFields { - if !strings.Contains(contentStr, field) { - return fmt.Errorf("missing required field '%s'", strings.TrimSuffix(field, ":")) - } - } - - // Check that it's either a node20 or composite action - isNode20 := strings.Contains(contentStr, "using: 'node20'") || strings.Contains(contentStr, "using: \"node20\"") - isComposite := strings.Contains(contentStr, "using: 'composite'") || strings.Contains(contentStr, "using: \"composite\"") - - if !isNode20 && !isComposite { - return fmt.Errorf("action must use either 'node20' or 'composite' runtime") - } - - return nil -} - // buildAction builds a single action by bundling its dependencies func buildAction(actionsDir, actionName string) error { actionsBuildLog.Printf("Building action: %s", actionName) diff --git a/pkg/cli/validators.go b/pkg/cli/validators.go index 8363269f53..7223e67e3f 100644 --- a/pkg/cli/validators.go +++ b/pkg/cli/validators.go @@ -2,6 +2,9 @@ package cli import ( "errors" + "fmt" + "os" + "path/filepath" "regexp" "strings" @@ -46,3 +49,42 @@ func ValidateWorkflowIntent(s string) error { validatorsLog.Printf("Workflow intent validated successfully: %d chars", len(trimmed)) return nil } + +// validateActionYml validates that an action.yml file exists and contains required fields. +// +// This validates GitHub Actions custom action structure: +// - action.yml file exists in the action directory +// - Required fields are present (name, description, runs) +// - Runtime is either 'node20' or 'composite' +func validateActionYml(actionPath string) error { + ymlPath := filepath.Join(actionPath, "action.yml") + + if _, err := os.Stat(ymlPath); os.IsNotExist(err) { + return fmt.Errorf("action.yml not found") + } + + content, err := os.ReadFile(ymlPath) + if err != nil { + return fmt.Errorf("failed to read action.yml: %w", err) + } + + contentStr := string(content) + + // Check required fields + requiredFields := []string{"name:", "description:", "runs:"} + for _, field := range requiredFields { + if !strings.Contains(contentStr, field) { + return fmt.Errorf("missing required field '%s'", strings.TrimSuffix(field, ":")) + } + } + + // Check that it's either a node20 or composite action + isNode20 := strings.Contains(contentStr, "using: 'node20'") || strings.Contains(contentStr, "using: \"node20\"") + isComposite := strings.Contains(contentStr, "using: 'composite'") || strings.Contains(contentStr, "using: \"composite\"") + + if !isNode20 && !isComposite { + return fmt.Errorf("action must use either 'node20' or 'composite' runtime") + } + + return nil +} From adf03998328084a3f837be6978b9f10f0fa86a13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 04:48:07 +0000 Subject: [PATCH 6/7] chore: apply formatting to refactored files Apply go fmt formatting to files after refactoring. Removes trailing blank lines and extra whitespace per project standards. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/compile_validation.go | 1 - pkg/cli/git.go | 2 -- 2 files changed, 3 deletions(-) diff --git a/pkg/cli/compile_validation.go b/pkg/cli/compile_validation.go index e9b9f54702..4da8c65de6 100644 --- a/pkg/cli/compile_validation.go +++ b/pkg/cli/compile_validation.go @@ -187,4 +187,3 @@ func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData return nil } - diff --git a/pkg/cli/git.go b/pkg/cli/git.go index 6a58272103..1c459aac2d 100644 --- a/pkg/cli/git.go +++ b/pkg/cli/git.go @@ -152,8 +152,6 @@ func stageWorkflowChanges() { } } - - // ensureLogsGitignore ensures that .github/aw/logs/.gitignore exists to ignore log files func ensureLogsGitignore() error { gitLog.Print("Ensuring .github/aw/logs/.gitignore exists") From f71be53e18b5bddb780ededef38bf94a8ca9d128 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 04:49:44 +0000 Subject: [PATCH 7/7] fix: remove orphaned comment from git.go Remove orphaned function comment for confirmPushOperation that was moved to interactive.go. This comment was inadvertently left behind during refactoring. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/git.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cli/git.go b/pkg/cli/git.go index 1c459aac2d..53a230ec75 100644 --- a/pkg/cli/git.go +++ b/pkg/cli/git.go @@ -623,4 +623,3 @@ func checkOnDefaultBranch(verbose bool) error { return nil } -// confirmPushOperation prompts the user to confirm push operation (skips in CI)